mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-06 07:03:52 +02:00
refactor: ui cleanup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user