diff --git a/src-tauri/src/group_manager.rs b/src-tauri/src/group_manager.rs index f0128f7..cf8e6fb 100644 --- a/src-tauri/src/group_manager.rs +++ b/src-tauri/src/group_manager.rs @@ -268,7 +268,9 @@ impl GroupManager { } } - // Create result including all groups (even those with 0 count) + // Create result including all groups (even those with 0 count). + // The "Default" pseudo-group is intentionally not returned: profiles + // without a group_id are surfaced through the "All" filter instead. let mut result = Vec::new(); for group in groups { let count = group_counts.get(&group.id).copied().unwrap_or(0); @@ -281,18 +283,6 @@ impl GroupManager { }); } - // Add default group count (profiles without group_id), always include even if 0 - let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count(); - let default_group = GroupWithCount { - id: "default".to_string(), - name: "Default".to_string(), - count: default_count, - sync_enabled: false, - last_sync: None, - }; - // Insert at the beginning for consistent ordering with UI expectations - result.insert(0, default_group); - Ok(result) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b77ea5c..0b09e4b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,7 +74,7 @@ use profile::manager::{ use profile::password::{ change_profile_password, is_profile_locked, lock_profile, remove_profile_password, - set_profile_password, unlock_profile, + set_profile_password, unlock_profile, verify_profile_password, }; use browser_version_manager::{ @@ -103,6 +103,7 @@ use sync::{ is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities, set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, + verify_e2e_password, }; use tag_manager::get_all_tags; @@ -2112,6 +2113,7 @@ pub fn run() { enable_sync_for_all_entities, set_e2e_password, check_has_e2e_password, + verify_e2e_password, delete_e2e_password, rollover_encryption_for_all_entities, read_profile_cookies, @@ -2177,6 +2179,7 @@ pub fn run() { set_profile_password, change_profile_password, remove_profile_password, + verify_profile_password, unlock_profile, lock_profile, is_profile_locked, diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index dab19e3..8b9054b 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -473,6 +473,8 @@ impl ProfileManager { // Save profile with new name self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&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()); @@ -678,6 +680,8 @@ impl ProfileManager { profile.group_id = group_id.clone(); self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&profile); + // Auto-enable sync for new group if profile has sync enabled if profile.is_sync_enabled() { if let Some(ref new_group_id) = group_id { @@ -732,6 +736,8 @@ impl ProfileManager { // Save profile self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&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()); @@ -766,6 +772,8 @@ impl ProfileManager { // Save profile self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&profile); + // Emit profile note update event if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); @@ -792,6 +800,8 @@ impl ProfileManager { self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&profile); + if let Err(e) = events::emit("profile-updated", &profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } @@ -821,6 +831,8 @@ impl ProfileManager { self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&profile); + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -845,6 +857,8 @@ impl ProfileManager { self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&profile); + if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } @@ -1060,6 +1074,8 @@ impl ProfileManager { format!("Failed to save profile: {e}").into() })?; + crate::sync::queue_profile_sync_if_eligible(&profile); + log::info!( "Camoufox configuration updated for profile '{}' (ID: {}).", profile.name, @@ -1120,6 +1136,8 @@ impl ProfileManager { format!("Failed to save profile: {e}").into() })?; + crate::sync::queue_profile_sync_if_eligible(&profile); + log::info!( "Wayfern configuration updated for profile '{}' (ID: {}).", profile.name, @@ -1174,6 +1192,8 @@ impl ProfileManager { format!("Failed to save profile: {e}").into() })?; + crate::sync::queue_profile_sync_if_eligible(&profile); + // Auto-enable sync for new proxy if profile has sync enabled if profile.is_sync_enabled() { if let Some(ref new_proxy_id) = proxy_id { @@ -1263,6 +1283,8 @@ impl ProfileManager { format!("Failed to save profile: {e}").into() })?; + crate::sync::queue_profile_sync_if_eligible(&profile); + // Auto-enable sync for the new VPN if profile has sync enabled. if profile.is_sync_enabled() { if let Some(ref new_vpn_id) = vpn_id { @@ -1300,6 +1322,8 @@ impl ProfileManager { profile.extension_group_id = extension_group_id.clone(); self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&profile); + // Auto-enable sync for the new extension group if profile has sync // enabled. The helper is sync internally; we fire-and-forget through // the async runtime so any I/O doesn't block this caller. diff --git a/src-tauri/src/profile/password.rs b/src-tauri/src/profile/password.rs index fb96e57..cdf2686 100644 --- a/src-tauri/src/profile/password.rs +++ b/src-tauri/src/profile/password.rs @@ -292,10 +292,45 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul .map_err(err_internal)?; cache_key(id, key); + crate::sync::queue_profile_sync_if_eligible(&profile); emit_profiles_changed(); Ok(()) } +/// Verify a profile password without unlocking. Used by the Settings UI's +/// "Validate" button so users can confirm they remember the password without +/// performing a destructive change. Honors the same lockout schedule as +/// `unlock_profile` so a brute-force attacker can't bypass rate-limiting by +/// hammering this command. +#[tauri::command] +pub async fn verify_profile_password(profile_id: String, password: String) -> Result<(), String> { + let id = parse_uuid(&profile_id)?; + let profile = load_profile(&id)?; + if !profile.password_protected { + return Err(err_code("PROFILE_NOT_PROTECTED")); + } + if let Err(secs) = check_lockout(&id) { + return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())])); + } + let salt = profile + .encryption_salt + .as_deref() + .ok_or_else(|| err_code("PROFILE_MISSING_SALT"))?; + let key = derive_profile_key(&password, salt).map_err(err_internal)?; + let dir = profile_data_dir(&profile); + match verify_key_against_dir(&key, &dir) { + Ok(()) => { + clear_failed_attempts(&id); + Ok(()) + } + Err(crate::profile::encryption::PasswordError::WrongPassword) => { + record_failed_attempt(id); + Err(err_code("INCORRECT_PASSWORD")) + } + Err(other) => Err(err_internal(other)), + } +} + #[tauri::command] pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> { let id = parse_uuid(&profile_id)?; @@ -396,6 +431,7 @@ pub async fn change_profile_password( drop_cached_key(&id); cache_key(id, new_key); + crate::sync::queue_profile_sync_if_eligible(&profile); emit_profiles_changed(); Ok(()) } @@ -464,6 +500,7 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re .map_err(err_internal)?; drop_cached_key(&id); + crate::sync::queue_profile_sync_if_eligible(&profile); emit_profiles_changed(); Ok(()) } diff --git a/src-tauri/src/sync/encryption.rs b/src-tauri/src/sync/encryption.rs index 074c246..f27916d 100644 --- a/src-tauri/src/sync/encryption.rs +++ b/src-tauri/src/sync/encryption.rs @@ -346,6 +346,14 @@ pub fn check_has_e2e_password() -> bool { has_e2e_password() } +#[tauri::command] +pub fn verify_e2e_password(password: String) -> Result { + match load_e2e_password()? { + Some(stored) => Ok(stored == password), + None => Err(serde_json::json!({ "code": "NO_E2E_PASSWORD_SET" }).to_string()), + } +} + #[tauri::command] pub async fn delete_e2e_password() -> Result<(), String> { enforce_team_owner_for_encryption_change().await?; diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 6416282..8e14d51 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -7,7 +7,9 @@ pub mod subscription; pub mod types; pub use client::SyncClient; -pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password}; +pub use encryption::{ + check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password, +}; pub use engine::{ enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts, @@ -22,3 +24,21 @@ pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, Syn pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler}; pub use subscription::{SubscriptionManager, SyncWorkItem}; pub use types::{SyncError, SyncResult}; + +/// Queue a profile sync if the profile has sync enabled. No-op otherwise. +/// +/// Called from profile metadata update paths so a rename / tag edit / proxy +/// reassignment shows up on other devices without waiting for the next +/// scheduled tick. Spawns the async queue call so this helper is callable +/// from both sync and async contexts. +pub fn queue_profile_sync_if_eligible(profile: &crate::profile::BrowserProfile) { + if !profile.is_sync_enabled() { + return; + } + let profile_id = profile.id.to_string(); + tauri::async_runtime::spawn(async move { + if let Some(scheduler) = get_global_scheduler() { + scheduler.queue_profile_sync(profile_id).await; + } + }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 571ea6e..1321cc5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -613,7 +613,9 @@ export default function Home() { wayfernConfig: profileData.wayfernConfig, groupId: profileData.groupId ?? - (selectedGroupId !== "default" ? selectedGroupId : undefined), + (selectedGroupId && selectedGroupId !== "__all__" + ? selectedGroupId + : undefined), ephemeral: profileData.ephemeral, dnsBlocklist: profileData.dnsBlocklist, launchHook: profileData.launchHook, @@ -1243,11 +1245,10 @@ export default function Home() { let filtered = profiles; // Filter by group. "__all__" is a virtual filter that shows every - // profile regardless of group; "default" shows ungrouped profiles. - if (selectedGroupId === "__all__") { + // profile (including ungrouped ones). Any other value is a real + // group id; ungrouped profiles only show through "All". + if (!selectedGroupId || selectedGroupId === "__all__") { filtered = profiles; - } else if (!selectedGroupId || selectedGroupId === "default") { - filtered = profiles.filter((profile) => !profile.group_id); } else { filtered = profiles.filter( (profile) => profile.group_id === selectedGroupId, @@ -1292,6 +1293,7 @@ export default function Home() { searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} groups={groupsData} + totalProfiles={profiles.length} selectedGroupId={selectedGroupId} onGroupSelect={handleSelectGroup} pageTitle={subPageTitle} diff --git a/src/components/account-page.tsx b/src/components/account-page.tsx index a4d0f77..82ae581 100644 --- a/src/components/account-page.tsx +++ b/src/components/account-page.tsx @@ -12,16 +12,20 @@ import { LuUser, } from "react-icons/lu"; import { LoadingButton } from "@/components/loading-button"; +import { + AnimatedTabs, + AnimatedTabsContent, + AnimatedTabsList, + AnimatedTabsTrigger, +} from "@/components/ui/animated-tabs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; -import { cn } from "@/lib/utils"; import type { SyncSettings } from "@/types"; interface AccountPageProps { @@ -194,25 +198,12 @@ export function AccountPage({
- - - + + + {t("account.tabs.account")} - - + {t("account.tabs.selfHosted")} - - + + - +
@@ -338,9 +324,9 @@ export function AccountPage({ )}
- + - + {selfHostedDisabled ? ( // Defensive: the tab trigger is disabled while the user is // logged in, so this branch shouldn't be reachable via UI — @@ -481,8 +467,8 @@ export function AccountPage({
)} - - + +
diff --git a/src/components/cookie-management-dialog.tsx b/src/components/cookie-management-dialog.tsx index a354b5c..0fb3f89 100644 --- a/src/components/cookie-management-dialog.tsx +++ b/src/components/cookie-management-dialog.tsx @@ -15,9 +15,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { FadingScrollArea } from "@/components/ui/fading-scroll-area"; import { Label } from "@/components/ui/label"; import { RippleButton } from "@/components/ui/ripple"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, @@ -563,7 +563,7 @@ export function CookieManagementDialog({ {t("cookies.management.noCookies")} ) : ( - +
{exportCookieData.domains.map((domain) => ( ))}
-
+ )} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 436d231..d335b0d 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -431,7 +431,9 @@ export function CreateProfileDialog({ vpnId: resolvedVpnId, wayfernConfig: finalWayfernConfig, groupId: - selectedGroupId !== "default" ? selectedGroupId : undefined, + selectedGroupId && selectedGroupId !== "__all__" + ? selectedGroupId + : undefined, extensionGroupId: selectedExtensionGroupId, ephemeral, dnsBlocklist: dnsBlocklist || undefined, @@ -459,7 +461,9 @@ export function CreateProfileDialog({ vpnId: resolvedVpnId, camoufoxConfig: finalCamoufoxConfig, groupId: - selectedGroupId !== "default" ? selectedGroupId : undefined, + selectedGroupId && selectedGroupId !== "__all__" + ? selectedGroupId + : undefined, extensionGroupId: selectedExtensionGroupId, ephemeral, dnsBlocklist: dnsBlocklist || undefined, @@ -487,7 +491,10 @@ export function CreateProfileDialog({ version: bestVersion.version, releaseType: bestVersion.releaseType, proxyId: selectedProxyId, - groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, + groupId: + selectedGroupId && selectedGroupId !== "__all__" + ? selectedGroupId + : undefined, dnsBlocklist: dnsBlocklist || undefined, launchHook: launchHook.trim() || undefined, password: passwordToSet, diff --git a/src/components/extension-management-dialog.tsx b/src/components/extension-management-dialog.tsx index a458df9..b13ccc6 100644 --- a/src/components/extension-management-dialog.tsx +++ b/src/components/extension-management-dialog.tsx @@ -1,18 +1,42 @@ "use client"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + type RowSelectionState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { FaChrome, FaFirefox } from "react-icons/fa"; import { GoPlus } from "react-icons/go"; import { + LuChevronDown, + LuChevronUp, LuExternalLink, LuPencil, LuPuzzle, + LuRefreshCw, LuTrash2, LuUpload, } from "react-icons/lu"; +import { + DataTableActionBar, + DataTableActionBarAction, + DataTableActionBarSelection, +} from "@/components/data-table-action-bar"; +import { AnimatedSwitch } from "@/components/ui/animated-switch"; +import { + AnimatedTabs, + AnimatedTabsContent, + AnimatedTabsList, + AnimatedTabsTrigger, +} from "@/components/ui/animated-tabs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -24,6 +48,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { FadingScrollArea } from "@/components/ui/fading-scroll-area"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ProBadge } from "@/components/ui/pro-badge"; @@ -35,6 +60,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Tooltip, TooltipContent, @@ -137,6 +170,18 @@ export function ExtensionManagementDialog({ ); const [isDeleting, setIsDeleting] = useState(false); + // Bulk delete state + const [bulkExtDeleteOpen, setBulkExtDeleteOpen] = useState(false); + const [bulkGroupDeleteOpen, setBulkGroupDeleteOpen] = useState(false); + + // Table state + const [extSorting, setExtSorting] = useState([]); + const [extRowSelection, setExtRowSelection] = useState({}); + const [groupSorting, setGroupSorting] = useState([]); + const [groupRowSelection, setGroupRowSelection] = useState( + {}, + ); + // Edit extension state const [editingExtension, setEditingExtension] = useState( null, @@ -207,6 +252,11 @@ export function ExtensionManagementDialog({ useEffect(() => { if (isOpen) { void loadData(); + } else { + // Drop selection when the dialog closes so the floating action + // bars (portaled to body) don't linger on the page. + setExtRowSelection({}); + setGroupRowSelection({}); } }, [isOpen, loadData]); @@ -473,59 +523,566 @@ export function ExtensionManagementDialog({ } }, [groupToDelete, loadData, t]); - const renderCompatIcons = (compat: string[]) => { - const hasChromium = compat.includes("chromium"); - const hasFirefox = compat.includes("firefox"); - if (!hasChromium && !hasFirefox) return null; - return ( -
- {hasChromium && ( - - - - - - - - {t("extensions.compatibility.chromium")} - - - )} - {hasFirefox && ( - - - - - - - - {t("extensions.compatibility.firefox")} - - - )} -
- ); - }; + const selectedExtensions = useMemo( + () => extensions.filter((ext) => extRowSelection[ext.id]), + [extensions, extRowSelection], + ); - const renderExtensionIcon = (ext: Extension, size: "sm" | "md" = "md") => { - const sizeClass = size === "sm" ? "size-4" : "size-5"; - if (extensionIcons[ext.id]) { - return ( - // biome-ignore lint/performance/noImgElement: base64 data URI icons cannot use next/image - + const selectedGroups = useMemo( + () => extensionGroups.filter((group) => groupRowSelection[group.id]), + [extensionGroups, groupRowSelection], + ); + + const handleBulkDeleteExtensions = useCallback(async () => { + if (selectedExtensions.length === 0) return; + setIsDeleting(true); + try { + await Promise.allSettled( + selectedExtensions.map((ext) => + invoke("delete_extension", { extensionId: ext.id }), + ), + ); + showSuccessToast(t("extensions.deleteSuccess")); + setBulkExtDeleteOpen(false); + setExtRowSelection({}); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } finally { + setIsDeleting(false); + } + }, [selectedExtensions, loadData, t]); + + const handleBulkDeleteGroups = useCallback(async () => { + if (selectedGroups.length === 0) return; + setIsDeleting(true); + try { + await Promise.allSettled( + selectedGroups.map((group) => + invoke("delete_extension_group", { groupId: group.id }), + ), + ); + showSuccessToast(t("extensions.groupDeleteSuccess")); + setBulkGroupDeleteOpen(false); + setGroupRowSelection({}); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } finally { + setIsDeleting(false); + } + }, [selectedGroups, loadData, t]); + + const handleBulkToggleExtSync = useCallback(async () => { + if (selectedExtensions.length === 0) return; + const allOn = selectedExtensions.every((e) => e.sync_enabled); + const targetEnabled = !allOn; + const results = await Promise.allSettled( + selectedExtensions.map((ext) => + invoke("set_extension_sync_enabled", { + extensionId: ext.id, + enabled: targetEnabled, + }), + ), + ); + const failed = results.filter((r) => r.status === "rejected").length; + if (failed > 0) { + showErrorToast(t("proxies.management.updateSyncFailed")); + } else { + showSuccessToast( + targetEnabled + ? t("extensions.syncEnabled") + : t("extensions.syncDisabled"), ); } - return ( - + void loadData(); + }, [selectedExtensions, loadData, t]); + + const handleBulkToggleGroupSync = useCallback(async () => { + if (selectedGroups.length === 0) return; + const allOn = selectedGroups.every((g) => g.sync_enabled); + const targetEnabled = !allOn; + const results = await Promise.allSettled( + selectedGroups.map((group) => + invoke("set_extension_group_sync_enabled", { + extensionGroupId: group.id, + enabled: targetEnabled, + }), + ), ); - }; + const failed = results.filter((r) => r.status === "rejected").length; + if (failed > 0) { + showErrorToast(t("proxies.management.updateSyncFailed")); + } else { + showSuccessToast( + targetEnabled + ? t("extensions.syncEnabled") + : t("extensions.syncDisabled"), + ); + } + void loadData(); + }, [selectedGroups, loadData, t]); + + const renderCompatIcons = useCallback( + (compat: string[]) => { + const hasChromium = compat.includes("chromium"); + const hasFirefox = compat.includes("firefox"); + if (!hasChromium && !hasFirefox) return null; + return ( +
+ {hasChromium && ( + + + + + + + + {t("extensions.compatibility.chromium")} + + + )} + {hasFirefox && ( + + + + + + + + {t("extensions.compatibility.firefox")} + + + )} +
+ ); + }, + [t], + ); + + const renderExtensionIcon = useCallback( + (ext: Extension, size: "sm" | "md" = "md") => { + const sizeClass = size === "sm" ? "size-4" : "size-5"; + if (extensionIcons[ext.id]) { + return ( + // biome-ignore lint/performance/noImgElement: base64 data URI icons cannot use next/image + + ); + } + return ( + + ); + }, + [extensionIcons], + ); const MAX_VISIBLE_ICONS = 3; + const extensionColumns = useMemo[]>( + () => [ + { + id: "select", + size: 36, + enableSorting: false, + header: ({ table }) => ( + { + table.toggleAllRowsSelected(!!value); + }} + aria-label={t("common.aria.selectAll")} + /> + ), + cell: ({ row }) => ( + { + row.toggleSelected(!!value); + }} + aria-label={t("common.aria.selectRow")} + /> + ), + }, + { + id: "icon", + size: 36, + enableSorting: false, + header: () => null, + cell: ({ row }) => renderExtensionIcon(row.original, "sm"), + }, + { + accessorKey: "name", + enableSorting: true, + sortingFn: "alphanumeric", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.name} + + ), + }, + { + id: "compat", + enableSorting: false, + header: () => null, + cell: ({ row }) => + renderCompatIcons(row.original.browser_compatibility), + }, + { + id: "sync", + size: 88, + enableSorting: false, + header: () => null, + cell: ({ row }) => { + const ext = row.original; + const syncDot = getSyncStatusDot(ext, extSyncStatus[ext.id], t); + return ( +
+ + +
+ + +

{syncDot.tooltip}

+
+ + + + + void handleToggleExtSync(ext)} + disabled={isTogglingExtSync[ext.id]} + /> + + + +

+ {ext.sync_enabled + ? t("syncTooltips.disable") + : t("syncTooltips.enable")} +

+
+
+
+ ); + }, + }, + { + id: "actions", + enableSorting: false, + header: () => null, + cell: ({ row }) => { + const ext = row.original; + return ( +
+ + + + + {t("extensions.editExtension")} + + + + + + {t("extensions.delete")} + +
+ ); + }, + }, + ], + [ + t, + extSyncStatus, + isTogglingExtSync, + handleToggleExtSync, + renderExtensionIcon, + renderCompatIcons, + ], + ); + + const extTable = useReactTable({ + data: extensions, + columns: extensionColumns, + state: { sorting: extSorting, rowSelection: extRowSelection }, + onSortingChange: setExtSorting, + onRowSelectionChange: setExtRowSelection, + enableRowSelection: () => !limitedMode, + getSortedRowModel: getSortedRowModel(), + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.id, + }); + + const groupColumns = useMemo[]>( + () => [ + { + id: "select", + size: 36, + enableSorting: false, + header: ({ table }) => ( + { + table.toggleAllRowsSelected(!!value); + }} + aria-label={t("common.aria.selectAll")} + /> + ), + cell: ({ row }) => ( + { + row.toggleSelected(!!value); + }} + aria-label={t("common.aria.selectRow")} + /> + ), + }, + { + accessorKey: "name", + enableSorting: true, + sortingFn: "alphanumeric", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.name} + + ), + }, + { + id: "extensions", + enableSorting: false, + header: () => null, + cell: ({ row }) => { + const group = row.original; + const groupExts = group.extension_ids + .map((id) => extensions.find((e) => e.id === id)) + .filter(Boolean) as Extension[]; + const visibleExts = groupExts.slice(0, MAX_VISIBLE_ICONS); + const overflowCount = groupExts.length - MAX_VISIBLE_ICONS; + return ( +
+ {visibleExts.map((ext) => ( + + + + {renderExtensionIcon(ext, "sm")} + + + {ext.name} + + ))} + {overflowCount > 0 && ( + + + + +{overflowCount} + + + +
+ {groupExts.slice(MAX_VISIBLE_ICONS).map((ext) => ( +

+ {ext.name} +

+ ))} +
+
+
+ )} + {groupExts.length === 0 && ( + + {t("extensions.noExtensionsInGroup")} + + )} +
+ ); + }, + }, + { + id: "sync", + size: 88, + enableSorting: false, + header: () => null, + cell: ({ row }) => { + const group = row.original; + const groupSyncDot = getSyncStatusDot( + group, + extSyncStatus[group.id], + t, + ); + return ( +
+ + +
+ + +

{groupSyncDot.tooltip}

+
+ + + + + void handleToggleGroupSync(group)} + disabled={isTogglingGroupSync[group.id]} + /> + + + +

+ {group.sync_enabled + ? t("syncTooltips.disable") + : t("syncTooltips.enable")} +

+
+
+
+ ); + }, + }, + { + id: "actions", + enableSorting: false, + header: () => null, + cell: ({ row }) => { + const group = row.original; + return ( +
+ + + + + {t("common.buttons.edit")} + + + + + + {t("extensions.deleteGroup")} + +
+ ); + }, + }, + ], + [ + t, + extensions, + extSyncStatus, + isTogglingGroupSync, + handleToggleGroupSync, + renderExtensionIcon, + ], + ); + + const groupTable = useReactTable({ + data: extensionGroups, + columns: groupColumns, + state: { sorting: groupSorting, rowSelection: groupRowSelection }, + onSortingChange: setGroupSorting, + onRowSelectionChange: setGroupRowSelection, + enableRowSelection: () => !limitedMode, + getSortedRowModel: getSortedRowModel(), + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.id, + }); + return ( <> @@ -543,453 +1100,316 @@ export function ExtensionManagementDialog({ )} - -
- {limitedMode && ( - <> -
-
-
-
-
-
-
- - - {t("extensions.proRequired")} - -
+
+ {limitedMode && ( + <> +
+
+
+
+
+
+
+ + + {t("extensions.proRequired")} +
- - )} +
+ + )} -
- {/* Tab selector */} -
- -
+ + {/* Notice */} +
+ {t("extensions.managedNotice")} +
+ + +
+ - {t("extensions.groupsTab")} - -
+ /> - {/* Notice */} -
- {t("extensions.managedNotice")} -
- - {activeTab === "extensions" && ( -
-
- -
- - -
-
- - {/* Upload form */} - {showUploadForm && pendingFile && ( -
-
- {t("extensions.selectedFile")}:{" "} - - {pendingFile.name} - -
-
- { - setExtensionName(e.target.value); - }} - placeholder={t("extensions.namePlaceholder")} - className="flex-1" - /> - void handleUpload()} - disabled={isUploading || !extensionName.trim()} - > - {isUploading - ? t("common.buttons.loading") - : t("common.buttons.add")} - - -
-
- )} - - {/* Extensions list */} - {isLoading ? ( + {/* Upload form */} + {showUploadForm && pendingFile && ( +
- {t("common.buttons.loading")} + {t("extensions.selectedFile")}:{" "} + + {pendingFile.name} +
- ) : extensions.length === 0 ? ( -
- {t("extensions.empty")} -
- ) : ( -
- {extensions.map((ext) => { - const syncDot = getSyncStatusDot( - ext, - extSyncStatus[ext.id], - t, - ); - return ( -
- - -
- - -

{syncDot.tooltip}

-
- - {renderExtensionIcon(ext, "sm")} - - {ext.name} - - - .{ext.file_type} - - {renderCompatIcons(ext.browser_compatibility)} - - -
- - void handleToggleExtSync(ext) - } - disabled={isTogglingExtSync[ext.id]} - /> -
-
- -

- {ext.sync_enabled - ? t("extensions.syncDisableTooltip") - : t("extensions.syncEnableTooltip")} -

-
-
-
- - - - - - {t("extensions.editExtension")} - - - - - - - - {t("extensions.delete")} - - -
-
- ); - })} -
- )} -
- )} - - {activeTab === "groups" && ( -
-
- - { - setShowCreateGroup(true); - }} - className="flex gap-2 items-center" - disabled={limitedMode} - > - - {t("extensions.createGroup")} - -
- - {/* Create group form */} - {showCreateGroup && ( -
+
{ - setNewGroupName(e.target.value); + setExtensionName(e.target.value); }} - placeholder={t("extensions.groupNamePlaceholder")} + placeholder={t("extensions.namePlaceholder")} className="flex-1" - onKeyDown={(e) => { - if (e.key === "Enter") void handleCreateGroup(); - }} /> void handleCreateGroup()} - disabled={!newGroupName.trim()} + onClick={() => void handleUpload()} + disabled={isUploading || !extensionName.trim()} > - {t("common.buttons.create")} + {isUploading + ? t("common.buttons.loading") + : t("common.buttons.add")}
- )} +
+ )} - {/* Groups list */} - {extensionGroups.length === 0 ? ( -
- {t("extensions.noGroups")} -
- ) : ( -
- {extensionGroups.map((group) => { - const groupExts = group.extension_ids - .map((id) => extensions.find((e) => e.id === id)) - .filter(Boolean) as Extension[]; - const visibleExts = groupExts.slice( - 0, - MAX_VISIBLE_ICONS, - ); - const overflowCount = - groupExts.length - MAX_VISIBLE_ICONS; - const groupSyncDot = getSyncStatusDot( - group, - extSyncStatus[group.id], - t, - ); - - return ( -
+ {t("common.buttons.loading")} +
+ ) : extensions.length === 0 ? ( +
+ {t("extensions.empty")} +
+ ) : ( + + + + {extTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {extTable.getRowModel().rows.map((row) => ( + - - -
- - -

{groupSyncDot.tooltip}

-
- - - {group.name} - + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + +
+
+ )} +
+ -
- {visibleExts.map((ext) => ( - - - - {renderExtensionIcon(ext, "sm")} - - - {ext.name} - - ))} - {overflowCount > 0 && ( - - - - +{overflowCount} - - - -
- {groupExts - .slice(MAX_VISIBLE_ICONS) - .map((ext) => ( -

- {ext.name} -

- ))} -
-
-
- )} - {groupExts.length === 0 && ( - - {t("extensions.noExtensionsInGroup")} - - )} -
+ +
+ {/* Create group form */} + {showCreateGroup && ( +
+ { + setNewGroupName(e.target.value); + }} + placeholder={t("extensions.groupNamePlaceholder")} + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateGroup(); + }} + /> + void handleCreateGroup()} + disabled={!newGroupName.trim()} + > + {t("common.buttons.create")} + + +
+ )} - - -
- - void handleToggleGroupSync(group) - } - disabled={isTogglingGroupSync[group.id]} - /> -
-
- -

- {group.sync_enabled - ? t("extensions.syncDisableTooltip") - : t("extensions.syncEnableTooltip")} -

-
-
- -
- - - - - - {t("common.buttons.edit")} - - - - - - - - {t("extensions.deleteGroup")} - - -
-
- ); - })} -
- )} -
- )} -
-
- + {/* Groups list */} + {extensionGroups.length === 0 ? ( +
+ {t("extensions.noGroups")} +
+ ) : ( + + + + {groupTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {groupTable.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + +
+
+ )} +
+ + +
{!subPage && ( @@ -1314,6 +1734,94 @@ export function ExtensionManagementDialog({ })} isLoading={isDeleting} /> + + {/* Bulk delete extensions confirmation */} + { + setBulkExtDeleteOpen(false); + }} + onConfirm={handleBulkDeleteExtensions} + title={t("extensions.bulkDelete.extensionsTitle")} + description={t("extensions.bulkDelete.extensionsDescription", { + count: selectedExtensions.length, + names: selectedExtensions.map((ext) => ext.name).join(", "), + })} + confirmButtonText={t("extensions.bulkDelete.confirmButton")} + isLoading={isDeleting} + /> + + {/* Bulk delete groups confirmation */} + { + setBulkGroupDeleteOpen(false); + }} + onConfirm={handleBulkDeleteGroups} + title={t("extensions.bulkDelete.groupsTitle")} + description={t("extensions.bulkDelete.groupsDescription", { + count: selectedGroups.length, + names: selectedGroups.map((group) => group.name).join(", "), + })} + confirmButtonText={t("extensions.bulkDelete.confirmButton")} + isLoading={isDeleting} + /> + + {/* Bulk action bars — only mount the active tab's bar; an always- + mounted DataTableActionBar (even with visible=false) keeps an + AnimatePresence wrapper alive that intermittently captured pointer + input on the proxy/extension subpages. */} + {isOpen && activeTab === "extensions" && ( + + + { + void handleBulkToggleExtSync(); + }} + > + + + { + setBulkExtDeleteOpen(true); + }} + > + + + + )} + + {isOpen && activeTab === "groups" && ( + + + { + void handleBulkToggleGroupSync(); + }} + > + + + { + setBulkGroupDeleteOpen(true); + }} + > + + + + )} ); } diff --git a/src/components/group-assignment-dialog.tsx b/src/components/group-assignment-dialog.tsx index b80d684..3f18c89 100644 --- a/src/components/group-assignment-dialog.tsx +++ b/src/components/group-assignment-dialog.tsx @@ -77,7 +77,7 @@ export function GroupAssignmentDialog({ const groupName = selectedGroupId ? groups.find((g) => g.id === selectedGroupId)?.name || t("groups.unknownGroup") - : t("groups.defaultGroup"); + : t("groups.noGroup"); toast.success( t("groups.assignSuccess", { @@ -175,17 +175,17 @@ export function GroupAssignmentDialog({
) : ( { - const val = Number.parseInt(e.target.value, 10); - if (!Number.isNaN(val)) { - setSettings({ ...settings, api_port: val }); - } - }} - className="w-24 font-mono" - min={1} - max={65535} - /> - {apiServerPort && ( - - {t("common.status.running")} - - )} -
-
- -
- -
-
+ <> +
+
+ +
{ + const val = Number.parseInt(e.target.value, 10); + if (!Number.isNaN(val)) { + setSettings({ ...settings, api_port: val }); + } + }} + className="w-24 font-mono" + min={1} + max={65535} />
+
+ +
+
+ +
+
+
+ + +
+ +
+
+
+ +
+
+
-

- {t("integrations.apiTokenHint", { - tokenSlot: "", - })} -

+
+                      {`curl -H "Authorization: Bearer \${TOKEN}" \\
+     http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
+                    
-
+ )} - + - -
+ +
void handleMcpToggle(!!checked)} + className="mt-0.5" /> -
+
diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 4206263..e15c699 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -416,6 +416,10 @@ function DnsCell({ { value: "pro_plus", labelKey: "dnsBlocklist.proPlus" }, { value: "ultimate", labelKey: "dnsBlocklist.ultimate" }, ]; + const currentLabel = + level === null + ? null + : (LEVELS.find((l) => l.value === level)?.labelKey ?? null); const onPick = async (nextLevel: string | null) => { setIsSaving(true); @@ -446,8 +450,8 @@ function DnsCell({ } > - - {level ?? "—"} + + {currentLabel ? meta.t(currentLabel) : "—"} @@ -2887,6 +2891,14 @@ export function ProfilesDataTable({
@@ -3003,7 +3015,9 @@ export function ProfilesDataTable({ /> {profileForInfoDialog && (() => { - const infoProfile = profileForInfoDialog; + const infoProfile = + profiles.find((p) => p.id === profileForInfoDialog.id) ?? + profileForInfoDialog; const infoIsRunning = browserState.isClient && runningProfiles.has(infoProfile.id); const infoIsLaunching = launchingProfiles.has(infoProfile.id); diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 1cbadf5..359d02a 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -16,7 +16,6 @@ import { LuGroup, LuKey, LuLink, - LuLock, LuLockOpen, LuPlus, LuPuzzle, @@ -33,6 +32,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -48,13 +48,9 @@ import { } from "@/components/ui/select"; import { WayfernConfigForm } from "@/components/wayfern-config-form"; import { translateBackendError } from "@/lib/backend-errors"; -import { - getBrowserDisplayName, - getOSDisplayName, - getProfileIcon, - isCrossOsProfile, -} from "@/lib/browser-utils"; +import { getProfileIcon } from "@/lib/browser-utils"; import { formatRelativeTime } from "@/lib/flag-utils"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; import type { BrowserProfile, @@ -94,7 +90,7 @@ interface ProfileInfoDialogProps { syncStatuses: Record; } -function OSIcon({ os }: { os: string }) { +function _OSIcon({ os }: { os: string }) { switch (os) { case "macos": return ; @@ -290,12 +286,8 @@ export function ProfileInfoDialog({ action(); }; - const releaseLabel = - profile.release_type.charAt(0).toUpperCase() + - profile.release_type.slice(1); const hasTags = profile.tags && profile.tags.length > 0; const hasNote = !!profile.note; - const showCrossOs = isCrossOsProfile(profile); // Items in the settings tab `actions` list MUST only open another dialog // (or trigger a navigation/action that closes this one). Do NOT put inline @@ -491,10 +483,8 @@ export function ProfileInfoDialog({ ; - releaseLabel: string; isRunning: boolean; isDisabled: boolean; - showCrossOs: boolean; networkLabel: string; groupName: string | null; extensionGroupName: string | null; @@ -564,10 +552,8 @@ type ProfileSection = function ProfileInfoLayout({ profile, ProfileIcon, - releaseLabel, isRunning, isDisabled, - showCrossOs, networkLabel, groupName, extensionGroupName, @@ -798,63 +784,8 @@ function ProfileInfoLayout({
- - {getBrowserDisplayName(profile.browser)} - - · - - {groupName ?? t("profileInfo.values.none")} - - {isRunning && ( - <> - · - - - {t("common.status.running")} - - - )} - {profile.ephemeral && ( - <> - · - - {t("profiles.ephemeralBadge")} - - - )} - {profile.password_protected && ( - <> - · - - - {t("profiles.passwordProtectedBadge")} - - - )} - {showCrossOs && ( - <> - · - - - {getOSDisplayName( - profile.host_os || - profile.camoufox_config?.os || - profile.wayfern_config?.os || - "", - )} - - - )} - · - - {releaseLabel} + + {profile.version}
@@ -1716,7 +1647,7 @@ function FingerprintSectionInline({ {error}

} {success && !error &&

{success}

} -
+
+ + { + if (!isVerifying) { + setIsVerifyOpen(open); + if (!open) setVerifyPassword(""); + } + }} + > + + + {t("profilePassword.verifyDialog.title")} + + {t("profilePassword.verifyDialog.description")} + + + setVerifyPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && verifyPassword.length > 0) { + e.preventDefault(); + void onVerify(); + } + }} + /> + + + + + +
); } diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index 15af640..c8e0cae 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -1,16 +1,45 @@ "use client"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + type RowSelectionState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { GoPlus } from "react-icons/go"; -import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu"; +import { + LuChevronDown, + LuChevronUp, + LuDownload, + LuPencil, + LuRefreshCw, + LuTrash2, + LuUpload, +} from "react-icons/lu"; import { toast } from "sonner"; +import { + DataTableActionBar, + DataTableActionBarAction, + DataTableActionBarSelection, +} from "@/components/data-table-action-bar"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; import { ProxyExportDialog } from "@/components/proxy-export-dialog"; import { ProxyFormDialog } from "@/components/proxy-form-dialog"; import { ProxyImportDialog } from "@/components/proxy-import-dialog"; +import { AnimatedSwitch } from "@/components/ui/animated-switch"; +import { + AnimatedTabs, + AnimatedTabsContent, + AnimatedTabsList, + AnimatedTabsTrigger, +} from "@/components/ui/animated-tabs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -22,7 +51,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { ScrollArea } from "@/components/ui/scroll-area"; +import { FadingScrollArea } from "@/components/ui/fading-scroll-area"; import { Table, TableBody, @@ -31,7 +60,6 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, @@ -153,13 +181,59 @@ export function ProxyManagementDialog({ Record >({}); + // Table state + const [proxiesSorting, setProxiesSorting] = useState([ + { id: "name", desc: false }, + ]); + const [proxiesRowSelection, setProxiesRowSelection] = + useState({}); + const [vpnsSorting, setVpnsSorting] = useState([ + { id: "name", desc: false }, + ]); + const [vpnsRowSelection, setVpnsRowSelection] = useState( + {}, + ); + + // Track the active tab so we can scope the floating action bar (portaled + // to body) to only the currently visible list. Initial value comes from + // initialTab; subsequent changes drive the animated tabs via onValueChange. + const [activeTab, setActiveTab] = useState<"proxies" | "vpns">(initialTab); + // Reset selections when the dialog closes so the floating action bar + // (portaled to body) doesn't linger on the page across navigations. + useEffect(() => { + if (!isOpen) { + setProxiesRowSelection({}); + setVpnsRowSelection({}); + } + }, [isOpen]); + + // Bulk delete state + const [isBulkDeletingProxies, setIsBulkDeletingProxies] = useState(false); + const [showBulkDeleteProxiesDialog, setShowBulkDeleteProxiesDialog] = + useState(false); + const [isBulkDeletingVpns, setIsBulkDeletingVpns] = useState(false); + const [showBulkDeleteVpnsDialog, setShowBulkDeleteVpnsDialog] = + useState(false); + const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents(); const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents(); - // Filter out cloud-managed and cloud-derived proxies (cloud proxies are deprecated) - const storedProxies = rawProxies - .filter((p) => !p.is_cloud_managed && !p.is_cloud_derived) - .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + // Filter out cloud-managed and cloud-derived proxies (cloud proxies are + // deprecated). Memoized — without this the derived array gets a new + // reference on every render, which made the [storedProxies] effect below + // refire every render → re-set state → re-render, freezing the page once + // the dialog mounted. Keeping the reference stable when the input is + // unchanged is what every consumer (useReactTable, useEffect, selection + // logic) actually wants. + const storedProxies = useMemo( + () => + rawProxies + .filter((p) => !p.is_cloud_managed && !p.is_cloud_derived) + .sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()), + ), + [rawProxies], + ); // Listen for proxy sync status events useEffect(() => { @@ -395,6 +469,589 @@ export function ProxyManagementDialog({ [t], ); + const proxyColumns = useMemo[]>( + () => [ + { + id: "select", + size: 36, + enableSorting: false, + header: ({ table }) => ( + { + table.toggleAllRowsSelected(!!value); + }} + aria-label={t("common.aria.selectAll")} + /> + ), + cell: ({ row }) => ( + { + row.toggleSelected(!!value); + }} + aria-label={t("common.aria.selectRow")} + /> + ), + }, + { + id: "status", + enableSorting: false, + header: () => null, + cell: ({ row }) => { + const proxy = row.original; + const syncDot = getSyncStatusDot( + proxy, + proxySyncStatus[proxy.id], + t, + proxySyncErrors[proxy.id], + ); + return ( + + +
+ + +

{syncDot.tooltip}

+
+ + ); + }, + }, + { + accessorKey: "name", + enableSorting: true, + sortingFn: "alphanumeric", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.name} + ), + }, + { + id: "protocol", + enableSorting: false, + header: () => t("proxies.management.protocolCol"), + cell: ({ row }) => ( + + {row.original.proxy_settings.proxy_type} + + ), + }, + { + id: "usage", + enableSorting: false, + header: () => t("proxies.management.usage"), + cell: ({ row }) => ( + {proxyUsage[row.original.id] ?? 0} + ), + }, + { + id: "sync", + enableSorting: false, + header: () => t("proxies.management.syncCol"), + cell: ({ row }) => { + const proxy = row.original; + const locked = proxyInUse[proxy.id]; + return ( + + + + void handleToggleSync(proxy)} + disabled={isTogglingSync[proxy.id] || locked} + /> + + + + {locked ? ( +

{t("syncTooltips.lockedInUse")}

+ ) : ( +

+ {proxy.sync_enabled + ? t("syncTooltips.disable") + : t("syncTooltips.enable")} +

+ )} +
+
+ ); + }, + }, + { + id: "actions", + enableSorting: false, + header: () => t("common.labels.actions"), + cell: ({ row }) => { + const proxy = row.original; + return ( +
+ { + setProxyCheckResults((prev) => ({ + ...prev, + [proxy.id]: result, + })); + }} + onCheckFailed={(result) => { + setProxyCheckResults((prev) => ({ + ...prev, + [proxy.id]: result, + })); + }} + /> + + + + + +

{t("proxies.management.editProxy")}

+
+
+ + + + + + + + {(proxyUsage[proxy.id] ?? 0) > 0 ? ( +

+ {(proxyUsage[proxy.id] ?? 0) === 1 + ? t("proxies.management.cannotDelete_one", { + count: proxyUsage[proxy.id], + }) + : t("proxies.management.cannotDelete_other", { + count: proxyUsage[proxy.id], + })} +

+ ) : ( +

{t("proxies.management.deleteProxy")}

+ )} +
+
+
+ ); + }, + }, + ], + [ + t, + proxySyncStatus, + proxySyncErrors, + proxyUsage, + isTogglingSync, + proxyInUse, + checkingProxyId, + proxyCheckResults, + handleToggleSync, + handleEditProxy, + handleDeleteProxy, + ], + ); + + const proxiesTable = useReactTable({ + data: storedProxies, + columns: proxyColumns, + state: { + sorting: proxiesSorting, + rowSelection: proxiesRowSelection, + }, + onSortingChange: setProxiesSorting, + onRowSelectionChange: setProxiesRowSelection, + enableRowSelection: (row) => !proxyInUse[row.original.id], + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: (row) => row.id, + }); + + const vpnColumns = useMemo[]>( + () => [ + { + id: "select", + size: 36, + enableSorting: false, + header: ({ table }) => ( + { + table.toggleAllRowsSelected(!!value); + }} + aria-label={t("common.aria.selectAll")} + /> + ), + cell: ({ row }) => ( + { + row.toggleSelected(!!value); + }} + aria-label={t("common.aria.selectRow")} + /> + ), + }, + { + accessorKey: "name", + enableSorting: true, + sortingFn: "alphanumeric", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vpn = row.original; + const syncDot = getSyncStatusDot( + vpn, + vpnSyncStatus[vpn.id], + t, + vpnSyncErrors[vpn.id], + ); + return ( +
+ + +
+ + +

{syncDot.tooltip}

+
+ + {vpn.name} +
+ ); + }, + }, + { + id: "type", + enableSorting: false, + header: () => t("common.labels.type"), + cell: () => WG, + }, + { + id: "usage", + enableSorting: false, + header: () => t("proxies.management.usage"), + cell: ({ row }) => ( + {vpnUsage[row.original.id] ?? 0} + ), + }, + { + id: "sync", + enableSorting: false, + header: () => t("proxies.management.syncCol"), + cell: ({ row }) => { + const vpn = row.original; + const locked = vpnInUse[vpn.id]; + return ( + + + + void handleToggleVpnSync(vpn)} + disabled={isTogglingVpnSync[vpn.id] || locked} + /> + + + + {locked ? ( +

{t("syncTooltips.lockedInUse")}

+ ) : ( +

+ {vpn.sync_enabled + ? t("syncTooltips.disable") + : t("syncTooltips.enable")} +

+ )} +
+
+ ); + }, + }, + { + id: "actions", + enableSorting: false, + header: () => t("common.labels.actions"), + cell: ({ row }) => { + const vpn = row.original; + return ( +
+ + + + + + +

{t("vpns.management.editVpn")}

+
+
+ + + + + + + + {(vpnUsage[vpn.id] ?? 0) > 0 ? ( +

+ {(vpnUsage[vpn.id] ?? 0) === 1 + ? t("vpns.management.cannotDelete_one", { + count: vpnUsage[vpn.id], + }) + : t("vpns.management.cannotDelete_other", { + count: vpnUsage[vpn.id], + })} +

+ ) : ( +

{t("vpns.management.deleteVpn")}

+ )} +
+
+
+ ); + }, + }, + ], + [ + t, + vpnSyncStatus, + vpnSyncErrors, + vpnUsage, + isTogglingVpnSync, + vpnInUse, + checkingVpnId, + handleToggleVpnSync, + handleEditVpn, + handleDeleteVpn, + ], + ); + + const vpnsTable = useReactTable({ + data: vpnConfigs, + columns: vpnColumns, + state: { + sorting: vpnsSorting, + rowSelection: vpnsRowSelection, + }, + onSortingChange: setVpnsSorting, + onRowSelectionChange: setVpnsRowSelection, + enableRowSelection: (row) => !vpnInUse[row.original.id], + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: (row) => row.id, + }); + + const selectedProxies = proxiesTable + .getFilteredSelectedRowModel() + .rows.map((row) => row.original); + const selectedVpns = vpnsTable + .getFilteredSelectedRowModel() + .rows.map((row) => row.original); + + const handleBulkDeleteProxies = useCallback(async () => { + if (selectedProxies.length === 0) return; + setIsBulkDeletingProxies(true); + try { + const results = await Promise.allSettled( + selectedProxies.map((proxy) => + invoke("delete_stored_proxy", { proxyId: proxy.id }), + ), + ); + const failed = results.filter((r) => r.status === "rejected").length; + const succeeded = results.length - failed; + if (succeeded > 0) { + toast.success(t("proxies.management.deleteSuccess")); + } + if (failed > 0) { + toast.error(t("proxies.management.deleteFailed")); + } + await emit("stored-proxies-changed"); + setProxiesRowSelection({}); + } finally { + setIsBulkDeletingProxies(false); + setShowBulkDeleteProxiesDialog(false); + } + }, [selectedProxies, t]); + + const handleBulkDeleteVpns = useCallback(async () => { + if (selectedVpns.length === 0) return; + setIsBulkDeletingVpns(true); + try { + const results = await Promise.allSettled( + selectedVpns.map((vpn) => + invoke("delete_vpn_config", { vpnId: vpn.id }), + ), + ); + const failed = results.filter((r) => r.status === "rejected").length; + const succeeded = results.length - failed; + if (succeeded > 0) { + toast.success(t("vpns.management.deleteSuccess")); + } + if (failed > 0) { + toast.error(t("vpns.management.deleteFailed")); + } + await emit("vpn-configs-changed"); + setVpnsRowSelection({}); + } finally { + setIsBulkDeletingVpns(false); + setShowBulkDeleteVpnsDialog(false); + } + }, [selectedVpns, t]); + + // Bulk-toggle sync: if every selectable row has sync ON, turn them all + // OFF; otherwise turn them all ON. Items locked by a synced profile + // (proxyInUse / vpnInUse) are skipped silently when the target is OFF. + const handleBulkToggleProxiesSync = useCallback(async () => { + if (selectedProxies.length === 0) return; + const allOn = selectedProxies.every((p) => p.sync_enabled); + const targetEnabled = !allOn; + const targets = selectedProxies.filter((p) => + targetEnabled ? !p.sync_enabled : p.sync_enabled && !proxyInUse[p.id], + ); + if (targets.length === 0) return; + const results = await Promise.allSettled( + targets.map((proxy) => + invoke("set_proxy_sync_enabled", { + proxyId: proxy.id, + enabled: targetEnabled, + }), + ), + ); + const failed = results.filter((r) => r.status === "rejected").length; + if (failed > 0) { + showErrorToast(t("proxies.management.updateSyncFailed")); + } else { + showSuccessToast( + targetEnabled + ? t("proxies.management.syncEnabled") + : t("proxies.management.syncDisabled"), + ); + } + await emit("stored-proxies-changed"); + }, [selectedProxies, proxyInUse, t]); + + const handleBulkToggleVpnsSync = useCallback(async () => { + if (selectedVpns.length === 0) return; + const allOn = selectedVpns.every((v) => v.sync_enabled); + const targetEnabled = !allOn; + const targets = selectedVpns.filter((v) => + targetEnabled ? !v.sync_enabled : v.sync_enabled && !vpnInUse[v.id], + ); + if (targets.length === 0) return; + const results = await Promise.allSettled( + targets.map((vpn) => + invoke("set_vpn_sync_enabled", { + vpnId: vpn.id, + enabled: targetEnabled, + }), + ), + ); + const failed = results.filter((r) => r.status === "rejected").length; + if (failed > 0) { + showErrorToast(t("vpns.management.updateSyncFailed")); + } else { + showSuccessToast( + targetEnabled + ? t("proxies.management.syncEnabled") + : t("proxies.management.syncDisabled"), + ); + } + await emit("vpn-configs-changed"); + }, [selectedVpns, vpnInUse, t]); + return ( <> @@ -408,469 +1065,251 @@ export function ProxyManagementDialog({ )} - - - setActiveTab(v as "proxies" | "vpns")} + className="flex-1 min-h-0 flex flex-col" + > +
+ + + {t("proxies.management.tabProxies")} + + {storedProxies.length} + + + + {t("proxies.management.tabVpns")} + + {vpnConfigs.length} + + + +
+ {activeTab === "proxies" && ( + <> + { + setShowImportDialog(true); + }} + className="flex gap-2 items-center" + > + + {t("common.buttons.import")} + + { + setShowExportDialog(true); + }} + className="flex gap-2 items-center" + disabled={storedProxies.length === 0} + > + + {t("common.buttons.export")} + + + + {t("proxies.management.newProxy")} + + )} - > - - {t("proxies.management.tabProxies")} - - - {t("proxies.management.tabVpns")} - - - - -
-
-
- { - setShowImportDialog(true); - }} - className="flex gap-2 items-center" - > - - {t("common.buttons.import")} - - { - setShowExportDialog(true); - }} - className="flex gap-2 items-center" - disabled={storedProxies.length === 0} - > - - {t("common.buttons.export")} - -
-
- - - {t("proxies.management.create")} - -
-
- - {isLoading ? ( -
- {t("proxies.management.loading")} -
- ) : storedProxies.length === 0 ? ( -
- {t("proxies.management.noneCreated")} -
- ) : ( -
-
- - - {t("common.labels.name")} - - {t("proxies.management.usage")} - - - {t("proxies.management.syncCol")} - - - {t("common.labels.actions")} - - - - - {storedProxies.map((proxy) => { - const syncDot = getSyncStatusDot( - proxy, - proxySyncStatus[proxy.id], - t, - proxySyncErrors[proxy.id], - ); - return ( - - -
- - -
- - -

{syncDot.tooltip}

-
- - {proxy.name} -
- - - - {proxyUsage[proxy.id] ?? 0} - - - - - -
- - void handleToggleSync(proxy) - } - disabled={ - isTogglingSync[proxy.id] || - proxyInUse[proxy.id] - } - /> -
-
- - {proxyInUse[proxy.id] ? ( -

- {t( - "proxies.management.syncCannotDisable", - )} -

- ) : ( -

- {proxy.sync_enabled - ? t( - "proxies.management.disableSync", - ) - : t( - "proxies.management.enableSync", - )} -

- )} -
-
-
- -
- { - setProxyCheckResults((prev) => ({ - ...prev, - [proxy.id]: result, - })); - }} - onCheckFailed={(result) => { - setProxyCheckResults((prev) => ({ - ...prev, - [proxy.id]: result, - })); - }} - /> - - - - - -

- {t("proxies.management.editProxy")} -

-
-
- - - - - - - - {(proxyUsage[proxy.id] ?? 0) > 0 ? ( -

- {(proxyUsage[proxy.id] ?? 0) === 1 - ? t( - "proxies.management.cannotDelete_one", - { - count: proxyUsage[proxy.id], - }, - ) - : t( - "proxies.management.cannotDelete_other", - { - count: proxyUsage[proxy.id], - }, - )} -

- ) : ( -

- {t( - "proxies.management.deleteProxy", - )} -

- )} -
-
-
-
- - ); - })} - -
-
- )} -
- - - -
-
-
- { - setShowVpnImportDialog(true); - }} - className="flex gap-2 items-center" - > - - {t("common.buttons.import")} - -
+ {activeTab === "vpns" && ( + <> + { + setShowVpnImportDialog(true); + }} + className="flex gap-2 items-center" + > + + {t("common.buttons.import")} + - {t("proxies.management.create")} + {t("proxies.management.newVpn")} -
+ + )} +
+
- {isLoadingVpns ? ( -
- {t("vpns.management.loading")} -
- ) : vpnConfigs.length === 0 ? ( -
- {t("vpns.management.noneCreated")} -
- ) : ( -
- - - - {t("common.labels.name")} - - {t("common.labels.type")} - - - {t("proxies.management.usage")} - - - {t("proxies.management.syncCol")} - - - {t("common.labels.actions")} - + +
+ {isLoading ? ( +
+ {t("proxies.management.loading")} +
+ ) : storedProxies.length === 0 ? ( +
+ {t("proxies.management.noneCreated")} +
+ ) : ( + +
+ + {proxiesTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} - - - {vpnConfigs.map((vpn) => { - const syncDot = getSyncStatusDot( - vpn, - vpnSyncStatus[vpn.id], - t, - vpnSyncErrors[vpn.id], - ); - return ( - - -
- - -
- - -

{syncDot.tooltip}

-
- - {vpn.name} -
- - - WG - - - - {vpnUsage[vpn.id] ?? 0} - - - - - -
- - void handleToggleVpnSync(vpn) - } - disabled={ - isTogglingVpnSync[vpn.id] || - vpnInUse[vpn.id] - } - /> -
-
- - {vpnInUse[vpn.id] ? ( -

- {t( - "vpns.management.syncCannotDisable", - )} -

- ) : ( -

- {vpn.sync_enabled - ? t( - "proxies.management.disableSync", - ) - : t( - "proxies.management.enableSync", - )} -

- )} -
-
-
- -
- - - - - - -

{t("vpns.management.editVpn")}

-
-
- - - - - - - - {(vpnUsage[vpn.id] ?? 0) > 0 ? ( -

- {(vpnUsage[vpn.id] ?? 0) === 1 - ? t( - "vpns.management.cannotDelete_one", - { count: vpnUsage[vpn.id] }, - ) - : t( - "vpns.management.cannotDelete_other", - { count: vpnUsage[vpn.id] }, - )} -

- ) : ( -

- {t("vpns.management.deleteVpn")} -

- )} -
-
-
-
- - ); - })} - -
-
- )} - - - - + ))} + + + {proxiesTable.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + + + + )} + + + + +
+ {isLoadingVpns ? ( +
+ {t("vpns.management.loading")} +
+ ) : vpnConfigs.length === 0 ? ( +
+ {t("vpns.management.noneCreated")} +
+ ) : ( + + + + {vpnsTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {vpnsTable.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + +
+
+ )} +
+
+ {!subPage && ( @@ -936,6 +1375,84 @@ export function ProxyManagementDialog({ setShowVpnImportDialog(false); }} /> + {isOpen && activeTab === "proxies" && ( + + + void handleBulkToggleProxiesSync()} + size="icon" + > + + + { + setShowBulkDeleteProxiesDialog(true); + }} + size="icon" + variant="destructive" + className="border-destructive bg-destructive/50 hover:bg-destructive/70" + > + + + + )} + {isOpen && activeTab === "vpns" && ( + + + void handleBulkToggleVpnsSync()} + size="icon" + > + + + { + setShowBulkDeleteVpnsDialog(true); + }} + size="icon" + variant="destructive" + className="border-destructive bg-destructive/50 hover:bg-destructive/70" + > + + + + )} + { + setShowBulkDeleteProxiesDialog(false); + }} + onConfirm={handleBulkDeleteProxies} + title={t("proxies.bulkDelete.proxiesTitle")} + description={t("proxies.bulkDelete.proxiesDescription", { + count: selectedProxies.length, + names: selectedProxies.map((p) => p.name).join(", "), + })} + confirmButtonText={t("proxies.bulkDelete.confirmButton", { + count: selectedProxies.length, + })} + isLoading={isBulkDeletingProxies} + /> + { + setShowBulkDeleteVpnsDialog(false); + }} + onConfirm={handleBulkDeleteVpns} + title={t("proxies.bulkDelete.vpnsTitle")} + description={t("proxies.bulkDelete.vpnsDescription", { + count: selectedVpns.length, + names: selectedVpns.map((v) => v.name).join(", "), + })} + confirmButtonText={t("proxies.bulkDelete.confirmButton", { + count: selectedVpns.length, + })} + isLoading={isBulkDeletingVpns} + /> ); } diff --git a/src/components/rail-nav.tsx b/src/components/rail-nav.tsx index fa9896c..f6152b4 100644 --- a/src/components/rail-nav.tsx +++ b/src/components/rail-nav.tsx @@ -236,9 +236,11 @@ interface RailItem { const TOP_ITEMS: RailItem[] = [ { page: "profiles", Icon: LuUser, labelKey: "rail.profiles" }, - { page: "proxies", Icon: FiWifi, labelKey: "rail.proxies" }, + { page: "proxies", Icon: FiWifi, labelKey: "rail.network" }, { page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" }, { page: "groups", Icon: LuUsers, labelKey: "rail.groups" }, + { page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" }, + { page: "account", Icon: LuCloud, labelKey: "rail.account" }, ]; interface MoreMenuItem { @@ -255,18 +257,6 @@ const MORE_ITEMS: MoreMenuItem[] = [ labelKey: "rail.more.importProfile", hintKey: "rail.more.importProfileHint", }, - { - page: "integrations", - Icon: LuPlug, - labelKey: "rail.more.integrations", - hintKey: "rail.more.integrationsHint", - }, - { - page: "account", - Icon: LuCloud, - labelKey: "rail.more.account", - hintKey: "rail.more.accountHint", - }, ]; export function RailNav({ currentPage, onNavigate }: RailNavProps) { diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index f402932..eeafe88 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -24,6 +24,7 @@ import { import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -132,6 +133,9 @@ export function SettingsDialog({ const [e2eError, setE2eError] = useState(""); const [isSavingE2e, setIsSavingE2e] = useState(false); const [isRemovingE2e, setIsRemovingE2e] = useState(false); + const [isVerifyE2eOpen, setIsVerifyE2eOpen] = useState(false); + const [verifyE2ePassword, setVerifyE2ePassword] = useState(""); + const [isVerifyingE2e, setIsVerifyingE2e] = useState(false); const [systemInfo, setSystemInfo] = useState<{ app_version: string; os: string; @@ -991,7 +995,18 @@ export function SettingsDialog({ {t("settings.encryption.passwordSetDescription")} -
+
+ + { + setIsVerifyingE2e(true); + try { + const ok = await invoke("verify_e2e_password", { + password: verifyE2ePassword, + }); + if (ok) { + showSuccessToast( + t("settings.encryption.validateDialog.matchToast"), + ); + setIsVerifyE2eOpen(false); + setVerifyE2ePassword(""); + } else { + showErrorToast( + t("settings.encryption.validateDialog.mismatchToast"), + ); + } + } catch (error) { + showErrorToast(String(error)); + } finally { + setIsVerifyingE2e(false); + } + }} + > + {t("settings.encryption.validateDialog.submit")} + + + + ); } diff --git a/src/components/traffic-details-dialog.tsx b/src/components/traffic-details-dialog.tsx index 9e2603e..a3413f0 100644 --- a/src/components/traffic-details-dialog.tsx +++ b/src/components/traffic-details-dialog.tsx @@ -23,6 +23,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { FadingScrollArea } from "@/components/ui/fading-scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, @@ -590,7 +591,7 @@ export function TrafficDetailsDialog({

{t("traffic.uniqueIps", { count: stats.unique_ips.length })}

-
+
{stats.unique_ips.map((ip) => ( ))}
-
+
)} diff --git a/src/components/ui/animated-switch.tsx b/src/components/ui/animated-switch.tsx new file mode 100644 index 0000000..5841ae5 --- /dev/null +++ b/src/components/ui/animated-switch.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { motion } from "motion/react"; +import { Switch as SwitchPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const MotionThumb = motion.create(SwitchPrimitive.Thumb); + +type AnimatedSwitchProps = React.ComponentProps; + +/** + * Toggle switch with a thumb that slides between the off (left) and on + * (right) positions and squashes wider while pressed. Animated via Framer + * Motion — no layout shift when the parent's width changes, and the + * pressed state is purely visual so external onCheckedChange semantics + * stay identical to a Radix Switch. + */ +function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) { + return ( + + + + ); +} + +export type { AnimatedSwitchProps }; +export { AnimatedSwitch }; diff --git a/src/components/ui/animated-tabs.tsx b/src/components/ui/animated-tabs.tsx new file mode 100644 index 0000000..bcbfd7e --- /dev/null +++ b/src/components/ui/animated-tabs.tsx @@ -0,0 +1,156 @@ +"use client"; + +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { motion } from "motion/react"; +import * as React from "react"; + +import { useControlledState } from "@/hooks/use-controlled-state"; +import { cn } from "@/lib/utils"; + +interface AnimatedTabsContextValue { + activeValue: string | undefined; + hoveredValue: string | null; + setHoveredValue: (value: string | null) => void; + indicatorId: string; +} + +const AnimatedTabsContext = + React.createContext(null); + +function useAnimatedTabs() { + const ctx = React.useContext(AnimatedTabsContext); + if (!ctx) { + throw new Error( + "AnimatedTabsTrigger must be rendered inside ", + ); + } + return ctx; +} + +type AnimatedTabsProps = React.ComponentProps; + +function AnimatedTabs({ + value: valueProp, + defaultValue, + onValueChange, + children, + ...props +}: AnimatedTabsProps) { + const [activeValue, setActiveValue] = useControlledState({ + value: valueProp, + defaultValue, + onChange: onValueChange, + }); + const [hoveredValue, setHoveredValue] = React.useState(null); + const indicatorId = React.useId(); + + return ( + + + {children} + + + ); +} + +type AnimatedTabsListProps = React.ComponentProps; + +function AnimatedTabsList({ + className, + onMouseLeave, + ...props +}: AnimatedTabsListProps) { + const { setHoveredValue } = useAnimatedTabs(); + return ( + { + setHoveredValue(null); + onMouseLeave?.(event); + }} + {...props} + /> + ); +} + +type AnimatedTabsTriggerProps = React.ComponentProps< + typeof TabsPrimitive.Trigger +>; + +function AnimatedTabsTrigger({ + value, + className, + children, + onMouseEnter, + ...props +}: AnimatedTabsTriggerProps) { + const { activeValue, hoveredValue, setHoveredValue, indicatorId } = + useAnimatedTabs(); + // The visible pill follows hover when present, otherwise sits on the + // active tab. Framer's `layoutId` handles the slide animation between + // mounted instances; only the trigger whose `value` matches `shownValue` + // renders the indicator, so the transition is a single-element move. + const shownValue = hoveredValue ?? activeValue; + const showIndicator = shownValue === value; + const isActive = activeValue === value; + + return ( + { + setHoveredValue(value); + onMouseEnter?.(event); + }} + className={cn( + "relative isolate inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors duration-150", + "text-muted-foreground hover:text-foreground", + isActive && "text-foreground", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", + "disabled:pointer-events-none disabled:opacity-50", + className, + )} + {...props} + > + {showIndicator && ( + + )} + {children} + + ); +} + +const AnimatedTabsContent = TabsPrimitive.Content; + +export type { + AnimatedTabsListProps, + AnimatedTabsProps, + AnimatedTabsTriggerProps, +}; +export { + AnimatedTabs, + AnimatedTabsContent, + AnimatedTabsList, + AnimatedTabsTrigger, +}; diff --git a/src/components/ui/fading-scroll-area.tsx b/src/components/ui/fading-scroll-area.tsx new file mode 100644 index 0000000..05e71db --- /dev/null +++ b/src/components/ui/fading-scroll-area.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { type HTMLAttributes, useRef } from "react"; +import { useScrollFade } from "@/hooks/use-scroll-fade"; +import { cn } from "@/lib/utils"; + +export type FadingScrollAreaProps = HTMLAttributes; + +/** + * Scrollable container with top/bottom fade overlays. The fades only become + * visible when the matching direction is actually scrollable. Use in place + * of `
` for + * lists that should match the borderless aesthetic of the profile table. + */ +export function FadingScrollArea({ + className, + children, + ...props +}: FadingScrollAreaProps) { + const ref = useRef(null); + useScrollFade(ref); + + return ( +
+ {children} +
+ ); +} diff --git a/src/hooks/use-group-events.ts b/src/hooks/use-group-events.ts index f0b00c4..9ff8684 100644 --- a/src/hooks/use-group-events.ts +++ b/src/hooks/use-group-events.ts @@ -38,6 +38,7 @@ export function useGroupEvents() { // Initial load and event listeners setup useEffect(() => { let groupsUnlisten: (() => void) | undefined; + let profilesUnlisten: (() => void) | undefined; const setupListeners = async () => { try { @@ -51,19 +52,13 @@ export function useGroupEvents() { }); // Also listen for profile changes since groups show profile counts - const profilesUnlisten = await listen("profiles-changed", () => { + profilesUnlisten = await listen("profiles-changed", () => { console.log( "Received profiles-changed event, reloading groups for updated counts", ); void loadGroups(); }); - // Store both listeners for cleanup - groupsUnlisten = () => { - groupsUnlisten?.(); - profilesUnlisten(); - }; - console.log("Group event listeners set up successfully"); } catch (err) { console.error("Failed to setup group event listeners:", err); @@ -79,9 +74,17 @@ export function useGroupEvents() { void setupListeners(); - // Cleanup listeners on unmount + // Cleanup listeners on unmount. + // NOTE: the previous version stored both unlisten fns by reassigning + // `groupsUnlisten` to a wrapper that called itself, which produced a + // `Maximum call stack size exceeded` crash whenever this effect tore + // down. React's reconciler then bailed out mid-commit and left stale + // overlay nodes in the DOM, blocking every subsequent click in the + // window. Holding the two unlisten fns in separate locals avoids both + // problems. return () => { if (groupsUnlisten) groupsUnlisten(); + if (profilesUnlisten) profilesUnlisten(); }; }, [loadGroups]); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bdd3d4f..a49c252 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -32,7 +32,8 @@ "downloading": "Downloading...", "minimize": "Minimize", "saving": "Saving…", - "saved": "Saved" + "saved": "Saved", + "copied": "Copied" }, "status": { "active": "Active", @@ -158,7 +159,15 @@ "passwordSaved": "Encryption password set", "passwordMismatch": "Passwords do not match", "passwordTooShort": "Password must be at least 8 characters", - "requiresProOrOwner": "Profile encryption is available for Pro users and team owners." + "requiresProOrOwner": "Profile encryption is available for Pro users and team owners.", + "validatePassword": "Validate", + "validateDialog": { + "title": "Validate Encryption Password", + "description": "Enter your encryption password to verify it matches the one stored on this device.", + "submit": "Validate", + "matchToast": "Password is correct", + "mismatchToast": "Password does not match" + } }, "commercial": { "title": "Commercial License", @@ -376,6 +385,9 @@ "deleteFailed": "Failed to delete proxy", "deleteTitle": "Delete Proxy", "deleteDescription": "This action cannot be undone. This will permanently delete the proxy \"{{name}}\".", + "newProxy": "New proxy", + "newVpn": "New VPN", + "protocolCol": "Protocol", "title": "Proxies & VPNs" }, "add": "Add Proxy", @@ -480,6 +492,13 @@ "continueButton": "Continue", "doneButton": "Done", "failed": "Failed to import proxies" + }, + "bulkDelete": { + "proxiesTitle": "Delete Selected Proxies", + "proxiesDescription": "This action cannot be undone. This will permanently delete {{count}} proxy(s): {{names}}.", + "vpnsTitle": "Delete Selected VPNs", + "vpnsDescription": "This action cannot be undone. This will permanently delete {{count}} VPN(s): {{names}}.", + "confirmButton": "Delete {{count}}" } }, "groups": { @@ -488,10 +507,8 @@ "add": "Add Group", "edit": "Edit Group", "delete": "Delete Group", - "defaultGroup": "Default", - "defaultGroupNoGroup": "Default (No Group)", - "moveToDefault": "Move profiles to Default group", - "noGroupDescription": "Profiles without a group will appear in the \"Default\" group.", + "moveToDefault": "Remove profiles from group", + "noGroupDescription": "Profiles without a group appear in the \"All\" filter.", "assignSuccess": "Successfully assigned {{count}} profile(s) to {{group}}", "noGroups": "No groups created", "noGroupsDescription": "Create a group to organize your profiles.", @@ -521,7 +538,6 @@ "loadingProfiles": "Loading associated profiles...", "associatedProfiles": "Associated Profiles ({{count}})", "whatToDoWithProfiles": "What should happen to these profiles?", - "moveToDefaultOption": "Move profiles to Default group", "deleteAlongWithGroup": "Delete profiles along with the group", "noAssociatedProfiles": "This group has no associated profiles.", "deleteGroup": "Delete Group", @@ -530,7 +546,10 @@ "unknownGroup": "Unknown Group", "profileGroupsAriaLabel": "Profile groups", "loading": "Loading groups...", - "all": "All" + "all": "All", + "noGroup": "No group", + "pageTitle": "Profile groups", + "pageDescription": "Profile groups let you organize browsers by client, environment, or use case. Sync groups across devices to share them." }, "sync": { "mode": { @@ -649,7 +668,8 @@ "addedToClaudeCode": "Added to Claude Code", "removedFromClaudeCode": "Removed from Claude Code", "config": "MCP Configuration", - "copyConfig": "Copy Configuration" + "copyConfig": "Copy Configuration", + "clientsLabel": "Clients" }, "tabApi": "Local API", "tabMcp": "MCP (AI Assistants)", @@ -675,7 +695,9 @@ "mcpStarted": "MCP server started on port {{port}}", "mcpStopped": "MCP server stopped", "mcpToggleFailed": "Failed to toggle MCP server", - "openSettings": "Open Integrations Settings" + "openSettings": "Open Integrations Settings", + "apiRunningOn": "Running on", + "apiExampleRequest": "Example request" }, "import": { "title": "Import Profile", @@ -1181,6 +1203,7 @@ "empty": "No extensions uploaded yet.", "noGroups": "No extension groups created yet.", "createGroup": "Create Group", + "newGroup": "New group", "addToGroup": "Add extension...", "removeFromGroup": "Remove from group", "deleteGroup": "Delete group", @@ -1228,7 +1251,14 @@ "syncEnableTooltip": "Enable sync", "syncDisableTooltip": "Disable sync", "loadGroupsFailed": "Failed to load extension groups", - "assignGroupFailed": "Failed to assign extension group" + "assignGroupFailed": "Failed to assign extension group", + "bulkDelete": { + "extensionsTitle": "Delete extensions", + "extensionsDescription": "Delete {{count}} extensions? {{names}}", + "groupsTitle": "Delete extension groups", + "groupsDescription": "Delete {{count}} extension groups? {{names}}", + "confirmButton": "Delete" + } }, "pro": { "badge": "PRO", @@ -1373,7 +1403,11 @@ "waiting": "Waiting to sync", "errorWith": "Sync error: {{error}}", "error": "Sync error", - "notSynced": "Not synced" + "notSynced": "Not synced", + "enable": "Enable sync", + "disable": "Disable sync", + "lockedInUse": "Sync is locked while in use by a synced profile", + "bulkToggle": "Toggle sync" }, "groupManagement": { "description": "Manage your profile groups", @@ -1387,7 +1421,13 @@ "syncCannotDisable": "Sync cannot be disabled while this group is used by synced profiles", "editGroupTooltip": "Edit group", "deleteGroupTooltip": "Delete group", - "loadFailed": "Failed to load groups" + "loadFailed": "Failed to load groups", + "bulkDelete": { + "title": "Delete groups", + "description": "Are you sure you want to delete {{count}} groups? {{names}}. Profiles will be moved to Default.", + "description_one": "Are you sure you want to delete {{count}} group? {{names}}. Profiles will be moved to Default.", + "confirmButton": "Delete groups" + } }, "proxyAssignment": { "title": "Assign Proxy / VPN", @@ -1721,7 +1761,14 @@ "modes": { "set": "Set", "change": "Change", - "remove": "Remove" + "remove": "Remove", + "validate": "Validate" + }, + "verifyDialog": { + "title": "Validate Profile Password", + "description": "Enter the profile password to confirm it matches the one stored on disk.", + "submit": "Validate", + "matchToast": "Password is correct" } }, "backendErrors": { @@ -1749,7 +1796,6 @@ }, "rail": { "profiles": "Profiles", - "proxies": "Proxies", "extensions": "Extensions", "groups": "Groups", "settings": "Settings", @@ -1757,18 +1803,17 @@ "label": "More", "closeAriaLabel": "Close menu", "importProfile": "Import profile", - "importProfileHint": "Bring profiles from another tool", - "integrations": "Integrations", - "integrationsHint": "Slack, MCP, automations", - "account": "Account", - "accountHint": "Cloud, billing, sign-in" - } + "importProfileHint": "Bring profiles from another tool" + }, + "network": "Network", + "integrations": "Integrations", + "account": "Account" }, "pageTitle": { - "proxies": "Proxies", + "proxies": "Network", "extensions": "Extensions", "groups": "Groups", - "vpns": "VPNs", + "vpns": "Network", "settings": "Settings", "integrations": "Integrations", "account": "Account", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 6024138..d70c0a0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -32,7 +32,8 @@ "downloading": "Descargando...", "minimize": "Minimizar", "saving": "Guardando…", - "saved": "Guardado" + "saved": "Guardado", + "copied": "Copiado" }, "status": { "active": "Activo", @@ -158,7 +159,15 @@ "passwordSaved": "Contraseña de cifrado establecida", "passwordMismatch": "Las contraseñas no coinciden", "passwordTooShort": "La contraseña debe tener al menos 8 caracteres", - "requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos." + "requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos.", + "validatePassword": "Validar", + "validateDialog": { + "title": "Validar contraseña de cifrado", + "description": "Introduce tu contraseña de cifrado para verificar que coincide con la almacenada en este dispositivo.", + "submit": "Validar", + "matchToast": "La contraseña es correcta", + "mismatchToast": "La contraseña no coincide" + } }, "commercial": { "title": "Licencia Comercial", @@ -376,6 +385,9 @@ "deleteFailed": "Error al eliminar el proxy", "deleteTitle": "Eliminar proxy", "deleteDescription": "Esta acción no se puede deshacer. Se eliminará permanentemente el proxy \"{{name}}\".", + "newProxy": "Nuevo proxy", + "newVpn": "Nueva VPN", + "protocolCol": "Protocolo", "title": "Proxies y VPN" }, "add": "Agregar Proxy", @@ -480,6 +492,13 @@ "continueButton": "Continuar", "doneButton": "Hecho", "failed": "Error al importar los proxies" + }, + "bulkDelete": { + "proxiesTitle": "Eliminar proxies seleccionados", + "proxiesDescription": "Esta acción no se puede deshacer. Se eliminarán permanentemente {{count}} proxy(s): {{names}}.", + "vpnsTitle": "Eliminar VPN seleccionadas", + "vpnsDescription": "Esta acción no se puede deshacer. Se eliminarán permanentemente {{count}} VPN(s): {{names}}.", + "confirmButton": "Eliminar {{count}}" } }, "groups": { @@ -488,10 +507,8 @@ "add": "Agregar Grupo", "edit": "Editar Grupo", "delete": "Eliminar Grupo", - "defaultGroup": "Predeterminado", - "defaultGroupNoGroup": "Predeterminado (Sin Grupo)", - "moveToDefault": "Mover perfiles al grupo Predeterminado", - "noGroupDescription": "Los perfiles sin grupo aparecerán en el grupo \"Predeterminado\".", + "moveToDefault": "Quitar perfiles del grupo", + "noGroupDescription": "Los perfiles sin grupo aparecen en el filtro «Todos».", "assignSuccess": "Se asignaron {{count}} perfil(es) a {{group}} exitosamente", "noGroups": "No hay grupos creados", "noGroupsDescription": "Crea un grupo para organizar tus perfiles.", @@ -521,7 +538,6 @@ "loadingProfiles": "Cargando perfiles asociados...", "associatedProfiles": "Perfiles Asociados ({{count}})", "whatToDoWithProfiles": "¿Qué hacer con estos perfiles?", - "moveToDefaultOption": "Mover perfiles al grupo Predeterminado", "deleteAlongWithGroup": "Eliminar perfiles junto con el grupo", "noAssociatedProfiles": "Este grupo no tiene perfiles asociados.", "deleteGroup": "Eliminar Grupo", @@ -530,7 +546,10 @@ "unknownGroup": "Grupo desconocido", "profileGroupsAriaLabel": "Grupos de perfiles", "loading": "Cargando grupos...", - "all": "Todos" + "all": "Todos", + "noGroup": "Sin grupo", + "pageTitle": "Grupos de perfiles", + "pageDescription": "Los grupos de perfiles te permiten organizar los navegadores por cliente, entorno o caso de uso. Sincroniza los grupos entre dispositivos para compartirlos." }, "sync": { "mode": { @@ -649,7 +668,8 @@ "addedToClaudeCode": "Agregado a Claude Code", "removedFromClaudeCode": "Eliminado de Claude Code", "config": "Configuración MCP", - "copyConfig": "Copiar Configuración" + "copyConfig": "Copiar Configuración", + "clientsLabel": "Clientes" }, "tabApi": "API local", "tabMcp": "MCP (asistentes IA)", @@ -675,7 +695,9 @@ "mcpStarted": "Servidor MCP iniciado en puerto {{port}}", "mcpStopped": "Servidor MCP detenido", "mcpToggleFailed": "Error al alternar el servidor MCP", - "openSettings": "Abrir configuración de integraciones" + "openSettings": "Abrir configuración de integraciones", + "apiRunningOn": "Ejecutándose en", + "apiExampleRequest": "Solicitud de ejemplo" }, "import": { "title": "Importar Perfil", @@ -1181,6 +1203,7 @@ "empty": "No se han subido extensiones aún.", "noGroups": "No se han creado grupos de extensiones aún.", "createGroup": "Crear Grupo", + "newGroup": "Nuevo grupo", "addToGroup": "Agregar extensión...", "removeFromGroup": "Eliminar del grupo", "deleteGroup": "Eliminar grupo", @@ -1228,7 +1251,14 @@ "syncEnableTooltip": "Habilitar sincronización", "syncDisableTooltip": "Deshabilitar sincronización", "loadGroupsFailed": "Error al cargar grupos de extensiones", - "assignGroupFailed": "Error al asignar grupo de extensiones" + "assignGroupFailed": "Error al asignar grupo de extensiones", + "bulkDelete": { + "extensionsTitle": "Eliminar extensiones", + "extensionsDescription": "¿Eliminar {{count}} extensiones? {{names}}", + "groupsTitle": "Eliminar grupos de extensiones", + "groupsDescription": "¿Eliminar {{count}} grupos de extensiones? {{names}}", + "confirmButton": "Eliminar" + } }, "pro": { "badge": "PRO", @@ -1373,7 +1403,11 @@ "waiting": "En espera de sincronización", "errorWith": "Error de sincronización: {{error}}", "error": "Error de sincronización", - "notSynced": "Sin sincronizar" + "notSynced": "Sin sincronizar", + "enable": "Activar sincronización", + "disable": "Desactivar sincronización", + "lockedInUse": "La sincronización está bloqueada mientras un perfil sincronizado lo use", + "bulkToggle": "Alternar sincronización" }, "groupManagement": { "description": "Administra tus grupos de perfiles", @@ -1387,7 +1421,13 @@ "syncCannotDisable": "No se puede desactivar la sincronización mientras este grupo esté en uso por perfiles sincronizados", "editGroupTooltip": "Editar grupo", "deleteGroupTooltip": "Eliminar grupo", - "loadFailed": "Error al cargar los grupos" + "loadFailed": "Error al cargar los grupos", + "bulkDelete": { + "title": "Eliminar grupos", + "description": "¿Estás seguro de que quieres eliminar {{count}} grupos? {{names}}. Los perfiles se moverán a Predeterminado.", + "description_one": "¿Estás seguro de que quieres eliminar {{count}} grupo? {{names}}. Los perfiles se moverán a Predeterminado.", + "confirmButton": "Eliminar grupos" + } }, "proxyAssignment": { "title": "Asignar proxy / VPN", @@ -1721,7 +1761,14 @@ "modes": { "set": "Establecer", "change": "Cambiar", - "remove": "Quitar" + "remove": "Quitar", + "validate": "Validar" + }, + "verifyDialog": { + "title": "Validar contraseña del perfil", + "description": "Introduce la contraseña del perfil para confirmar que coincide con la almacenada en disco.", + "submit": "Validar", + "matchToast": "La contraseña es correcta" } }, "backendErrors": { @@ -1749,7 +1796,6 @@ }, "rail": { "profiles": "Perfiles", - "proxies": "Proxies", "extensions": "Extensiones", "groups": "Grupos", "settings": "Ajustes", @@ -1757,18 +1803,17 @@ "label": "Más", "closeAriaLabel": "Cerrar menú", "importProfile": "Importar perfil", - "importProfileHint": "Trae perfiles de otra herramienta", - "integrations": "Integraciones", - "integrationsHint": "Slack, MCP, automatizaciones", - "account": "Cuenta", - "accountHint": "Nube, facturación, sesión" - } + "importProfileHint": "Trae perfiles de otra herramienta" + }, + "network": "Red", + "integrations": "Integraciones", + "account": "Cuenta" }, "pageTitle": { - "proxies": "Proxies", + "proxies": "Red", "extensions": "Extensiones", "groups": "Grupos", - "vpns": "VPN", + "vpns": "Red", "settings": "Ajustes", "integrations": "Integraciones", "account": "Cuenta", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9eca11c..00eeffa 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -32,7 +32,8 @@ "downloading": "Téléchargement...", "minimize": "Réduire", "saving": "Enregistrement…", - "saved": "Enregistré" + "saved": "Enregistré", + "copied": "Copié" }, "status": { "active": "Actif", @@ -158,7 +159,15 @@ "passwordSaved": "Mot de passe de chiffrement défini", "passwordMismatch": "Les mots de passe ne correspondent pas", "passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères", - "requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe." + "requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe.", + "validatePassword": "Valider", + "validateDialog": { + "title": "Valider le mot de passe de chiffrement", + "description": "Saisissez votre mot de passe de chiffrement pour vérifier qu'il correspond à celui enregistré sur cet appareil.", + "submit": "Valider", + "matchToast": "Le mot de passe est correct", + "mismatchToast": "Le mot de passe ne correspond pas" + } }, "commercial": { "title": "Licence commerciale", @@ -376,6 +385,9 @@ "deleteFailed": "Échec de la suppression du proxy", "deleteTitle": "Supprimer le proxy", "deleteDescription": "Cette action est irréversible. Le proxy « {{name}} » sera supprimé définitivement.", + "newProxy": "Nouveau proxy", + "newVpn": "Nouveau VPN", + "protocolCol": "Protocole", "title": "Proxys et VPN" }, "add": "Ajouter un proxy", @@ -480,6 +492,13 @@ "continueButton": "Continuer", "doneButton": "Terminé", "failed": "Échec de l'import des proxys" + }, + "bulkDelete": { + "proxiesTitle": "Supprimer les proxys sélectionnés", + "proxiesDescription": "Cette action est irréversible. {{count}} proxy(s) seront définitivement supprimés : {{names}}.", + "vpnsTitle": "Supprimer les VPN sélectionnés", + "vpnsDescription": "Cette action est irréversible. {{count}} VPN(s) seront définitivement supprimés : {{names}}.", + "confirmButton": "Supprimer {{count}}" } }, "groups": { @@ -488,10 +507,8 @@ "add": "Ajouter un groupe", "edit": "Modifier le groupe", "delete": "Supprimer le groupe", - "defaultGroup": "Par défaut", - "defaultGroupNoGroup": "Par défaut (Aucun groupe)", - "moveToDefault": "Déplacer les profils vers le groupe Par défaut", - "noGroupDescription": "Les profils sans groupe apparaîtront dans le groupe « Par défaut ».", + "moveToDefault": "Retirer les profils du groupe", + "noGroupDescription": "Les profils sans groupe apparaissent dans le filtre « Tous ».", "assignSuccess": "{{count}} profil(s) assigné(s) à {{group}} avec succès", "noGroups": "Aucun groupe créé", "noGroupsDescription": "Créez un groupe pour organiser vos profils.", @@ -521,7 +538,6 @@ "loadingProfiles": "Chargement des profils associés...", "associatedProfiles": "Profils Associés ({{count}})", "whatToDoWithProfiles": "Que faire de ces profils ?", - "moveToDefaultOption": "Déplacer les profils vers le groupe Par défaut", "deleteAlongWithGroup": "Supprimer les profils avec le groupe", "noAssociatedProfiles": "Ce groupe n'a pas de profils associés.", "deleteGroup": "Supprimer le Groupe", @@ -530,7 +546,10 @@ "unknownGroup": "Groupe inconnu", "profileGroupsAriaLabel": "Groupes de profils", "loading": "Chargement des groupes...", - "all": "Tous" + "all": "Tous", + "noGroup": "Aucun groupe", + "pageTitle": "Groupes de profils", + "pageDescription": "Les groupes de profils vous permettent d'organiser les navigateurs par client, environnement ou cas d'usage. Synchronisez les groupes entre appareils pour les partager." }, "sync": { "mode": { @@ -649,7 +668,8 @@ "addedToClaudeCode": "Ajouté à Claude Code", "removedFromClaudeCode": "Supprimé de Claude Code", "config": "Configuration MCP", - "copyConfig": "Copier la configuration" + "copyConfig": "Copier la configuration", + "clientsLabel": "Clients" }, "tabApi": "API locale", "tabMcp": "MCP (Assistants IA)", @@ -675,7 +695,9 @@ "mcpStarted": "Serveur MCP démarré sur le port {{port}}", "mcpStopped": "Serveur MCP arrêté", "mcpToggleFailed": "Échec du basculement du serveur MCP", - "openSettings": "Ouvrir les paramètres d'intégrations" + "openSettings": "Ouvrir les paramètres d'intégrations", + "apiRunningOn": "En cours sur", + "apiExampleRequest": "Exemple de requête" }, "import": { "title": "Importer un profil", @@ -1181,6 +1203,7 @@ "empty": "Aucune extension téléchargée pour l'instant.", "noGroups": "Aucun groupe d'extensions créé pour l'instant.", "createGroup": "Créer un Groupe", + "newGroup": "Nouveau groupe", "addToGroup": "Ajouter une extension...", "removeFromGroup": "Retirer du groupe", "deleteGroup": "Supprimer le groupe", @@ -1228,7 +1251,14 @@ "syncEnableTooltip": "Activer la synchronisation", "syncDisableTooltip": "Désactiver la synchronisation", "loadGroupsFailed": "Échec du chargement des groupes d'extensions", - "assignGroupFailed": "Échec de l'attribution du groupe d'extensions" + "assignGroupFailed": "Échec de l'attribution du groupe d'extensions", + "bulkDelete": { + "extensionsTitle": "Supprimer les extensions", + "extensionsDescription": "Supprimer {{count}} extensions ? {{names}}", + "groupsTitle": "Supprimer les groupes d'extensions", + "groupsDescription": "Supprimer {{count}} groupes d'extensions ? {{names}}", + "confirmButton": "Supprimer" + } }, "pro": { "badge": "PRO", @@ -1373,7 +1403,11 @@ "waiting": "En attente de synchronisation", "errorWith": "Erreur de synchronisation : {{error}}", "error": "Erreur de synchronisation", - "notSynced": "Non synchronisé" + "notSynced": "Non synchronisé", + "enable": "Activer la synchronisation", + "disable": "Désactiver la synchronisation", + "lockedInUse": "La synchronisation est verrouillée tant qu'un profil synchronisé l'utilise", + "bulkToggle": "Basculer la synchronisation" }, "groupManagement": { "description": "Gérez vos groupes de profils", @@ -1387,7 +1421,13 @@ "syncCannotDisable": "La sync ne peut pas être désactivée tant que ce groupe est utilisé par des profils synchronisés", "editGroupTooltip": "Modifier le groupe", "deleteGroupTooltip": "Supprimer le groupe", - "loadFailed": "Échec du chargement des groupes" + "loadFailed": "Échec du chargement des groupes", + "bulkDelete": { + "title": "Supprimer les groupes", + "description": "Êtes-vous sûr de vouloir supprimer {{count}} groupes ? {{names}}. Les profils seront déplacés vers Par défaut.", + "description_one": "Êtes-vous sûr de vouloir supprimer {{count}} groupe ? {{names}}. Les profils seront déplacés vers Par défaut.", + "confirmButton": "Supprimer les groupes" + } }, "proxyAssignment": { "title": "Assigner un proxy / VPN", @@ -1721,7 +1761,14 @@ "modes": { "set": "Définir", "change": "Modifier", - "remove": "Supprimer" + "remove": "Supprimer", + "validate": "Valider" + }, + "verifyDialog": { + "title": "Valider le mot de passe du profil", + "description": "Saisissez le mot de passe du profil pour vérifier qu'il correspond à celui enregistré sur le disque.", + "submit": "Valider", + "matchToast": "Le mot de passe est correct" } }, "backendErrors": { @@ -1749,7 +1796,6 @@ }, "rail": { "profiles": "Profils", - "proxies": "Proxys", "extensions": "Extensions", "groups": "Groupes", "settings": "Paramètres", @@ -1757,18 +1803,17 @@ "label": "Plus", "closeAriaLabel": "Fermer le menu", "importProfile": "Importer un profil", - "importProfileHint": "Importer depuis un autre outil", - "integrations": "Intégrations", - "integrationsHint": "Slack, MCP, automatisations", - "account": "Compte", - "accountHint": "Cloud, facturation, connexion" - } + "importProfileHint": "Importer depuis un autre outil" + }, + "network": "Réseau", + "integrations": "Intégrations", + "account": "Compte" }, "pageTitle": { - "proxies": "Proxys", + "proxies": "Réseau", "extensions": "Extensions", "groups": "Groupes", - "vpns": "VPN", + "vpns": "Réseau", "settings": "Paramètres", "integrations": "Intégrations", "account": "Compte", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3e5a435..244ba1d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -32,7 +32,8 @@ "downloading": "ダウンロード中...", "minimize": "最小化", "saving": "保存中…", - "saved": "保存しました" + "saved": "保存しました", + "copied": "コピーしました" }, "status": { "active": "アクティブ", @@ -158,7 +159,15 @@ "passwordSaved": "暗号化パスワードが設定されました", "passwordMismatch": "パスワードが一致しません", "passwordTooShort": "パスワードは8文字以上である必要があります", - "requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。" + "requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。", + "validatePassword": "確認", + "validateDialog": { + "title": "暗号化パスワードを確認", + "description": "このデバイスに保存されているパスワードと一致するか、暗号化パスワードを入力してください。", + "submit": "確認", + "matchToast": "パスワードが一致しました", + "mismatchToast": "パスワードが一致しません" + } }, "commercial": { "title": "商用ライセンス", @@ -376,6 +385,9 @@ "deleteFailed": "プロキシの削除に失敗しました", "deleteTitle": "プロキシを削除", "deleteDescription": "この操作は取り消せません。プロキシ「{{name}}」は完全に削除されます。", + "newProxy": "新しいプロキシ", + "newVpn": "新しいVPN", + "protocolCol": "プロトコル", "title": "プロキシと VPN" }, "add": "プロキシを追加", @@ -480,6 +492,13 @@ "continueButton": "続ける", "doneButton": "完了", "failed": "プロキシのインポートに失敗しました" + }, + "bulkDelete": { + "proxiesTitle": "選択したプロキシを削除", + "proxiesDescription": "この操作は取り消せません。{{count}} 件のプロキシを完全に削除します: {{names}}", + "vpnsTitle": "選択したVPNを削除", + "vpnsDescription": "この操作は取り消せません。{{count}} 件のVPNを完全に削除します: {{names}}", + "confirmButton": "{{count}} 件を削除" } }, "groups": { @@ -488,10 +507,8 @@ "add": "グループを追加", "edit": "グループを編集", "delete": "グループを削除", - "defaultGroup": "デフォルト", - "defaultGroupNoGroup": "デフォルト(グループなし)", - "moveToDefault": "プロファイルをデフォルトグループに移動", - "noGroupDescription": "グループに属していないプロファイルは「デフォルト」グループに表示されます。", + "moveToDefault": "プロファイルをグループから外す", + "noGroupDescription": "グループに属さないプロファイルは「すべて」フィルターに表示されます。", "assignSuccess": "{{count}} 件のプロファイルを {{group}} に割り当てました", "noGroups": "グループがありません", "noGroupsDescription": "プロファイルを整理するためのグループを作成してください。", @@ -521,7 +538,6 @@ "loadingProfiles": "関連するプロファイルを読み込んでいます...", "associatedProfiles": "関連プロファイル ({{count}})", "whatToDoWithProfiles": "これらのプロファイルをどうしますか?", - "moveToDefaultOption": "プロファイルをデフォルトグループに移動", "deleteAlongWithGroup": "プロファイルもグループと一緒に削除", "noAssociatedProfiles": "このグループには関連するプロファイルがありません。", "deleteGroup": "グループを削除", @@ -530,7 +546,10 @@ "unknownGroup": "不明なグループ", "profileGroupsAriaLabel": "プロファイルグループ", "loading": "グループを読み込み中...", - "all": "すべて" + "all": "すべて", + "noGroup": "グループなし", + "pageTitle": "プロファイルグループ", + "pageDescription": "プロファイルグループを使うと、クライアント、環境、用途ごとにブラウザを整理できます。デバイス間でグループを同期して共有しましょう。" }, "sync": { "mode": { @@ -649,7 +668,8 @@ "addedToClaudeCode": "Claude Code に追加しました", "removedFromClaudeCode": "Claude Code から削除しました", "config": "MCP設定", - "copyConfig": "設定をコピー" + "copyConfig": "設定をコピー", + "clientsLabel": "クライアント" }, "tabApi": "ローカル API", "tabMcp": "MCP (AI アシスタント)", @@ -675,7 +695,9 @@ "mcpStarted": "MCP サーバーをポート {{port}} で起動しました", "mcpStopped": "MCP サーバーを停止しました", "mcpToggleFailed": "MCP サーバーの切り替えに失敗しました", - "openSettings": "統合設定を開く" + "openSettings": "統合設定を開く", + "apiRunningOn": "実行中", + "apiExampleRequest": "リクエスト例" }, "import": { "title": "プロファイルをインポート", @@ -1181,6 +1203,7 @@ "empty": "まだ拡張機能がアップロードされていません。", "noGroups": "まだ拡張機能グループが作成されていません。", "createGroup": "グループを作成", + "newGroup": "新しいグループ", "addToGroup": "拡張機能を追加...", "removeFromGroup": "グループから削除", "deleteGroup": "グループを削除", @@ -1228,7 +1251,14 @@ "syncEnableTooltip": "同期を有効にする", "syncDisableTooltip": "同期を無効にする", "loadGroupsFailed": "拡張機能グループの読み込みに失敗しました", - "assignGroupFailed": "拡張機能グループの割り当てに失敗しました" + "assignGroupFailed": "拡張機能グループの割り当てに失敗しました", + "bulkDelete": { + "extensionsTitle": "拡張機能を削除", + "extensionsDescription": "{{count}}件の拡張機能を削除しますか? {{names}}", + "groupsTitle": "拡張機能グループを削除", + "groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}", + "confirmButton": "削除" + } }, "pro": { "badge": "PRO", @@ -1373,7 +1403,11 @@ "waiting": "同期待ち", "errorWith": "同期エラー: {{error}}", "error": "同期エラー", - "notSynced": "未同期" + "notSynced": "未同期", + "enable": "同期を有効化", + "disable": "同期を無効化", + "lockedInUse": "同期されたプロファイルが使用中のため、同期は無効化できません", + "bulkToggle": "同期を切り替え" }, "groupManagement": { "description": "プロファイルのグループを管理します", @@ -1387,7 +1421,13 @@ "syncCannotDisable": "このグループが同期されたプロファイルで使用されている間は同期を無効にできません", "editGroupTooltip": "グループを編集", "deleteGroupTooltip": "グループを削除", - "loadFailed": "グループの読み込みに失敗しました" + "loadFailed": "グループの読み込みに失敗しました", + "bulkDelete": { + "title": "グループを削除", + "description": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。", + "description_one": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。", + "confirmButton": "グループを削除" + } }, "proxyAssignment": { "title": "プロキシ / VPN を割り当てる", @@ -1721,7 +1761,14 @@ "modes": { "set": "設定", "change": "変更", - "remove": "削除" + "remove": "削除", + "validate": "確認" + }, + "verifyDialog": { + "title": "プロファイルパスワードを確認", + "description": "ディスクに保存されているプロファイルパスワードと一致するか入力してください。", + "submit": "確認", + "matchToast": "パスワードが一致しました" } }, "backendErrors": { @@ -1749,7 +1796,6 @@ }, "rail": { "profiles": "プロファイル", - "proxies": "プロキシ", "extensions": "拡張機能", "groups": "グループ", "settings": "設定", @@ -1757,18 +1803,17 @@ "label": "その他", "closeAriaLabel": "メニューを閉じる", "importProfile": "プロファイルをインポート", - "importProfileHint": "別のツールから取り込む", - "integrations": "連携", - "integrationsHint": "Slack、MCP、自動化", - "account": "アカウント", - "accountHint": "クラウド、請求、サインイン" - } + "importProfileHint": "別のツールから取り込む" + }, + "network": "ネットワーク", + "integrations": "連携", + "account": "アカウント" }, "pageTitle": { - "proxies": "プロキシ", + "proxies": "ネットワーク", "extensions": "拡張機能", "groups": "グループ", - "vpns": "VPN", + "vpns": "ネットワーク", "settings": "設定", "integrations": "連携", "account": "アカウント", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index ea217a3..31532ba 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -32,7 +32,8 @@ "downloading": "Baixando...", "minimize": "Minimizar", "saving": "Salvando…", - "saved": "Salvo" + "saved": "Salvo", + "copied": "Copiado" }, "status": { "active": "Ativo", @@ -158,7 +159,15 @@ "passwordSaved": "Senha de criptografia definida", "passwordMismatch": "As senhas não coincidem", "passwordTooShort": "A senha deve ter pelo menos 8 caracteres", - "requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe." + "requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe.", + "validatePassword": "Validar", + "validateDialog": { + "title": "Validar senha de criptografia", + "description": "Digite sua senha de criptografia para verificar se corresponde à armazenada neste dispositivo.", + "submit": "Validar", + "matchToast": "A senha está correta", + "mismatchToast": "A senha não corresponde" + } }, "commercial": { "title": "Licença Comercial", @@ -376,6 +385,9 @@ "deleteFailed": "Falha ao excluir proxy", "deleteTitle": "Excluir proxy", "deleteDescription": "Esta ação não pode ser desfeita. O proxy \"{{name}}\" será excluído permanentemente.", + "newProxy": "Novo proxy", + "newVpn": "Nova VPN", + "protocolCol": "Protocolo", "title": "Proxies e VPNs" }, "add": "Adicionar Proxy", @@ -480,6 +492,13 @@ "continueButton": "Continuar", "doneButton": "Concluído", "failed": "Falha ao importar proxies" + }, + "bulkDelete": { + "proxiesTitle": "Excluir proxies selecionados", + "proxiesDescription": "Esta ação não pode ser desfeita. Isso excluirá permanentemente {{count}} proxy(s): {{names}}.", + "vpnsTitle": "Excluir VPNs selecionadas", + "vpnsDescription": "Esta ação não pode ser desfeita. Isso excluirá permanentemente {{count}} VPN(s): {{names}}.", + "confirmButton": "Excluir {{count}}" } }, "groups": { @@ -488,10 +507,8 @@ "add": "Adicionar Grupo", "edit": "Editar Grupo", "delete": "Excluir Grupo", - "defaultGroup": "Padrão", - "defaultGroupNoGroup": "Padrão (Sem Grupo)", - "moveToDefault": "Mover perfis para o grupo Padrão", - "noGroupDescription": "Perfis sem grupo aparecerão no grupo \"Padrão\".", + "moveToDefault": "Remover perfis do grupo", + "noGroupDescription": "Perfis sem grupo aparecem no filtro \"Todos\".", "assignSuccess": "{{count}} perfil(s) atribuído(s) a {{group}} com sucesso", "noGroups": "Nenhum grupo criado", "noGroupsDescription": "Crie um grupo para organizar seus perfis.", @@ -521,7 +538,6 @@ "loadingProfiles": "Carregando perfis associados...", "associatedProfiles": "Perfis Associados ({{count}})", "whatToDoWithProfiles": "O que fazer com esses perfis?", - "moveToDefaultOption": "Mover perfis para o grupo Padrão", "deleteAlongWithGroup": "Excluir perfis junto com o grupo", "noAssociatedProfiles": "Este grupo não tem perfis associados.", "deleteGroup": "Excluir Grupo", @@ -530,7 +546,10 @@ "unknownGroup": "Grupo desconhecido", "profileGroupsAriaLabel": "Grupos de perfis", "loading": "Carregando grupos...", - "all": "Todos" + "all": "Todos", + "noGroup": "Sem grupo", + "pageTitle": "Grupos de perfis", + "pageDescription": "Os grupos de perfis permitem organizar os navegadores por cliente, ambiente ou caso de uso. Sincronize grupos entre dispositivos para compartilhá-los." }, "sync": { "mode": { @@ -649,7 +668,8 @@ "addedToClaudeCode": "Adicionado ao Claude Code", "removedFromClaudeCode": "Removido do Claude Code", "config": "Configuração MCP", - "copyConfig": "Copiar Configuração" + "copyConfig": "Copiar Configuração", + "clientsLabel": "Clientes" }, "tabApi": "API local", "tabMcp": "MCP (Assistentes de IA)", @@ -675,7 +695,9 @@ "mcpStarted": "Servidor MCP iniciado na porta {{port}}", "mcpStopped": "Servidor MCP parado", "mcpToggleFailed": "Falha ao alternar o servidor MCP", - "openSettings": "Abrir configurações de integrações" + "openSettings": "Abrir configurações de integrações", + "apiRunningOn": "Em execução em", + "apiExampleRequest": "Exemplo de solicitação" }, "import": { "title": "Importar Perfil", @@ -1181,6 +1203,7 @@ "empty": "Nenhuma extensão enviada ainda.", "noGroups": "Nenhum grupo de extensões criado ainda.", "createGroup": "Criar Grupo", + "newGroup": "Novo grupo", "addToGroup": "Adicionar extensão...", "removeFromGroup": "Remover do grupo", "deleteGroup": "Excluir grupo", @@ -1228,7 +1251,14 @@ "syncEnableTooltip": "Ativar sincronização", "syncDisableTooltip": "Desativar sincronização", "loadGroupsFailed": "Falha ao carregar grupos de extensões", - "assignGroupFailed": "Falha ao atribuir grupo de extensões" + "assignGroupFailed": "Falha ao atribuir grupo de extensões", + "bulkDelete": { + "extensionsTitle": "Excluir extensões", + "extensionsDescription": "Excluir {{count}} extensões? {{names}}", + "groupsTitle": "Excluir grupos de extensões", + "groupsDescription": "Excluir {{count}} grupos de extensões? {{names}}", + "confirmButton": "Excluir" + } }, "pro": { "badge": "PRO", @@ -1373,7 +1403,11 @@ "waiting": "Aguardando sincronização", "errorWith": "Erro de sincronização: {{error}}", "error": "Erro de sincronização", - "notSynced": "Não sincronizado" + "notSynced": "Não sincronizado", + "enable": "Ativar sincronização", + "disable": "Desativar sincronização", + "lockedInUse": "A sincronização está bloqueada enquanto um perfil sincronizado a usar", + "bulkToggle": "Alternar sincronização" }, "groupManagement": { "description": "Gerencie seus grupos de perfis", @@ -1387,7 +1421,13 @@ "syncCannotDisable": "A sincronização não pode ser desativada enquanto este grupo estiver em uso por perfis sincronizados", "editGroupTooltip": "Editar grupo", "deleteGroupTooltip": "Excluir grupo", - "loadFailed": "Falha ao carregar grupos" + "loadFailed": "Falha ao carregar grupos", + "bulkDelete": { + "title": "Excluir grupos", + "description": "Tem certeza que deseja excluir {{count}} grupos? {{names}}. Os perfis serão movidos para Padrão.", + "description_one": "Tem certeza que deseja excluir {{count}} grupo? {{names}}. Os perfis serão movidos para Padrão.", + "confirmButton": "Excluir grupos" + } }, "proxyAssignment": { "title": "Atribuir proxy / VPN", @@ -1721,7 +1761,14 @@ "modes": { "set": "Definir", "change": "Alterar", - "remove": "Remover" + "remove": "Remover", + "validate": "Validar" + }, + "verifyDialog": { + "title": "Validar senha do perfil", + "description": "Digite a senha do perfil para confirmar se corresponde à armazenada em disco.", + "submit": "Validar", + "matchToast": "A senha está correta" } }, "backendErrors": { @@ -1749,7 +1796,6 @@ }, "rail": { "profiles": "Perfis", - "proxies": "Proxies", "extensions": "Extensões", "groups": "Grupos", "settings": "Configurações", @@ -1757,18 +1803,17 @@ "label": "Mais", "closeAriaLabel": "Fechar menu", "importProfile": "Importar perfil", - "importProfileHint": "Trazer perfis de outra ferramenta", - "integrations": "Integrações", - "integrationsHint": "Slack, MCP, automações", - "account": "Conta", - "accountHint": "Nuvem, cobrança, login" - } + "importProfileHint": "Trazer perfis de outra ferramenta" + }, + "network": "Rede", + "integrations": "Integrações", + "account": "Conta" }, "pageTitle": { - "proxies": "Proxies", + "proxies": "Rede", "extensions": "Extensões", "groups": "Grupos", - "vpns": "VPN", + "vpns": "Rede", "settings": "Configurações", "integrations": "Integrações", "account": "Conta", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6800af2..00030b9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -32,7 +32,8 @@ "downloading": "Загрузка...", "minimize": "Свернуть", "saving": "Сохраняем…", - "saved": "Сохранено" + "saved": "Сохранено", + "copied": "Скопировано" }, "status": { "active": "Активен", @@ -158,7 +159,15 @@ "passwordSaved": "Пароль шифрования установлен", "passwordMismatch": "Пароли не совпадают", "passwordTooShort": "Пароль должен содержать не менее 8 символов", - "requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд." + "requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд.", + "validatePassword": "Проверить", + "validateDialog": { + "title": "Проверка пароля шифрования", + "description": "Введите пароль шифрования, чтобы убедиться, что он совпадает с сохранённым на этом устройстве.", + "submit": "Проверить", + "matchToast": "Пароль верен", + "mismatchToast": "Пароль не совпадает" + } }, "commercial": { "title": "Коммерческая лицензия", @@ -376,6 +385,9 @@ "deleteFailed": "Не удалось удалить прокси", "deleteTitle": "Удалить прокси", "deleteDescription": "Это действие нельзя отменить. Прокси «{{name}}» будет удален навсегда.", + "newProxy": "Новый прокси", + "newVpn": "Новый VPN", + "protocolCol": "Протокол", "title": "Прокси и VPN" }, "add": "Добавить прокси", @@ -480,6 +492,13 @@ "continueButton": "Продолжить", "doneButton": "Готово", "failed": "Не удалось импортировать прокси" + }, + "bulkDelete": { + "proxiesTitle": "Удалить выбранные прокси", + "proxiesDescription": "Это действие нельзя отменить. Будет безвозвратно удалено прокси: {{count}} — {{names}}.", + "vpnsTitle": "Удалить выбранные VPN", + "vpnsDescription": "Это действие нельзя отменить. Будет безвозвратно удалено VPN: {{count}} — {{names}}.", + "confirmButton": "Удалить {{count}}" } }, "groups": { @@ -488,10 +507,8 @@ "add": "Добавить группу", "edit": "Редактировать группу", "delete": "Удалить группу", - "defaultGroup": "По умолчанию", - "defaultGroupNoGroup": "По умолчанию (Без группы)", - "moveToDefault": "Переместить профили в группу по умолчанию", - "noGroupDescription": "Профили без группы будут отображаться в группе «По умолчанию».", + "moveToDefault": "Убрать профили из группы", + "noGroupDescription": "Профили без группы отображаются в фильтре «Все».", "assignSuccess": "Успешно назначено {{count}} профиль(ей) в {{group}}", "noGroups": "Группы не созданы", "noGroupsDescription": "Создайте группу для организации профилей.", @@ -521,7 +538,6 @@ "loadingProfiles": "Загрузка связанных профилей...", "associatedProfiles": "Связанные профили ({{count}})", "whatToDoWithProfiles": "Что сделать с этими профилями?", - "moveToDefaultOption": "Переместить профили в группу По умолчанию", "deleteAlongWithGroup": "Удалить профили вместе с группой", "noAssociatedProfiles": "У этой группы нет связанных профилей.", "deleteGroup": "Удалить группу", @@ -530,7 +546,10 @@ "unknownGroup": "Неизвестная группа", "profileGroupsAriaLabel": "Группы профилей", "loading": "Загрузка групп...", - "all": "Все" + "all": "Все", + "noGroup": "Без группы", + "pageTitle": "Группы профилей", + "pageDescription": "Группы профилей позволяют организовать браузеры по клиенту, окружению или сценарию использования. Синхронизируйте группы между устройствами, чтобы делиться ими." }, "sync": { "mode": { @@ -649,7 +668,8 @@ "addedToClaudeCode": "Добавлено в Claude Code", "removedFromClaudeCode": "Удалено из Claude Code", "config": "Конфигурация MCP", - "copyConfig": "Копировать конфигурацию" + "copyConfig": "Копировать конфигурацию", + "clientsLabel": "Клиенты" }, "tabApi": "Локальный API", "tabMcp": "MCP (ИИ-ассистенты)", @@ -675,7 +695,9 @@ "mcpStarted": "MCP сервер запущен на порту {{port}}", "mcpStopped": "MCP сервер остановлен", "mcpToggleFailed": "Не удалось переключить MCP сервер", - "openSettings": "Открыть настройки интеграций" + "openSettings": "Открыть настройки интеграций", + "apiRunningOn": "Запущен на", + "apiExampleRequest": "Пример запроса" }, "import": { "title": "Импорт профиля", @@ -1181,6 +1203,7 @@ "empty": "Расширения ещё не загружены.", "noGroups": "Группы расширений ещё не созданы.", "createGroup": "Создать группу", + "newGroup": "Новая группа", "addToGroup": "Добавить расширение...", "removeFromGroup": "Удалить из группы", "deleteGroup": "Удалить группу", @@ -1228,7 +1251,14 @@ "syncEnableTooltip": "Включить синхронизацию", "syncDisableTooltip": "Отключить синхронизацию", "loadGroupsFailed": "Не удалось загрузить группы расширений", - "assignGroupFailed": "Не удалось назначить группу расширений" + "assignGroupFailed": "Не удалось назначить группу расширений", + "bulkDelete": { + "extensionsTitle": "Удалить расширения", + "extensionsDescription": "Удалить {{count}} расширений? {{names}}", + "groupsTitle": "Удалить группы расширений", + "groupsDescription": "Удалить {{count}} групп расширений? {{names}}", + "confirmButton": "Удалить" + } }, "pro": { "badge": "PRO", @@ -1373,7 +1403,11 @@ "waiting": "Ожидание синхронизации", "errorWith": "Ошибка синхронизации: {{error}}", "error": "Ошибка синхронизации", - "notSynced": "Не синхронизировано" + "notSynced": "Не синхронизировано", + "enable": "Включить синхронизацию", + "disable": "Отключить синхронизацию", + "lockedInUse": "Синхронизация заблокирована, пока используется синхронизируемым профилем", + "bulkToggle": "Переключить синхронизацию" }, "groupManagement": { "description": "Управляйте группами профилей", @@ -1387,7 +1421,13 @@ "syncCannotDisable": "Нельзя отключить синхронизацию, пока эта группа используется синхронизированными профилями", "editGroupTooltip": "Редактировать группу", "deleteGroupTooltip": "Удалить группу", - "loadFailed": "Не удалось загрузить группы" + "loadFailed": "Не удалось загрузить группы", + "bulkDelete": { + "title": "Удалить группы", + "description": "Вы уверены, что хотите удалить {{count}} групп? {{names}}. Профили будут перемещены в группу по умолчанию.", + "description_one": "Вы уверены, что хотите удалить {{count}} группу? {{names}}. Профили будут перемещены в группу по умолчанию.", + "confirmButton": "Удалить группы" + } }, "proxyAssignment": { "title": "Назначить прокси / VPN", @@ -1721,7 +1761,14 @@ "modes": { "set": "Задать", "change": "Изменить", - "remove": "Удалить" + "remove": "Удалить", + "validate": "Проверить" + }, + "verifyDialog": { + "title": "Проверка пароля профиля", + "description": "Введите пароль профиля, чтобы убедиться, что он совпадает с сохранённым на диске.", + "submit": "Проверить", + "matchToast": "Пароль верен" } }, "backendErrors": { @@ -1749,7 +1796,6 @@ }, "rail": { "profiles": "Профили", - "proxies": "Прокси", "extensions": "Расширения", "groups": "Группы", "settings": "Настройки", @@ -1757,18 +1803,17 @@ "label": "Ещё", "closeAriaLabel": "Закрыть меню", "importProfile": "Импорт профиля", - "importProfileHint": "Перенести профили из другого инструмента", - "integrations": "Интеграции", - "integrationsHint": "Slack, MCP, автоматизации", - "account": "Аккаунт", - "accountHint": "Облако, оплата, вход" - } + "importProfileHint": "Перенести профили из другого инструмента" + }, + "network": "Сеть", + "integrations": "Интеграции", + "account": "Аккаунт" }, "pageTitle": { - "proxies": "Прокси", + "proxies": "Сеть", "extensions": "Расширения", "groups": "Группы", - "vpns": "VPN", + "vpns": "Сеть", "settings": "Настройки", "integrations": "Интеграции", "account": "Аккаунт", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e546b1f..8c835f5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -32,7 +32,8 @@ "downloading": "下载中...", "minimize": "最小化", "saving": "正在保存…", - "saved": "已保存" + "saved": "已保存", + "copied": "已复制" }, "status": { "active": "活跃", @@ -158,7 +159,15 @@ "passwordSaved": "加密密码已设置", "passwordMismatch": "密码不匹配", "passwordTooShort": "密码必须至少8个字符", - "requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。" + "requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。", + "validatePassword": "验证", + "validateDialog": { + "title": "验证加密密码", + "description": "输入加密密码以验证它是否与此设备上存储的密码匹配。", + "submit": "验证", + "matchToast": "密码正确", + "mismatchToast": "密码不匹配" + } }, "commercial": { "title": "商业许可", @@ -376,6 +385,9 @@ "deleteFailed": "删除代理失败", "deleteTitle": "删除代理", "deleteDescription": "此操作无法撤消。代理「{{name}}」将被永久删除。", + "newProxy": "新建代理", + "newVpn": "新建 VPN", + "protocolCol": "协议", "title": "代理和 VPN" }, "add": "添加代理", @@ -480,6 +492,13 @@ "continueButton": "继续", "doneButton": "完成", "failed": "导入代理失败" + }, + "bulkDelete": { + "proxiesTitle": "删除所选代理", + "proxiesDescription": "此操作无法撤销。将永久删除 {{count}} 个代理:{{names}}。", + "vpnsTitle": "删除所选 VPN", + "vpnsDescription": "此操作无法撤销。将永久删除 {{count}} 个 VPN:{{names}}。", + "confirmButton": "删除 {{count}}" } }, "groups": { @@ -488,10 +507,8 @@ "add": "添加分组", "edit": "编辑分组", "delete": "删除分组", - "defaultGroup": "默认", - "defaultGroupNoGroup": "默认(无分组)", - "moveToDefault": "将配置文件移至默认分组", - "noGroupDescription": "未分组的配置文件将显示在「默认」分组中。", + "moveToDefault": "将配置文件移出分组", + "noGroupDescription": "未分组的配置文件显示在「全部」筛选中。", "assignSuccess": "已成功将 {{count}} 个配置文件分配到 {{group}}", "noGroups": "暂无分组", "noGroupsDescription": "创建分组来组织您的配置文件。", @@ -521,7 +538,6 @@ "loadingProfiles": "正在加载关联的配置文件...", "associatedProfiles": "关联的配置文件 ({{count}})", "whatToDoWithProfiles": "这些配置文件应该怎么办?", - "moveToDefaultOption": "将配置文件移至默认组", "deleteAlongWithGroup": "将配置文件与组一起删除", "noAssociatedProfiles": "此组没有关联的配置文件。", "deleteGroup": "删除组", @@ -530,7 +546,10 @@ "unknownGroup": "未知分组", "profileGroupsAriaLabel": "配置文件分组", "loading": "正在加载组...", - "all": "全部" + "all": "全部", + "noGroup": "无分组", + "pageTitle": "配置文件分组", + "pageDescription": "配置文件分组可让您按客户端、环境或使用场景整理浏览器。在多台设备之间同步分组以便共享。" }, "sync": { "mode": { @@ -649,7 +668,8 @@ "addedToClaudeCode": "已添加到 Claude Code", "removedFromClaudeCode": "已从 Claude Code 移除", "config": "MCP 配置", - "copyConfig": "复制配置" + "copyConfig": "复制配置", + "clientsLabel": "客户端" }, "tabApi": "本地 API", "tabMcp": "MCP (AI 助手)", @@ -675,7 +695,9 @@ "mcpStarted": "MCP 服务器已在端口 {{port}} 上启动", "mcpStopped": "MCP 服务器已停止", "mcpToggleFailed": "切换 MCP 服务器失败", - "openSettings": "打开集成设置" + "openSettings": "打开集成设置", + "apiRunningOn": "运行于", + "apiExampleRequest": "示例请求" }, "import": { "title": "导入配置文件", @@ -1181,6 +1203,7 @@ "empty": "尚未上传任何扩展程序。", "noGroups": "尚未创建任何扩展程序组。", "createGroup": "创建分组", + "newGroup": "新建分组", "addToGroup": "添加扩展程序...", "removeFromGroup": "从分组中移除", "deleteGroup": "删除分组", @@ -1228,7 +1251,14 @@ "syncEnableTooltip": "启用同步", "syncDisableTooltip": "禁用同步", "loadGroupsFailed": "加载扩展组失败", - "assignGroupFailed": "分配扩展组失败" + "assignGroupFailed": "分配扩展组失败", + "bulkDelete": { + "extensionsTitle": "删除扩展", + "extensionsDescription": "删除 {{count}} 个扩展?{{names}}", + "groupsTitle": "删除扩展组", + "groupsDescription": "删除 {{count}} 个扩展组?{{names}}", + "confirmButton": "删除" + } }, "pro": { "badge": "PRO", @@ -1373,7 +1403,11 @@ "waiting": "等待同步", "errorWith": "同步错误: {{error}}", "error": "同步错误", - "notSynced": "未同步" + "notSynced": "未同步", + "enable": "启用同步", + "disable": "禁用同步", + "lockedInUse": "同步配置文件正在使用,无法禁用同步", + "bulkToggle": "切换同步" }, "groupManagement": { "description": "管理你的配置文件分组", @@ -1387,7 +1421,13 @@ "syncCannotDisable": "此分组被同步的配置文件使用时无法禁用同步", "editGroupTooltip": "编辑分组", "deleteGroupTooltip": "删除分组", - "loadFailed": "加载分组失败" + "loadFailed": "加载分组失败", + "bulkDelete": { + "title": "删除分组", + "description": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。", + "description_one": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。", + "confirmButton": "删除分组" + } }, "proxyAssignment": { "title": "分配代理 / VPN", @@ -1721,7 +1761,14 @@ "modes": { "set": "设置", "change": "更改", - "remove": "删除" + "remove": "删除", + "validate": "验证" + }, + "verifyDialog": { + "title": "验证配置文件密码", + "description": "输入配置文件密码以确认它与磁盘上存储的密码匹配。", + "submit": "验证", + "matchToast": "密码正确" } }, "backendErrors": { @@ -1749,7 +1796,6 @@ }, "rail": { "profiles": "配置文件", - "proxies": "代理", "extensions": "扩展", "groups": "分组", "settings": "设置", @@ -1757,18 +1803,17 @@ "label": "更多", "closeAriaLabel": "关闭菜单", "importProfile": "导入配置文件", - "importProfileHint": "从其他工具导入", - "integrations": "集成", - "integrationsHint": "Slack、MCP、自动化", - "account": "账户", - "accountHint": "云、订阅、登录" - } + "importProfileHint": "从其他工具导入" + }, + "network": "网络", + "integrations": "集成", + "account": "账号" }, "pageTitle": { - "proxies": "代理", + "proxies": "网络", "extensions": "扩展", "groups": "分组", - "vpns": "VPN", + "vpns": "网络", "settings": "设置", "integrations": "集成", "account": "账户", diff --git a/src/styles/globals.css b/src/styles/globals.css index 602ab76..261f766 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -157,26 +157,41 @@ } } -/* Scroll-fade utility: a vertical mask whose top/bottom 16px fade to - transparent ONLY when the matching direction is scrollable. The component - sets `data-fade-top` / `data-fade-bottom` attributes on its container as - the user scrolls; each attribute toggles its own end of the mask via a - CSS variable, so the two edges are independent. */ +/* Scroll-fade utility: a vertical mask that thins the alpha of the top and + bottom 24px of the scroll container ONLY when that direction is actually + scrollable. useScrollFade() writes `data-fade-top` / `data-fade-bottom` + on the container as the user scrolls; each attribute toggles its own + end of the mask via a CSS variable. + + Mask is preferred over a colored gradient overlay: an overlay paints + bg-color over content, which leaves a visible band wherever content + passes through it. Mask just fades alpha — content gracefully fades to + nothing at the edges. + + `--scroll-fade-top-offset` pushes the top-edge fade band down by N + pixels so a sticky table header stays fully opaque and only the body + rows scrolling past it fade. Set it inline on a scroll container whose + first N px are occupied by sticky chrome. */ .scroll-fade { --top-mask: black; --bottom-mask: black; + --scroll-fade-top-offset: 0px; -webkit-mask-image: linear-gradient( to bottom, - var(--top-mask), - black 16px, - black calc(100% - 16px), + black 0, + black var(--scroll-fade-top-offset), + var(--top-mask) var(--scroll-fade-top-offset), + black calc(var(--scroll-fade-top-offset) + 24px), + black calc(100% - 24px), var(--bottom-mask) ); mask-image: linear-gradient( to bottom, - var(--top-mask), - black 16px, - black calc(100% - 16px), + black 0, + black var(--scroll-fade-top-offset), + var(--top-mask) var(--scroll-fade-top-offset), + black calc(var(--scroll-fade-top-offset) + 24px), + black calc(100% - 24px), var(--bottom-mask) ); }