refactor: ui cleanup

This commit is contained in:
zhom committed 2026-05-15 15:44:20 +04:00
1 parent 56b0da990b
commit c8a43b43f1
35 files changed
+3792 -1674

No files matched your search

+3 -13
View File
@@ -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(); let mut result = Vec::new();
for group in groups { for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0); 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) Ok(result)
} }
} }
+4 -1
View File
@@ -74,7 +74,7 @@ use profile::manager::{
use profile::password::{ use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_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::{ 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, 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_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, 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; use tag_manager::get_all_tags;
@@ -2112,6 +2113,7 @@ pub fn run() {
enable_sync_for_all_entities, enable_sync_for_all_entities,
set_e2e_password, set_e2e_password,
check_has_e2e_password, check_has_e2e_password,
verify_e2e_password,
delete_e2e_password, delete_e2e_password,
rollover_encryption_for_all_entities, rollover_encryption_for_all_entities,
read_profile_cookies, read_profile_cookies,
@@ -2177,6 +2179,7 @@ pub fn run() {
set_profile_password, set_profile_password,
change_profile_password, change_profile_password,
remove_profile_password, remove_profile_password,
verify_profile_password,
unlock_profile, unlock_profile,
lock_profile, lock_profile,
is_profile_locked, is_profile_locked,
+24
View File
@@ -473,6 +473,8 @@ impl ProfileManager {
// Save profile with new name // Save profile with new name
self.save_profile(&profile)?; 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) // Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -678,6 +680,8 @@ impl ProfileManager {
profile.group_id = group_id.clone(); profile.group_id = group_id.clone();
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new group if profile has sync enabled // Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() { if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id { if let Some(ref new_group_id) = group_id {
@@ -732,6 +736,8 @@ impl ProfileManager {
// Save profile // Save profile
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Update global tag suggestions from all profiles // Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -766,6 +772,8 @@ impl ProfileManager {
// Save profile // Save profile
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Emit profile note update event // Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}"); log::warn!("Warning: Failed to emit profiles-changed event: {e}");
@@ -792,6 +800,8 @@ impl ProfileManager {
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) { if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}"); log::warn!("Warning: Failed to emit profile update event: {e}");
} }
@@ -821,6 +831,8 @@ impl ProfileManager {
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}"); log::warn!("Warning: Failed to emit profiles-changed event: {e}");
} }
@@ -845,6 +857,8 @@ impl ProfileManager {
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") { if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}"); log::warn!("Warning: Failed to emit profiles-changed event: {e}");
} }
@@ -1060,6 +1074,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() format!("Failed to save profile: {e}").into()
})?; })?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!( log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).", "Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name, profile.name,
@@ -1120,6 +1136,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() format!("Failed to save profile: {e}").into()
})?; })?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!( log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).", "Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name, profile.name,
@@ -1174,6 +1192,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() 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 // Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() { if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id { if let Some(ref new_proxy_id) = proxy_id {
@@ -1263,6 +1283,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into() 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. // Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() { if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id { if let Some(ref new_vpn_id) = vpn_id {
@@ -1300,6 +1322,8 @@ impl ProfileManager {
profile.extension_group_id = extension_group_id.clone(); profile.extension_group_id = extension_group_id.clone();
self.save_profile(&profile)?; self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new extension group if profile has sync // Auto-enable sync for the new extension group if profile has sync
// enabled. The helper is sync internally; we fire-and-forget through // enabled. The helper is sync internally; we fire-and-forget through
// the async runtime so any I/O doesn't block this caller. // the async runtime so any I/O doesn't block this caller.
+37
View File
@@ -292,10 +292,45 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
.map_err(err_internal)?; .map_err(err_internal)?;
cache_key(id, key); cache_key(id, key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed(); emit_profiles_changed();
Ok(()) 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] #[tauri::command]
pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> { pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?; let id = parse_uuid(&profile_id)?;
@@ -396,6 +431,7 @@ pub async fn change_profile_password(
drop_cached_key(&id); drop_cached_key(&id);
cache_key(id, new_key); cache_key(id, new_key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed(); emit_profiles_changed();
Ok(()) Ok(())
} }
@@ -464,6 +500,7 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
.map_err(err_internal)?; .map_err(err_internal)?;
drop_cached_key(&id); drop_cached_key(&id);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed(); emit_profiles_changed();
Ok(()) Ok(())
} }
+8
View File
@@ -346,6 +346,14 @@ pub fn check_has_e2e_password() -> bool {
has_e2e_password() has_e2e_password()
} }
#[tauri::command]
pub fn verify_e2e_password(password: String) -> Result<bool, String> {
match load_e2e_password()? {
Some(stored) => Ok(stored == password),
None => Err(serde_json::json!({ "code": "NO_E2E_PASSWORD_SET" }).to_string()),
}
}
#[tauri::command] #[tauri::command]
pub async fn delete_e2e_password() -> Result<(), String> { pub async fn delete_e2e_password() -> Result<(), String> {
enforce_team_owner_for_encryption_change().await?; enforce_team_owner_for_encryption_change().await?;
+21 -1
View File
@@ -7,7 +7,9 @@ pub mod subscription;
pub mod types; pub mod types;
pub use client::SyncClient; 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::{ pub use engine::{
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed, 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, 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 scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem}; pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult}; 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;
}
});
}
+7 -5
View File
@@ -613,7 +613,9 @@ export default function Home() {
wayfernConfig: profileData.wayfernConfig, wayfernConfig: profileData.wayfernConfig,
groupId: groupId:
profileData.groupId ?? profileData.groupId ??
(selectedGroupId !== "default" ? selectedGroupId : undefined), (selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined),
ephemeral: profileData.ephemeral, ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist, dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook, launchHook: profileData.launchHook,
@@ -1243,11 +1245,10 @@ export default function Home() {
let filtered = profiles; let filtered = profiles;
// Filter by group. "__all__" is a virtual filter that shows every // Filter by group. "__all__" is a virtual filter that shows every
// profile regardless of group; "default" shows ungrouped profiles. // profile (including ungrouped ones). Any other value is a real
if (selectedGroupId === "__all__") { // group id; ungrouped profiles only show through "All".
if (!selectedGroupId || selectedGroupId === "__all__") {
filtered = profiles; filtered = profiles;
} else if (!selectedGroupId || selectedGroupId === "default") {
filtered = profiles.filter((profile) => !profile.group_id);
} else { } else {
filtered = profiles.filter( filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId, (profile) => profile.group_id === selectedGroupId,
@@ -1292,6 +1293,7 @@ export default function Home() {
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
groups={groupsData} groups={groupsData}
totalProfiles={profiles.length}
selectedGroupId={selectedGroupId} selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup} onGroupSelect={handleSelectGroup}
pageTitle={subPageTitle} pageTitle={subPageTitle}
+18 -32
View File
@@ -12,16 +12,20 @@ import {
LuUser, LuUser,
} from "react-icons/lu"; } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { translateBackendError } from "@/lib/backend-errors"; import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { SyncSettings } from "@/types"; import type { SyncSettings } from "@/types";
interface AccountPageProps { interface AccountPageProps {
@@ -194,25 +198,12 @@ export function AccountPage({
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}> <Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col"> <DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-4 p-4">
<Tabs defaultValue="account"> <AnimatedTabs defaultValue="account">
<TabsList <AnimatedTabsList>
className={cn( <AnimatedTabsTrigger value="account">
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="account"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
{t("account.tabs.account")} {t("account.tabs.account")}
</TabsTrigger> </AnimatedTabsTrigger>
<TabsTrigger <AnimatedTabsTrigger
value="self-hosted" value="self-hosted"
disabled={selfHostedDisabled} disabled={selfHostedDisabled}
title={ title={
@@ -220,17 +211,12 @@ export function AccountPage({
? t("account.selfHosted.disabledWhileLoggedIn") ? t("account.selfHosted.disabledWhileLoggedIn")
: undefined : undefined
} }
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs disabled:opacity-50 disabled:hover:text-muted-foreground",
)}
> >
{t("account.tabs.selfHosted")} {t("account.tabs.selfHosted")}
</TabsTrigger> </AnimatedTabsTrigger>
</TabsList> </AnimatedTabsList>
<TabsContent value="account" className="mt-4"> <AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="grid place-items-center size-12 rounded-full bg-accent text-foreground shrink-0"> <div className="grid place-items-center size-12 rounded-full bg-accent text-foreground shrink-0">
@@ -338,9 +324,9 @@ export function AccountPage({
)} )}
</div> </div>
</div> </div>
</TabsContent> </AnimatedTabsContent>
<TabsContent value="self-hosted" className="mt-4"> <AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? ( {selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is // Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI — // logged in, so this branch shouldn't be reachable via UI —
@@ -481,8 +467,8 @@ export function AccountPage({
</div> </div>
</div> </div>
)} )}
</TabsContent> </AnimatedTabsContent>
</Tabs> </AnimatedTabs>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+3 -3
View File
@@ -15,9 +15,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple"; import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -563,7 +563,7 @@ export function CookieManagementDialog({
{t("cookies.management.noCookies")} {t("cookies.management.noCookies")}
</div> </div>
) : ( ) : (
<ScrollArea className="h-[200px] border rounded-md"> <FadingScrollArea className="h-[200px]">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => ( {exportCookieData.domains.map((domain) => (
<ExportDomainRow <ExportDomainRow
@@ -577,7 +577,7 @@ export function CookieManagementDialog({
/> />
))} ))}
</div> </div>
</ScrollArea> </FadingScrollArea>
)} )}
</div> </div>
+10 -3
View File
@@ -431,7 +431,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId, vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig, wayfernConfig: finalWayfernConfig,
groupId: groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined, selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId, extensionGroupId: selectedExtensionGroupId,
ephemeral, ephemeral,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
@@ -459,7 +461,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId, vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig, camoufoxConfig: finalCamoufoxConfig,
groupId: groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined, selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId, extensionGroupId: selectedExtensionGroupId,
ephemeral, ephemeral,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
@@ -487,7 +491,10 @@ export function CreateProfileDialog({
version: bestVersion.version, version: bestVersion.version,
releaseType: bestVersion.releaseType, releaseType: bestVersion.releaseType,
proxyId: selectedProxyId, proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
dnsBlocklist: dnsBlocklist || undefined, dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined, launchHook: launchHook.trim() || undefined,
password: passwordToSet, password: passwordToSet,
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -77,7 +77,7 @@ export function GroupAssignmentDialog({
const groupName = selectedGroupId const groupName = selectedGroupId
? groups.find((g) => g.id === selectedGroupId)?.name || ? groups.find((g) => g.id === selectedGroupId)?.name ||
t("groups.unknownGroup") t("groups.unknownGroup")
: t("groups.defaultGroup"); : t("groups.noGroup");
toast.success( toast.success(
t("groups.assignSuccess", { t("groups.assignSuccess", {
@@ -175,17 +175,17 @@ export function GroupAssignmentDialog({
</div> </div>
) : ( ) : (
<Select <Select
value={selectedGroupId ?? "default"} value={selectedGroupId ?? "__none__"}
onValueChange={(value) => { onValueChange={(value) => {
setSelectedGroupId(value === "default" ? null : value); setSelectedGroupId(value === "__none__" ? null : value);
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t("groupAssignment.placeholder")} /> <SelectValue placeholder={t("groupAssignment.placeholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="default"> <SelectItem value="__none__">
{t("groups.defaultGroupNoGroup")} {t("groups.noGroup")}
</SelectItem> </SelectItem>
{groups.map((group) => ( {groups.map((group) => (
<SelectItem key={group.id} value={group.id}> <SelectItem key={group.id} value={group.id}>
+1 -3
View File
@@ -183,9 +183,7 @@ export function GroupBadges({
} }
}} }}
> >
<span> <span>{group.name}</span>
{group.id === "default" ? t("groups.defaultGroup") : group.name}
</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm"> <span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count} {group.count}
</span> </span>
+404 -133
View File
@@ -1,14 +1,37 @@
"use client"; "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 { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; 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 { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go"; import { GoPlus } from "react-icons/go";
import { LuPencil, LuTrash2 } from "react-icons/lu"; import {
LuChevronDown,
LuChevronUp,
LuFolder,
LuPencil,
LuRefreshCw,
LuTrash2,
} from "react-icons/lu";
import { CreateGroupDialog } from "@/components/create-group-dialog"; import { CreateGroupDialog } from "@/components/create-group-dialog";
import {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
} from "@/components/data-table-action-bar";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { DeleteGroupDialog } from "@/components/delete-group-dialog"; import { DeleteGroupDialog } from "@/components/delete-group-dialog";
import { EditGroupDialog } from "@/components/edit-group-dialog"; import { EditGroupDialog } from "@/components/edit-group-dialog";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -20,8 +43,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Table, Table,
TableBody, TableBody,
@@ -111,6 +133,8 @@ export function GroupManagementDialog({
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | null>( const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | null>(
null, null,
); );
@@ -125,6 +149,12 @@ export function GroupManagementDialog({
{}, {},
); );
// Table state
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Listen for group sync status events // Listen for group sync status events
useEffect(() => { useEffect(() => {
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
@@ -246,9 +276,272 @@ export function GroupManagementDialog({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
void loadGroups(); void loadGroups();
} else {
// Drop any selection when the dialog closes so the floating
// action bar (portaled to body) doesn't linger on the page.
setRowSelection({});
} }
}, [isOpen, loadGroups]); }, [isOpen, loadGroups]);
const columns = useMemo<ColumnDef<GroupWithCount>[]>(
() => [
{
id: "select",
size: 36,
enableSorting: false,
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => {
table.toggleAllRowsSelected(!!value);
}}
aria-label={t("common.aria.selectAll")}
disabled={table.getRowModel().rows.length === 0}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => {
row.toggleSelected(!!value);
}}
aria-label={t("common.aria.selectRow")}
/>
),
},
{
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
>
{t("common.labels.name")}
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<LuChevronDown className="ml-2 size-4" />
) : null}
</Button>
),
cell: ({ row }) => {
const group = row.original;
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
t,
groupSyncErrors[group.id],
);
return (
<div className="flex items-center gap-2 font-medium">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`size-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
<LuFolder className="size-4 text-muted-foreground" />
{group.name}
</div>
);
},
},
{
id: "count",
size: 80,
enableSorting: false,
header: () => t("groupManagement.profilesCol"),
cell: ({ row }) => (
<Badge variant="secondary">{row.original.count}</Badge>
),
},
{
id: "sync",
size: 96,
enableSorting: false,
header: () => t("proxies.management.syncCol"),
cell: ({ row }) => {
const group = row.original;
const locked = groupInUse[group.id];
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center">
<AnimatedSwitch
checked={group.sync_enabled}
onCheckedChange={() => handleToggleSync(group)}
disabled={isTogglingSync[group.id] || locked}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{locked ? (
<p>{t("syncTooltips.lockedInUse")}</p>
) : (
<p>
{group.sync_enabled
? t("syncTooltips.disable")
: t("syncTooltips.enable")}
</p>
)}
</TooltipContent>
</Tooltip>
);
},
},
{
id: "actions",
size: 96,
enableSorting: false,
header: () => t("common.labels.actions"),
cell: ({ row }) => {
const group = row.original;
return (
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditGroup(group);
}}
>
<LuPencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("groupManagement.editGroupTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteGroup(group);
}}
>
<LuTrash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("groupManagement.deleteGroupTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
);
},
},
],
[
t,
groupSyncStatus,
groupSyncErrors,
groupInUse,
isTogglingSync,
handleToggleSync,
handleEditGroup,
handleDeleteGroup,
],
);
const table = useReactTable({
data: groups,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedGroupsForBulk = useMemo(
() => selectedRows.map((row) => row.original),
[selectedRows],
);
const selectedNames = useMemo(
() => selectedGroupsForBulk.map((g) => g.name).join(", "),
[selectedGroupsForBulk],
);
const handleBulkDelete = useCallback(async () => {
if (selectedGroupsForBulk.length === 0) return;
setIsBulkDeleting(true);
try {
const ids = selectedGroupsForBulk.map((g) => g.id);
const results = await Promise.allSettled(
ids.map((groupId) => invoke("delete_profile_group", { groupId })),
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
showErrorToast(t("groups.deleteFailed"));
} else {
showSuccessToast(t("groups.deleteSuccess"));
}
table.toggleAllRowsSelected(false);
setBulkDeleteOpen(false);
await loadGroups();
onGroupManagementComplete();
} catch (err) {
console.error("Bulk group delete failed:", err);
showErrorToast(
err instanceof Error ? err.message : t("groups.deleteFailed"),
);
} finally {
setIsBulkDeleting(false);
}
}, [selectedGroupsForBulk, table, loadGroups, onGroupManagementComplete, t]);
const handleBulkToggleSync = useCallback(async () => {
if (selectedGroupsForBulk.length === 0) return;
const allOn = selectedGroupsForBulk.every((g) => g.sync_enabled);
const targetEnabled = !allOn;
const targets = selectedGroupsForBulk.filter((g) =>
targetEnabled ? !g.sync_enabled : g.sync_enabled && !groupInUse[g.id],
);
if (targets.length === 0) return;
const results = await Promise.allSettled(
targets.map((group) =>
invoke("set_group_sync_enabled", {
groupId: 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("proxies.management.syncEnabled")
: t("proxies.management.syncDisabled"),
);
}
await loadGroups();
}, [selectedGroupsForBulk, groupInUse, loadGroups, t]);
return ( return (
<> <>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}> <Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
@@ -262,16 +555,22 @@ export function GroupManagementDialog({
</DialogHeader> </DialogHeader>
)} )}
<div className="space-y-4"> <div className="flex flex-col gap-4 flex-1 min-h-0">
{/* Create new group button */} <div className="flex items-start justify-between gap-3">
<div className="flex justify-between items-center"> <div className="flex flex-col gap-1">
<Label>{t("groupManagement.groupsLabel")}</Label> <h2 className="text-base font-semibold">
{t("groups.pageTitle")}
</h2>
<p className="text-xs text-muted-foreground">
{t("groups.pageDescription")}
</p>
</div>
<RippleButton <RippleButton
size="sm" size="sm"
onClick={() => { onClick={() => {
setCreateDialogOpen(true); setCreateDialogOpen(true);
}} }}
className="flex gap-2 items-center" className="flex gap-2 items-center shrink-0"
> >
<GoPlus className="size-4" /> <GoPlus className="size-4" />
{t("proxies.management.create")} {t("proxies.management.create")}
@@ -294,131 +593,64 @@ export function GroupManagementDialog({
{t("groups.noGroupsDescription")} {t("groups.noGroupsDescription")}
</div> </div>
) : ( ) : (
<div className="border rounded-md"> <FadingScrollArea
<ScrollArea className="h-[240px]"> className="flex-1 min-h-0"
<Table> style={
<TableHeader> {
<TableRow> "--scroll-fade-top-offset": "32px",
<TableHead>{t("common.labels.name")}</TableHead> } as React.CSSProperties
<TableHead className="w-20"> }
{t("groupManagement.profilesCol")} >
</TableHead> <Table>
<TableHead className="w-24"> <TableHeader className="sticky top-0 z-10 bg-background">
{t("proxies.management.syncCol")} {table.getHeaderGroups().map((headerGroup) => (
</TableHead> <TableRow key={headerGroup.id}>
<TableHead className="w-24"> {headerGroup.headers.map((header) => (
{t("common.labels.actions")} <TableHead
</TableHead> key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow> </TableRow>
</TableHeader> ))}
<TableBody> </TableHeader>
{groups.map((group) => { <TableBody>
const syncDot = getSyncStatusDot( {table.getRowModel().rows.map((row) => (
group, <TableRow
groupSyncStatus[group.id], key={row.id}
t, data-state={row.getIsSelected() && "selected"}
groupSyncErrors[group.id], >
); {row.getVisibleCells().map((cell) => (
return ( <TableCell
<TableRow key={group.id}> key={cell.id}
<TableCell className="font-medium"> style={{
<div className="flex items-center gap-2"> width: cell.column.columnDef.size
<Tooltip> ? `${cell.column.getSize()}px`
<TooltipTrigger asChild> : undefined,
<div }}
className={`size-2 rounded-full shrink-0 ${syncDot.color} ${ >
syncDot.animate ? "animate-pulse" : "" {flexRender(
}`} cell.column.columnDef.cell,
/> cell.getContext(),
</TooltipTrigger> )}
<TooltipContent> </TableCell>
<p>{syncDot.tooltip}</p> ))}
</TooltipContent> </TableRow>
</Tooltip> ))}
{group.name} </TableBody>
</div> </Table>
</TableCell> </FadingScrollArea>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
{t("groupManagement.syncCannotDisable")}
</p>
) : (
<p>
{group.sync_enabled
? t("proxies.management.disableSync")
: t("proxies.management.enableSync")}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditGroup(group);
}}
>
<LuPencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("groupManagement.editGroupTooltip")}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteGroup(group);
}}
>
<LuTrash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("groupManagement.deleteGroupTooltip")}
</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)} )}
</div> </div>
@@ -432,6 +664,45 @@ export function GroupManagementDialog({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{isOpen && (
<DataTableActionBar table={table}>
<DataTableActionBarSelection table={table} />
<DataTableActionBarAction
tooltip={t("syncTooltips.bulkToggle")}
onClick={() => {
void handleBulkToggleSync();
}}
size="icon"
>
<LuRefreshCw />
</DataTableActionBarAction>
<DataTableActionBarAction
tooltip={t("common.buttons.delete")}
onClick={() => setBulkDeleteOpen(true)}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
>
<LuTrash2 />
</DataTableActionBarAction>
</DataTableActionBar>
)}
<DeleteConfirmationDialog
isOpen={bulkDeleteOpen}
onClose={() => {
if (!isBulkDeleting) setBulkDeleteOpen(false);
}}
onConfirm={handleBulkDelete}
title={t("groupManagement.bulkDelete.title")}
description={t("groupManagement.bulkDelete.description", {
count: selectedGroupsForBulk.length,
names: selectedNames,
})}
confirmButtonText={t("groupManagement.bulkDelete.confirmButton")}
isLoading={isBulkDeleting}
/>
<CreateGroupDialog <CreateGroupDialog
isOpen={createDialogOpen} isOpen={createDialogOpen}
onClose={() => { onClose={() => {
+4 -9
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go"; import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu"; import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
@@ -30,6 +30,7 @@ interface Props {
searchQuery: string; searchQuery: string;
onSearchQueryChange: (query: string) => void; onSearchQueryChange: (query: string) => void;
groups: GroupWithCount[]; groups: GroupWithCount[];
totalProfiles: number;
selectedGroupId: string | null; selectedGroupId: string | null;
onGroupSelect: (groupId: string) => void; onGroupSelect: (groupId: string) => void;
pageTitle?: string; pageTitle?: string;
@@ -40,6 +41,7 @@ const HomeHeader = ({
searchQuery, searchQuery,
onSearchQueryChange, onSearchQueryChange,
groups, groups,
totalProfiles,
selectedGroupId, selectedGroupId,
onGroupSelect, onGroupSelect,
pageTitle, pageTitle,
@@ -54,11 +56,6 @@ const HomeHeader = ({
const isMacOS = platform === "macos"; const isMacOS = platform === "macos";
const showProfileToolbar = !pageTitle; const showProfileToolbar = !pageTitle;
const totalProfiles = useMemo(
() => groups.reduce((sum, g) => sum + g.count, 0),
[groups],
);
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after // Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
// HOLD_MS, but quick clicks still reach buttons/inputs underneath. // HOLD_MS, but quick clicks still reach buttons/inputs underneath.
const holdTimeoutRef = useRef<number | null>(null); const holdTimeoutRef = useRef<number | null>(null);
@@ -247,8 +244,6 @@ const HomeHeader = ({
})()} })()}
{groups.map((group) => { {groups.map((group) => {
const active = selectedGroupId === group.id; const active = selectedGroupId === group.id;
const label =
group.id === "default" ? t("groups.defaultGroup") : group.name;
return ( return (
<button <button
key={group.id} key={group.id}
@@ -263,7 +258,7 @@ const HomeHeader = ({
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
> >
<span>{label}</span> <span>{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">
{group.count} {group.count}
</span> </span>
+24 -26
View File
@@ -9,6 +9,12 @@ import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form"; import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -304,31 +310,23 @@ export function ImportProfileDialog({
<div className="overflow-y-auto flex-1 space-y-6 min-h-0"> <div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{currentStep === "select" && ( {currentStep === "select" && (
<> <AnimatedTabs
<div className="flex gap-2"> value={importMode}
<RippleButton onValueChange={(v) =>
variant={importMode === "auto-detect" ? "default" : "outline"} setImportMode(v as "auto-detect" | "manual")
onClick={() => { }
setImportMode("auto-detect"); className="flex flex-col gap-6"
}} >
className="flex-1" <AnimatedTabsList>
disabled={isLoading} <AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
>
{t("importProfile.autoDetect")} {t("importProfile.autoDetect")}
</RippleButton> </AnimatedTabsTrigger>
<RippleButton <AnimatedTabsTrigger value="manual" disabled={isLoading}>
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
{t("importProfile.manualImport")} {t("importProfile.manualImport")}
</RippleButton> </AnimatedTabsTrigger>
</div> </AnimatedTabsList>
{importMode === "auto-detect" && ( <AnimatedTabsContent value="auto-detect">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
{t("importProfile.detectedProfilesTitle")} {t("importProfile.detectedProfilesTitle")}
@@ -439,9 +437,9 @@ export function ImportProfileDialog({
</div> </div>
)} )}
</div> </div>
)} </AnimatedTabsContent>
{importMode === "manual" && ( <AnimatedTabsContent value="manual">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
{t("importProfile.manualTitle")} {t("importProfile.manualTitle")}
@@ -539,8 +537,8 @@ export function ImportProfileDialog({
</div> </div>
</div> </div>
</div> </div>
)} </AnimatedTabsContent>
</> </AnimatedTabs>
)} )}
{currentStep === "configure" && currentMappedBrowser && ( {currentStep === "configure" && currentMappedBrowser && (
+274 -235
View File
@@ -4,6 +4,14 @@ import { invoke } from "@tauri-apps/api/core";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuPlug } from "react-icons/lu";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
@@ -14,7 +22,6 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard"; import { CopyToClipboard } from "./ui/copy-to-clipboard";
@@ -218,172 +225,204 @@ export function IntegrationsDialog({
)} )}
<div className="overflow-y-auto flex-1 min-h-0"> <div className="overflow-y-auto flex-1 min-h-0">
<Tabs defaultValue="api" className="w-full"> <AnimatedTabs defaultValue="api">
<TabsList className="grid w-full grid-cols-2"> <AnimatedTabsList>
<TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger> <AnimatedTabsTrigger value="api">
<TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger> {t("integrations.tabApi")}
</TabsList> </AnimatedTabsTrigger>
<AnimatedTabsTrigger value="mcp">
{t("integrations.tabMcp")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<TabsContent value="api" className="space-y-4 mt-4"> <AnimatedTabsContent
<div className="flex items-center gap-x-2"> value="api"
<Checkbox className="mt-4 flex flex-col gap-4"
id="api-enabled" >
checked={apiServerPort !== null} <div className="rounded-md border bg-card p-4 flex flex-col gap-4">
disabled={isApiStarting} <div className="flex items-start justify-between gap-3">
onCheckedChange={(checked) => void handleApiToggle(!!checked)} <div className="flex items-start gap-3">
/> <LuPlug className="size-5 mt-0.5 text-muted-foreground" />
<div className="grid gap-1.5 leading-none"> <div className="flex flex-col gap-1">
<Label <Label className="text-sm font-medium">
htmlFor="api-enabled" {t("integrations.apiEnableLabel")}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" </Label>
> <p className="text-xs text-muted-foreground">
{t("integrations.apiEnableLabel")} {t("integrations.apiEnableDescription")}
</Label> </p>
<p className="text-xs text-muted-foreground"> </div>
{t("integrations.apiEnableDescription")} </div>
</p> <AnimatedSwitch
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(checked)}
/>
</div> </div>
{apiServerPort && (
<div className="flex items-center gap-2 text-xs">
<span className="size-1.5 rounded-full bg-success" />
<span className="text-muted-foreground">
{t("integrations.apiRunningOn")}
</span>
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
http://127.0.0.1:{apiServerPort}
</code>
</div>
)}
</div> </div>
{settings.api_enabled && ( {settings.api_enabled && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40"> <>
<div className="space-y-2"> <div className="grid grid-cols-2 gap-4">
<Label className="text-sm font-medium"> <div className="rounded-md border bg-card p-4 flex flex-col gap-2">
{t("integrations.apiPortLabel")} <Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
</Label> {t("integrations.apiPortLabel")}
<div className="flex items-center gap-x-2"> </Label>
<Button <div className="flex items-center gap-2">
size="sm"
disabled={
isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(t("integrations.apiInvalidPort"), {
description: t(
"integrations.apiInvalidPortDescription",
),
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
}
} catch (e) {
showErrorToast(t("integrations.apiStartFailed"), {
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
});
} finally {
setIsApiStarting(false);
}
}}
>
{t("common.buttons.save")}
</Button>
<Input
type="number"
value={settings.api_port}
onChange={(e) => {
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 && (
<span className="text-xs text-muted-foreground">
{t("common.status.running")}
</span>
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("integrations.apiTokenLabel")}
</Label>
<div className="flex items-center gap-x-2">
<div className="relative flex-1">
<Input <Input
type={showApiToken ? "text" : "password"} type="number"
value={settings.api_token ?? ""} value={settings.api_port}
readOnly onChange={(e) => {
className="font-mono pr-10" 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}
/> />
<Button <Button
type="button"
variant="ghost"
size="sm" size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent" variant="outline"
onClick={() => { disabled={
setShowApiToken(!showApiToken); isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(t("integrations.apiInvalidPort"), {
description: t(
"integrations.apiInvalidPortDescription",
),
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
}
} catch (e) {
showErrorToast(t("integrations.apiStartFailed"), {
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
});
} finally {
setIsApiStarting(false);
}
}} }}
> >
{showApiToken ? ( {t("common.buttons.save")}
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</Button> </Button>
</div> </div>
</div>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiTokenLabel")}
</Label>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="font-mono pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
}}
>
{showApiToken ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</Button>
</div>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
/>
</div>
</div>
</div>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiExampleRequest")}
</Label>
<CopyToClipboard <CopyToClipboard
text={settings.api_token ?? ""} text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
successMessage={t("integrations.tokenCopied")} successMessage={t("common.buttons.copied")}
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <pre className="font-mono text-[11px] whitespace-pre overflow-x-auto bg-background rounded p-3">
{t("integrations.apiTokenHint", { {`curl -H "Authorization: Bearer \${TOKEN}" \\
tokenSlot: "<token>", http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
})} </pre>
</p>
</div> </div>
</div> </>
)} )}
</TabsContent> </AnimatedTabsContent>
<TabsContent value="mcp" className="space-y-4 mt-4"> <AnimatedTabsContent value="mcp" className="mt-4">
<div className="flex items-center gap-x-2"> <div className="flex items-start gap-x-3">
<Checkbox <Checkbox
id="mcp-enabled" id="mcp-enabled"
checked={settings.mcp_enabled && mcpConfig !== null} checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting} disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) => void handleMcpToggle(!!checked)} onCheckedChange={(checked) => void handleMcpToggle(!!checked)}
className="mt-0.5"
/> />
<div className="grid gap-1.5 leading-none"> <div className="grid gap-1 leading-none">
<Label <Label
htmlFor="mcp-enabled" htmlFor="mcp-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
@@ -402,9 +441,9 @@ export function IntegrationsDialog({
</div> </div>
{mcpConfig && ( {mcpConfig && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40"> <div className="mt-6 flex flex-col gap-5 pt-5 border-t">
<div className="space-y-2"> <div className="flex flex-col gap-1.5">
<Label className="text-sm font-medium"> <Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.url")} {t("integrations.mcp.url")}
</Label> </Label>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
@@ -438,99 +477,99 @@ export function IntegrationsDialog({
</div> </div>
</div> </div>
<div className="space-y-2 pt-1 border-t"> <div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground"> <Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.claudeDesktopTitle")} {t("integrations.mcp.clientsLabel")}
</p> </Label>
{mcpInClaudeDesktop ? ( <div className="flex items-center justify-between gap-x-3 py-1">
<Button <span className="text-sm">
variant="outline" {t("integrations.mcp.claudeDesktopTitle")}
size="sm" </span>
className="w-full" {mcpInClaudeDesktop ? (
onClick={async () => { <Button
try { variant="outline"
await invoke("remove_mcp_from_claude_desktop"); size="sm"
setMcpInClaudeDesktop(false); onClick={async () => {
showSuccessToast( try {
t("integrations.mcp.removedFromClaudeDesktop"), await invoke("remove_mcp_from_claude_desktop");
); setMcpInClaudeDesktop(false);
} catch (e) { showSuccessToast(
showErrorToast(String(e)); t("integrations.mcp.removedFromClaudeDesktop"),
} );
}} } catch (e) {
> showErrorToast(String(e));
{t("integrations.mcp.removeFromClaudeDesktop")} }
</Button> }}
) : ( >
<Button {t("integrations.mcp.removeFromClaudeDesktop")}
variant="outline" </Button>
size="sm" ) : (
className="w-full" <Button
onClick={async () => { variant="outline"
try { size="sm"
await invoke("add_mcp_to_claude_desktop"); onClick={async () => {
setMcpInClaudeDesktop(true); try {
showSuccessToast( await invoke("add_mcp_to_claude_desktop");
t("integrations.mcp.addedToClaudeDesktop"), setMcpInClaudeDesktop(true);
); showSuccessToast(
} catch (e) { t("integrations.mcp.addedToClaudeDesktop"),
showErrorToast(String(e)); );
} } catch (e) {
}} showErrorToast(String(e));
> }
{t("integrations.mcp.addToClaudeDesktop")} }}
</Button> >
)} {t("integrations.mcp.addToClaudeDesktop")}
</div> </Button>
)}
<div className="space-y-2 pt-1 border-t"> </div>
<p className="text-xs font-medium text-muted-foreground"> <div className="flex items-center justify-between gap-x-3 py-1">
{t("integrations.mcp.claudeCodeTitle")} <span className="text-sm">
</p> {t("integrations.mcp.claudeCodeTitle")}
{mcpInClaudeCode ? ( </span>
<Button {mcpInClaudeCode ? (
variant="outline" <Button
size="sm" variant="outline"
className="w-full" size="sm"
onClick={async () => { onClick={async () => {
try { try {
await invoke("remove_mcp_from_claude_code"); await invoke("remove_mcp_from_claude_code");
setMcpInClaudeCode(false); setMcpInClaudeCode(false);
showSuccessToast( showSuccessToast(
t("integrations.mcp.removedFromClaudeCode"), t("integrations.mcp.removedFromClaudeCode"),
); );
} catch (e) { } catch (e) {
showErrorToast(String(e)); showErrorToast(String(e));
} }
}} }}
> >
{t("integrations.mcp.removeFromClaudeCode")} {t("integrations.mcp.removeFromClaudeCode")}
</Button> </Button>
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full" onClick={async () => {
onClick={async () => { try {
try { await invoke("add_mcp_to_claude_code");
await invoke("add_mcp_to_claude_code"); setMcpInClaudeCode(true);
setMcpInClaudeCode(true); showSuccessToast(
showSuccessToast( t("integrations.mcp.addedToClaudeCode"),
t("integrations.mcp.addedToClaudeCode"), );
); } catch (e) {
} catch (e) { showErrorToast(String(e));
showErrorToast(String(e)); }
} }}
}} >
> {t("integrations.mcp.addToClaudeCode")}
{t("integrations.mcp.addToClaudeCode")} </Button>
</Button> )}
)} </div>
</div> </div>
</div> </div>
)} )}
</TabsContent> </AnimatedTabsContent>
</Tabs> </AnimatedTabs>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+17 -3
View File
@@ -416,6 +416,10 @@ function DnsCell({
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" }, { value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" }, { value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
]; ];
const currentLabel =
level === null
? null
: (LEVELS.find((l) => l.value === level)?.labelKey ?? null);
const onPick = async (nextLevel: string | null) => { const onPick = async (nextLevel: string | null) => {
setIsSaving(true); setIsSaving(true);
@@ -446,8 +450,8 @@ function DnsCell({
} }
> >
<FiWifi className="size-3 shrink-0" /> <FiWifi className="size-3 shrink-0" />
<span className="flex-1 truncate uppercase text-[10px] font-mono tracking-wide"> <span className="flex-1 truncate text-[11px] tracking-wide">
{level ?? "—"} {currentLabel ? meta.t(currentLabel) : "—"}
</span> </span>
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" /> <LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button> </button>
@@ -2887,6 +2891,14 @@ export function ProfilesDataTable({
<div <div
ref={scrollParentRef} ref={scrollParentRef}
className="overflow-auto relative flex-1 min-h-0 scroll-fade" className="overflow-auto relative flex-1 min-h-0 scroll-fade"
style={
{
// Sticky table header is 32px tall (h-8); shift the top
// fade band below it so the header stays fully opaque and
// only body rows fade as they scroll past.
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
> >
<Table className="table-fixed"> <Table className="table-fixed">
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0"> <TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
@@ -3003,7 +3015,9 @@ export function ProfilesDataTable({
/> />
{profileForInfoDialog && {profileForInfoDialog &&
(() => { (() => {
const infoProfile = profileForInfoDialog; const infoProfile =
profiles.find((p) => p.id === profileForInfoDialog.id) ??
profileForInfoDialog;
const infoIsRunning = const infoIsRunning =
browserState.isClient && runningProfiles.has(infoProfile.id); browserState.isClient && runningProfiles.has(infoProfile.id);
const infoIsLaunching = launchingProfiles.has(infoProfile.id); const infoIsLaunching = launchingProfiles.has(infoProfile.id);
+107 -81
View File
@@ -16,7 +16,6 @@ import {
LuGroup, LuGroup,
LuKey, LuKey,
LuLink, LuLink,
LuLock,
LuLockOpen, LuLockOpen,
LuPlus, LuPlus,
LuPuzzle, LuPuzzle,
@@ -33,6 +32,7 @@ import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -48,13 +48,9 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { WayfernConfigForm } from "@/components/wayfern-config-form"; import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { translateBackendError } from "@/lib/backend-errors"; import { translateBackendError } from "@/lib/backend-errors";
import { import { getProfileIcon } from "@/lib/browser-utils";
getBrowserDisplayName,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils"; import { formatRelativeTime } from "@/lib/flag-utils";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { import type {
BrowserProfile, BrowserProfile,
@@ -94,7 +90,7 @@ interface ProfileInfoDialogProps {
syncStatuses: Record<string, { status: string; error?: string }>; syncStatuses: Record<string, { status: string; error?: string }>;
} }
function OSIcon({ os }: { os: string }) { function _OSIcon({ os }: { os: string }) {
switch (os) { switch (os) {
case "macos": case "macos":
return <FaApple className="size-3.5" />; return <FaApple className="size-3.5" />;
@@ -290,12 +286,8 @@ export function ProfileInfoDialog({
action(); action();
}; };
const releaseLabel =
profile.release_type.charAt(0).toUpperCase() +
profile.release_type.slice(1);
const hasTags = profile.tags && profile.tags.length > 0; const hasTags = profile.tags && profile.tags.length > 0;
const hasNote = !!profile.note; const hasNote = !!profile.note;
const showCrossOs = isCrossOsProfile(profile);
// Items in the settings tab `actions` list MUST only open another dialog // 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 // (or trigger a navigation/action that closes this one). Do NOT put inline
@@ -491,10 +483,8 @@ export function ProfileInfoDialog({
<ProfileInfoLayout <ProfileInfoLayout
profile={profile} profile={profile}
ProfileIcon={ProfileIcon} ProfileIcon={ProfileIcon}
releaseLabel={releaseLabel}
isRunning={isRunning} isRunning={isRunning}
isDisabled={isDisabled} isDisabled={isDisabled}
showCrossOs={showCrossOs}
networkLabel={networkLabel} networkLabel={networkLabel}
groupName={groupName} groupName={groupName}
extensionGroupName={extensionGroupName} extensionGroupName={extensionGroupName}
@@ -520,10 +510,8 @@ export function ProfileInfoDialog({
interface ProfileInfoLayoutProps { interface ProfileInfoLayoutProps {
profile: BrowserProfile; profile: BrowserProfile;
ProfileIcon: React.ComponentType<{ className?: string }>; ProfileIcon: React.ComponentType<{ className?: string }>;
releaseLabel: string;
isRunning: boolean; isRunning: boolean;
isDisabled: boolean; isDisabled: boolean;
showCrossOs: boolean;
networkLabel: string; networkLabel: string;
groupName: string | null; groupName: string | null;
extensionGroupName: string | null; extensionGroupName: string | null;
@@ -564,10 +552,8 @@ type ProfileSection =
function ProfileInfoLayout({ function ProfileInfoLayout({
profile, profile,
ProfileIcon, ProfileIcon,
releaseLabel,
isRunning, isRunning,
isDisabled, isDisabled,
showCrossOs,
networkLabel, networkLabel,
groupName, groupName,
extensionGroupName, extensionGroupName,
@@ -798,63 +784,8 @@ function ProfileInfoLayout({
</h3> </h3>
</div> </div>
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px]"> <div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px]">
<span className="font-mono uppercase text-muted-foreground"> <span className="font-mono text-muted-foreground">
{getBrowserDisplayName(profile.browser)} {profile.version}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{groupName ?? t("profileInfo.values.none")}
</span>
{isRunning && (
<>
<span className="text-muted-foreground">·</span>
<span className="inline-flex items-center gap-1 text-success">
<span className="size-1.5 rounded-full bg-success" />
{t("common.status.running")}
</span>
</>
)}
{profile.ephemeral && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground uppercase">
{t("profiles.ephemeralBadge")}
</span>
</>
)}
{profile.password_protected && (
<>
<span className="text-muted-foreground">·</span>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<LuLock className="size-3" />
{t("profiles.passwordProtectedBadge")}
</span>
</>
)}
{showCrossOs && (
<>
<span className="text-muted-foreground">·</span>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<OSIcon
os={
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
""
}
/>
{getOSDisplayName(
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
"",
)}
</span>
</>
)}
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{releaseLabel}
</span> </span>
</div> </div>
</div> </div>
@@ -1716,7 +1647,7 @@ function FingerprintSectionInline({
<SharedCamoufoxConfigForm <SharedCamoufoxConfigForm
config={camoufoxConfig} config={camoufoxConfig}
onConfigChange={onCamoufoxChange} onConfigChange={onCamoufoxChange}
forceAdvanced={false} forceAdvanced={true}
readOnly={isDisabled} readOnly={isDisabled}
browserType="camoufox" browserType="camoufox"
crossOsUnlocked={crossOsUnlocked} crossOsUnlocked={crossOsUnlocked}
@@ -1729,6 +1660,7 @@ function FingerprintSectionInline({
<WayfernConfigForm <WayfernConfigForm
config={wayfernConfig} config={wayfernConfig}
onConfigChange={onWayfernChange} onConfigChange={onWayfernChange}
forceAdvanced={true}
readOnly={isDisabled} readOnly={isDisabled}
crossOsUnlocked={crossOsUnlocked} crossOsUnlocked={crossOsUnlocked}
profileVersion={profile.version} profileVersion={profile.version}
@@ -1739,7 +1671,7 @@ function FingerprintSectionInline({
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-destructive">{error}</p>}
{success && !error && <p className="text-xs text-success">{success}</p>} {success && !error && <p className="text-xs text-success">{success}</p>}
<div className="flex items-center gap-2 sticky bottom-0 bg-background pt-2 -mx-3 px-3 -mb-3 pb-3 border-t border-border"> <div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<Button <Button
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
@@ -1790,6 +1722,30 @@ function SecuritySectionInline({
const [isSubmitting, setIsSubmitting] = React.useState(false); const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [success, setSuccess] = React.useState<string | null>(null); const [success, setSuccess] = React.useState<string | null>(null);
const [isVerifyOpen, setIsVerifyOpen] = React.useState(false);
const [verifyPassword, setVerifyPassword] = React.useState("");
const [isVerifying, setIsVerifying] = React.useState(false);
const onVerify = async () => {
setIsVerifying(true);
try {
await invoke("verify_profile_password", {
profileId: profile.id,
password: verifyPassword,
});
showSuccessToast(t("profilePassword.verifyDialog.matchToast"));
setIsVerifyOpen(false);
setVerifyPassword("");
} catch (e) {
const message = translateBackendError(
t as unknown as Parameters<typeof translateBackendError>[0],
e,
);
showErrorToast(message);
} finally {
setIsVerifying(false);
}
};
// Reset the form whenever the underlying profile state changes (e.g. the // Reset the form whenever the underlying profile state changes (e.g. the
// user just set a password — flip to "change" mode and clear fields). // user just set a password — flip to "change" mode and clear fields).
@@ -1837,24 +1793,29 @@ function SecuritySectionInline({
profileId: profile.id, profileId: profile.id,
password, password,
}); });
setSuccess(t("profilePassword.toasts.set")); showSuccessToast(t("profilePassword.toasts.set"));
} else if (mode === "change") { } else if (mode === "change") {
await invoke("change_profile_password", { await invoke("change_profile_password", {
profileId: profile.id, profileId: profile.id,
oldPassword, oldPassword,
newPassword: password, newPassword: password,
}); });
setSuccess(t("profilePassword.toasts.changed")); showSuccessToast(t("profilePassword.toasts.changed"));
} else { } else {
await invoke("remove_profile_password", { await invoke("remove_profile_password", {
profileId: profile.id, profileId: profile.id,
password: oldPassword, password: oldPassword,
}); });
setSuccess(t("profilePassword.toasts.removed")); showSuccessToast(t("profilePassword.toasts.removed"));
} }
reset(); reset();
} catch (e) { } catch (e) {
setError(String(e)); const message = translateBackendError(
t as unknown as Parameters<typeof translateBackendError>[0],
e,
);
setError(message);
showErrorToast(message);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -1874,6 +1835,19 @@ function SecuritySectionInline({
{profile.password_protected && ( {profile.password_protected && (
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button
type="button"
onClick={() => {
setVerifyPassword("");
setIsVerifyOpen(true);
}}
className={cn(
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
"border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
)}
>
{t("profilePassword.modes.validate")}
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -1973,6 +1947,58 @@ function SecuritySectionInline({
? t("profilePassword.modes.change") ? t("profilePassword.modes.change")
: t("profilePassword.modes.remove")} : t("profilePassword.modes.remove")}
</Button> </Button>
<Dialog
open={isVerifyOpen}
onOpenChange={(open) => {
if (!isVerifying) {
setIsVerifyOpen(open);
if (!open) setVerifyPassword("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("profilePassword.verifyDialog.title")}</DialogTitle>
<DialogDescription>
{t("profilePassword.verifyDialog.description")}
</DialogDescription>
</DialogHeader>
<Input
type="password"
placeholder={t("profilePassword.fields.currentPassword")}
value={verifyPassword}
autoFocus
onChange={(e) => setVerifyPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && verifyPassword.length > 0) {
e.preventDefault();
void onVerify();
}
}}
/>
<DialogFooter>
<Button
variant="outline"
disabled={isVerifying}
onClick={() => {
setIsVerifyOpen(false);
setVerifyPassword("");
}}
>
{t("common.buttons.cancel")}
</Button>
<Button
disabled={isVerifying || verifyPassword.length === 0}
onClick={() => void onVerify()}
>
{isVerifying
? t("common.buttons.loading")
: t("profilePassword.verifyDialog.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
File diff suppressed because it is too large. Load diff
+3 -13
View File
@@ -236,9 +236,11 @@ interface RailItem {
const TOP_ITEMS: RailItem[] = [ const TOP_ITEMS: RailItem[] = [
{ page: "profiles", Icon: LuUser, labelKey: "rail.profiles" }, { 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: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" }, { page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
{ page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" },
{ page: "account", Icon: LuCloud, labelKey: "rail.account" },
]; ];
interface MoreMenuItem { interface MoreMenuItem {
@@ -255,18 +257,6 @@ const MORE_ITEMS: MoreMenuItem[] = [
labelKey: "rail.more.importProfile", labelKey: "rail.more.importProfile",
hintKey: "rail.more.importProfileHint", 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) { export function RailNav({ currentPage, onNavigate }: RailNavProps) {
+114 -1
View File
@@ -24,6 +24,7 @@ import {
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -132,6 +133,9 @@ export function SettingsDialog({
const [e2eError, setE2eError] = useState(""); const [e2eError, setE2eError] = useState("");
const [isSavingE2e, setIsSavingE2e] = useState(false); const [isSavingE2e, setIsSavingE2e] = useState(false);
const [isRemovingE2e, setIsRemovingE2e] = 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<{ const [systemInfo, setSystemInfo] = useState<{
app_version: string; app_version: string;
os: string; os: string;
@@ -991,7 +995,18 @@ export function SettingsDialog({
{t("settings.encryption.passwordSetDescription")} {t("settings.encryption.passwordSetDescription")}
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
disabled={isRemovingE2e}
onClick={() => {
setVerifyE2ePassword("");
setIsVerifyE2eOpen(true);
}}
>
{t("settings.encryption.validatePassword")}
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -1317,6 +1332,104 @@ export function SettingsDialog({
isOpen={dnsBlocklistDialogOpen} isOpen={dnsBlocklistDialogOpen}
onClose={() => setDnsBlocklistDialogOpen(false)} onClose={() => setDnsBlocklistDialogOpen(false)}
/> />
<Dialog
open={isVerifyE2eOpen}
onOpenChange={(open) => {
if (!isVerifyingE2e) {
setIsVerifyE2eOpen(open);
if (!open) setVerifyE2ePassword("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("settings.encryption.validateDialog.title")}
</DialogTitle>
<DialogDescription>
{t("settings.encryption.validateDialog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Input
type="password"
placeholder={t("settings.encryption.passwordPlaceholder")}
value={verifyE2ePassword}
autoFocus
onChange={(e) => setVerifyE2ePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && verifyE2ePassword.length > 0) {
e.preventDefault();
void (async () => {
setIsVerifyingE2e(true);
try {
const ok = await invoke<boolean>("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);
}
})();
}
}}
/>
</div>
<DialogFooter>
<Button
variant="outline"
disabled={isVerifyingE2e}
onClick={() => {
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
}}
>
{t("common.buttons.cancel")}
</Button>
<LoadingButton
isLoading={isVerifyingE2e}
disabled={verifyE2ePassword.length === 0}
onClick={async () => {
setIsVerifyingE2e(true);
try {
const ok = await invoke<boolean>("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")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }
+3 -2
View File
@@ -23,6 +23,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Select, Select,
@@ -590,7 +591,7 @@ export function TrafficDetailsDialog({
<h3 className="text-sm font-medium mb-2"> <h3 className="text-sm font-medium mb-2">
{t("traffic.uniqueIps", { count: stats.unique_ips.length })} {t("traffic.uniqueIps", { count: stats.unique_ips.length })}
</h3> </h3>
<div className="border rounded-md p-3 max-h-[120px] overflow-y-auto"> <FadingScrollArea className="p-3 max-h-[120px]">
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{stats.unique_ips.map((ip) => ( {stats.unique_ips.map((ip) => (
<span <span
@@ -601,7 +602,7 @@ export function TrafficDetailsDialog({
</span> </span>
))} ))}
</div> </div>
</div> </FadingScrollArea>
</div> </div>
)} )}
+50
View File
@@ -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<typeof SwitchPrimitive.Root>;
/**
* 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 (
<SwitchPrimitive.Root
data-slot="animated-switch"
className={cn(
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent",
"bg-input data-[state=checked]:bg-primary",
"transition-colors duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<MotionThumb
data-slot="animated-switch-thumb"
className={cn(
"pointer-events-none block size-4 rounded-full shadow-sm ring-0",
"bg-background data-[state=checked]:bg-primary-foreground",
)}
layout
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
whileTap={{ width: 22 }}
style={{ marginLeft: 2, marginRight: 2 }}
/>
</SwitchPrimitive.Root>
);
}
export type { AnimatedSwitchProps };
export { AnimatedSwitch };
+156
View File
@@ -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<AnimatedTabsContextValue | null>(null);
function useAnimatedTabs() {
const ctx = React.useContext(AnimatedTabsContext);
if (!ctx) {
throw new Error(
"AnimatedTabsTrigger must be rendered inside <AnimatedTabs>",
);
}
return ctx;
}
type AnimatedTabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
function AnimatedTabs({
value: valueProp,
defaultValue,
onValueChange,
children,
...props
}: AnimatedTabsProps) {
const [activeValue, setActiveValue] = useControlledState({
value: valueProp,
defaultValue,
onChange: onValueChange,
});
const [hoveredValue, setHoveredValue] = React.useState<string | null>(null);
const indicatorId = React.useId();
return (
<AnimatedTabsContext.Provider
value={{
activeValue,
hoveredValue,
setHoveredValue,
indicatorId,
}}
>
<TabsPrimitive.Root
data-slot="animated-tabs"
value={activeValue}
defaultValue={defaultValue}
onValueChange={setActiveValue}
{...props}
>
{children}
</TabsPrimitive.Root>
</AnimatedTabsContext.Provider>
);
}
type AnimatedTabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
function AnimatedTabsList({
className,
onMouseLeave,
...props
}: AnimatedTabsListProps) {
const { setHoveredValue } = useAnimatedTabs();
return (
<TabsPrimitive.List
data-slot="animated-tabs-list"
className={cn(
"relative inline-flex items-center gap-1 rounded-md p-0",
className,
)}
onMouseLeave={(event) => {
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 (
<TabsPrimitive.Trigger
data-slot="animated-tabs-trigger"
value={value}
onMouseEnter={(event) => {
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 && (
<motion.span
layoutId={`animated-tabs-indicator-${indicatorId}`}
className="absolute inset-0 -z-10 rounded-md bg-accent"
transition={{ type: "spring", stiffness: 360, damping: 32 }}
/>
)}
{children}
</TabsPrimitive.Trigger>
);
}
const AnimatedTabsContent = TabsPrimitive.Content;
export type {
AnimatedTabsListProps,
AnimatedTabsProps,
AnimatedTabsTriggerProps,
};
export {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
};
+32
View File
@@ -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<HTMLDivElement>;
/**
* Scrollable container with top/bottom fade overlays. The fades only become
* visible when the matching direction is actually scrollable. Use in place
* of `<div className="border rounded-md max-h-[...] overflow-auto">` for
* lists that should match the borderless aesthetic of the profile table.
*/
export function FadingScrollArea({
className,
children,
...props
}: FadingScrollAreaProps) {
const ref = useRef<HTMLDivElement>(null);
useScrollFade(ref);
return (
<div
ref={ref}
className={cn("overflow-y-auto scroll-fade", className)}
{...props}
>
{children}
</div>
);
}
+11 -8
View File
@@ -38,6 +38,7 @@ export function useGroupEvents() {
// Initial load and event listeners setup // Initial load and event listeners setup
useEffect(() => { useEffect(() => {
let groupsUnlisten: (() => void) | undefined; let groupsUnlisten: (() => void) | undefined;
let profilesUnlisten: (() => void) | undefined;
const setupListeners = async () => { const setupListeners = async () => {
try { try {
@@ -51,19 +52,13 @@ export function useGroupEvents() {
}); });
// Also listen for profile changes since groups show profile counts // Also listen for profile changes since groups show profile counts
const profilesUnlisten = await listen("profiles-changed", () => { profilesUnlisten = await listen("profiles-changed", () => {
console.log( console.log(
"Received profiles-changed event, reloading groups for updated counts", "Received profiles-changed event, reloading groups for updated counts",
); );
void loadGroups(); void loadGroups();
}); });
// Store both listeners for cleanup
groupsUnlisten = () => {
groupsUnlisten?.();
profilesUnlisten();
};
console.log("Group event listeners set up successfully"); console.log("Group event listeners set up successfully");
} catch (err) { } catch (err) {
console.error("Failed to setup group event listeners:", err); console.error("Failed to setup group event listeners:", err);
@@ -79,9 +74,17 @@ export function useGroupEvents() {
void setupListeners(); 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 () => { return () => {
if (groupsUnlisten) groupsUnlisten(); if (groupsUnlisten) groupsUnlisten();
if (profilesUnlisten) profilesUnlisten();
}; };
}, [loadGroups]); }, [loadGroups]);
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Downloading...", "downloading": "Downloading...",
"minimize": "Minimize", "minimize": "Minimize",
"saving": "Saving…", "saving": "Saving…",
"saved": "Saved" "saved": "Saved",
"copied": "Copied"
}, },
"status": { "status": {
"active": "Active", "active": "Active",
@@ -158,7 +159,15 @@
"passwordSaved": "Encryption password set", "passwordSaved": "Encryption password set",
"passwordMismatch": "Passwords do not match", "passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters", "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": { "commercial": {
"title": "Commercial License", "title": "Commercial License",
@@ -376,6 +385,9 @@
"deleteFailed": "Failed to delete proxy", "deleteFailed": "Failed to delete proxy",
"deleteTitle": "Delete Proxy", "deleteTitle": "Delete Proxy",
"deleteDescription": "This action cannot be undone. This will permanently delete the proxy \"{{name}}\".", "deleteDescription": "This action cannot be undone. This will permanently delete the proxy \"{{name}}\".",
"newProxy": "New proxy",
"newVpn": "New VPN",
"protocolCol": "Protocol",
"title": "Proxies & VPNs" "title": "Proxies & VPNs"
}, },
"add": "Add Proxy", "add": "Add Proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continue", "continueButton": "Continue",
"doneButton": "Done", "doneButton": "Done",
"failed": "Failed to import proxies" "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": { "groups": {
@@ -488,10 +507,8 @@
"add": "Add Group", "add": "Add Group",
"edit": "Edit Group", "edit": "Edit Group",
"delete": "Delete Group", "delete": "Delete Group",
"defaultGroup": "Default", "moveToDefault": "Remove profiles from group",
"defaultGroupNoGroup": "Default (No Group)", "noGroupDescription": "Profiles without a group appear in the \"All\" filter.",
"moveToDefault": "Move profiles to Default group",
"noGroupDescription": "Profiles without a group will appear in the \"Default\" group.",
"assignSuccess": "Successfully assigned {{count}} profile(s) to {{group}}", "assignSuccess": "Successfully assigned {{count}} profile(s) to {{group}}",
"noGroups": "No groups created", "noGroups": "No groups created",
"noGroupsDescription": "Create a group to organize your profiles.", "noGroupsDescription": "Create a group to organize your profiles.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Loading associated profiles...", "loadingProfiles": "Loading associated profiles...",
"associatedProfiles": "Associated Profiles ({{count}})", "associatedProfiles": "Associated Profiles ({{count}})",
"whatToDoWithProfiles": "What should happen to these profiles?", "whatToDoWithProfiles": "What should happen to these profiles?",
"moveToDefaultOption": "Move profiles to Default group",
"deleteAlongWithGroup": "Delete profiles along with the group", "deleteAlongWithGroup": "Delete profiles along with the group",
"noAssociatedProfiles": "This group has no associated profiles.", "noAssociatedProfiles": "This group has no associated profiles.",
"deleteGroup": "Delete Group", "deleteGroup": "Delete Group",
@@ -530,7 +546,10 @@
"unknownGroup": "Unknown Group", "unknownGroup": "Unknown Group",
"profileGroupsAriaLabel": "Profile groups", "profileGroupsAriaLabel": "Profile groups",
"loading": "Loading 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": { "sync": {
"mode": { "mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Added to Claude Code", "addedToClaudeCode": "Added to Claude Code",
"removedFromClaudeCode": "Removed from Claude Code", "removedFromClaudeCode": "Removed from Claude Code",
"config": "MCP Configuration", "config": "MCP Configuration",
"copyConfig": "Copy Configuration" "copyConfig": "Copy Configuration",
"clientsLabel": "Clients"
}, },
"tabApi": "Local API", "tabApi": "Local API",
"tabMcp": "MCP (AI Assistants)", "tabMcp": "MCP (AI Assistants)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP server started on port {{port}}", "mcpStarted": "MCP server started on port {{port}}",
"mcpStopped": "MCP server stopped", "mcpStopped": "MCP server stopped",
"mcpToggleFailed": "Failed to toggle MCP server", "mcpToggleFailed": "Failed to toggle MCP server",
"openSettings": "Open Integrations Settings" "openSettings": "Open Integrations Settings",
"apiRunningOn": "Running on",
"apiExampleRequest": "Example request"
}, },
"import": { "import": {
"title": "Import Profile", "title": "Import Profile",
@@ -1181,6 +1203,7 @@
"empty": "No extensions uploaded yet.", "empty": "No extensions uploaded yet.",
"noGroups": "No extension groups created yet.", "noGroups": "No extension groups created yet.",
"createGroup": "Create Group", "createGroup": "Create Group",
"newGroup": "New group",
"addToGroup": "Add extension...", "addToGroup": "Add extension...",
"removeFromGroup": "Remove from group", "removeFromGroup": "Remove from group",
"deleteGroup": "Delete group", "deleteGroup": "Delete group",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Enable sync", "syncEnableTooltip": "Enable sync",
"syncDisableTooltip": "Disable sync", "syncDisableTooltip": "Disable sync",
"loadGroupsFailed": "Failed to load extension groups", "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": { "pro": {
"badge": "PRO", "badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "Waiting to sync", "waiting": "Waiting to sync",
"errorWith": "Sync error: {{error}}", "errorWith": "Sync error: {{error}}",
"error": "Sync 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": { "groupManagement": {
"description": "Manage your profile groups", "description": "Manage your profile groups",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "Sync cannot be disabled while this group is used by synced profiles", "syncCannotDisable": "Sync cannot be disabled while this group is used by synced profiles",
"editGroupTooltip": "Edit group", "editGroupTooltip": "Edit group",
"deleteGroupTooltip": "Delete 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": { "proxyAssignment": {
"title": "Assign Proxy / VPN", "title": "Assign Proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": { "modes": {
"set": "Set", "set": "Set",
"change": "Change", "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": { "backendErrors": {
@@ -1749,7 +1796,6 @@
}, },
"rail": { "rail": {
"profiles": "Profiles", "profiles": "Profiles",
"proxies": "Proxies",
"extensions": "Extensions", "extensions": "Extensions",
"groups": "Groups", "groups": "Groups",
"settings": "Settings", "settings": "Settings",
@@ -1757,18 +1803,17 @@
"label": "More", "label": "More",
"closeAriaLabel": "Close menu", "closeAriaLabel": "Close menu",
"importProfile": "Import profile", "importProfile": "Import profile",
"importProfileHint": "Bring profiles from another tool", "importProfileHint": "Bring profiles from another tool"
"integrations": "Integrations", },
"integrationsHint": "Slack, MCP, automations", "network": "Network",
"account": "Account", "integrations": "Integrations",
"accountHint": "Cloud, billing, sign-in" "account": "Account"
}
}, },
"pageTitle": { "pageTitle": {
"proxies": "Proxies", "proxies": "Network",
"extensions": "Extensions", "extensions": "Extensions",
"groups": "Groups", "groups": "Groups",
"vpns": "VPNs", "vpns": "Network",
"settings": "Settings", "settings": "Settings",
"integrations": "Integrations", "integrations": "Integrations",
"account": "Account", "account": "Account",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Descargando...", "downloading": "Descargando...",
"minimize": "Minimizar", "minimize": "Minimizar",
"saving": "Guardando…", "saving": "Guardando…",
"saved": "Guardado" "saved": "Guardado",
"copied": "Copiado"
}, },
"status": { "status": {
"active": "Activo", "active": "Activo",
@@ -158,7 +159,15 @@
"passwordSaved": "Contraseña de cifrado establecida", "passwordSaved": "Contraseña de cifrado establecida",
"passwordMismatch": "Las contraseñas no coinciden", "passwordMismatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres", "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": { "commercial": {
"title": "Licencia Comercial", "title": "Licencia Comercial",
@@ -376,6 +385,9 @@
"deleteFailed": "Error al eliminar el proxy", "deleteFailed": "Error al eliminar el proxy",
"deleteTitle": "Eliminar proxy", "deleteTitle": "Eliminar proxy",
"deleteDescription": "Esta acción no se puede deshacer. Se eliminará permanentemente el proxy \"{{name}}\".", "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" "title": "Proxies y VPN"
}, },
"add": "Agregar Proxy", "add": "Agregar Proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continuar", "continueButton": "Continuar",
"doneButton": "Hecho", "doneButton": "Hecho",
"failed": "Error al importar los proxies" "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": { "groups": {
@@ -488,10 +507,8 @@
"add": "Agregar Grupo", "add": "Agregar Grupo",
"edit": "Editar Grupo", "edit": "Editar Grupo",
"delete": "Eliminar Grupo", "delete": "Eliminar Grupo",
"defaultGroup": "Predeterminado", "moveToDefault": "Quitar perfiles del grupo",
"defaultGroupNoGroup": "Predeterminado (Sin Grupo)", "noGroupDescription": "Los perfiles sin grupo aparecen en el filtro «Todos».",
"moveToDefault": "Mover perfiles al grupo Predeterminado",
"noGroupDescription": "Los perfiles sin grupo aparecerán en el grupo \"Predeterminado\".",
"assignSuccess": "Se asignaron {{count}} perfil(es) a {{group}} exitosamente", "assignSuccess": "Se asignaron {{count}} perfil(es) a {{group}} exitosamente",
"noGroups": "No hay grupos creados", "noGroups": "No hay grupos creados",
"noGroupsDescription": "Crea un grupo para organizar tus perfiles.", "noGroupsDescription": "Crea un grupo para organizar tus perfiles.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Cargando perfiles asociados...", "loadingProfiles": "Cargando perfiles asociados...",
"associatedProfiles": "Perfiles Asociados ({{count}})", "associatedProfiles": "Perfiles Asociados ({{count}})",
"whatToDoWithProfiles": "¿Qué hacer con estos perfiles?", "whatToDoWithProfiles": "¿Qué hacer con estos perfiles?",
"moveToDefaultOption": "Mover perfiles al grupo Predeterminado",
"deleteAlongWithGroup": "Eliminar perfiles junto con el grupo", "deleteAlongWithGroup": "Eliminar perfiles junto con el grupo",
"noAssociatedProfiles": "Este grupo no tiene perfiles asociados.", "noAssociatedProfiles": "Este grupo no tiene perfiles asociados.",
"deleteGroup": "Eliminar Grupo", "deleteGroup": "Eliminar Grupo",
@@ -530,7 +546,10 @@
"unknownGroup": "Grupo desconocido", "unknownGroup": "Grupo desconocido",
"profileGroupsAriaLabel": "Grupos de perfiles", "profileGroupsAriaLabel": "Grupos de perfiles",
"loading": "Cargando grupos...", "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": { "sync": {
"mode": { "mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Agregado a Claude Code", "addedToClaudeCode": "Agregado a Claude Code",
"removedFromClaudeCode": "Eliminado de Claude Code", "removedFromClaudeCode": "Eliminado de Claude Code",
"config": "Configuración MCP", "config": "Configuración MCP",
"copyConfig": "Copiar Configuración" "copyConfig": "Copiar Configuración",
"clientsLabel": "Clientes"
}, },
"tabApi": "API local", "tabApi": "API local",
"tabMcp": "MCP (asistentes IA)", "tabMcp": "MCP (asistentes IA)",
@@ -675,7 +695,9 @@
"mcpStarted": "Servidor MCP iniciado en puerto {{port}}", "mcpStarted": "Servidor MCP iniciado en puerto {{port}}",
"mcpStopped": "Servidor MCP detenido", "mcpStopped": "Servidor MCP detenido",
"mcpToggleFailed": "Error al alternar el servidor MCP", "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": { "import": {
"title": "Importar Perfil", "title": "Importar Perfil",
@@ -1181,6 +1203,7 @@
"empty": "No se han subido extensiones aún.", "empty": "No se han subido extensiones aún.",
"noGroups": "No se han creado grupos de extensiones aún.", "noGroups": "No se han creado grupos de extensiones aún.",
"createGroup": "Crear Grupo", "createGroup": "Crear Grupo",
"newGroup": "Nuevo grupo",
"addToGroup": "Agregar extensión...", "addToGroup": "Agregar extensión...",
"removeFromGroup": "Eliminar del grupo", "removeFromGroup": "Eliminar del grupo",
"deleteGroup": "Eliminar grupo", "deleteGroup": "Eliminar grupo",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Habilitar sincronización", "syncEnableTooltip": "Habilitar sincronización",
"syncDisableTooltip": "Deshabilitar sincronización", "syncDisableTooltip": "Deshabilitar sincronización",
"loadGroupsFailed": "Error al cargar grupos de extensiones", "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": { "pro": {
"badge": "PRO", "badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "En espera de sincronización", "waiting": "En espera de sincronización",
"errorWith": "Error de sincronización: {{error}}", "errorWith": "Error de sincronización: {{error}}",
"error": "Error de sincronización", "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": { "groupManagement": {
"description": "Administra tus grupos de perfiles", "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", "syncCannotDisable": "No se puede desactivar la sincronización mientras este grupo esté en uso por perfiles sincronizados",
"editGroupTooltip": "Editar grupo", "editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Eliminar 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": { "proxyAssignment": {
"title": "Asignar proxy / VPN", "title": "Asignar proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": { "modes": {
"set": "Establecer", "set": "Establecer",
"change": "Cambiar", "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": { "backendErrors": {
@@ -1749,7 +1796,6 @@
}, },
"rail": { "rail": {
"profiles": "Perfiles", "profiles": "Perfiles",
"proxies": "Proxies",
"extensions": "Extensiones", "extensions": "Extensiones",
"groups": "Grupos", "groups": "Grupos",
"settings": "Ajustes", "settings": "Ajustes",
@@ -1757,18 +1803,17 @@
"label": "Más", "label": "Más",
"closeAriaLabel": "Cerrar menú", "closeAriaLabel": "Cerrar menú",
"importProfile": "Importar perfil", "importProfile": "Importar perfil",
"importProfileHint": "Trae perfiles de otra herramienta", "importProfileHint": "Trae perfiles de otra herramienta"
"integrations": "Integraciones", },
"integrationsHint": "Slack, MCP, automatizaciones", "network": "Red",
"account": "Cuenta", "integrations": "Integraciones",
"accountHint": "Nube, facturación, sesión" "account": "Cuenta"
}
}, },
"pageTitle": { "pageTitle": {
"proxies": "Proxies", "proxies": "Red",
"extensions": "Extensiones", "extensions": "Extensiones",
"groups": "Grupos", "groups": "Grupos",
"vpns": "VPN", "vpns": "Red",
"settings": "Ajustes", "settings": "Ajustes",
"integrations": "Integraciones", "integrations": "Integraciones",
"account": "Cuenta", "account": "Cuenta",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Téléchargement...", "downloading": "Téléchargement...",
"minimize": "Réduire", "minimize": "Réduire",
"saving": "Enregistrement…", "saving": "Enregistrement…",
"saved": "Enregistré" "saved": "Enregistré",
"copied": "Copié"
}, },
"status": { "status": {
"active": "Actif", "active": "Actif",
@@ -158,7 +159,15 @@
"passwordSaved": "Mot de passe de chiffrement défini", "passwordSaved": "Mot de passe de chiffrement défini",
"passwordMismatch": "Les mots de passe ne correspondent pas", "passwordMismatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères", "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": { "commercial": {
"title": "Licence commerciale", "title": "Licence commerciale",
@@ -376,6 +385,9 @@
"deleteFailed": "Échec de la suppression du proxy", "deleteFailed": "Échec de la suppression du proxy",
"deleteTitle": "Supprimer le proxy", "deleteTitle": "Supprimer le proxy",
"deleteDescription": "Cette action est irréversible. Le proxy « {{name}} » sera supprimé définitivement.", "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" "title": "Proxys et VPN"
}, },
"add": "Ajouter un proxy", "add": "Ajouter un proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continuer", "continueButton": "Continuer",
"doneButton": "Terminé", "doneButton": "Terminé",
"failed": "Échec de l'import des proxys" "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": { "groups": {
@@ -488,10 +507,8 @@
"add": "Ajouter un groupe", "add": "Ajouter un groupe",
"edit": "Modifier le groupe", "edit": "Modifier le groupe",
"delete": "Supprimer le groupe", "delete": "Supprimer le groupe",
"defaultGroup": "Par défaut", "moveToDefault": "Retirer les profils du groupe",
"defaultGroupNoGroup": "Par défaut (Aucun groupe)", "noGroupDescription": "Les profils sans groupe apparaissent dans le filtre « Tous ».",
"moveToDefault": "Déplacer les profils vers le groupe Par défaut",
"noGroupDescription": "Les profils sans groupe apparaîtront dans le groupe « Par défaut ».",
"assignSuccess": "{{count}} profil(s) assigné(s) à {{group}} avec succès", "assignSuccess": "{{count}} profil(s) assigné(s) à {{group}} avec succès",
"noGroups": "Aucun groupe créé", "noGroups": "Aucun groupe créé",
"noGroupsDescription": "Créez un groupe pour organiser vos profils.", "noGroupsDescription": "Créez un groupe pour organiser vos profils.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Chargement des profils associés...", "loadingProfiles": "Chargement des profils associés...",
"associatedProfiles": "Profils Associés ({{count}})", "associatedProfiles": "Profils Associés ({{count}})",
"whatToDoWithProfiles": "Que faire de ces profils ?", "whatToDoWithProfiles": "Que faire de ces profils ?",
"moveToDefaultOption": "Déplacer les profils vers le groupe Par défaut",
"deleteAlongWithGroup": "Supprimer les profils avec le groupe", "deleteAlongWithGroup": "Supprimer les profils avec le groupe",
"noAssociatedProfiles": "Ce groupe n'a pas de profils associés.", "noAssociatedProfiles": "Ce groupe n'a pas de profils associés.",
"deleteGroup": "Supprimer le Groupe", "deleteGroup": "Supprimer le Groupe",
@@ -530,7 +546,10 @@
"unknownGroup": "Groupe inconnu", "unknownGroup": "Groupe inconnu",
"profileGroupsAriaLabel": "Groupes de profils", "profileGroupsAriaLabel": "Groupes de profils",
"loading": "Chargement des groupes...", "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": { "sync": {
"mode": { "mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Ajouté à Claude Code", "addedToClaudeCode": "Ajouté à Claude Code",
"removedFromClaudeCode": "Supprimé de Claude Code", "removedFromClaudeCode": "Supprimé de Claude Code",
"config": "Configuration MCP", "config": "Configuration MCP",
"copyConfig": "Copier la configuration" "copyConfig": "Copier la configuration",
"clientsLabel": "Clients"
}, },
"tabApi": "API locale", "tabApi": "API locale",
"tabMcp": "MCP (Assistants IA)", "tabMcp": "MCP (Assistants IA)",
@@ -675,7 +695,9 @@
"mcpStarted": "Serveur MCP démarré sur le port {{port}}", "mcpStarted": "Serveur MCP démarré sur le port {{port}}",
"mcpStopped": "Serveur MCP arrêté", "mcpStopped": "Serveur MCP arrêté",
"mcpToggleFailed": "Échec du basculement du serveur MCP", "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": { "import": {
"title": "Importer un profil", "title": "Importer un profil",
@@ -1181,6 +1203,7 @@
"empty": "Aucune extension téléchargée pour l'instant.", "empty": "Aucune extension téléchargée pour l'instant.",
"noGroups": "Aucun groupe d'extensions créé pour l'instant.", "noGroups": "Aucun groupe d'extensions créé pour l'instant.",
"createGroup": "Créer un Groupe", "createGroup": "Créer un Groupe",
"newGroup": "Nouveau groupe",
"addToGroup": "Ajouter une extension...", "addToGroup": "Ajouter une extension...",
"removeFromGroup": "Retirer du groupe", "removeFromGroup": "Retirer du groupe",
"deleteGroup": "Supprimer le groupe", "deleteGroup": "Supprimer le groupe",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Activer la synchronisation", "syncEnableTooltip": "Activer la synchronisation",
"syncDisableTooltip": "Désactiver la synchronisation", "syncDisableTooltip": "Désactiver la synchronisation",
"loadGroupsFailed": "Échec du chargement des groupes d'extensions", "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": { "pro": {
"badge": "PRO", "badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "En attente de synchronisation", "waiting": "En attente de synchronisation",
"errorWith": "Erreur de synchronisation : {{error}}", "errorWith": "Erreur de synchronisation : {{error}}",
"error": "Erreur de synchronisation", "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": { "groupManagement": {
"description": "Gérez vos groupes de profils", "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", "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", "editGroupTooltip": "Modifier le groupe",
"deleteGroupTooltip": "Supprimer 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": { "proxyAssignment": {
"title": "Assigner un proxy / VPN", "title": "Assigner un proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": { "modes": {
"set": "Définir", "set": "Définir",
"change": "Modifier", "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": { "backendErrors": {
@@ -1749,7 +1796,6 @@
}, },
"rail": { "rail": {
"profiles": "Profils", "profiles": "Profils",
"proxies": "Proxys",
"extensions": "Extensions", "extensions": "Extensions",
"groups": "Groupes", "groups": "Groupes",
"settings": "Paramètres", "settings": "Paramètres",
@@ -1757,18 +1803,17 @@
"label": "Plus", "label": "Plus",
"closeAriaLabel": "Fermer le menu", "closeAriaLabel": "Fermer le menu",
"importProfile": "Importer un profil", "importProfile": "Importer un profil",
"importProfileHint": "Importer depuis un autre outil", "importProfileHint": "Importer depuis un autre outil"
"integrations": "Intégrations", },
"integrationsHint": "Slack, MCP, automatisations", "network": "Réseau",
"account": "Compte", "integrations": "Intégrations",
"accountHint": "Cloud, facturation, connexion" "account": "Compte"
}
}, },
"pageTitle": { "pageTitle": {
"proxies": "Proxys", "proxies": "Réseau",
"extensions": "Extensions", "extensions": "Extensions",
"groups": "Groupes", "groups": "Groupes",
"vpns": "VPN", "vpns": "Réseau",
"settings": "Paramètres", "settings": "Paramètres",
"integrations": "Intégrations", "integrations": "Intégrations",
"account": "Compte", "account": "Compte",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "ダウンロード中...", "downloading": "ダウンロード中...",
"minimize": "最小化", "minimize": "最小化",
"saving": "保存中…", "saving": "保存中…",
"saved": "保存しました" "saved": "保存しました",
"copied": "コピーしました"
}, },
"status": { "status": {
"active": "アクティブ", "active": "アクティブ",
@@ -158,7 +159,15 @@
"passwordSaved": "暗号化パスワードが設定されました", "passwordSaved": "暗号化パスワードが設定されました",
"passwordMismatch": "パスワードが一致しません", "passwordMismatch": "パスワードが一致しません",
"passwordTooShort": "パスワードは8文字以上である必要があります", "passwordTooShort": "パスワードは8文字以上である必要があります",
"requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。" "requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。",
"validatePassword": "確認",
"validateDialog": {
"title": "暗号化パスワードを確認",
"description": "このデバイスに保存されているパスワードと一致するか、暗号化パスワードを入力してください。",
"submit": "確認",
"matchToast": "パスワードが一致しました",
"mismatchToast": "パスワードが一致しません"
}
}, },
"commercial": { "commercial": {
"title": "商用ライセンス", "title": "商用ライセンス",
@@ -376,6 +385,9 @@
"deleteFailed": "プロキシの削除に失敗しました", "deleteFailed": "プロキシの削除に失敗しました",
"deleteTitle": "プロキシを削除", "deleteTitle": "プロキシを削除",
"deleteDescription": "この操作は取り消せません。プロキシ「{{name}}」は完全に削除されます。", "deleteDescription": "この操作は取り消せません。プロキシ「{{name}}」は完全に削除されます。",
"newProxy": "新しいプロキシ",
"newVpn": "新しいVPN",
"protocolCol": "プロトコル",
"title": "プロキシと VPN" "title": "プロキシと VPN"
}, },
"add": "プロキシを追加", "add": "プロキシを追加",
@@ -480,6 +492,13 @@
"continueButton": "続ける", "continueButton": "続ける",
"doneButton": "完了", "doneButton": "完了",
"failed": "プロキシのインポートに失敗しました" "failed": "プロキシのインポートに失敗しました"
},
"bulkDelete": {
"proxiesTitle": "選択したプロキシを削除",
"proxiesDescription": "この操作は取り消せません。{{count}} 件のプロキシを完全に削除します: {{names}}",
"vpnsTitle": "選択したVPNを削除",
"vpnsDescription": "この操作は取り消せません。{{count}} 件のVPNを完全に削除します: {{names}}",
"confirmButton": "{{count}} 件を削除"
} }
}, },
"groups": { "groups": {
@@ -488,10 +507,8 @@
"add": "グループを追加", "add": "グループを追加",
"edit": "グループを編集", "edit": "グループを編集",
"delete": "グループを削除", "delete": "グループを削除",
"defaultGroup": "デフォルト", "moveToDefault": "プロファイルをグループから外す",
"defaultGroupNoGroup": "デフォルト(グループなし)", "noGroupDescription": "グループに属さないプロファイルは「すべて」フィルターに表示されます。",
"moveToDefault": "プロファイルをデフォルトグループに移動",
"noGroupDescription": "グループに属していないプロファイルは「デフォルト」グループに表示されます。",
"assignSuccess": "{{count}} 件のプロファイルを {{group}} に割り当てました", "assignSuccess": "{{count}} 件のプロファイルを {{group}} に割り当てました",
"noGroups": "グループがありません", "noGroups": "グループがありません",
"noGroupsDescription": "プロファイルを整理するためのグループを作成してください。", "noGroupsDescription": "プロファイルを整理するためのグループを作成してください。",
@@ -521,7 +538,6 @@
"loadingProfiles": "関連するプロファイルを読み込んでいます...", "loadingProfiles": "関連するプロファイルを読み込んでいます...",
"associatedProfiles": "関連プロファイル ({{count}})", "associatedProfiles": "関連プロファイル ({{count}})",
"whatToDoWithProfiles": "これらのプロファイルをどうしますか?", "whatToDoWithProfiles": "これらのプロファイルをどうしますか?",
"moveToDefaultOption": "プロファイルをデフォルトグループに移動",
"deleteAlongWithGroup": "プロファイルもグループと一緒に削除", "deleteAlongWithGroup": "プロファイルもグループと一緒に削除",
"noAssociatedProfiles": "このグループには関連するプロファイルがありません。", "noAssociatedProfiles": "このグループには関連するプロファイルがありません。",
"deleteGroup": "グループを削除", "deleteGroup": "グループを削除",
@@ -530,7 +546,10 @@
"unknownGroup": "不明なグループ", "unknownGroup": "不明なグループ",
"profileGroupsAriaLabel": "プロファイルグループ", "profileGroupsAriaLabel": "プロファイルグループ",
"loading": "グループを読み込み中...", "loading": "グループを読み込み中...",
"all": "すべて" "all": "すべて",
"noGroup": "グループなし",
"pageTitle": "プロファイルグループ",
"pageDescription": "プロファイルグループを使うと、クライアント、環境、用途ごとにブラウザを整理できます。デバイス間でグループを同期して共有しましょう。"
}, },
"sync": { "sync": {
"mode": { "mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Claude Code に追加しました", "addedToClaudeCode": "Claude Code に追加しました",
"removedFromClaudeCode": "Claude Code から削除しました", "removedFromClaudeCode": "Claude Code から削除しました",
"config": "MCP設定", "config": "MCP設定",
"copyConfig": "設定をコピー" "copyConfig": "設定をコピー",
"clientsLabel": "クライアント"
}, },
"tabApi": "ローカル API", "tabApi": "ローカル API",
"tabMcp": "MCP (AI アシスタント)", "tabMcp": "MCP (AI アシスタント)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP サーバーをポート {{port}} で起動しました", "mcpStarted": "MCP サーバーをポート {{port}} で起動しました",
"mcpStopped": "MCP サーバーを停止しました", "mcpStopped": "MCP サーバーを停止しました",
"mcpToggleFailed": "MCP サーバーの切り替えに失敗しました", "mcpToggleFailed": "MCP サーバーの切り替えに失敗しました",
"openSettings": "統合設定を開く" "openSettings": "統合設定を開く",
"apiRunningOn": "実行中",
"apiExampleRequest": "リクエスト例"
}, },
"import": { "import": {
"title": "プロファイルをインポート", "title": "プロファイルをインポート",
@@ -1181,6 +1203,7 @@
"empty": "まだ拡張機能がアップロードされていません。", "empty": "まだ拡張機能がアップロードされていません。",
"noGroups": "まだ拡張機能グループが作成されていません。", "noGroups": "まだ拡張機能グループが作成されていません。",
"createGroup": "グループを作成", "createGroup": "グループを作成",
"newGroup": "新しいグループ",
"addToGroup": "拡張機能を追加...", "addToGroup": "拡張機能を追加...",
"removeFromGroup": "グループから削除", "removeFromGroup": "グループから削除",
"deleteGroup": "グループを削除", "deleteGroup": "グループを削除",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "同期を有効にする", "syncEnableTooltip": "同期を有効にする",
"syncDisableTooltip": "同期を無効にする", "syncDisableTooltip": "同期を無効にする",
"loadGroupsFailed": "拡張機能グループの読み込みに失敗しました", "loadGroupsFailed": "拡張機能グループの読み込みに失敗しました",
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました" "assignGroupFailed": "拡張機能グループの割り当てに失敗しました",
"bulkDelete": {
"extensionsTitle": "拡張機能を削除",
"extensionsDescription": "{{count}}件の拡張機能を削除しますか? {{names}}",
"groupsTitle": "拡張機能グループを削除",
"groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}",
"confirmButton": "削除"
}
}, },
"pro": { "pro": {
"badge": "PRO", "badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "同期待ち", "waiting": "同期待ち",
"errorWith": "同期エラー: {{error}}", "errorWith": "同期エラー: {{error}}",
"error": "同期エラー", "error": "同期エラー",
"notSynced": "未同期" "notSynced": "未同期",
"enable": "同期を有効化",
"disable": "同期を無効化",
"lockedInUse": "同期されたプロファイルが使用中のため、同期は無効化できません",
"bulkToggle": "同期を切り替え"
}, },
"groupManagement": { "groupManagement": {
"description": "プロファイルのグループを管理します", "description": "プロファイルのグループを管理します",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "このグループが同期されたプロファイルで使用されている間は同期を無効にできません", "syncCannotDisable": "このグループが同期されたプロファイルで使用されている間は同期を無効にできません",
"editGroupTooltip": "グループを編集", "editGroupTooltip": "グループを編集",
"deleteGroupTooltip": "グループを削除", "deleteGroupTooltip": "グループを削除",
"loadFailed": "グループの読み込みに失敗しました" "loadFailed": "グループの読み込みに失敗しました",
"bulkDelete": {
"title": "グループを削除",
"description": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。",
"description_one": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。",
"confirmButton": "グループを削除"
}
}, },
"proxyAssignment": { "proxyAssignment": {
"title": "プロキシ / VPN を割り当てる", "title": "プロキシ / VPN を割り当てる",
@@ -1721,7 +1761,14 @@
"modes": { "modes": {
"set": "設定", "set": "設定",
"change": "変更", "change": "変更",
"remove": "削除" "remove": "削除",
"validate": "確認"
},
"verifyDialog": {
"title": "プロファイルパスワードを確認",
"description": "ディスクに保存されているプロファイルパスワードと一致するか入力してください。",
"submit": "確認",
"matchToast": "パスワードが一致しました"
} }
}, },
"backendErrors": { "backendErrors": {
@@ -1749,7 +1796,6 @@
}, },
"rail": { "rail": {
"profiles": "プロファイル", "profiles": "プロファイル",
"proxies": "プロキシ",
"extensions": "拡張機能", "extensions": "拡張機能",
"groups": "グループ", "groups": "グループ",
"settings": "設定", "settings": "設定",
@@ -1757,18 +1803,17 @@
"label": "その他", "label": "その他",
"closeAriaLabel": "メニューを閉じる", "closeAriaLabel": "メニューを閉じる",
"importProfile": "プロファイルをインポート", "importProfile": "プロファイルをインポート",
"importProfileHint": "別のツールから取り込む", "importProfileHint": "別のツールから取り込む"
"integrations": "連携", },
"integrationsHint": "Slack、MCP、自動化", "network": "ネットワーク",
"account": "アカウント", "integrations": "連携",
"accountHint": "クラウド、請求、サインイン" "account": "アカウント"
}
}, },
"pageTitle": { "pageTitle": {
"proxies": "プロキシ", "proxies": "ネットワーク",
"extensions": "拡張機能", "extensions": "拡張機能",
"groups": "グループ", "groups": "グループ",
"vpns": "VPN", "vpns": "ネットワーク",
"settings": "設定", "settings": "設定",
"integrations": "連携", "integrations": "連携",
"account": "アカウント", "account": "アカウント",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Baixando...", "downloading": "Baixando...",
"minimize": "Minimizar", "minimize": "Minimizar",
"saving": "Salvando…", "saving": "Salvando…",
"saved": "Salvo" "saved": "Salvo",
"copied": "Copiado"
}, },
"status": { "status": {
"active": "Ativo", "active": "Ativo",
@@ -158,7 +159,15 @@
"passwordSaved": "Senha de criptografia definida", "passwordSaved": "Senha de criptografia definida",
"passwordMismatch": "As senhas não coincidem", "passwordMismatch": "As senhas não coincidem",
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres", "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": { "commercial": {
"title": "Licença Comercial", "title": "Licença Comercial",
@@ -376,6 +385,9 @@
"deleteFailed": "Falha ao excluir proxy", "deleteFailed": "Falha ao excluir proxy",
"deleteTitle": "Excluir proxy", "deleteTitle": "Excluir proxy",
"deleteDescription": "Esta ação não pode ser desfeita. O proxy \"{{name}}\" será excluído permanentemente.", "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" "title": "Proxies e VPNs"
}, },
"add": "Adicionar Proxy", "add": "Adicionar Proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continuar", "continueButton": "Continuar",
"doneButton": "Concluído", "doneButton": "Concluído",
"failed": "Falha ao importar proxies" "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": { "groups": {
@@ -488,10 +507,8 @@
"add": "Adicionar Grupo", "add": "Adicionar Grupo",
"edit": "Editar Grupo", "edit": "Editar Grupo",
"delete": "Excluir Grupo", "delete": "Excluir Grupo",
"defaultGroup": "Padrão", "moveToDefault": "Remover perfis do grupo",
"defaultGroupNoGroup": "Padrão (Sem Grupo)", "noGroupDescription": "Perfis sem grupo aparecem no filtro \"Todos\".",
"moveToDefault": "Mover perfis para o grupo Padrão",
"noGroupDescription": "Perfis sem grupo aparecerão no grupo \"Padrão\".",
"assignSuccess": "{{count}} perfil(s) atribuído(s) a {{group}} com sucesso", "assignSuccess": "{{count}} perfil(s) atribuído(s) a {{group}} com sucesso",
"noGroups": "Nenhum grupo criado", "noGroups": "Nenhum grupo criado",
"noGroupsDescription": "Crie um grupo para organizar seus perfis.", "noGroupsDescription": "Crie um grupo para organizar seus perfis.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Carregando perfis associados...", "loadingProfiles": "Carregando perfis associados...",
"associatedProfiles": "Perfis Associados ({{count}})", "associatedProfiles": "Perfis Associados ({{count}})",
"whatToDoWithProfiles": "O que fazer com esses perfis?", "whatToDoWithProfiles": "O que fazer com esses perfis?",
"moveToDefaultOption": "Mover perfis para o grupo Padrão",
"deleteAlongWithGroup": "Excluir perfis junto com o grupo", "deleteAlongWithGroup": "Excluir perfis junto com o grupo",
"noAssociatedProfiles": "Este grupo não tem perfis associados.", "noAssociatedProfiles": "Este grupo não tem perfis associados.",
"deleteGroup": "Excluir Grupo", "deleteGroup": "Excluir Grupo",
@@ -530,7 +546,10 @@
"unknownGroup": "Grupo desconhecido", "unknownGroup": "Grupo desconhecido",
"profileGroupsAriaLabel": "Grupos de perfis", "profileGroupsAriaLabel": "Grupos de perfis",
"loading": "Carregando grupos...", "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": { "sync": {
"mode": { "mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Adicionado ao Claude Code", "addedToClaudeCode": "Adicionado ao Claude Code",
"removedFromClaudeCode": "Removido do Claude Code", "removedFromClaudeCode": "Removido do Claude Code",
"config": "Configuração MCP", "config": "Configuração MCP",
"copyConfig": "Copiar Configuração" "copyConfig": "Copiar Configuração",
"clientsLabel": "Clientes"
}, },
"tabApi": "API local", "tabApi": "API local",
"tabMcp": "MCP (Assistentes de IA)", "tabMcp": "MCP (Assistentes de IA)",
@@ -675,7 +695,9 @@
"mcpStarted": "Servidor MCP iniciado na porta {{port}}", "mcpStarted": "Servidor MCP iniciado na porta {{port}}",
"mcpStopped": "Servidor MCP parado", "mcpStopped": "Servidor MCP parado",
"mcpToggleFailed": "Falha ao alternar o servidor MCP", "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": { "import": {
"title": "Importar Perfil", "title": "Importar Perfil",
@@ -1181,6 +1203,7 @@
"empty": "Nenhuma extensão enviada ainda.", "empty": "Nenhuma extensão enviada ainda.",
"noGroups": "Nenhum grupo de extensões criado ainda.", "noGroups": "Nenhum grupo de extensões criado ainda.",
"createGroup": "Criar Grupo", "createGroup": "Criar Grupo",
"newGroup": "Novo grupo",
"addToGroup": "Adicionar extensão...", "addToGroup": "Adicionar extensão...",
"removeFromGroup": "Remover do grupo", "removeFromGroup": "Remover do grupo",
"deleteGroup": "Excluir grupo", "deleteGroup": "Excluir grupo",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Ativar sincronização", "syncEnableTooltip": "Ativar sincronização",
"syncDisableTooltip": "Desativar sincronização", "syncDisableTooltip": "Desativar sincronização",
"loadGroupsFailed": "Falha ao carregar grupos de extensões", "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": { "pro": {
"badge": "PRO", "badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "Aguardando sincronização", "waiting": "Aguardando sincronização",
"errorWith": "Erro de sincronização: {{error}}", "errorWith": "Erro de sincronização: {{error}}",
"error": "Erro de sincronização", "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": { "groupManagement": {
"description": "Gerencie seus grupos de perfis", "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", "syncCannotDisable": "A sincronização não pode ser desativada enquanto este grupo estiver em uso por perfis sincronizados",
"editGroupTooltip": "Editar grupo", "editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Excluir 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": { "proxyAssignment": {
"title": "Atribuir proxy / VPN", "title": "Atribuir proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": { "modes": {
"set": "Definir", "set": "Definir",
"change": "Alterar", "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": { "backendErrors": {
@@ -1749,7 +1796,6 @@
}, },
"rail": { "rail": {
"profiles": "Perfis", "profiles": "Perfis",
"proxies": "Proxies",
"extensions": "Extensões", "extensions": "Extensões",
"groups": "Grupos", "groups": "Grupos",
"settings": "Configurações", "settings": "Configurações",
@@ -1757,18 +1803,17 @@
"label": "Mais", "label": "Mais",
"closeAriaLabel": "Fechar menu", "closeAriaLabel": "Fechar menu",
"importProfile": "Importar perfil", "importProfile": "Importar perfil",
"importProfileHint": "Trazer perfis de outra ferramenta", "importProfileHint": "Trazer perfis de outra ferramenta"
"integrations": "Integrações", },
"integrationsHint": "Slack, MCP, automações", "network": "Rede",
"account": "Conta", "integrations": "Integrações",
"accountHint": "Nuvem, cobrança, login" "account": "Conta"
}
}, },
"pageTitle": { "pageTitle": {
"proxies": "Proxies", "proxies": "Rede",
"extensions": "Extensões", "extensions": "Extensões",
"groups": "Grupos", "groups": "Grupos",
"vpns": "VPN", "vpns": "Rede",
"settings": "Configurações", "settings": "Configurações",
"integrations": "Integrações", "integrations": "Integrações",
"account": "Conta", "account": "Conta",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Загрузка...", "downloading": "Загрузка...",
"minimize": "Свернуть", "minimize": "Свернуть",
"saving": "Сохраняем…", "saving": "Сохраняем…",
"saved": "Сохранено" "saved": "Сохранено",
"copied": "Скопировано"
}, },
"status": { "status": {
"active": "Активен", "active": "Активен",
@@ -158,7 +159,15 @@
"passwordSaved": "Пароль шифрования установлен", "passwordSaved": "Пароль шифрования установлен",
"passwordMismatch": "Пароли не совпадают", "passwordMismatch": "Пароли не совпадают",
"passwordTooShort": "Пароль должен содержать не менее 8 символов", "passwordTooShort": "Пароль должен содержать не менее 8 символов",
"requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд." "requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд.",
"validatePassword": "Проверить",
"validateDialog": {
"title": "Проверка пароля шифрования",
"description": "Введите пароль шифрования, чтобы убедиться, что он совпадает с сохранённым на этом устройстве.",
"submit": "Проверить",
"matchToast": "Пароль верен",
"mismatchToast": "Пароль не совпадает"
}
}, },
"commercial": { "commercial": {
"title": "Коммерческая лицензия", "title": "Коммерческая лицензия",
@@ -376,6 +385,9 @@
"deleteFailed": "Не удалось удалить прокси", "deleteFailed": "Не удалось удалить прокси",
"deleteTitle": "Удалить прокси", "deleteTitle": "Удалить прокси",
"deleteDescription": "Это действие нельзя отменить. Прокси «{{name}}» будет удален навсегда.", "deleteDescription": "Это действие нельзя отменить. Прокси «{{name}}» будет удален навсегда.",
"newProxy": "Новый прокси",
"newVpn": "Новый VPN",
"protocolCol": "Протокол",
"title": "Прокси и VPN" "title": "Прокси и VPN"
}, },
"add": "Добавить прокси", "add": "Добавить прокси",
@@ -480,6 +492,13 @@
"continueButton": "Продолжить", "continueButton": "Продолжить",
"doneButton": "Готово", "doneButton": "Готово",
"failed": "Не удалось импортировать прокси" "failed": "Не удалось импортировать прокси"
},
"bulkDelete": {
"proxiesTitle": "Удалить выбранные прокси",
"proxiesDescription": "Это действие нельзя отменить. Будет безвозвратно удалено прокси: {{count}} — {{names}}.",
"vpnsTitle": "Удалить выбранные VPN",
"vpnsDescription": "Это действие нельзя отменить. Будет безвозвратно удалено VPN: {{count}} — {{names}}.",
"confirmButton": "Удалить {{count}}"
} }
}, },
"groups": { "groups": {
@@ -488,10 +507,8 @@
"add": "Добавить группу", "add": "Добавить группу",
"edit": "Редактировать группу", "edit": "Редактировать группу",
"delete": "Удалить группу", "delete": "Удалить группу",
"defaultGroup": "По умолчанию", "moveToDefault": "Убрать профили из группы",
"defaultGroupNoGroup": "По умолчанию (Без группы)", "noGroupDescription": "Профили без группы отображаются в фильтре «Все».",
"moveToDefault": "Переместить профили в группу по умолчанию",
"noGroupDescription": "Профили без группы будут отображаться в группе «По умолчанию».",
"assignSuccess": "Успешно назначено {{count}} профиль(ей) в {{group}}", "assignSuccess": "Успешно назначено {{count}} профиль(ей) в {{group}}",
"noGroups": "Группы не созданы", "noGroups": "Группы не созданы",
"noGroupsDescription": "Создайте группу для организации профилей.", "noGroupsDescription": "Создайте группу для организации профилей.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Загрузка связанных профилей...", "loadingProfiles": "Загрузка связанных профилей...",
"associatedProfiles": "Связанные профили ({{count}})", "associatedProfiles": "Связанные профили ({{count}})",
"whatToDoWithProfiles": "Что сделать с этими профилями?", "whatToDoWithProfiles": "Что сделать с этими профилями?",
"moveToDefaultOption": "Переместить профили в группу По умолчанию",
"deleteAlongWithGroup": "Удалить профили вместе с группой", "deleteAlongWithGroup": "Удалить профили вместе с группой",
"noAssociatedProfiles": "У этой группы нет связанных профилей.", "noAssociatedProfiles": "У этой группы нет связанных профилей.",
"deleteGroup": "Удалить группу", "deleteGroup": "Удалить группу",
@@ -530,7 +546,10 @@
"unknownGroup": "Неизвестная группа", "unknownGroup": "Неизвестная группа",
"profileGroupsAriaLabel": "Группы профилей", "profileGroupsAriaLabel": "Группы профилей",
"loading": "Загрузка групп...", "loading": "Загрузка групп...",
"all": "Все" "all": "Все",
"noGroup": "Без группы",
"pageTitle": "Группы профилей",
"pageDescription": "Группы профилей позволяют организовать браузеры по клиенту, окружению или сценарию использования. Синхронизируйте группы между устройствами, чтобы делиться ими."
}, },
"sync": { "sync": {
"mode": { "mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Добавлено в Claude Code", "addedToClaudeCode": "Добавлено в Claude Code",
"removedFromClaudeCode": "Удалено из Claude Code", "removedFromClaudeCode": "Удалено из Claude Code",
"config": "Конфигурация MCP", "config": "Конфигурация MCP",
"copyConfig": "Копировать конфигурацию" "copyConfig": "Копировать конфигурацию",
"clientsLabel": "Клиенты"
}, },
"tabApi": "Локальный API", "tabApi": "Локальный API",
"tabMcp": "MCP (ИИ-ассистенты)", "tabMcp": "MCP (ИИ-ассистенты)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP сервер запущен на порту {{port}}", "mcpStarted": "MCP сервер запущен на порту {{port}}",
"mcpStopped": "MCP сервер остановлен", "mcpStopped": "MCP сервер остановлен",
"mcpToggleFailed": "Не удалось переключить MCP сервер", "mcpToggleFailed": "Не удалось переключить MCP сервер",
"openSettings": "Открыть настройки интеграций" "openSettings": "Открыть настройки интеграций",
"apiRunningOn": "Запущен на",
"apiExampleRequest": "Пример запроса"
}, },
"import": { "import": {
"title": "Импорт профиля", "title": "Импорт профиля",
@@ -1181,6 +1203,7 @@
"empty": "Расширения ещё не загружены.", "empty": "Расширения ещё не загружены.",
"noGroups": "Группы расширений ещё не созданы.", "noGroups": "Группы расширений ещё не созданы.",
"createGroup": "Создать группу", "createGroup": "Создать группу",
"newGroup": "Новая группа",
"addToGroup": "Добавить расширение...", "addToGroup": "Добавить расширение...",
"removeFromGroup": "Удалить из группы", "removeFromGroup": "Удалить из группы",
"deleteGroup": "Удалить группу", "deleteGroup": "Удалить группу",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Включить синхронизацию", "syncEnableTooltip": "Включить синхронизацию",
"syncDisableTooltip": "Отключить синхронизацию", "syncDisableTooltip": "Отключить синхронизацию",
"loadGroupsFailed": "Не удалось загрузить группы расширений", "loadGroupsFailed": "Не удалось загрузить группы расширений",
"assignGroupFailed": "Не удалось назначить группу расширений" "assignGroupFailed": "Не удалось назначить группу расширений",
"bulkDelete": {
"extensionsTitle": "Удалить расширения",
"extensionsDescription": "Удалить {{count}} расширений? {{names}}",
"groupsTitle": "Удалить группы расширений",
"groupsDescription": "Удалить {{count}} групп расширений? {{names}}",
"confirmButton": "Удалить"
}
}, },
"pro": { "pro": {
"badge": "PRO", "badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "Ожидание синхронизации", "waiting": "Ожидание синхронизации",
"errorWith": "Ошибка синхронизации: {{error}}", "errorWith": "Ошибка синхронизации: {{error}}",
"error": "Ошибка синхронизации", "error": "Ошибка синхронизации",
"notSynced": "Не синхронизировано" "notSynced": "Не синхронизировано",
"enable": "Включить синхронизацию",
"disable": "Отключить синхронизацию",
"lockedInUse": "Синхронизация заблокирована, пока используется синхронизируемым профилем",
"bulkToggle": "Переключить синхронизацию"
}, },
"groupManagement": { "groupManagement": {
"description": "Управляйте группами профилей", "description": "Управляйте группами профилей",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "Нельзя отключить синхронизацию, пока эта группа используется синхронизированными профилями", "syncCannotDisable": "Нельзя отключить синхронизацию, пока эта группа используется синхронизированными профилями",
"editGroupTooltip": "Редактировать группу", "editGroupTooltip": "Редактировать группу",
"deleteGroupTooltip": "Удалить группу", "deleteGroupTooltip": "Удалить группу",
"loadFailed": "Не удалось загрузить группы" "loadFailed": "Не удалось загрузить группы",
"bulkDelete": {
"title": "Удалить группы",
"description": "Вы уверены, что хотите удалить {{count}} групп? {{names}}. Профили будут перемещены в группу по умолчанию.",
"description_one": "Вы уверены, что хотите удалить {{count}} группу? {{names}}. Профили будут перемещены в группу по умолчанию.",
"confirmButton": "Удалить группы"
}
}, },
"proxyAssignment": { "proxyAssignment": {
"title": "Назначить прокси / VPN", "title": "Назначить прокси / VPN",
@@ -1721,7 +1761,14 @@
"modes": { "modes": {
"set": "Задать", "set": "Задать",
"change": "Изменить", "change": "Изменить",
"remove": "Удалить" "remove": "Удалить",
"validate": "Проверить"
},
"verifyDialog": {
"title": "Проверка пароля профиля",
"description": "Введите пароль профиля, чтобы убедиться, что он совпадает с сохранённым на диске.",
"submit": "Проверить",
"matchToast": "Пароль верен"
} }
}, },
"backendErrors": { "backendErrors": {
@@ -1749,7 +1796,6 @@
}, },
"rail": { "rail": {
"profiles": "Профили", "profiles": "Профили",
"proxies": "Прокси",
"extensions": "Расширения", "extensions": "Расширения",
"groups": "Группы", "groups": "Группы",
"settings": "Настройки", "settings": "Настройки",
@@ -1757,18 +1803,17 @@
"label": "Ещё", "label": "Ещё",
"closeAriaLabel": "Закрыть меню", "closeAriaLabel": "Закрыть меню",
"importProfile": "Импорт профиля", "importProfile": "Импорт профиля",
"importProfileHint": "Перенести профили из другого инструмента", "importProfileHint": "Перенести профили из другого инструмента"
"integrations": "Интеграции", },
"integrationsHint": "Slack, MCP, автоматизации", "network": "Сеть",
"account": "Аккаунт", "integrations": "Интеграции",
"accountHint": "Облако, оплата, вход" "account": "Аккаунт"
}
}, },
"pageTitle": { "pageTitle": {
"proxies": "Прокси", "proxies": "Сеть",
"extensions": "Расширения", "extensions": "Расширения",
"groups": "Группы", "groups": "Группы",
"vpns": "VPN", "vpns": "Сеть",
"settings": "Настройки", "settings": "Настройки",
"integrations": "Интеграции", "integrations": "Интеграции",
"account": "Аккаунт", "account": "Аккаунт",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "下载中...", "downloading": "下载中...",
"minimize": "最小化", "minimize": "最小化",
"saving": "正在保存…", "saving": "正在保存…",
"saved": "已保存" "saved": "已保存",
"copied": "已复制"
}, },
"status": { "status": {
"active": "活跃", "active": "活跃",
@@ -158,7 +159,15 @@
"passwordSaved": "加密密码已设置", "passwordSaved": "加密密码已设置",
"passwordMismatch": "密码不匹配", "passwordMismatch": "密码不匹配",
"passwordTooShort": "密码必须至少8个字符", "passwordTooShort": "密码必须至少8个字符",
"requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。" "requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。",
"validatePassword": "验证",
"validateDialog": {
"title": "验证加密密码",
"description": "输入加密密码以验证它是否与此设备上存储的密码匹配。",
"submit": "验证",
"matchToast": "密码正确",
"mismatchToast": "密码不匹配"
}
}, },
"commercial": { "commercial": {
"title": "商业许可", "title": "商业许可",
@@ -376,6 +385,9 @@
"deleteFailed": "删除代理失败", "deleteFailed": "删除代理失败",
"deleteTitle": "删除代理", "deleteTitle": "删除代理",
"deleteDescription": "此操作无法撤消。代理「{{name}}」将被永久删除。", "deleteDescription": "此操作无法撤消。代理「{{name}}」将被永久删除。",
"newProxy": "新建代理",
"newVpn": "新建 VPN",
"protocolCol": "协议",
"title": "代理和 VPN" "title": "代理和 VPN"
}, },
"add": "添加代理", "add": "添加代理",
@@ -480,6 +492,13 @@
"continueButton": "继续", "continueButton": "继续",
"doneButton": "完成", "doneButton": "完成",
"failed": "导入代理失败" "failed": "导入代理失败"
},
"bulkDelete": {
"proxiesTitle": "删除所选代理",
"proxiesDescription": "此操作无法撤销。将永久删除 {{count}} 个代理:{{names}}。",
"vpnsTitle": "删除所选 VPN",
"vpnsDescription": "此操作无法撤销。将永久删除 {{count}} 个 VPN:{{names}}。",
"confirmButton": "删除 {{count}}"
} }
}, },
"groups": { "groups": {
@@ -488,10 +507,8 @@
"add": "添加分组", "add": "添加分组",
"edit": "编辑分组", "edit": "编辑分组",
"delete": "删除分组", "delete": "删除分组",
"defaultGroup": "默认", "moveToDefault": "将配置文件移出分组",
"defaultGroupNoGroup": "默认(无分组)", "noGroupDescription": "未分组的配置文件显示在「全部」筛选中。",
"moveToDefault": "将配置文件移至默认分组",
"noGroupDescription": "未分组的配置文件将显示在「默认」分组中。",
"assignSuccess": "已成功将 {{count}} 个配置文件分配到 {{group}}", "assignSuccess": "已成功将 {{count}} 个配置文件分配到 {{group}}",
"noGroups": "暂无分组", "noGroups": "暂无分组",
"noGroupsDescription": "创建分组来组织您的配置文件。", "noGroupsDescription": "创建分组来组织您的配置文件。",
@@ -521,7 +538,6 @@
"loadingProfiles": "正在加载关联的配置文件...", "loadingProfiles": "正在加载关联的配置文件...",
"associatedProfiles": "关联的配置文件 ({{count}})", "associatedProfiles": "关联的配置文件 ({{count}})",
"whatToDoWithProfiles": "这些配置文件应该怎么办?", "whatToDoWithProfiles": "这些配置文件应该怎么办?",
"moveToDefaultOption": "将配置文件移至默认组",
"deleteAlongWithGroup": "将配置文件与组一起删除", "deleteAlongWithGroup": "将配置文件与组一起删除",
"noAssociatedProfiles": "此组没有关联的配置文件。", "noAssociatedProfiles": "此组没有关联的配置文件。",
"deleteGroup": "删除组", "deleteGroup": "删除组",
@@ -530,7 +546,10 @@
"unknownGroup": "未知分组", "unknownGroup": "未知分组",
"profileGroupsAriaLabel": "配置文件分组", "profileGroupsAriaLabel": "配置文件分组",
"loading": "正在加载组...", "loading": "正在加载组...",
"all": "全部" "all": "全部",
"noGroup": "无分组",
"pageTitle": "配置文件分组",
"pageDescription": "配置文件分组可让您按客户端、环境或使用场景整理浏览器。在多台设备之间同步分组以便共享。"
}, },
"sync": { "sync": {
"mode": { "mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "已添加到 Claude Code", "addedToClaudeCode": "已添加到 Claude Code",
"removedFromClaudeCode": "已从 Claude Code 移除", "removedFromClaudeCode": "已从 Claude Code 移除",
"config": "MCP 配置", "config": "MCP 配置",
"copyConfig": "复制配置" "copyConfig": "复制配置",
"clientsLabel": "客户端"
}, },
"tabApi": "本地 API", "tabApi": "本地 API",
"tabMcp": "MCP (AI 助手)", "tabMcp": "MCP (AI 助手)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP 服务器已在端口 {{port}} 上启动", "mcpStarted": "MCP 服务器已在端口 {{port}} 上启动",
"mcpStopped": "MCP 服务器已停止", "mcpStopped": "MCP 服务器已停止",
"mcpToggleFailed": "切换 MCP 服务器失败", "mcpToggleFailed": "切换 MCP 服务器失败",
"openSettings": "打开集成设置" "openSettings": "打开集成设置",
"apiRunningOn": "运行于",
"apiExampleRequest": "示例请求"
}, },
"import": { "import": {
"title": "导入配置文件", "title": "导入配置文件",
@@ -1181,6 +1203,7 @@
"empty": "尚未上传任何扩展程序。", "empty": "尚未上传任何扩展程序。",
"noGroups": "尚未创建任何扩展程序组。", "noGroups": "尚未创建任何扩展程序组。",
"createGroup": "创建分组", "createGroup": "创建分组",
"newGroup": "新建分组",
"addToGroup": "添加扩展程序...", "addToGroup": "添加扩展程序...",
"removeFromGroup": "从分组中移除", "removeFromGroup": "从分组中移除",
"deleteGroup": "删除分组", "deleteGroup": "删除分组",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "启用同步", "syncEnableTooltip": "启用同步",
"syncDisableTooltip": "禁用同步", "syncDisableTooltip": "禁用同步",
"loadGroupsFailed": "加载扩展组失败", "loadGroupsFailed": "加载扩展组失败",
"assignGroupFailed": "分配扩展组失败" "assignGroupFailed": "分配扩展组失败",
"bulkDelete": {
"extensionsTitle": "删除扩展",
"extensionsDescription": "删除 {{count}} 个扩展?{{names}}",
"groupsTitle": "删除扩展组",
"groupsDescription": "删除 {{count}} 个扩展组?{{names}}",
"confirmButton": "删除"
}
}, },
"pro": { "pro": {
"badge": "PRO", "badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "等待同步", "waiting": "等待同步",
"errorWith": "同步错误: {{error}}", "errorWith": "同步错误: {{error}}",
"error": "同步错误", "error": "同步错误",
"notSynced": "未同步" "notSynced": "未同步",
"enable": "启用同步",
"disable": "禁用同步",
"lockedInUse": "同步配置文件正在使用,无法禁用同步",
"bulkToggle": "切换同步"
}, },
"groupManagement": { "groupManagement": {
"description": "管理你的配置文件分组", "description": "管理你的配置文件分组",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "此分组被同步的配置文件使用时无法禁用同步", "syncCannotDisable": "此分组被同步的配置文件使用时无法禁用同步",
"editGroupTooltip": "编辑分组", "editGroupTooltip": "编辑分组",
"deleteGroupTooltip": "删除分组", "deleteGroupTooltip": "删除分组",
"loadFailed": "加载分组失败" "loadFailed": "加载分组失败",
"bulkDelete": {
"title": "删除分组",
"description": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。",
"description_one": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。",
"confirmButton": "删除分组"
}
}, },
"proxyAssignment": { "proxyAssignment": {
"title": "分配代理 / VPN", "title": "分配代理 / VPN",
@@ -1721,7 +1761,14 @@
"modes": { "modes": {
"set": "设置", "set": "设置",
"change": "更改", "change": "更改",
"remove": "删除" "remove": "删除",
"validate": "验证"
},
"verifyDialog": {
"title": "验证配置文件密码",
"description": "输入配置文件密码以确认它与磁盘上存储的密码匹配。",
"submit": "验证",
"matchToast": "密码正确"
} }
}, },
"backendErrors": { "backendErrors": {
@@ -1749,7 +1796,6 @@
}, },
"rail": { "rail": {
"profiles": "配置文件", "profiles": "配置文件",
"proxies": "代理",
"extensions": "扩展", "extensions": "扩展",
"groups": "分组", "groups": "分组",
"settings": "设置", "settings": "设置",
@@ -1757,18 +1803,17 @@
"label": "更多", "label": "更多",
"closeAriaLabel": "关闭菜单", "closeAriaLabel": "关闭菜单",
"importProfile": "导入配置文件", "importProfile": "导入配置文件",
"importProfileHint": "从其他工具导入", "importProfileHint": "从其他工具导入"
"integrations": "集成", },
"integrationsHint": "Slack、MCP、自动化", "network": "网络",
"account": "账户", "integrations": "集成",
"accountHint": "云、订阅、登录" "account": "账号"
}
}, },
"pageTitle": { "pageTitle": {
"proxies": "代理", "proxies": "网络",
"extensions": "扩展", "extensions": "扩展",
"groups": "分组", "groups": "分组",
"vpns": "VPN", "vpns": "网络",
"settings": "设置", "settings": "设置",
"integrations": "集成", "integrations": "集成",
"account": "账户", "account": "账户",
+26 -11
View File
@@ -157,26 +157,41 @@
} }
} }
/* Scroll-fade utility: a vertical mask whose top/bottom 16px fade to /* Scroll-fade utility: a vertical mask that thins the alpha of the top and
transparent ONLY when the matching direction is scrollable. The component bottom 24px of the scroll container ONLY when that direction is actually
sets `data-fade-top` / `data-fade-bottom` attributes on its container as scrollable. useScrollFade() writes `data-fade-top` / `data-fade-bottom`
the user scrolls; each attribute toggles its own end of the mask via a on the container as the user scrolls; each attribute toggles its own
CSS variable, so the two edges are independent. */ 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 { .scroll-fade {
--top-mask: black; --top-mask: black;
--bottom-mask: black; --bottom-mask: black;
--scroll-fade-top-offset: 0px;
-webkit-mask-image: linear-gradient( -webkit-mask-image: linear-gradient(
to bottom, to bottom,
var(--top-mask), black 0,
black 16px, black var(--scroll-fade-top-offset),
black calc(100% - 16px), var(--top-mask) var(--scroll-fade-top-offset),
black calc(var(--scroll-fade-top-offset) + 24px),
black calc(100% - 24px),
var(--bottom-mask) var(--bottom-mask)
); );
mask-image: linear-gradient( mask-image: linear-gradient(
to bottom, to bottom,
var(--top-mask), black 0,
black 16px, black var(--scroll-fade-top-offset),
black calc(100% - 16px), var(--top-mask) var(--scroll-fade-top-offset),
black calc(var(--scroll-fade-top-offset) + 24px),
black calc(100% - 24px),
var(--bottom-mask) var(--bottom-mask)
); );
} }