mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-26 18:17:49 +02:00
refactor: ui cleanup
This commit is contained in:
+7
-5
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user