refactor: ui cleanup

This commit is contained in:
zhom
2026-05-15 15:44:20 +04:00
parent 56b0da990b
commit c8a43b43f1
35 changed files with 3792 additions and 1674 deletions
+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();
for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0);
@@ -281,18 +283,6 @@ impl GroupManager {
});
}
// Add default group count (profiles without group_id), always include even if 0
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
let default_group = GroupWithCount {
id: "default".to_string(),
name: "Default".to_string(),
count: default_count,
sync_enabled: false,
last_sync: None,
};
// Insert at the beginning for consistent ordering with UI expectations
result.insert(0, default_group);
Ok(result)
}
}
+4 -1
View File
@@ -74,7 +74,7 @@ use profile::manager::{
use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_profile_password,
set_profile_password, unlock_profile,
set_profile_password, unlock_profile, verify_profile_password,
};
use browser_version_manager::{
@@ -103,6 +103,7 @@ use sync::{
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
verify_e2e_password,
};
use tag_manager::get_all_tags;
@@ -2112,6 +2113,7 @@ pub fn run() {
enable_sync_for_all_entities,
set_e2e_password,
check_has_e2e_password,
verify_e2e_password,
delete_e2e_password,
rollover_encryption_for_all_entities,
read_profile_cookies,
@@ -2177,6 +2179,7 @@ pub fn run() {
set_profile_password,
change_profile_password,
remove_profile_password,
verify_profile_password,
unlock_profile,
lock_profile,
is_profile_locked,
+24
View File
@@ -473,6 +473,8 @@ impl ProfileManager {
// Save profile with new name
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -678,6 +680,8 @@ impl ProfileManager {
profile.group_id = group_id.clone();
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id {
@@ -732,6 +736,8 @@ impl ProfileManager {
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -766,6 +772,8 @@ impl ProfileManager {
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
@@ -792,6 +800,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -821,6 +831,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -845,6 +857,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -1060,6 +1074,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1120,6 +1136,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1174,6 +1192,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id {
@@ -1263,6 +1283,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id {
@@ -1300,6 +1322,8 @@ impl ProfileManager {
profile.extension_group_id = extension_group_id.clone();
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new extension group if profile has sync
// enabled. The helper is sync internally; we fire-and-forget through
// the async runtime so any I/O doesn't block this caller.
+37
View File
@@ -292,10 +292,45 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
.map_err(err_internal)?;
cache_key(id, key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
/// Verify a profile password without unlocking. Used by the Settings UI's
/// "Validate" button so users can confirm they remember the password without
/// performing a destructive change. Honors the same lockout schedule as
/// `unlock_profile` so a brute-force attacker can't bypass rate-limiting by
/// hammering this command.
#[tauri::command]
pub async fn verify_profile_password(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?;
let profile = load_profile(&id)?;
if !profile.password_protected {
return Err(err_code("PROFILE_NOT_PROTECTED"));
}
if let Err(secs) = check_lockout(&id) {
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
}
let salt = profile
.encryption_salt
.as_deref()
.ok_or_else(|| err_code("PROFILE_MISSING_SALT"))?;
let key = derive_profile_key(&password, salt).map_err(err_internal)?;
let dir = profile_data_dir(&profile);
match verify_key_against_dir(&key, &dir) {
Ok(()) => {
clear_failed_attempts(&id);
Ok(())
}
Err(crate::profile::encryption::PasswordError::WrongPassword) => {
record_failed_attempt(id);
Err(err_code("INCORRECT_PASSWORD"))
}
Err(other) => Err(err_internal(other)),
}
}
#[tauri::command]
pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?;
@@ -396,6 +431,7 @@ pub async fn change_profile_password(
drop_cached_key(&id);
cache_key(id, new_key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
@@ -464,6 +500,7 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
.map_err(err_internal)?;
drop_cached_key(&id);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
+8
View File
@@ -346,6 +346,14 @@ pub fn check_has_e2e_password() -> bool {
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]
pub async fn delete_e2e_password() -> Result<(), String> {
enforce_team_owner_for_encryption_change().await?;
+21 -1
View File
@@ -7,7 +7,9 @@ pub mod subscription;
pub mod types;
pub use client::SyncClient;
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
@@ -22,3 +24,21 @@ pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, Syn
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult};
/// Queue a profile sync if the profile has sync enabled. No-op otherwise.
///
/// Called from profile metadata update paths so a rename / tag edit / proxy
/// reassignment shows up on other devices without waiting for the next
/// scheduled tick. Spawns the async queue call so this helper is callable
/// from both sync and async contexts.
pub fn queue_profile_sync_if_eligible(profile: &crate::profile::BrowserProfile) {
if !profile.is_sync_enabled() {
return;
}
let profile_id = profile.id.to_string();
tauri::async_runtime::spawn(async move {
if let Some(scheduler) = get_global_scheduler() {
scheduler.queue_profile_sync(profile_id).await;
}
});
}
+7 -5
View File
@@ -613,7 +613,9 @@ export default function Home() {
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ??
(selectedGroupId !== "default" ? selectedGroupId : undefined),
(selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined),
ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook,
@@ -1243,11 +1245,10 @@ export default function Home() {
let filtered = profiles;
// Filter by group. "__all__" is a virtual filter that shows every
// profile regardless of group; "default" shows ungrouped profiles.
if (selectedGroupId === "__all__") {
// profile (including ungrouped ones). Any other value is a real
// group id; ungrouped profiles only show through "All".
if (!selectedGroupId || selectedGroupId === "__all__") {
filtered = profiles;
} else if (!selectedGroupId || selectedGroupId === "default") {
filtered = profiles.filter((profile) => !profile.group_id);
} else {
filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId,
@@ -1292,6 +1293,7 @@ export default function Home() {
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
groups={groupsData}
totalProfiles={profiles.length}
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
pageTitle={subPageTitle}
+18 -32
View File
@@ -12,16 +12,20 @@ import {
LuUser,
} from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { SyncSettings } from "@/types";
interface AccountPageProps {
@@ -194,25 +198,12 @@ export function AccountPage({
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4">
<Tabs defaultValue="account">
<TabsList
className={cn(
"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",
)}
>
<AnimatedTabs defaultValue="account">
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
{t("account.tabs.account")}
</TabsTrigger>
<TabsTrigger
</AnimatedTabsTrigger>
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
title={
@@ -220,17 +211,12 @@ export function AccountPage({
? t("account.selfHosted.disabledWhileLoggedIn")
: 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")}
</TabsTrigger>
</TabsList>
</AnimatedTabsTrigger>
</AnimatedTabsList>
<TabsContent value="account" className="mt-4">
<AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<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>
</TabsContent>
</AnimatedTabsContent>
<TabsContent value="self-hosted" className="mt-4">
<AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI —
@@ -481,8 +467,8 @@ export function AccountPage({
</div>
</div>
)}
</TabsContent>
</Tabs>
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</DialogContent>
</Dialog>
+3 -3
View File
@@ -15,9 +15,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -563,7 +563,7 @@ export function CookieManagementDialog({
{t("cookies.management.noCookies")}
</div>
) : (
<ScrollArea className="h-[200px] border rounded-md">
<FadingScrollArea className="h-[200px]">
<div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => (
<ExportDomainRow
@@ -577,7 +577,7 @@ export function CookieManagementDialog({
/>
))}
</div>
</ScrollArea>
</FadingScrollArea>
)}
</div>
+10 -3
View File
@@ -431,7 +431,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
@@ -459,7 +461,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
@@ -487,7 +491,10 @@ export function CreateProfileDialog({
version: bestVersion.version,
releaseType: bestVersion.releaseType,
proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -77,7 +77,7 @@ export function GroupAssignmentDialog({
const groupName = selectedGroupId
? groups.find((g) => g.id === selectedGroupId)?.name ||
t("groups.unknownGroup")
: t("groups.defaultGroup");
: t("groups.noGroup");
toast.success(
t("groups.assignSuccess", {
@@ -175,17 +175,17 @@ export function GroupAssignmentDialog({
</div>
) : (
<Select
value={selectedGroupId ?? "default"}
value={selectedGroupId ?? "__none__"}
onValueChange={(value) => {
setSelectedGroupId(value === "default" ? null : value);
setSelectedGroupId(value === "__none__" ? null : value);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("groupAssignment.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{t("groups.defaultGroupNoGroup")}
<SelectItem value="__none__">
{t("groups.noGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
+1 -3
View File
@@ -183,9 +183,7 @@ export function GroupBadges({
}
}}
>
<span>
{group.id === "default" ? t("groups.defaultGroup") : group.name}
</span>
<span>{group.name}</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count}
</span>
+404 -133
View File
@@ -1,14 +1,37 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type RowSelectionState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { 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 {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
} from "@/components/data-table-action-bar";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { DeleteGroupDialog } from "@/components/delete-group-dialog";
import { EditGroupDialog } from "@/components/edit-group-dialog";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -20,8 +43,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import {
Table,
TableBody,
@@ -111,6 +133,8 @@ export function GroupManagementDialog({
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | 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
useEffect(() => {
let unlisten: (() => void) | undefined;
@@ -246,9 +276,272 @@ export function GroupManagementDialog({
useEffect(() => {
if (isOpen) {
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]);
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 (
<>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
@@ -262,16 +555,22 @@ export function GroupManagementDialog({
</DialogHeader>
)}
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>{t("groupManagement.groupsLabel")}</Label>
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-base font-semibold">
{t("groups.pageTitle")}
</h2>
<p className="text-xs text-muted-foreground">
{t("groups.pageDescription")}
</p>
</div>
<RippleButton
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
className="flex gap-2 items-center"
className="flex gap-2 items-center shrink-0"
>
<GoPlus className="size-4" />
{t("proxies.management.create")}
@@ -294,131 +593,64 @@ export function GroupManagementDialog({
{t("groups.noGroupsDescription")}
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="w-20">
{t("groupManagement.profilesCol")}
</TableHead>
<TableHead className="w-24">
{t("proxies.management.syncCol")}
</TableHead>
<TableHead className="w-24">
{t("common.labels.actions")}
</TableHead>
<FadingScrollArea
className="flex-1 min-h-0"
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<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>
</TableHeader>
<TableBody>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
t,
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<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>
{group.name}
</div>
</TableCell>
<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>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</FadingScrollArea>
)}
</div>
@@ -432,6 +664,45 @@ export function GroupManagementDialog({
</DialogContent>
</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
isOpen={createDialogOpen}
onClose={() => {
+4 -9
View File
@@ -1,7 +1,7 @@
"use client";
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 { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
@@ -30,6 +30,7 @@ interface Props {
searchQuery: string;
onSearchQueryChange: (query: string) => void;
groups: GroupWithCount[];
totalProfiles: number;
selectedGroupId: string | null;
onGroupSelect: (groupId: string) => void;
pageTitle?: string;
@@ -40,6 +41,7 @@ const HomeHeader = ({
searchQuery,
onSearchQueryChange,
groups,
totalProfiles,
selectedGroupId,
onGroupSelect,
pageTitle,
@@ -54,11 +56,6 @@ const HomeHeader = ({
const isMacOS = platform === "macos";
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
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
const holdTimeoutRef = useRef<number | null>(null);
@@ -247,8 +244,6 @@ const HomeHeader = ({
})()}
{groups.map((group) => {
const active = selectedGroupId === group.id;
const label =
group.id === "default" ? t("groups.defaultGroup") : group.name;
return (
<button
key={group.id}
@@ -263,7 +258,7 @@ const HomeHeader = ({
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{label}</span>
<span>{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
+24 -26
View File
@@ -9,6 +9,12 @@ import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -304,31 +310,23 @@ export function ImportProfileDialog({
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{currentStep === "select" && (
<>
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
<AnimatedTabs
value={importMode}
onValueChange={(v) =>
setImportMode(v as "auto-detect" | "manual")
}
className="flex flex-col gap-6"
>
<AnimatedTabsList>
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
{t("importProfile.autoDetect")}
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
{t("importProfile.manualImport")}
</RippleButton>
</div>
</AnimatedTabsTrigger>
</AnimatedTabsList>
{importMode === "auto-detect" && (
<AnimatedTabsContent value="auto-detect">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.detectedProfilesTitle")}
@@ -439,9 +437,9 @@ export function ImportProfileDialog({
</div>
)}
</div>
)}
</AnimatedTabsContent>
{importMode === "manual" && (
<AnimatedTabsContent value="manual">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.manualTitle")}
@@ -539,8 +537,8 @@ export function ImportProfileDialog({
</div>
</div>
</div>
)}
</>
</AnimatedTabsContent>
</AnimatedTabs>
)}
{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 { useCallback, useEffect, useState } from "react";
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 { Checkbox } from "@/components/ui/checkbox";
import {
@@ -14,7 +22,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
@@ -218,172 +225,204 @@ export function IntegrationsDialog({
)}
<div className="overflow-y-auto flex-1 min-h-0">
<Tabs defaultValue="api" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger>
<TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger>
</TabsList>
<AnimatedTabs defaultValue="api">
<AnimatedTabsList>
<AnimatedTabsTrigger value="api">
{t("integrations.tabApi")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="mcp">
{t("integrations.tabMcp")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<TabsContent value="api" className="space-y-4 mt-4">
<div className="flex items-center gap-x-2">
<Checkbox
id="api-enabled"
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(!!checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="api-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
<AnimatedTabsContent
value="api"
className="mt-4 flex flex-col gap-4"
>
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuPlug className="size-5 mt-0.5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
</div>
</div>
<AnimatedSwitch
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(checked)}
/>
</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>
{settings.api_enabled && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center gap-x-2">
<Button
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">
<>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="font-mono pr-10"
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}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
variant="outline"
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);
}
}}
>
{showApiToken ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
{t("common.buttons.save")}
</Button>
</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
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
successMessage={t("common.buttons.copied")}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("integrations.apiTokenHint", {
tokenSlot: "<token>",
})}
</p>
<pre className="font-mono text-[11px] whitespace-pre overflow-x-auto bg-background rounded p-3">
{`curl -H "Authorization: Bearer \${TOKEN}" \\
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
</pre>
</div>
</div>
</>
)}
</TabsContent>
</AnimatedTabsContent>
<TabsContent value="mcp" className="space-y-4 mt-4">
<div className="flex items-center gap-x-2">
<AnimatedTabsContent value="mcp" className="mt-4">
<div className="flex items-start gap-x-3">
<Checkbox
id="mcp-enabled"
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
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
htmlFor="mcp-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
@@ -402,9 +441,9 @@ export function IntegrationsDialog({
</div>
{mcpConfig && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
<div className="mt-6 flex flex-col gap-5 pt-5 border-t">
<div className="flex flex-col gap-1.5">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.url")}
</Label>
<div className="flex items-center gap-x-2">
@@ -438,99 +477,99 @@ export function IntegrationsDialog({
</div>
</div>
<div className="space-y-2 pt-1 border-t">
<p className="text-xs font-medium text-muted-foreground">
{t("integrations.mcp.claudeDesktopTitle")}
</p>
{mcpInClaudeDesktop ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_desktop");
setMcpInClaudeDesktop(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeDesktop")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_desktop");
setMcpInClaudeDesktop(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeDesktop")}
</Button>
)}
</div>
<div className="space-y-2 pt-1 border-t">
<p className="text-xs font-medium text-muted-foreground">
{t("integrations.mcp.claudeCodeTitle")}
</p>
{mcpInClaudeCode ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_code");
setMcpInClaudeCode(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeCode")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_code");
setMcpInClaudeCode(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeCode")}
</Button>
)}
<div className="flex flex-col gap-2">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.clientsLabel")}
</Label>
<div className="flex items-center justify-between gap-x-3 py-1">
<span className="text-sm">
{t("integrations.mcp.claudeDesktopTitle")}
</span>
{mcpInClaudeDesktop ? (
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_desktop");
setMcpInClaudeDesktop(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeDesktop")}
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_desktop");
setMcpInClaudeDesktop(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeDesktop")}
</Button>
)}
</div>
<div className="flex items-center justify-between gap-x-3 py-1">
<span className="text-sm">
{t("integrations.mcp.claudeCodeTitle")}
</span>
{mcpInClaudeCode ? (
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_code");
setMcpInClaudeCode(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeCode")}
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_code");
setMcpInClaudeCode(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeCode")}
</Button>
)}
</div>
</div>
</div>
)}
</TabsContent>
</Tabs>
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</DialogContent>
</Dialog>
+17 -3
View File
@@ -416,6 +416,10 @@ function DnsCell({
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
];
const currentLabel =
level === null
? null
: (LEVELS.find((l) => l.value === level)?.labelKey ?? null);
const onPick = async (nextLevel: string | null) => {
setIsSaving(true);
@@ -446,8 +450,8 @@ function DnsCell({
}
>
<FiWifi className="size-3 shrink-0" />
<span className="flex-1 truncate uppercase text-[10px] font-mono tracking-wide">
{level ?? "—"}
<span className="flex-1 truncate text-[11px] tracking-wide">
{currentLabel ? meta.t(currentLabel) : "—"}
</span>
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
@@ -2887,6 +2891,14 @@ export function ProfilesDataTable({
<div
ref={scrollParentRef}
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">
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
@@ -3003,7 +3015,9 @@ export function ProfilesDataTable({
/>
{profileForInfoDialog &&
(() => {
const infoProfile = profileForInfoDialog;
const infoProfile =
profiles.find((p) => p.id === profileForInfoDialog.id) ??
profileForInfoDialog;
const infoIsRunning =
browserState.isClient && runningProfiles.has(infoProfile.id);
const infoIsLaunching = launchingProfiles.has(infoProfile.id);
+107 -81
View File
@@ -16,7 +16,6 @@ import {
LuGroup,
LuKey,
LuLink,
LuLock,
LuLockOpen,
LuPlus,
LuPuzzle,
@@ -33,6 +32,7 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -48,13 +48,9 @@ import {
} from "@/components/ui/select";
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { translateBackendError } from "@/lib/backend-errors";
import {
getBrowserDisplayName,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { getProfileIcon } from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
@@ -94,7 +90,7 @@ interface ProfileInfoDialogProps {
syncStatuses: Record<string, { status: string; error?: string }>;
}
function OSIcon({ os }: { os: string }) {
function _OSIcon({ os }: { os: string }) {
switch (os) {
case "macos":
return <FaApple className="size-3.5" />;
@@ -290,12 +286,8 @@ export function ProfileInfoDialog({
action();
};
const releaseLabel =
profile.release_type.charAt(0).toUpperCase() +
profile.release_type.slice(1);
const hasTags = profile.tags && profile.tags.length > 0;
const hasNote = !!profile.note;
const showCrossOs = isCrossOsProfile(profile);
// Items in the settings tab `actions` list MUST only open another dialog
// (or trigger a navigation/action that closes this one). Do NOT put inline
@@ -491,10 +483,8 @@ export function ProfileInfoDialog({
<ProfileInfoLayout
profile={profile}
ProfileIcon={ProfileIcon}
releaseLabel={releaseLabel}
isRunning={isRunning}
isDisabled={isDisabled}
showCrossOs={showCrossOs}
networkLabel={networkLabel}
groupName={groupName}
extensionGroupName={extensionGroupName}
@@ -520,10 +510,8 @@ export function ProfileInfoDialog({
interface ProfileInfoLayoutProps {
profile: BrowserProfile;
ProfileIcon: React.ComponentType<{ className?: string }>;
releaseLabel: string;
isRunning: boolean;
isDisabled: boolean;
showCrossOs: boolean;
networkLabel: string;
groupName: string | null;
extensionGroupName: string | null;
@@ -564,10 +552,8 @@ type ProfileSection =
function ProfileInfoLayout({
profile,
ProfileIcon,
releaseLabel,
isRunning,
isDisabled,
showCrossOs,
networkLabel,
groupName,
extensionGroupName,
@@ -798,63 +784,8 @@ function ProfileInfoLayout({
</h3>
</div>
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px]">
<span className="font-mono uppercase text-muted-foreground">
{getBrowserDisplayName(profile.browser)}
</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 className="font-mono text-muted-foreground">
{profile.version}
</span>
</div>
</div>
@@ -1716,7 +1647,7 @@ function FingerprintSectionInline({
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={onCamoufoxChange}
forceAdvanced={false}
forceAdvanced={true}
readOnly={isDisabled}
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
@@ -1729,6 +1660,7 @@ function FingerprintSectionInline({
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={onWayfernChange}
forceAdvanced={true}
readOnly={isDisabled}
crossOsUnlocked={crossOsUnlocked}
profileVersion={profile.version}
@@ -1739,7 +1671,7 @@ function FingerprintSectionInline({
{error && <p className="text-xs text-destructive">{error}</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
size="sm"
className="h-7 text-xs"
@@ -1790,6 +1722,30 @@ function SecuritySectionInline({
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = 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
// user just set a password — flip to "change" mode and clear fields).
@@ -1837,24 +1793,29 @@ function SecuritySectionInline({
profileId: profile.id,
password,
});
setSuccess(t("profilePassword.toasts.set"));
showSuccessToast(t("profilePassword.toasts.set"));
} else if (mode === "change") {
await invoke("change_profile_password", {
profileId: profile.id,
oldPassword,
newPassword: password,
});
setSuccess(t("profilePassword.toasts.changed"));
showSuccessToast(t("profilePassword.toasts.changed"));
} else {
await invoke("remove_profile_password", {
profileId: profile.id,
password: oldPassword,
});
setSuccess(t("profilePassword.toasts.removed"));
showSuccessToast(t("profilePassword.toasts.removed"));
}
reset();
} catch (e) {
setError(String(e));
const message = translateBackendError(
t as unknown as Parameters<typeof translateBackendError>[0],
e,
);
setError(message);
showErrorToast(message);
} finally {
setIsSubmitting(false);
}
@@ -1874,6 +1835,19 @@ function SecuritySectionInline({
{profile.password_protected && (
<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
type="button"
onClick={() => {
@@ -1973,6 +1947,58 @@ function SecuritySectionInline({
? t("profilePassword.modes.change")
: t("profilePassword.modes.remove")}
</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>
);
}
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -236,9 +236,11 @@ interface RailItem {
const TOP_ITEMS: RailItem[] = [
{ page: "profiles", Icon: LuUser, labelKey: "rail.profiles" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.proxies" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.network" },
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
{ page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" },
{ page: "account", Icon: LuCloud, labelKey: "rail.account" },
];
interface MoreMenuItem {
@@ -255,18 +257,6 @@ const MORE_ITEMS: MoreMenuItem[] = [
labelKey: "rail.more.importProfile",
hintKey: "rail.more.importProfileHint",
},
{
page: "integrations",
Icon: LuPlug,
labelKey: "rail.more.integrations",
hintKey: "rail.more.integrationsHint",
},
{
page: "account",
Icon: LuCloud,
labelKey: "rail.more.account",
hintKey: "rail.more.accountHint",
},
];
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
+114 -1
View File
@@ -24,6 +24,7 @@ import {
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -132,6 +133,9 @@ export function SettingsDialog({
const [e2eError, setE2eError] = useState("");
const [isSavingE2e, setIsSavingE2e] = useState(false);
const [isRemovingE2e, setIsRemovingE2e] = useState(false);
const [isVerifyE2eOpen, setIsVerifyE2eOpen] = useState(false);
const [verifyE2ePassword, setVerifyE2ePassword] = useState("");
const [isVerifyingE2e, setIsVerifyingE2e] = useState(false);
const [systemInfo, setSystemInfo] = useState<{
app_version: string;
os: string;
@@ -991,7 +995,18 @@ export function SettingsDialog({
{t("settings.encryption.passwordSetDescription")}
</span>
</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
variant="outline"
size="sm"
@@ -1317,6 +1332,104 @@ export function SettingsDialog({
isOpen={dnsBlocklistDialogOpen}
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,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
@@ -590,7 +591,7 @@ export function TrafficDetailsDialog({
<h3 className="text-sm font-medium mb-2">
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
</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">
{stats.unique_ips.map((ip) => (
<span
@@ -601,7 +602,7 @@ export function TrafficDetailsDialog({
</span>
))}
</div>
</div>
</FadingScrollArea>
</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
useEffect(() => {
let groupsUnlisten: (() => void) | undefined;
let profilesUnlisten: (() => void) | undefined;
const setupListeners = async () => {
try {
@@ -51,19 +52,13 @@ export function useGroupEvents() {
});
// Also listen for profile changes since groups show profile counts
const profilesUnlisten = await listen("profiles-changed", () => {
profilesUnlisten = await listen("profiles-changed", () => {
console.log(
"Received profiles-changed event, reloading groups for updated counts",
);
void loadGroups();
});
// Store both listeners for cleanup
groupsUnlisten = () => {
groupsUnlisten?.();
profilesUnlisten();
};
console.log("Group event listeners set up successfully");
} catch (err) {
console.error("Failed to setup group event listeners:", err);
@@ -79,9 +74,17 @@ export function useGroupEvents() {
void setupListeners();
// Cleanup listeners on unmount
// Cleanup listeners on unmount.
// NOTE: the previous version stored both unlisten fns by reassigning
// `groupsUnlisten` to a wrapper that called itself, which produced a
// `Maximum call stack size exceeded` crash whenever this effect tore
// down. React's reconciler then bailed out mid-commit and left stale
// overlay nodes in the DOM, blocking every subsequent click in the
// window. Holding the two unlisten fns in separate locals avoids both
// problems.
return () => {
if (groupsUnlisten) groupsUnlisten();
if (profilesUnlisten) profilesUnlisten();
};
}, [loadGroups]);
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Downloading...",
"minimize": "Minimize",
"saving": "Saving…",
"saved": "Saved"
"saved": "Saved",
"copied": "Copied"
},
"status": {
"active": "Active",
@@ -158,7 +159,15 @@
"passwordSaved": "Encryption password set",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters",
"requiresProOrOwner": "Profile encryption is available for Pro users and team owners."
"requiresProOrOwner": "Profile encryption is available for Pro users and team owners.",
"validatePassword": "Validate",
"validateDialog": {
"title": "Validate Encryption Password",
"description": "Enter your encryption password to verify it matches the one stored on this device.",
"submit": "Validate",
"matchToast": "Password is correct",
"mismatchToast": "Password does not match"
}
},
"commercial": {
"title": "Commercial License",
@@ -376,6 +385,9 @@
"deleteFailed": "Failed to delete proxy",
"deleteTitle": "Delete Proxy",
"deleteDescription": "This action cannot be undone. This will permanently delete the proxy \"{{name}}\".",
"newProxy": "New proxy",
"newVpn": "New VPN",
"protocolCol": "Protocol",
"title": "Proxies & VPNs"
},
"add": "Add Proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continue",
"doneButton": "Done",
"failed": "Failed to import proxies"
},
"bulkDelete": {
"proxiesTitle": "Delete Selected Proxies",
"proxiesDescription": "This action cannot be undone. This will permanently delete {{count}} proxy(s): {{names}}.",
"vpnsTitle": "Delete Selected VPNs",
"vpnsDescription": "This action cannot be undone. This will permanently delete {{count}} VPN(s): {{names}}.",
"confirmButton": "Delete {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Add Group",
"edit": "Edit Group",
"delete": "Delete Group",
"defaultGroup": "Default",
"defaultGroupNoGroup": "Default (No Group)",
"moveToDefault": "Move profiles to Default group",
"noGroupDescription": "Profiles without a group will appear in the \"Default\" group.",
"moveToDefault": "Remove profiles from group",
"noGroupDescription": "Profiles without a group appear in the \"All\" filter.",
"assignSuccess": "Successfully assigned {{count}} profile(s) to {{group}}",
"noGroups": "No groups created",
"noGroupsDescription": "Create a group to organize your profiles.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Loading associated profiles...",
"associatedProfiles": "Associated Profiles ({{count}})",
"whatToDoWithProfiles": "What should happen to these profiles?",
"moveToDefaultOption": "Move profiles to Default group",
"deleteAlongWithGroup": "Delete profiles along with the group",
"noAssociatedProfiles": "This group has no associated profiles.",
"deleteGroup": "Delete Group",
@@ -530,7 +546,10 @@
"unknownGroup": "Unknown Group",
"profileGroupsAriaLabel": "Profile groups",
"loading": "Loading groups...",
"all": "All"
"all": "All",
"noGroup": "No group",
"pageTitle": "Profile groups",
"pageDescription": "Profile groups let you organize browsers by client, environment, or use case. Sync groups across devices to share them."
},
"sync": {
"mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Added to Claude Code",
"removedFromClaudeCode": "Removed from Claude Code",
"config": "MCP Configuration",
"copyConfig": "Copy Configuration"
"copyConfig": "Copy Configuration",
"clientsLabel": "Clients"
},
"tabApi": "Local API",
"tabMcp": "MCP (AI Assistants)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP server started on port {{port}}",
"mcpStopped": "MCP server stopped",
"mcpToggleFailed": "Failed to toggle MCP server",
"openSettings": "Open Integrations Settings"
"openSettings": "Open Integrations Settings",
"apiRunningOn": "Running on",
"apiExampleRequest": "Example request"
},
"import": {
"title": "Import Profile",
@@ -1181,6 +1203,7 @@
"empty": "No extensions uploaded yet.",
"noGroups": "No extension groups created yet.",
"createGroup": "Create Group",
"newGroup": "New group",
"addToGroup": "Add extension...",
"removeFromGroup": "Remove from group",
"deleteGroup": "Delete group",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Enable sync",
"syncDisableTooltip": "Disable sync",
"loadGroupsFailed": "Failed to load extension groups",
"assignGroupFailed": "Failed to assign extension group"
"assignGroupFailed": "Failed to assign extension group",
"bulkDelete": {
"extensionsTitle": "Delete extensions",
"extensionsDescription": "Delete {{count}} extensions? {{names}}",
"groupsTitle": "Delete extension groups",
"groupsDescription": "Delete {{count}} extension groups? {{names}}",
"confirmButton": "Delete"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "Waiting to sync",
"errorWith": "Sync error: {{error}}",
"error": "Sync error",
"notSynced": "Not synced"
"notSynced": "Not synced",
"enable": "Enable sync",
"disable": "Disable sync",
"lockedInUse": "Sync is locked while in use by a synced profile",
"bulkToggle": "Toggle sync"
},
"groupManagement": {
"description": "Manage your profile groups",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "Sync cannot be disabled while this group is used by synced profiles",
"editGroupTooltip": "Edit group",
"deleteGroupTooltip": "Delete group",
"loadFailed": "Failed to load groups"
"loadFailed": "Failed to load groups",
"bulkDelete": {
"title": "Delete groups",
"description": "Are you sure you want to delete {{count}} groups? {{names}}. Profiles will be moved to Default.",
"description_one": "Are you sure you want to delete {{count}} group? {{names}}. Profiles will be moved to Default.",
"confirmButton": "Delete groups"
}
},
"proxyAssignment": {
"title": "Assign Proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Set",
"change": "Change",
"remove": "Remove"
"remove": "Remove",
"validate": "Validate"
},
"verifyDialog": {
"title": "Validate Profile Password",
"description": "Enter the profile password to confirm it matches the one stored on disk.",
"submit": "Validate",
"matchToast": "Password is correct"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Profiles",
"proxies": "Proxies",
"extensions": "Extensions",
"groups": "Groups",
"settings": "Settings",
@@ -1757,18 +1803,17 @@
"label": "More",
"closeAriaLabel": "Close menu",
"importProfile": "Import profile",
"importProfileHint": "Bring profiles from another tool",
"integrations": "Integrations",
"integrationsHint": "Slack, MCP, automations",
"account": "Account",
"accountHint": "Cloud, billing, sign-in"
}
"importProfileHint": "Bring profiles from another tool"
},
"network": "Network",
"integrations": "Integrations",
"account": "Account"
},
"pageTitle": {
"proxies": "Proxies",
"proxies": "Network",
"extensions": "Extensions",
"groups": "Groups",
"vpns": "VPNs",
"vpns": "Network",
"settings": "Settings",
"integrations": "Integrations",
"account": "Account",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Descargando...",
"minimize": "Minimizar",
"saving": "Guardando…",
"saved": "Guardado"
"saved": "Guardado",
"copied": "Copiado"
},
"status": {
"active": "Activo",
@@ -158,7 +159,15 @@
"passwordSaved": "Contraseña de cifrado establecida",
"passwordMismatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos."
"requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos.",
"validatePassword": "Validar",
"validateDialog": {
"title": "Validar contraseña de cifrado",
"description": "Introduce tu contraseña de cifrado para verificar que coincide con la almacenada en este dispositivo.",
"submit": "Validar",
"matchToast": "La contraseña es correcta",
"mismatchToast": "La contraseña no coincide"
}
},
"commercial": {
"title": "Licencia Comercial",
@@ -376,6 +385,9 @@
"deleteFailed": "Error al eliminar el proxy",
"deleteTitle": "Eliminar proxy",
"deleteDescription": "Esta acción no se puede deshacer. Se eliminará permanentemente el proxy \"{{name}}\".",
"newProxy": "Nuevo proxy",
"newVpn": "Nueva VPN",
"protocolCol": "Protocolo",
"title": "Proxies y VPN"
},
"add": "Agregar Proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continuar",
"doneButton": "Hecho",
"failed": "Error al importar los proxies"
},
"bulkDelete": {
"proxiesTitle": "Eliminar proxies seleccionados",
"proxiesDescription": "Esta acción no se puede deshacer. Se eliminarán permanentemente {{count}} proxy(s): {{names}}.",
"vpnsTitle": "Eliminar VPN seleccionadas",
"vpnsDescription": "Esta acción no se puede deshacer. Se eliminarán permanentemente {{count}} VPN(s): {{names}}.",
"confirmButton": "Eliminar {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Agregar Grupo",
"edit": "Editar Grupo",
"delete": "Eliminar Grupo",
"defaultGroup": "Predeterminado",
"defaultGroupNoGroup": "Predeterminado (Sin Grupo)",
"moveToDefault": "Mover perfiles al grupo Predeterminado",
"noGroupDescription": "Los perfiles sin grupo aparecerán en el grupo \"Predeterminado\".",
"moveToDefault": "Quitar perfiles del grupo",
"noGroupDescription": "Los perfiles sin grupo aparecen en el filtro «Todos».",
"assignSuccess": "Se asignaron {{count}} perfil(es) a {{group}} exitosamente",
"noGroups": "No hay grupos creados",
"noGroupsDescription": "Crea un grupo para organizar tus perfiles.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Cargando perfiles asociados...",
"associatedProfiles": "Perfiles Asociados ({{count}})",
"whatToDoWithProfiles": "¿Qué hacer con estos perfiles?",
"moveToDefaultOption": "Mover perfiles al grupo Predeterminado",
"deleteAlongWithGroup": "Eliminar perfiles junto con el grupo",
"noAssociatedProfiles": "Este grupo no tiene perfiles asociados.",
"deleteGroup": "Eliminar Grupo",
@@ -530,7 +546,10 @@
"unknownGroup": "Grupo desconocido",
"profileGroupsAriaLabel": "Grupos de perfiles",
"loading": "Cargando grupos...",
"all": "Todos"
"all": "Todos",
"noGroup": "Sin grupo",
"pageTitle": "Grupos de perfiles",
"pageDescription": "Los grupos de perfiles te permiten organizar los navegadores por cliente, entorno o caso de uso. Sincroniza los grupos entre dispositivos para compartirlos."
},
"sync": {
"mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Agregado a Claude Code",
"removedFromClaudeCode": "Eliminado de Claude Code",
"config": "Configuración MCP",
"copyConfig": "Copiar Configuración"
"copyConfig": "Copiar Configuración",
"clientsLabel": "Clientes"
},
"tabApi": "API local",
"tabMcp": "MCP (asistentes IA)",
@@ -675,7 +695,9 @@
"mcpStarted": "Servidor MCP iniciado en puerto {{port}}",
"mcpStopped": "Servidor MCP detenido",
"mcpToggleFailed": "Error al alternar el servidor MCP",
"openSettings": "Abrir configuración de integraciones"
"openSettings": "Abrir configuración de integraciones",
"apiRunningOn": "Ejecutándose en",
"apiExampleRequest": "Solicitud de ejemplo"
},
"import": {
"title": "Importar Perfil",
@@ -1181,6 +1203,7 @@
"empty": "No se han subido extensiones aún.",
"noGroups": "No se han creado grupos de extensiones aún.",
"createGroup": "Crear Grupo",
"newGroup": "Nuevo grupo",
"addToGroup": "Agregar extensión...",
"removeFromGroup": "Eliminar del grupo",
"deleteGroup": "Eliminar grupo",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Habilitar sincronización",
"syncDisableTooltip": "Deshabilitar sincronización",
"loadGroupsFailed": "Error al cargar grupos de extensiones",
"assignGroupFailed": "Error al asignar grupo de extensiones"
"assignGroupFailed": "Error al asignar grupo de extensiones",
"bulkDelete": {
"extensionsTitle": "Eliminar extensiones",
"extensionsDescription": "¿Eliminar {{count}} extensiones? {{names}}",
"groupsTitle": "Eliminar grupos de extensiones",
"groupsDescription": "¿Eliminar {{count}} grupos de extensiones? {{names}}",
"confirmButton": "Eliminar"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "En espera de sincronización",
"errorWith": "Error de sincronización: {{error}}",
"error": "Error de sincronización",
"notSynced": "Sin sincronizar"
"notSynced": "Sin sincronizar",
"enable": "Activar sincronización",
"disable": "Desactivar sincronización",
"lockedInUse": "La sincronización está bloqueada mientras un perfil sincronizado lo use",
"bulkToggle": "Alternar sincronización"
},
"groupManagement": {
"description": "Administra tus grupos de perfiles",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "No se puede desactivar la sincronización mientras este grupo esté en uso por perfiles sincronizados",
"editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Eliminar grupo",
"loadFailed": "Error al cargar los grupos"
"loadFailed": "Error al cargar los grupos",
"bulkDelete": {
"title": "Eliminar grupos",
"description": "¿Estás seguro de que quieres eliminar {{count}} grupos? {{names}}. Los perfiles se moverán a Predeterminado.",
"description_one": "¿Estás seguro de que quieres eliminar {{count}} grupo? {{names}}. Los perfiles se moverán a Predeterminado.",
"confirmButton": "Eliminar grupos"
}
},
"proxyAssignment": {
"title": "Asignar proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Establecer",
"change": "Cambiar",
"remove": "Quitar"
"remove": "Quitar",
"validate": "Validar"
},
"verifyDialog": {
"title": "Validar contraseña del perfil",
"description": "Introduce la contraseña del perfil para confirmar que coincide con la almacenada en disco.",
"submit": "Validar",
"matchToast": "La contraseña es correcta"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Perfiles",
"proxies": "Proxies",
"extensions": "Extensiones",
"groups": "Grupos",
"settings": "Ajustes",
@@ -1757,18 +1803,17 @@
"label": "Más",
"closeAriaLabel": "Cerrar menú",
"importProfile": "Importar perfil",
"importProfileHint": "Trae perfiles de otra herramienta",
"integrations": "Integraciones",
"integrationsHint": "Slack, MCP, automatizaciones",
"account": "Cuenta",
"accountHint": "Nube, facturación, sesión"
}
"importProfileHint": "Trae perfiles de otra herramienta"
},
"network": "Red",
"integrations": "Integraciones",
"account": "Cuenta"
},
"pageTitle": {
"proxies": "Proxies",
"proxies": "Red",
"extensions": "Extensiones",
"groups": "Grupos",
"vpns": "VPN",
"vpns": "Red",
"settings": "Ajustes",
"integrations": "Integraciones",
"account": "Cuenta",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Téléchargement...",
"minimize": "Réduire",
"saving": "Enregistrement…",
"saved": "Enregistré"
"saved": "Enregistré",
"copied": "Copié"
},
"status": {
"active": "Actif",
@@ -158,7 +159,15 @@
"passwordSaved": "Mot de passe de chiffrement défini",
"passwordMismatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe."
"requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe.",
"validatePassword": "Valider",
"validateDialog": {
"title": "Valider le mot de passe de chiffrement",
"description": "Saisissez votre mot de passe de chiffrement pour vérifier qu'il correspond à celui enregistré sur cet appareil.",
"submit": "Valider",
"matchToast": "Le mot de passe est correct",
"mismatchToast": "Le mot de passe ne correspond pas"
}
},
"commercial": {
"title": "Licence commerciale",
@@ -376,6 +385,9 @@
"deleteFailed": "Échec de la suppression du proxy",
"deleteTitle": "Supprimer le proxy",
"deleteDescription": "Cette action est irréversible. Le proxy « {{name}} » sera supprimé définitivement.",
"newProxy": "Nouveau proxy",
"newVpn": "Nouveau VPN",
"protocolCol": "Protocole",
"title": "Proxys et VPN"
},
"add": "Ajouter un proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continuer",
"doneButton": "Terminé",
"failed": "Échec de l'import des proxys"
},
"bulkDelete": {
"proxiesTitle": "Supprimer les proxys sélectionnés",
"proxiesDescription": "Cette action est irréversible. {{count}} proxy(s) seront définitivement supprimés : {{names}}.",
"vpnsTitle": "Supprimer les VPN sélectionnés",
"vpnsDescription": "Cette action est irréversible. {{count}} VPN(s) seront définitivement supprimés : {{names}}.",
"confirmButton": "Supprimer {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Ajouter un groupe",
"edit": "Modifier le groupe",
"delete": "Supprimer le groupe",
"defaultGroup": "Par défaut",
"defaultGroupNoGroup": "Par défaut (Aucun groupe)",
"moveToDefault": "Déplacer les profils vers le groupe Par défaut",
"noGroupDescription": "Les profils sans groupe apparaîtront dans le groupe « Par défaut ».",
"moveToDefault": "Retirer les profils du groupe",
"noGroupDescription": "Les profils sans groupe apparaissent dans le filtre « Tous ».",
"assignSuccess": "{{count}} profil(s) assigné(s) à {{group}} avec succès",
"noGroups": "Aucun groupe créé",
"noGroupsDescription": "Créez un groupe pour organiser vos profils.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Chargement des profils associés...",
"associatedProfiles": "Profils Associés ({{count}})",
"whatToDoWithProfiles": "Que faire de ces profils ?",
"moveToDefaultOption": "Déplacer les profils vers le groupe Par défaut",
"deleteAlongWithGroup": "Supprimer les profils avec le groupe",
"noAssociatedProfiles": "Ce groupe n'a pas de profils associés.",
"deleteGroup": "Supprimer le Groupe",
@@ -530,7 +546,10 @@
"unknownGroup": "Groupe inconnu",
"profileGroupsAriaLabel": "Groupes de profils",
"loading": "Chargement des groupes...",
"all": "Tous"
"all": "Tous",
"noGroup": "Aucun groupe",
"pageTitle": "Groupes de profils",
"pageDescription": "Les groupes de profils vous permettent d'organiser les navigateurs par client, environnement ou cas d'usage. Synchronisez les groupes entre appareils pour les partager."
},
"sync": {
"mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Ajouté à Claude Code",
"removedFromClaudeCode": "Supprimé de Claude Code",
"config": "Configuration MCP",
"copyConfig": "Copier la configuration"
"copyConfig": "Copier la configuration",
"clientsLabel": "Clients"
},
"tabApi": "API locale",
"tabMcp": "MCP (Assistants IA)",
@@ -675,7 +695,9 @@
"mcpStarted": "Serveur MCP démarré sur le port {{port}}",
"mcpStopped": "Serveur MCP arrêté",
"mcpToggleFailed": "Échec du basculement du serveur MCP",
"openSettings": "Ouvrir les paramètres d'intégrations"
"openSettings": "Ouvrir les paramètres d'intégrations",
"apiRunningOn": "En cours sur",
"apiExampleRequest": "Exemple de requête"
},
"import": {
"title": "Importer un profil",
@@ -1181,6 +1203,7 @@
"empty": "Aucune extension téléchargée pour l'instant.",
"noGroups": "Aucun groupe d'extensions créé pour l'instant.",
"createGroup": "Créer un Groupe",
"newGroup": "Nouveau groupe",
"addToGroup": "Ajouter une extension...",
"removeFromGroup": "Retirer du groupe",
"deleteGroup": "Supprimer le groupe",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Activer la synchronisation",
"syncDisableTooltip": "Désactiver la synchronisation",
"loadGroupsFailed": "Échec du chargement des groupes d'extensions",
"assignGroupFailed": "Échec de l'attribution du groupe d'extensions"
"assignGroupFailed": "Échec de l'attribution du groupe d'extensions",
"bulkDelete": {
"extensionsTitle": "Supprimer les extensions",
"extensionsDescription": "Supprimer {{count}} extensions ? {{names}}",
"groupsTitle": "Supprimer les groupes d'extensions",
"groupsDescription": "Supprimer {{count}} groupes d'extensions ? {{names}}",
"confirmButton": "Supprimer"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "En attente de synchronisation",
"errorWith": "Erreur de synchronisation : {{error}}",
"error": "Erreur de synchronisation",
"notSynced": "Non synchronisé"
"notSynced": "Non synchronisé",
"enable": "Activer la synchronisation",
"disable": "Désactiver la synchronisation",
"lockedInUse": "La synchronisation est verrouillée tant qu'un profil synchronisé l'utilise",
"bulkToggle": "Basculer la synchronisation"
},
"groupManagement": {
"description": "Gérez vos groupes de profils",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce groupe est utilisé par des profils synchronisés",
"editGroupTooltip": "Modifier le groupe",
"deleteGroupTooltip": "Supprimer le groupe",
"loadFailed": "Échec du chargement des groupes"
"loadFailed": "Échec du chargement des groupes",
"bulkDelete": {
"title": "Supprimer les groupes",
"description": "Êtes-vous sûr de vouloir supprimer {{count}} groupes ? {{names}}. Les profils seront déplacés vers Par défaut.",
"description_one": "Êtes-vous sûr de vouloir supprimer {{count}} groupe ? {{names}}. Les profils seront déplacés vers Par défaut.",
"confirmButton": "Supprimer les groupes"
}
},
"proxyAssignment": {
"title": "Assigner un proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Définir",
"change": "Modifier",
"remove": "Supprimer"
"remove": "Supprimer",
"validate": "Valider"
},
"verifyDialog": {
"title": "Valider le mot de passe du profil",
"description": "Saisissez le mot de passe du profil pour vérifier qu'il correspond à celui enregistré sur le disque.",
"submit": "Valider",
"matchToast": "Le mot de passe est correct"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Profils",
"proxies": "Proxys",
"extensions": "Extensions",
"groups": "Groupes",
"settings": "Paramètres",
@@ -1757,18 +1803,17 @@
"label": "Plus",
"closeAriaLabel": "Fermer le menu",
"importProfile": "Importer un profil",
"importProfileHint": "Importer depuis un autre outil",
"integrations": "Intégrations",
"integrationsHint": "Slack, MCP, automatisations",
"account": "Compte",
"accountHint": "Cloud, facturation, connexion"
}
"importProfileHint": "Importer depuis un autre outil"
},
"network": "Réseau",
"integrations": "Intégrations",
"account": "Compte"
},
"pageTitle": {
"proxies": "Proxys",
"proxies": "Réseau",
"extensions": "Extensions",
"groups": "Groupes",
"vpns": "VPN",
"vpns": "Réseau",
"settings": "Paramètres",
"integrations": "Intégrations",
"account": "Compte",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "ダウンロード中...",
"minimize": "最小化",
"saving": "保存中…",
"saved": "保存しました"
"saved": "保存しました",
"copied": "コピーしました"
},
"status": {
"active": "アクティブ",
@@ -158,7 +159,15 @@
"passwordSaved": "暗号化パスワードが設定されました",
"passwordMismatch": "パスワードが一致しません",
"passwordTooShort": "パスワードは8文字以上である必要があります",
"requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。"
"requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。",
"validatePassword": "確認",
"validateDialog": {
"title": "暗号化パスワードを確認",
"description": "このデバイスに保存されているパスワードと一致するか、暗号化パスワードを入力してください。",
"submit": "確認",
"matchToast": "パスワードが一致しました",
"mismatchToast": "パスワードが一致しません"
}
},
"commercial": {
"title": "商用ライセンス",
@@ -376,6 +385,9 @@
"deleteFailed": "プロキシの削除に失敗しました",
"deleteTitle": "プロキシを削除",
"deleteDescription": "この操作は取り消せません。プロキシ「{{name}}」は完全に削除されます。",
"newProxy": "新しいプロキシ",
"newVpn": "新しいVPN",
"protocolCol": "プロトコル",
"title": "プロキシと VPN"
},
"add": "プロキシを追加",
@@ -480,6 +492,13 @@
"continueButton": "続ける",
"doneButton": "完了",
"failed": "プロキシのインポートに失敗しました"
},
"bulkDelete": {
"proxiesTitle": "選択したプロキシを削除",
"proxiesDescription": "この操作は取り消せません。{{count}} 件のプロキシを完全に削除します: {{names}}",
"vpnsTitle": "選択したVPNを削除",
"vpnsDescription": "この操作は取り消せません。{{count}} 件のVPNを完全に削除します: {{names}}",
"confirmButton": "{{count}} 件を削除"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "グループを追加",
"edit": "グループを編集",
"delete": "グループを削除",
"defaultGroup": "デフォルト",
"defaultGroupNoGroup": "デフォルト(グループなし)",
"moveToDefault": "プロファイルをデフォルトグループに移動",
"noGroupDescription": "グループに属していないプロファイルは「デフォルト」グループに表示されます。",
"moveToDefault": "プロファイルをグループから外す",
"noGroupDescription": "グループに属さないプロファイルは「すべて」フィルターに表示されます。",
"assignSuccess": "{{count}} 件のプロファイルを {{group}} に割り当てました",
"noGroups": "グループがありません",
"noGroupsDescription": "プロファイルを整理するためのグループを作成してください。",
@@ -521,7 +538,6 @@
"loadingProfiles": "関連するプロファイルを読み込んでいます...",
"associatedProfiles": "関連プロファイル ({{count}})",
"whatToDoWithProfiles": "これらのプロファイルをどうしますか?",
"moveToDefaultOption": "プロファイルをデフォルトグループに移動",
"deleteAlongWithGroup": "プロファイルもグループと一緒に削除",
"noAssociatedProfiles": "このグループには関連するプロファイルがありません。",
"deleteGroup": "グループを削除",
@@ -530,7 +546,10 @@
"unknownGroup": "不明なグループ",
"profileGroupsAriaLabel": "プロファイルグループ",
"loading": "グループを読み込み中...",
"all": "すべて"
"all": "すべて",
"noGroup": "グループなし",
"pageTitle": "プロファイルグループ",
"pageDescription": "プロファイルグループを使うと、クライアント、環境、用途ごとにブラウザを整理できます。デバイス間でグループを同期して共有しましょう。"
},
"sync": {
"mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Claude Code に追加しました",
"removedFromClaudeCode": "Claude Code から削除しました",
"config": "MCP設定",
"copyConfig": "設定をコピー"
"copyConfig": "設定をコピー",
"clientsLabel": "クライアント"
},
"tabApi": "ローカル API",
"tabMcp": "MCP (AI アシスタント)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP サーバーをポート {{port}} で起動しました",
"mcpStopped": "MCP サーバーを停止しました",
"mcpToggleFailed": "MCP サーバーの切り替えに失敗しました",
"openSettings": "統合設定を開く"
"openSettings": "統合設定を開く",
"apiRunningOn": "実行中",
"apiExampleRequest": "リクエスト例"
},
"import": {
"title": "プロファイルをインポート",
@@ -1181,6 +1203,7 @@
"empty": "まだ拡張機能がアップロードされていません。",
"noGroups": "まだ拡張機能グループが作成されていません。",
"createGroup": "グループを作成",
"newGroup": "新しいグループ",
"addToGroup": "拡張機能を追加...",
"removeFromGroup": "グループから削除",
"deleteGroup": "グループを削除",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "同期を有効にする",
"syncDisableTooltip": "同期を無効にする",
"loadGroupsFailed": "拡張機能グループの読み込みに失敗しました",
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました"
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました",
"bulkDelete": {
"extensionsTitle": "拡張機能を削除",
"extensionsDescription": "{{count}}件の拡張機能を削除しますか? {{names}}",
"groupsTitle": "拡張機能グループを削除",
"groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}",
"confirmButton": "削除"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "同期待ち",
"errorWith": "同期エラー: {{error}}",
"error": "同期エラー",
"notSynced": "未同期"
"notSynced": "未同期",
"enable": "同期を有効化",
"disable": "同期を無効化",
"lockedInUse": "同期されたプロファイルが使用中のため、同期は無効化できません",
"bulkToggle": "同期を切り替え"
},
"groupManagement": {
"description": "プロファイルのグループを管理します",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "このグループが同期されたプロファイルで使用されている間は同期を無効にできません",
"editGroupTooltip": "グループを編集",
"deleteGroupTooltip": "グループを削除",
"loadFailed": "グループの読み込みに失敗しました"
"loadFailed": "グループの読み込みに失敗しました",
"bulkDelete": {
"title": "グループを削除",
"description": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。",
"description_one": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。",
"confirmButton": "グループを削除"
}
},
"proxyAssignment": {
"title": "プロキシ / VPN を割り当てる",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "設定",
"change": "変更",
"remove": "削除"
"remove": "削除",
"validate": "確認"
},
"verifyDialog": {
"title": "プロファイルパスワードを確認",
"description": "ディスクに保存されているプロファイルパスワードと一致するか入力してください。",
"submit": "確認",
"matchToast": "パスワードが一致しました"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "プロファイル",
"proxies": "プロキシ",
"extensions": "拡張機能",
"groups": "グループ",
"settings": "設定",
@@ -1757,18 +1803,17 @@
"label": "その他",
"closeAriaLabel": "メニューを閉じる",
"importProfile": "プロファイルをインポート",
"importProfileHint": "別のツールから取り込む",
"integrations": "連携",
"integrationsHint": "Slack、MCP、自動化",
"account": "アカウント",
"accountHint": "クラウド、請求、サインイン"
}
"importProfileHint": "別のツールから取り込む"
},
"network": "ネットワーク",
"integrations": "連携",
"account": "アカウント"
},
"pageTitle": {
"proxies": "プロキシ",
"proxies": "ネットワーク",
"extensions": "拡張機能",
"groups": "グループ",
"vpns": "VPN",
"vpns": "ネットワーク",
"settings": "設定",
"integrations": "連携",
"account": "アカウント",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Baixando...",
"minimize": "Minimizar",
"saving": "Salvando…",
"saved": "Salvo"
"saved": "Salvo",
"copied": "Copiado"
},
"status": {
"active": "Ativo",
@@ -158,7 +159,15 @@
"passwordSaved": "Senha de criptografia definida",
"passwordMismatch": "As senhas não coincidem",
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres",
"requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe."
"requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe.",
"validatePassword": "Validar",
"validateDialog": {
"title": "Validar senha de criptografia",
"description": "Digite sua senha de criptografia para verificar se corresponde à armazenada neste dispositivo.",
"submit": "Validar",
"matchToast": "A senha está correta",
"mismatchToast": "A senha não corresponde"
}
},
"commercial": {
"title": "Licença Comercial",
@@ -376,6 +385,9 @@
"deleteFailed": "Falha ao excluir proxy",
"deleteTitle": "Excluir proxy",
"deleteDescription": "Esta ação não pode ser desfeita. O proxy \"{{name}}\" será excluído permanentemente.",
"newProxy": "Novo proxy",
"newVpn": "Nova VPN",
"protocolCol": "Protocolo",
"title": "Proxies e VPNs"
},
"add": "Adicionar Proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continuar",
"doneButton": "Concluído",
"failed": "Falha ao importar proxies"
},
"bulkDelete": {
"proxiesTitle": "Excluir proxies selecionados",
"proxiesDescription": "Esta ação não pode ser desfeita. Isso excluirá permanentemente {{count}} proxy(s): {{names}}.",
"vpnsTitle": "Excluir VPNs selecionadas",
"vpnsDescription": "Esta ação não pode ser desfeita. Isso excluirá permanentemente {{count}} VPN(s): {{names}}.",
"confirmButton": "Excluir {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Adicionar Grupo",
"edit": "Editar Grupo",
"delete": "Excluir Grupo",
"defaultGroup": "Padrão",
"defaultGroupNoGroup": "Padrão (Sem Grupo)",
"moveToDefault": "Mover perfis para o grupo Padrão",
"noGroupDescription": "Perfis sem grupo aparecerão no grupo \"Padrão\".",
"moveToDefault": "Remover perfis do grupo",
"noGroupDescription": "Perfis sem grupo aparecem no filtro \"Todos\".",
"assignSuccess": "{{count}} perfil(s) atribuído(s) a {{group}} com sucesso",
"noGroups": "Nenhum grupo criado",
"noGroupsDescription": "Crie um grupo para organizar seus perfis.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Carregando perfis associados...",
"associatedProfiles": "Perfis Associados ({{count}})",
"whatToDoWithProfiles": "O que fazer com esses perfis?",
"moveToDefaultOption": "Mover perfis para o grupo Padrão",
"deleteAlongWithGroup": "Excluir perfis junto com o grupo",
"noAssociatedProfiles": "Este grupo não tem perfis associados.",
"deleteGroup": "Excluir Grupo",
@@ -530,7 +546,10 @@
"unknownGroup": "Grupo desconhecido",
"profileGroupsAriaLabel": "Grupos de perfis",
"loading": "Carregando grupos...",
"all": "Todos"
"all": "Todos",
"noGroup": "Sem grupo",
"pageTitle": "Grupos de perfis",
"pageDescription": "Os grupos de perfis permitem organizar os navegadores por cliente, ambiente ou caso de uso. Sincronize grupos entre dispositivos para compartilhá-los."
},
"sync": {
"mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Adicionado ao Claude Code",
"removedFromClaudeCode": "Removido do Claude Code",
"config": "Configuração MCP",
"copyConfig": "Copiar Configuração"
"copyConfig": "Copiar Configuração",
"clientsLabel": "Clientes"
},
"tabApi": "API local",
"tabMcp": "MCP (Assistentes de IA)",
@@ -675,7 +695,9 @@
"mcpStarted": "Servidor MCP iniciado na porta {{port}}",
"mcpStopped": "Servidor MCP parado",
"mcpToggleFailed": "Falha ao alternar o servidor MCP",
"openSettings": "Abrir configurações de integrações"
"openSettings": "Abrir configurações de integrações",
"apiRunningOn": "Em execução em",
"apiExampleRequest": "Exemplo de solicitação"
},
"import": {
"title": "Importar Perfil",
@@ -1181,6 +1203,7 @@
"empty": "Nenhuma extensão enviada ainda.",
"noGroups": "Nenhum grupo de extensões criado ainda.",
"createGroup": "Criar Grupo",
"newGroup": "Novo grupo",
"addToGroup": "Adicionar extensão...",
"removeFromGroup": "Remover do grupo",
"deleteGroup": "Excluir grupo",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Ativar sincronização",
"syncDisableTooltip": "Desativar sincronização",
"loadGroupsFailed": "Falha ao carregar grupos de extensões",
"assignGroupFailed": "Falha ao atribuir grupo de extensões"
"assignGroupFailed": "Falha ao atribuir grupo de extensões",
"bulkDelete": {
"extensionsTitle": "Excluir extensões",
"extensionsDescription": "Excluir {{count}} extensões? {{names}}",
"groupsTitle": "Excluir grupos de extensões",
"groupsDescription": "Excluir {{count}} grupos de extensões? {{names}}",
"confirmButton": "Excluir"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "Aguardando sincronização",
"errorWith": "Erro de sincronização: {{error}}",
"error": "Erro de sincronização",
"notSynced": "Não sincronizado"
"notSynced": "Não sincronizado",
"enable": "Ativar sincronização",
"disable": "Desativar sincronização",
"lockedInUse": "A sincronização está bloqueada enquanto um perfil sincronizado a usar",
"bulkToggle": "Alternar sincronização"
},
"groupManagement": {
"description": "Gerencie seus grupos de perfis",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "A sincronização não pode ser desativada enquanto este grupo estiver em uso por perfis sincronizados",
"editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Excluir grupo",
"loadFailed": "Falha ao carregar grupos"
"loadFailed": "Falha ao carregar grupos",
"bulkDelete": {
"title": "Excluir grupos",
"description": "Tem certeza que deseja excluir {{count}} grupos? {{names}}. Os perfis serão movidos para Padrão.",
"description_one": "Tem certeza que deseja excluir {{count}} grupo? {{names}}. Os perfis serão movidos para Padrão.",
"confirmButton": "Excluir grupos"
}
},
"proxyAssignment": {
"title": "Atribuir proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Definir",
"change": "Alterar",
"remove": "Remover"
"remove": "Remover",
"validate": "Validar"
},
"verifyDialog": {
"title": "Validar senha do perfil",
"description": "Digite a senha do perfil para confirmar se corresponde à armazenada em disco.",
"submit": "Validar",
"matchToast": "A senha está correta"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Perfis",
"proxies": "Proxies",
"extensions": "Extensões",
"groups": "Grupos",
"settings": "Configurações",
@@ -1757,18 +1803,17 @@
"label": "Mais",
"closeAriaLabel": "Fechar menu",
"importProfile": "Importar perfil",
"importProfileHint": "Trazer perfis de outra ferramenta",
"integrations": "Integrações",
"integrationsHint": "Slack, MCP, automações",
"account": "Conta",
"accountHint": "Nuvem, cobrança, login"
}
"importProfileHint": "Trazer perfis de outra ferramenta"
},
"network": "Rede",
"integrations": "Integrações",
"account": "Conta"
},
"pageTitle": {
"proxies": "Proxies",
"proxies": "Rede",
"extensions": "Extensões",
"groups": "Grupos",
"vpns": "VPN",
"vpns": "Rede",
"settings": "Configurações",
"integrations": "Integrações",
"account": "Conta",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "Загрузка...",
"minimize": "Свернуть",
"saving": "Сохраняем…",
"saved": "Сохранено"
"saved": "Сохранено",
"copied": "Скопировано"
},
"status": {
"active": "Активен",
@@ -158,7 +159,15 @@
"passwordSaved": "Пароль шифрования установлен",
"passwordMismatch": "Пароли не совпадают",
"passwordTooShort": "Пароль должен содержать не менее 8 символов",
"requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд."
"requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд.",
"validatePassword": "Проверить",
"validateDialog": {
"title": "Проверка пароля шифрования",
"description": "Введите пароль шифрования, чтобы убедиться, что он совпадает с сохранённым на этом устройстве.",
"submit": "Проверить",
"matchToast": "Пароль верен",
"mismatchToast": "Пароль не совпадает"
}
},
"commercial": {
"title": "Коммерческая лицензия",
@@ -376,6 +385,9 @@
"deleteFailed": "Не удалось удалить прокси",
"deleteTitle": "Удалить прокси",
"deleteDescription": "Это действие нельзя отменить. Прокси «{{name}}» будет удален навсегда.",
"newProxy": "Новый прокси",
"newVpn": "Новый VPN",
"protocolCol": "Протокол",
"title": "Прокси и VPN"
},
"add": "Добавить прокси",
@@ -480,6 +492,13 @@
"continueButton": "Продолжить",
"doneButton": "Готово",
"failed": "Не удалось импортировать прокси"
},
"bulkDelete": {
"proxiesTitle": "Удалить выбранные прокси",
"proxiesDescription": "Это действие нельзя отменить. Будет безвозвратно удалено прокси: {{count}} — {{names}}.",
"vpnsTitle": "Удалить выбранные VPN",
"vpnsDescription": "Это действие нельзя отменить. Будет безвозвратно удалено VPN: {{count}} — {{names}}.",
"confirmButton": "Удалить {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Добавить группу",
"edit": "Редактировать группу",
"delete": "Удалить группу",
"defaultGroup": "По умолчанию",
"defaultGroupNoGroup": "По умолчанию (Без группы)",
"moveToDefault": "Переместить профили в группу по умолчанию",
"noGroupDescription": "Профили без группы будут отображаться в группе «По умолчанию».",
"moveToDefault": "Убрать профили из группы",
"noGroupDescription": "Профили без группы отображаются в фильтре «Все».",
"assignSuccess": "Успешно назначено {{count}} профиль(ей) в {{group}}",
"noGroups": "Группы не созданы",
"noGroupsDescription": "Создайте группу для организации профилей.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Загрузка связанных профилей...",
"associatedProfiles": "Связанные профили ({{count}})",
"whatToDoWithProfiles": "Что сделать с этими профилями?",
"moveToDefaultOption": "Переместить профили в группу По умолчанию",
"deleteAlongWithGroup": "Удалить профили вместе с группой",
"noAssociatedProfiles": "У этой группы нет связанных профилей.",
"deleteGroup": "Удалить группу",
@@ -530,7 +546,10 @@
"unknownGroup": "Неизвестная группа",
"profileGroupsAriaLabel": "Группы профилей",
"loading": "Загрузка групп...",
"all": "Все"
"all": "Все",
"noGroup": "Без группы",
"pageTitle": "Группы профилей",
"pageDescription": "Группы профилей позволяют организовать браузеры по клиенту, окружению или сценарию использования. Синхронизируйте группы между устройствами, чтобы делиться ими."
},
"sync": {
"mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "Добавлено в Claude Code",
"removedFromClaudeCode": "Удалено из Claude Code",
"config": "Конфигурация MCP",
"copyConfig": "Копировать конфигурацию"
"copyConfig": "Копировать конфигурацию",
"clientsLabel": "Клиенты"
},
"tabApi": "Локальный API",
"tabMcp": "MCP (ИИ-ассистенты)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP сервер запущен на порту {{port}}",
"mcpStopped": "MCP сервер остановлен",
"mcpToggleFailed": "Не удалось переключить MCP сервер",
"openSettings": "Открыть настройки интеграций"
"openSettings": "Открыть настройки интеграций",
"apiRunningOn": "Запущен на",
"apiExampleRequest": "Пример запроса"
},
"import": {
"title": "Импорт профиля",
@@ -1181,6 +1203,7 @@
"empty": "Расширения ещё не загружены.",
"noGroups": "Группы расширений ещё не созданы.",
"createGroup": "Создать группу",
"newGroup": "Новая группа",
"addToGroup": "Добавить расширение...",
"removeFromGroup": "Удалить из группы",
"deleteGroup": "Удалить группу",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Включить синхронизацию",
"syncDisableTooltip": "Отключить синхронизацию",
"loadGroupsFailed": "Не удалось загрузить группы расширений",
"assignGroupFailed": "Не удалось назначить группу расширений"
"assignGroupFailed": "Не удалось назначить группу расширений",
"bulkDelete": {
"extensionsTitle": "Удалить расширения",
"extensionsDescription": "Удалить {{count}} расширений? {{names}}",
"groupsTitle": "Удалить группы расширений",
"groupsDescription": "Удалить {{count}} групп расширений? {{names}}",
"confirmButton": "Удалить"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "Ожидание синхронизации",
"errorWith": "Ошибка синхронизации: {{error}}",
"error": "Ошибка синхронизации",
"notSynced": "Не синхронизировано"
"notSynced": "Не синхронизировано",
"enable": "Включить синхронизацию",
"disable": "Отключить синхронизацию",
"lockedInUse": "Синхронизация заблокирована, пока используется синхронизируемым профилем",
"bulkToggle": "Переключить синхронизацию"
},
"groupManagement": {
"description": "Управляйте группами профилей",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "Нельзя отключить синхронизацию, пока эта группа используется синхронизированными профилями",
"editGroupTooltip": "Редактировать группу",
"deleteGroupTooltip": "Удалить группу",
"loadFailed": "Не удалось загрузить группы"
"loadFailed": "Не удалось загрузить группы",
"bulkDelete": {
"title": "Удалить группы",
"description": "Вы уверены, что хотите удалить {{count}} групп? {{names}}. Профили будут перемещены в группу по умолчанию.",
"description_one": "Вы уверены, что хотите удалить {{count}} группу? {{names}}. Профили будут перемещены в группу по умолчанию.",
"confirmButton": "Удалить группы"
}
},
"proxyAssignment": {
"title": "Назначить прокси / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Задать",
"change": "Изменить",
"remove": "Удалить"
"remove": "Удалить",
"validate": "Проверить"
},
"verifyDialog": {
"title": "Проверка пароля профиля",
"description": "Введите пароль профиля, чтобы убедиться, что он совпадает с сохранённым на диске.",
"submit": "Проверить",
"matchToast": "Пароль верен"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Профили",
"proxies": "Прокси",
"extensions": "Расширения",
"groups": "Группы",
"settings": "Настройки",
@@ -1757,18 +1803,17 @@
"label": "Ещё",
"closeAriaLabel": "Закрыть меню",
"importProfile": "Импорт профиля",
"importProfileHint": "Перенести профили из другого инструмента",
"integrations": "Интеграции",
"integrationsHint": "Slack, MCP, автоматизации",
"account": "Аккаунт",
"accountHint": "Облако, оплата, вход"
}
"importProfileHint": "Перенести профили из другого инструмента"
},
"network": "Сеть",
"integrations": "Интеграции",
"account": "Аккаунт"
},
"pageTitle": {
"proxies": "Прокси",
"proxies": "Сеть",
"extensions": "Расширения",
"groups": "Группы",
"vpns": "VPN",
"vpns": "Сеть",
"settings": "Настройки",
"integrations": "Интеграции",
"account": "Аккаунт",
+68 -23
View File
@@ -32,7 +32,8 @@
"downloading": "下载中...",
"minimize": "最小化",
"saving": "正在保存…",
"saved": "已保存"
"saved": "已保存",
"copied": "已复制"
},
"status": {
"active": "活跃",
@@ -158,7 +159,15 @@
"passwordSaved": "加密密码已设置",
"passwordMismatch": "密码不匹配",
"passwordTooShort": "密码必须至少8个字符",
"requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。"
"requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。",
"validatePassword": "验证",
"validateDialog": {
"title": "验证加密密码",
"description": "输入加密密码以验证它是否与此设备上存储的密码匹配。",
"submit": "验证",
"matchToast": "密码正确",
"mismatchToast": "密码不匹配"
}
},
"commercial": {
"title": "商业许可",
@@ -376,6 +385,9 @@
"deleteFailed": "删除代理失败",
"deleteTitle": "删除代理",
"deleteDescription": "此操作无法撤消。代理「{{name}}」将被永久删除。",
"newProxy": "新建代理",
"newVpn": "新建 VPN",
"protocolCol": "协议",
"title": "代理和 VPN"
},
"add": "添加代理",
@@ -480,6 +492,13 @@
"continueButton": "继续",
"doneButton": "完成",
"failed": "导入代理失败"
},
"bulkDelete": {
"proxiesTitle": "删除所选代理",
"proxiesDescription": "此操作无法撤销。将永久删除 {{count}} 个代理:{{names}}。",
"vpnsTitle": "删除所选 VPN",
"vpnsDescription": "此操作无法撤销。将永久删除 {{count}} 个 VPN{{names}}。",
"confirmButton": "删除 {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "添加分组",
"edit": "编辑分组",
"delete": "删除分组",
"defaultGroup": "默认",
"defaultGroupNoGroup": "默认(无分组)",
"moveToDefault": "将配置文件移至默认分组",
"noGroupDescription": "未分组的配置文件将显示在「默认」分组中。",
"moveToDefault": "将配置文件移出分组",
"noGroupDescription": "未分组的配置文件显示在「全部」筛选中。",
"assignSuccess": "已成功将 {{count}} 个配置文件分配到 {{group}}",
"noGroups": "暂无分组",
"noGroupsDescription": "创建分组来组织您的配置文件。",
@@ -521,7 +538,6 @@
"loadingProfiles": "正在加载关联的配置文件...",
"associatedProfiles": "关联的配置文件 ({{count}})",
"whatToDoWithProfiles": "这些配置文件应该怎么办?",
"moveToDefaultOption": "将配置文件移至默认组",
"deleteAlongWithGroup": "将配置文件与组一起删除",
"noAssociatedProfiles": "此组没有关联的配置文件。",
"deleteGroup": "删除组",
@@ -530,7 +546,10 @@
"unknownGroup": "未知分组",
"profileGroupsAriaLabel": "配置文件分组",
"loading": "正在加载组...",
"all": "全部"
"all": "全部",
"noGroup": "无分组",
"pageTitle": "配置文件分组",
"pageDescription": "配置文件分组可让您按客户端、环境或使用场景整理浏览器。在多台设备之间同步分组以便共享。"
},
"sync": {
"mode": {
@@ -649,7 +668,8 @@
"addedToClaudeCode": "已添加到 Claude Code",
"removedFromClaudeCode": "已从 Claude Code 移除",
"config": "MCP 配置",
"copyConfig": "复制配置"
"copyConfig": "复制配置",
"clientsLabel": "客户端"
},
"tabApi": "本地 API",
"tabMcp": "MCP (AI 助手)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP 服务器已在端口 {{port}} 上启动",
"mcpStopped": "MCP 服务器已停止",
"mcpToggleFailed": "切换 MCP 服务器失败",
"openSettings": "打开集成设置"
"openSettings": "打开集成设置",
"apiRunningOn": "运行于",
"apiExampleRequest": "示例请求"
},
"import": {
"title": "导入配置文件",
@@ -1181,6 +1203,7 @@
"empty": "尚未上传任何扩展程序。",
"noGroups": "尚未创建任何扩展程序组。",
"createGroup": "创建分组",
"newGroup": "新建分组",
"addToGroup": "添加扩展程序...",
"removeFromGroup": "从分组中移除",
"deleteGroup": "删除分组",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "启用同步",
"syncDisableTooltip": "禁用同步",
"loadGroupsFailed": "加载扩展组失败",
"assignGroupFailed": "分配扩展组失败"
"assignGroupFailed": "分配扩展组失败",
"bulkDelete": {
"extensionsTitle": "删除扩展",
"extensionsDescription": "删除 {{count}} 个扩展?{{names}}",
"groupsTitle": "删除扩展组",
"groupsDescription": "删除 {{count}} 个扩展组?{{names}}",
"confirmButton": "删除"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "等待同步",
"errorWith": "同步错误: {{error}}",
"error": "同步错误",
"notSynced": "未同步"
"notSynced": "未同步",
"enable": "启用同步",
"disable": "禁用同步",
"lockedInUse": "同步配置文件正在使用,无法禁用同步",
"bulkToggle": "切换同步"
},
"groupManagement": {
"description": "管理你的配置文件分组",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "此分组被同步的配置文件使用时无法禁用同步",
"editGroupTooltip": "编辑分组",
"deleteGroupTooltip": "删除分组",
"loadFailed": "加载分组失败"
"loadFailed": "加载分组失败",
"bulkDelete": {
"title": "删除分组",
"description": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。",
"description_one": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。",
"confirmButton": "删除分组"
}
},
"proxyAssignment": {
"title": "分配代理 / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "设置",
"change": "更改",
"remove": "删除"
"remove": "删除",
"validate": "验证"
},
"verifyDialog": {
"title": "验证配置文件密码",
"description": "输入配置文件密码以确认它与磁盘上存储的密码匹配。",
"submit": "验证",
"matchToast": "密码正确"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "配置文件",
"proxies": "代理",
"extensions": "扩展",
"groups": "分组",
"settings": "设置",
@@ -1757,18 +1803,17 @@
"label": "更多",
"closeAriaLabel": "关闭菜单",
"importProfile": "导入配置文件",
"importProfileHint": "从其他工具导入",
"integrations": "集成",
"integrationsHint": "Slack、MCP、自动化",
"account": "账户",
"accountHint": "云、订阅、登录"
}
"importProfileHint": "从其他工具导入"
},
"network": "网络",
"integrations": "集成",
"account": "账号"
},
"pageTitle": {
"proxies": "代理",
"proxies": "网络",
"extensions": "扩展",
"groups": "分组",
"vpns": "VPN",
"vpns": "网络",
"settings": "设置",
"integrations": "集成",
"account": "账户",
+26 -11
View File
@@ -157,26 +157,41 @@
}
}
/* Scroll-fade utility: a vertical mask whose top/bottom 16px fade to
transparent ONLY when the matching direction is scrollable. The component
sets `data-fade-top` / `data-fade-bottom` attributes on its container as
the user scrolls; each attribute toggles its own end of the mask via a
CSS variable, so the two edges are independent. */
/* Scroll-fade utility: a vertical mask that thins the alpha of the top and
bottom 24px of the scroll container ONLY when that direction is actually
scrollable. useScrollFade() writes `data-fade-top` / `data-fade-bottom`
on the container as the user scrolls; each attribute toggles its own
end of the mask via a CSS variable.
Mask is preferred over a colored gradient overlay: an overlay paints
bg-color over content, which leaves a visible band wherever content
passes through it. Mask just fades alpha content gracefully fades to
nothing at the edges.
`--scroll-fade-top-offset` pushes the top-edge fade band down by N
pixels so a sticky table header stays fully opaque and only the body
rows scrolling past it fade. Set it inline on a scroll container whose
first N px are occupied by sticky chrome. */
.scroll-fade {
--top-mask: black;
--bottom-mask: black;
--scroll-fade-top-offset: 0px;
-webkit-mask-image: linear-gradient(
to bottom,
var(--top-mask),
black 16px,
black calc(100% - 16px),
black 0,
black var(--scroll-fade-top-offset),
var(--top-mask) var(--scroll-fade-top-offset),
black calc(var(--scroll-fade-top-offset) + 24px),
black calc(100% - 24px),
var(--bottom-mask)
);
mask-image: linear-gradient(
to bottom,
var(--top-mask),
black 16px,
black calc(100% - 16px),
black 0,
black var(--scroll-fade-top-offset),
var(--top-mask) var(--scroll-fade-top-offset),
black calc(var(--scroll-fade-top-offset) + 24px),
black calc(100% - 24px),
var(--bottom-mask)
);
}