mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-26 10:08:04 +02:00
1472 lines
48 KiB
TypeScript
1472 lines
48 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
type ColumnDef,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getSortedRowModel,
|
|
type RowSelectionState,
|
|
type SortingState,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { emit, listen } from "@tauri-apps/api/event";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { GoPlus } from "react-icons/go";
|
|
import {
|
|
LuChevronDown,
|
|
LuChevronUp,
|
|
LuDownload,
|
|
LuPencil,
|
|
LuRefreshCw,
|
|
LuTrash2,
|
|
LuUpload,
|
|
} from "react-icons/lu";
|
|
import { toast } from "sonner";
|
|
import {
|
|
DataTableActionBar,
|
|
DataTableActionBarAction,
|
|
DataTableActionBarSelection,
|
|
} from "@/components/data-table-action-bar";
|
|
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
|
import { ProxyExportDialog } from "@/components/proxy-export-dialog";
|
|
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
|
import { ProxyImportDialog } from "@/components/proxy-import-dialog";
|
|
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
|
import {
|
|
AnimatedTabs,
|
|
AnimatedTabsContent,
|
|
AnimatedTabsList,
|
|
AnimatedTabsTrigger,
|
|
} from "@/components/ui/animated-tabs";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
|
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
|
|
import { ProxyCheckButton } from "./proxy-check-button";
|
|
import { RippleButton } from "./ui/ripple";
|
|
import { VpnCheckButton } from "./vpn-check-button";
|
|
import { VpnFormDialog } from "./vpn-form-dialog";
|
|
import { VpnImportDialog } from "./vpn-import-dialog";
|
|
|
|
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
|
|
|
function getSyncStatusDot(
|
|
item: { sync_enabled?: boolean; last_sync?: number },
|
|
liveStatus: SyncStatus | undefined,
|
|
t: (key: string, options?: Record<string, unknown>) => string,
|
|
errorMessage?: string,
|
|
): { color: string; tooltip: string; animate: boolean } {
|
|
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
|
|
|
switch (status) {
|
|
case "syncing":
|
|
return {
|
|
color: "bg-warning",
|
|
tooltip: t("syncTooltips.syncing"),
|
|
animate: true,
|
|
};
|
|
case "synced":
|
|
return {
|
|
color: "bg-success",
|
|
tooltip: item.last_sync
|
|
? t("syncTooltips.syncedAt", {
|
|
time: new Date(item.last_sync * 1000).toLocaleString(),
|
|
})
|
|
: t("syncTooltips.synced"),
|
|
animate: false,
|
|
};
|
|
case "waiting":
|
|
return {
|
|
color: "bg-warning",
|
|
tooltip: t("syncTooltips.waiting"),
|
|
animate: false,
|
|
};
|
|
case "error":
|
|
return {
|
|
color: "bg-destructive",
|
|
tooltip: errorMessage
|
|
? t("syncTooltips.errorWith", { error: errorMessage })
|
|
: t("syncTooltips.error"),
|
|
animate: false,
|
|
};
|
|
default:
|
|
return {
|
|
color: "bg-muted-foreground",
|
|
tooltip: t("syncTooltips.notSynced"),
|
|
animate: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
interface ProxyManagementDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
subPage?: boolean;
|
|
/** Which tab to display first when the dialog mounts; defaults to "proxies". */
|
|
initialTab?: "proxies" | "vpns";
|
|
}
|
|
|
|
export function ProxyManagementDialog({
|
|
isOpen,
|
|
onClose,
|
|
subPage,
|
|
initialTab = "proxies",
|
|
}: ProxyManagementDialogProps) {
|
|
const { t } = useTranslation();
|
|
// Proxy state
|
|
const [showProxyForm, setShowProxyForm] = useState(false);
|
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
|
const [showExportDialog, setShowExportDialog] = useState(false);
|
|
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
|
|
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [checkingProxyId, setCheckingProxyId] = useState<string | null>(null);
|
|
const [proxyCheckResults, setProxyCheckResults] = useState<
|
|
Record<string, ProxyCheckResult>
|
|
>({});
|
|
const [proxySyncStatus, setProxySyncStatus] = useState<
|
|
Record<string, SyncStatus>
|
|
>({});
|
|
const [proxySyncErrors, setProxySyncErrors] = useState<
|
|
Record<string, string>
|
|
>({});
|
|
const [proxyInUse, setProxyInUse] = useState<Record<string, boolean>>({});
|
|
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
|
|
{},
|
|
);
|
|
|
|
// VPN state
|
|
const [showVpnForm, setShowVpnForm] = useState(false);
|
|
const [showVpnImportDialog, setShowVpnImportDialog] = useState(false);
|
|
const [editingVpn, setEditingVpn] = useState<VpnConfig | null>(null);
|
|
const [vpnToDelete, setVpnToDelete] = useState<VpnConfig | null>(null);
|
|
const [isDeletingVpn, setIsDeletingVpn] = useState(false);
|
|
const [checkingVpnId, setCheckingVpnId] = useState<string | null>(null);
|
|
const [vpnSyncStatus, setVpnSyncStatus] = useState<
|
|
Record<string, SyncStatus>
|
|
>({});
|
|
const [vpnSyncErrors, setVpnSyncErrors] = useState<Record<string, string>>(
|
|
{},
|
|
);
|
|
const [vpnInUse, setVpnInUse] = useState<Record<string, boolean>>({});
|
|
const [isTogglingVpnSync, setIsTogglingVpnSync] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
|
|
// Table state
|
|
const [proxiesSorting, setProxiesSorting] = useState<SortingState>([
|
|
{ id: "name", desc: false },
|
|
]);
|
|
const [proxiesRowSelection, setProxiesRowSelection] =
|
|
useState<RowSelectionState>({});
|
|
const [vpnsSorting, setVpnsSorting] = useState<SortingState>([
|
|
{ id: "name", desc: false },
|
|
]);
|
|
const [vpnsRowSelection, setVpnsRowSelection] = useState<RowSelectionState>(
|
|
{},
|
|
);
|
|
|
|
// Track the active tab so we can scope the floating action bar (portaled
|
|
// to body) to only the currently visible list. Initial value comes from
|
|
// initialTab; subsequent changes drive the animated tabs via onValueChange.
|
|
const [activeTab, setActiveTab] = useState<"proxies" | "vpns">(initialTab);
|
|
// Reset selections when the dialog closes so the floating action bar
|
|
// (portaled to body) doesn't linger on the page across navigations.
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setProxiesRowSelection({});
|
|
setVpnsRowSelection({});
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Bulk delete state
|
|
const [isBulkDeletingProxies, setIsBulkDeletingProxies] = useState(false);
|
|
const [showBulkDeleteProxiesDialog, setShowBulkDeleteProxiesDialog] =
|
|
useState(false);
|
|
const [isBulkDeletingVpns, setIsBulkDeletingVpns] = useState(false);
|
|
const [showBulkDeleteVpnsDialog, setShowBulkDeleteVpnsDialog] =
|
|
useState(false);
|
|
|
|
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
|
|
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
|
|
|
|
// Filter out cloud-managed and cloud-derived proxies (cloud proxies are
|
|
// deprecated). Memoized — without this the derived array gets a new
|
|
// reference on every render, which made the [storedProxies] effect below
|
|
// refire every render → re-set state → re-render, freezing the page once
|
|
// the dialog mounted. Keeping the reference stable when the input is
|
|
// unchanged is what every consumer (useReactTable, useEffect, selection
|
|
// logic) actually wants.
|
|
const storedProxies = useMemo(
|
|
() =>
|
|
rawProxies
|
|
.filter((p) => !p.is_cloud_managed && !p.is_cloud_derived)
|
|
.sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
|
),
|
|
[rawProxies],
|
|
);
|
|
|
|
// Listen for proxy sync status events
|
|
useEffect(() => {
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
const setupListener = async () => {
|
|
unlisten = await listen<{ id: string; status: string; error?: string }>(
|
|
"proxy-sync-status",
|
|
(event) => {
|
|
const { id, status, error } = event.payload;
|
|
setProxySyncStatus((prev) => ({
|
|
...prev,
|
|
[id]: status as SyncStatus,
|
|
}));
|
|
if (error) {
|
|
setProxySyncErrors((prev) => ({ ...prev, [id]: error }));
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
void setupListener();
|
|
return () => {
|
|
unlisten?.();
|
|
};
|
|
}, []);
|
|
|
|
// Listen for VPN sync status events
|
|
useEffect(() => {
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
const setupListener = async () => {
|
|
unlisten = await listen<{ id: string; status: string; error?: string }>(
|
|
"vpn-sync-status",
|
|
(event) => {
|
|
const { id, status, error } = event.payload;
|
|
setVpnSyncStatus((prev) => ({
|
|
...prev,
|
|
[id]: status as SyncStatus,
|
|
}));
|
|
if (error) {
|
|
setVpnSyncErrors((prev) => ({ ...prev, [id]: error }));
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
void setupListener();
|
|
return () => {
|
|
unlisten?.();
|
|
};
|
|
}, []);
|
|
|
|
// Load cached check results on mount and when proxies change
|
|
useEffect(() => {
|
|
const loadCachedResults = async () => {
|
|
const results: Record<string, ProxyCheckResult> = {};
|
|
const inUse: Record<string, boolean> = {};
|
|
for (const proxy of storedProxies) {
|
|
try {
|
|
const cached = await invoke<ProxyCheckResult | null>(
|
|
"get_cached_proxy_check",
|
|
{ proxyId: proxy.id },
|
|
);
|
|
if (cached) {
|
|
results[proxy.id] = cached;
|
|
}
|
|
|
|
const inUseBySynced = await invoke<boolean>(
|
|
"is_proxy_in_use_by_synced_profile",
|
|
{ proxyId: proxy.id },
|
|
);
|
|
inUse[proxy.id] = inUseBySynced;
|
|
} catch (_error) {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
setProxyCheckResults(results);
|
|
setProxyInUse(inUse);
|
|
};
|
|
if (storedProxies.length > 0) {
|
|
void loadCachedResults();
|
|
}
|
|
}, [storedProxies]);
|
|
|
|
// Load VPN in-use status
|
|
useEffect(() => {
|
|
const loadVpnInUse = async () => {
|
|
const inUse: Record<string, boolean> = {};
|
|
for (const vpn of vpnConfigs) {
|
|
try {
|
|
const inUseBySynced = await invoke<boolean>(
|
|
"is_vpn_in_use_by_synced_profile",
|
|
{ vpnId: vpn.id },
|
|
);
|
|
inUse[vpn.id] = inUseBySynced;
|
|
} catch (_error) {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
setVpnInUse(inUse);
|
|
};
|
|
if (vpnConfigs.length > 0) {
|
|
void loadVpnInUse();
|
|
}
|
|
}, [vpnConfigs]);
|
|
|
|
// Proxy handlers
|
|
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
|
|
setProxyToDelete(proxy);
|
|
}, []);
|
|
|
|
const handleConfirmDelete = useCallback(async () => {
|
|
if (!proxyToDelete) return;
|
|
setIsDeleting(true);
|
|
try {
|
|
await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id });
|
|
toast.success(t("proxies.management.deleteSuccess"));
|
|
await emit("stored-proxies-changed");
|
|
} catch (error) {
|
|
console.error("Failed to delete proxy:", error);
|
|
toast.error(t("proxies.management.deleteFailed"));
|
|
} finally {
|
|
setIsDeleting(false);
|
|
setProxyToDelete(null);
|
|
}
|
|
}, [proxyToDelete, t]);
|
|
|
|
const handleCreateProxy = useCallback(() => {
|
|
setEditingProxy(null);
|
|
setShowProxyForm(true);
|
|
}, []);
|
|
|
|
const handleEditProxy = useCallback((proxy: StoredProxy) => {
|
|
setEditingProxy(proxy);
|
|
setShowProxyForm(true);
|
|
}, []);
|
|
|
|
const handleProxyFormClose = useCallback(() => {
|
|
setShowProxyForm(false);
|
|
setEditingProxy(null);
|
|
}, []);
|
|
|
|
const handleToggleSync = useCallback(
|
|
async (proxy: StoredProxy) => {
|
|
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true }));
|
|
try {
|
|
await invoke("set_proxy_sync_enabled", {
|
|
proxyId: proxy.id,
|
|
enabled: !proxy.sync_enabled,
|
|
});
|
|
showSuccessToast(
|
|
proxy.sync_enabled
|
|
? t("proxies.management.syncDisabled")
|
|
: t("proxies.management.syncEnabled"),
|
|
);
|
|
await emit("stored-proxies-changed");
|
|
} catch (error) {
|
|
console.error("Failed to toggle sync:", error);
|
|
showErrorToast(
|
|
parseBackendError(error)
|
|
? translateBackendError(t, error)
|
|
: t("proxies.management.updateSyncFailed"),
|
|
);
|
|
} finally {
|
|
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false }));
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
// VPN handlers
|
|
const handleDeleteVpn = useCallback((vpn: VpnConfig) => {
|
|
setVpnToDelete(vpn);
|
|
}, []);
|
|
|
|
const handleConfirmDeleteVpn = useCallback(async () => {
|
|
if (!vpnToDelete) return;
|
|
setIsDeletingVpn(true);
|
|
try {
|
|
await invoke("delete_vpn_config", { vpnId: vpnToDelete.id });
|
|
toast.success(t("vpns.management.deleteSuccess"));
|
|
await emit("vpn-configs-changed");
|
|
} catch (error) {
|
|
console.error("Failed to delete VPN:", error);
|
|
toast.error(t("vpns.management.deleteFailed"));
|
|
} finally {
|
|
setIsDeletingVpn(false);
|
|
setVpnToDelete(null);
|
|
}
|
|
}, [vpnToDelete, t]);
|
|
|
|
const handleCreateVpn = useCallback(() => {
|
|
setEditingVpn(null);
|
|
setShowVpnForm(true);
|
|
}, []);
|
|
|
|
const handleEditVpn = useCallback((vpn: VpnConfig) => {
|
|
setEditingVpn(vpn);
|
|
setShowVpnForm(true);
|
|
}, []);
|
|
|
|
const handleVpnFormClose = useCallback(() => {
|
|
setShowVpnForm(false);
|
|
setEditingVpn(null);
|
|
}, []);
|
|
|
|
const handleToggleVpnSync = useCallback(
|
|
async (vpn: VpnConfig) => {
|
|
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
|
|
try {
|
|
await invoke("set_vpn_sync_enabled", {
|
|
vpnId: vpn.id,
|
|
enabled: !vpn.sync_enabled,
|
|
});
|
|
showSuccessToast(
|
|
vpn.sync_enabled
|
|
? t("proxies.management.syncDisabled")
|
|
: t("proxies.management.syncEnabled"),
|
|
);
|
|
await emit("vpn-configs-changed");
|
|
} catch (error) {
|
|
console.error("Failed to toggle VPN sync:", error);
|
|
showErrorToast(
|
|
parseBackendError(error)
|
|
? translateBackendError(t, error)
|
|
: t("proxies.management.updateSyncFailed"),
|
|
);
|
|
} finally {
|
|
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const proxyColumns = useMemo<ColumnDef<StoredProxy>[]>(
|
|
() => [
|
|
{
|
|
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")}
|
|
/>
|
|
),
|
|
cell: ({ row }) => (
|
|
<Checkbox
|
|
checked={row.getIsSelected()}
|
|
disabled={!row.getCanSelect()}
|
|
onCheckedChange={(value) => {
|
|
row.toggleSelected(!!value);
|
|
}}
|
|
aria-label={t("common.aria.selectRow")}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
id: "status",
|
|
enableSorting: false,
|
|
header: () => null,
|
|
cell: ({ row }) => {
|
|
const proxy = row.original;
|
|
const syncDot = getSyncStatusDot(
|
|
proxy,
|
|
proxySyncStatus[proxy.id],
|
|
t,
|
|
proxySyncErrors[proxy.id],
|
|
);
|
|
return (
|
|
<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>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
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 }) => (
|
|
<span className="font-medium">{row.original.name}</span>
|
|
),
|
|
},
|
|
{
|
|
id: "protocol",
|
|
enableSorting: false,
|
|
header: () => t("proxies.management.protocolCol"),
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
{row.original.proxy_settings.proxy_type}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "usage",
|
|
enableSorting: false,
|
|
header: () => t("proxies.management.usage"),
|
|
cell: ({ row }) => (
|
|
<Badge variant="secondary">{proxyUsage[row.original.id] ?? 0}</Badge>
|
|
),
|
|
},
|
|
{
|
|
id: "sync",
|
|
enableSorting: false,
|
|
header: () => t("proxies.management.syncCol"),
|
|
cell: ({ row }) => {
|
|
const proxy = row.original;
|
|
const locked = proxyInUse[proxy.id];
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex items-center">
|
|
<AnimatedSwitch
|
|
checked={proxy.sync_enabled}
|
|
onCheckedChange={() => void handleToggleSync(proxy)}
|
|
disabled={isTogglingSync[proxy.id] || locked}
|
|
/>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{locked ? (
|
|
<p>{t("syncTooltips.lockedInUse")}</p>
|
|
) : (
|
|
<p>
|
|
{proxy.sync_enabled
|
|
? t("syncTooltips.disable")
|
|
: t("syncTooltips.enable")}
|
|
</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
enableSorting: false,
|
|
header: () => t("common.labels.actions"),
|
|
cell: ({ row }) => {
|
|
const proxy = row.original;
|
|
return (
|
|
<div className="flex gap-1">
|
|
<ProxyCheckButton
|
|
proxy={proxy}
|
|
profileId={proxy.id}
|
|
checkingProfileId={checkingProxyId}
|
|
cachedResult={proxyCheckResults[proxy.id]}
|
|
setCheckingProfileId={setCheckingProxyId}
|
|
onCheckComplete={(result) => {
|
|
setProxyCheckResults((prev) => ({
|
|
...prev,
|
|
[proxy.id]: result,
|
|
}));
|
|
}}
|
|
onCheckFailed={(result) => {
|
|
setProxyCheckResults((prev) => ({
|
|
...prev,
|
|
[proxy.id]: result,
|
|
}));
|
|
}}
|
|
/>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
handleEditProxy(proxy);
|
|
}}
|
|
>
|
|
<LuPencil className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t("proxies.management.editProxy")}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
handleDeleteProxy(proxy);
|
|
}}
|
|
disabled={(proxyUsage[proxy.id] ?? 0) > 0}
|
|
>
|
|
<LuTrash2 className="size-4" />
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
|
<p>
|
|
{(proxyUsage[proxy.id] ?? 0) === 1
|
|
? t("proxies.management.cannotDelete_one", {
|
|
count: proxyUsage[proxy.id],
|
|
})
|
|
: t("proxies.management.cannotDelete_other", {
|
|
count: proxyUsage[proxy.id],
|
|
})}
|
|
</p>
|
|
) : (
|
|
<p>{t("proxies.management.deleteProxy")}</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[
|
|
t,
|
|
proxySyncStatus,
|
|
proxySyncErrors,
|
|
proxyUsage,
|
|
isTogglingSync,
|
|
proxyInUse,
|
|
checkingProxyId,
|
|
proxyCheckResults,
|
|
handleToggleSync,
|
|
handleEditProxy,
|
|
handleDeleteProxy,
|
|
],
|
|
);
|
|
|
|
const proxiesTable = useReactTable({
|
|
data: storedProxies,
|
|
columns: proxyColumns,
|
|
state: {
|
|
sorting: proxiesSorting,
|
|
rowSelection: proxiesRowSelection,
|
|
},
|
|
onSortingChange: setProxiesSorting,
|
|
onRowSelectionChange: setProxiesRowSelection,
|
|
enableRowSelection: (row) => !proxyInUse[row.original.id],
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getRowId: (row) => row.id,
|
|
});
|
|
|
|
const vpnColumns = useMemo<ColumnDef<VpnConfig>[]>(
|
|
() => [
|
|
{
|
|
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")}
|
|
/>
|
|
),
|
|
cell: ({ row }) => (
|
|
<Checkbox
|
|
checked={row.getIsSelected()}
|
|
disabled={!row.getCanSelect()}
|
|
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 vpn = row.original;
|
|
const syncDot = getSyncStatusDot(
|
|
vpn,
|
|
vpnSyncStatus[vpn.id],
|
|
t,
|
|
vpnSyncErrors[vpn.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>
|
|
{vpn.name}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "type",
|
|
enableSorting: false,
|
|
header: () => t("common.labels.type"),
|
|
cell: () => <Badge variant="outline">WG</Badge>,
|
|
},
|
|
{
|
|
id: "usage",
|
|
enableSorting: false,
|
|
header: () => t("proxies.management.usage"),
|
|
cell: ({ row }) => (
|
|
<Badge variant="secondary">{vpnUsage[row.original.id] ?? 0}</Badge>
|
|
),
|
|
},
|
|
{
|
|
id: "sync",
|
|
enableSorting: false,
|
|
header: () => t("proxies.management.syncCol"),
|
|
cell: ({ row }) => {
|
|
const vpn = row.original;
|
|
const locked = vpnInUse[vpn.id];
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex items-center">
|
|
<AnimatedSwitch
|
|
checked={vpn.sync_enabled}
|
|
onCheckedChange={() => void handleToggleVpnSync(vpn)}
|
|
disabled={isTogglingVpnSync[vpn.id] || locked}
|
|
/>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{locked ? (
|
|
<p>{t("syncTooltips.lockedInUse")}</p>
|
|
) : (
|
|
<p>
|
|
{vpn.sync_enabled
|
|
? t("syncTooltips.disable")
|
|
: t("syncTooltips.enable")}
|
|
</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
enableSorting: false,
|
|
header: () => t("common.labels.actions"),
|
|
cell: ({ row }) => {
|
|
const vpn = row.original;
|
|
return (
|
|
<div className="flex gap-1">
|
|
<VpnCheckButton
|
|
vpnId={vpn.id}
|
|
vpnName={vpn.name}
|
|
checkingVpnId={checkingVpnId}
|
|
setCheckingVpnId={setCheckingVpnId}
|
|
/>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
handleEditVpn(vpn);
|
|
}}
|
|
>
|
|
<LuPencil className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t("vpns.management.editVpn")}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
handleDeleteVpn(vpn);
|
|
}}
|
|
disabled={(vpnUsage[vpn.id] ?? 0) > 0}
|
|
>
|
|
<LuTrash2 className="size-4" />
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
|
<p>
|
|
{(vpnUsage[vpn.id] ?? 0) === 1
|
|
? t("vpns.management.cannotDelete_one", {
|
|
count: vpnUsage[vpn.id],
|
|
})
|
|
: t("vpns.management.cannotDelete_other", {
|
|
count: vpnUsage[vpn.id],
|
|
})}
|
|
</p>
|
|
) : (
|
|
<p>{t("vpns.management.deleteVpn")}</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[
|
|
t,
|
|
vpnSyncStatus,
|
|
vpnSyncErrors,
|
|
vpnUsage,
|
|
isTogglingVpnSync,
|
|
vpnInUse,
|
|
checkingVpnId,
|
|
handleToggleVpnSync,
|
|
handleEditVpn,
|
|
handleDeleteVpn,
|
|
],
|
|
);
|
|
|
|
const vpnsTable = useReactTable({
|
|
data: vpnConfigs,
|
|
columns: vpnColumns,
|
|
state: {
|
|
sorting: vpnsSorting,
|
|
rowSelection: vpnsRowSelection,
|
|
},
|
|
onSortingChange: setVpnsSorting,
|
|
onRowSelectionChange: setVpnsRowSelection,
|
|
enableRowSelection: (row) => !vpnInUse[row.original.id],
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getRowId: (row) => row.id,
|
|
});
|
|
|
|
const selectedProxies = proxiesTable
|
|
.getFilteredSelectedRowModel()
|
|
.rows.map((row) => row.original);
|
|
const selectedVpns = vpnsTable
|
|
.getFilteredSelectedRowModel()
|
|
.rows.map((row) => row.original);
|
|
|
|
const handleBulkDeleteProxies = useCallback(async () => {
|
|
if (selectedProxies.length === 0) return;
|
|
setIsBulkDeletingProxies(true);
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
selectedProxies.map((proxy) =>
|
|
invoke("delete_stored_proxy", { proxyId: proxy.id }),
|
|
),
|
|
);
|
|
const failed = results.filter((r) => r.status === "rejected").length;
|
|
const succeeded = results.length - failed;
|
|
if (succeeded > 0) {
|
|
toast.success(t("proxies.management.deleteSuccess"));
|
|
}
|
|
if (failed > 0) {
|
|
toast.error(t("proxies.management.deleteFailed"));
|
|
}
|
|
await emit("stored-proxies-changed");
|
|
setProxiesRowSelection({});
|
|
} finally {
|
|
setIsBulkDeletingProxies(false);
|
|
setShowBulkDeleteProxiesDialog(false);
|
|
}
|
|
}, [selectedProxies, t]);
|
|
|
|
const handleBulkDeleteVpns = useCallback(async () => {
|
|
if (selectedVpns.length === 0) return;
|
|
setIsBulkDeletingVpns(true);
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
selectedVpns.map((vpn) =>
|
|
invoke("delete_vpn_config", { vpnId: vpn.id }),
|
|
),
|
|
);
|
|
const failed = results.filter((r) => r.status === "rejected").length;
|
|
const succeeded = results.length - failed;
|
|
if (succeeded > 0) {
|
|
toast.success(t("vpns.management.deleteSuccess"));
|
|
}
|
|
if (failed > 0) {
|
|
toast.error(t("vpns.management.deleteFailed"));
|
|
}
|
|
await emit("vpn-configs-changed");
|
|
setVpnsRowSelection({});
|
|
} finally {
|
|
setIsBulkDeletingVpns(false);
|
|
setShowBulkDeleteVpnsDialog(false);
|
|
}
|
|
}, [selectedVpns, t]);
|
|
|
|
// Bulk-toggle sync: if every selectable row has sync ON, turn them all
|
|
// OFF; otherwise turn them all ON. Items locked by a synced profile
|
|
// (proxyInUse / vpnInUse) are skipped silently when the target is OFF.
|
|
const handleBulkToggleProxiesSync = useCallback(async () => {
|
|
if (selectedProxies.length === 0) return;
|
|
const allOn = selectedProxies.every((p) => p.sync_enabled);
|
|
const targetEnabled = !allOn;
|
|
const targets = selectedProxies.filter((p) =>
|
|
targetEnabled ? !p.sync_enabled : p.sync_enabled && !proxyInUse[p.id],
|
|
);
|
|
if (targets.length === 0) return;
|
|
const results = await Promise.allSettled(
|
|
targets.map((proxy) =>
|
|
invoke("set_proxy_sync_enabled", {
|
|
proxyId: proxy.id,
|
|
enabled: targetEnabled,
|
|
}),
|
|
),
|
|
);
|
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
|
| PromiseRejectedResult
|
|
| undefined;
|
|
if (firstRejection) {
|
|
showErrorToast(
|
|
parseBackendError(firstRejection.reason)
|
|
? translateBackendError(t, firstRejection.reason)
|
|
: t("proxies.management.updateSyncFailed"),
|
|
);
|
|
} else {
|
|
showSuccessToast(
|
|
targetEnabled
|
|
? t("proxies.management.syncEnabled")
|
|
: t("proxies.management.syncDisabled"),
|
|
);
|
|
}
|
|
await emit("stored-proxies-changed");
|
|
}, [selectedProxies, proxyInUse, t]);
|
|
|
|
const handleBulkToggleVpnsSync = useCallback(async () => {
|
|
if (selectedVpns.length === 0) return;
|
|
const allOn = selectedVpns.every((v) => v.sync_enabled);
|
|
const targetEnabled = !allOn;
|
|
const targets = selectedVpns.filter((v) =>
|
|
targetEnabled ? !v.sync_enabled : v.sync_enabled && !vpnInUse[v.id],
|
|
);
|
|
if (targets.length === 0) return;
|
|
const results = await Promise.allSettled(
|
|
targets.map((vpn) =>
|
|
invoke("set_vpn_sync_enabled", {
|
|
vpnId: vpn.id,
|
|
enabled: targetEnabled,
|
|
}),
|
|
),
|
|
);
|
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
|
| PromiseRejectedResult
|
|
| undefined;
|
|
if (firstRejection) {
|
|
showErrorToast(
|
|
parseBackendError(firstRejection.reason)
|
|
? translateBackendError(t, firstRejection.reason)
|
|
: t("proxies.management.updateSyncFailed"),
|
|
);
|
|
} else {
|
|
showSuccessToast(
|
|
targetEnabled
|
|
? t("proxies.management.syncEnabled")
|
|
: t("proxies.management.syncDisabled"),
|
|
);
|
|
}
|
|
await emit("vpn-configs-changed");
|
|
}, [selectedVpns, vpnInUse, t]);
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
|
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
|
{!subPage && (
|
|
<DialogHeader>
|
|
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("proxies.management.description")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
)}
|
|
|
|
<AnimatedTabs
|
|
key={initialTab}
|
|
defaultValue={initialTab}
|
|
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
|
|
className="flex-1 min-h-0 flex flex-col"
|
|
>
|
|
<div className="flex items-center justify-between gap-3 shrink-0">
|
|
<AnimatedTabsList>
|
|
<AnimatedTabsTrigger value="proxies">
|
|
<span>{t("proxies.management.tabProxies")}</span>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{storedProxies.length}
|
|
</span>
|
|
</AnimatedTabsTrigger>
|
|
<AnimatedTabsTrigger value="vpns">
|
|
<span>{t("proxies.management.tabVpns")}</span>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{vpnConfigs.length}
|
|
</span>
|
|
</AnimatedTabsTrigger>
|
|
</AnimatedTabsList>
|
|
<div className="flex items-center gap-2">
|
|
{activeTab === "proxies" && (
|
|
<>
|
|
<RippleButton
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowImportDialog(true);
|
|
}}
|
|
className="flex gap-2 items-center"
|
|
>
|
|
<LuUpload className="size-4" />
|
|
{t("common.buttons.import")}
|
|
</RippleButton>
|
|
<RippleButton
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowExportDialog(true);
|
|
}}
|
|
className="flex gap-2 items-center"
|
|
disabled={storedProxies.length === 0}
|
|
>
|
|
<LuDownload className="size-4" />
|
|
{t("common.buttons.export")}
|
|
</RippleButton>
|
|
<RippleButton
|
|
size="sm"
|
|
onClick={handleCreateProxy}
|
|
className="flex gap-2 items-center"
|
|
>
|
|
<GoPlus className="size-4" />
|
|
{t("proxies.management.newProxy")}
|
|
</RippleButton>
|
|
</>
|
|
)}
|
|
{activeTab === "vpns" && (
|
|
<>
|
|
<RippleButton
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowVpnImportDialog(true);
|
|
}}
|
|
className="flex gap-2 items-center"
|
|
>
|
|
<LuUpload className="size-4" />
|
|
{t("common.buttons.import")}
|
|
</RippleButton>
|
|
<RippleButton
|
|
size="sm"
|
|
onClick={handleCreateVpn}
|
|
className="flex gap-2 items-center"
|
|
>
|
|
<GoPlus className="size-4" />
|
|
{t("proxies.management.newVpn")}
|
|
</RippleButton>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatedTabsContent
|
|
value="proxies"
|
|
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
|
>
|
|
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
|
{isLoading ? (
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("proxies.management.loading")}
|
|
</div>
|
|
) : storedProxies.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("proxies.management.noneCreated")}
|
|
</div>
|
|
) : (
|
|
<FadingScrollArea
|
|
className="flex-1 min-h-0"
|
|
style={
|
|
{
|
|
"--scroll-fade-top-offset": "32px",
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<Table className="w-full">
|
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
|
{proxiesTable.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,
|
|
}}
|
|
className={cn(
|
|
header.column.id !== "name" &&
|
|
header.column.id !== "select" &&
|
|
"whitespace-nowrap w-px",
|
|
)}
|
|
>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{proxiesTable.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>
|
|
</AnimatedTabsContent>
|
|
|
|
<AnimatedTabsContent
|
|
value="vpns"
|
|
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
|
>
|
|
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
|
{isLoadingVpns ? (
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("vpns.management.loading")}
|
|
</div>
|
|
) : vpnConfigs.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("vpns.management.noneCreated")}
|
|
</div>
|
|
) : (
|
|
<FadingScrollArea
|
|
className="flex-1 min-h-0"
|
|
style={
|
|
{
|
|
"--scroll-fade-top-offset": "32px",
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<Table className="w-full">
|
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
|
{vpnsTable.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,
|
|
}}
|
|
className={cn(
|
|
header.column.id !== "name" &&
|
|
header.column.id !== "select" &&
|
|
"whitespace-nowrap w-px",
|
|
)}
|
|
>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{vpnsTable.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>
|
|
</AnimatedTabsContent>
|
|
</AnimatedTabs>
|
|
|
|
{!subPage && (
|
|
<DialogFooter>
|
|
<RippleButton variant="outline" onClick={onClose}>
|
|
{t("common.buttons.close")}
|
|
</RippleButton>
|
|
</DialogFooter>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<ProxyFormDialog
|
|
isOpen={showProxyForm}
|
|
onClose={handleProxyFormClose}
|
|
editingProxy={editingProxy}
|
|
/>
|
|
<DeleteConfirmationDialog
|
|
isOpen={proxyToDelete !== null}
|
|
onClose={() => {
|
|
setProxyToDelete(null);
|
|
}}
|
|
onConfirm={handleConfirmDelete}
|
|
title={t("proxies.management.deleteTitle")}
|
|
description={t("proxies.management.deleteDescription", {
|
|
name: proxyToDelete?.name ?? "",
|
|
})}
|
|
confirmButtonText={t("common.buttons.delete")}
|
|
isLoading={isDeleting}
|
|
/>
|
|
<ProxyImportDialog
|
|
isOpen={showImportDialog}
|
|
onClose={() => {
|
|
setShowImportDialog(false);
|
|
}}
|
|
/>
|
|
<ProxyExportDialog
|
|
isOpen={showExportDialog}
|
|
onClose={() => {
|
|
setShowExportDialog(false);
|
|
}}
|
|
/>
|
|
<VpnFormDialog
|
|
isOpen={showVpnForm}
|
|
onClose={handleVpnFormClose}
|
|
editingVpn={editingVpn}
|
|
/>
|
|
<DeleteConfirmationDialog
|
|
isOpen={vpnToDelete !== null}
|
|
onClose={() => {
|
|
setVpnToDelete(null);
|
|
}}
|
|
onConfirm={handleConfirmDeleteVpn}
|
|
title={t("vpns.management.deleteTitle")}
|
|
description={t("vpns.management.deleteDescription", {
|
|
name: vpnToDelete?.name ?? "",
|
|
})}
|
|
confirmButtonText={t("common.buttons.delete")}
|
|
isLoading={isDeletingVpn}
|
|
/>
|
|
<VpnImportDialog
|
|
isOpen={showVpnImportDialog}
|
|
onClose={() => {
|
|
setShowVpnImportDialog(false);
|
|
}}
|
|
/>
|
|
{isOpen && activeTab === "proxies" && (
|
|
<DataTableActionBar table={proxiesTable}>
|
|
<DataTableActionBarSelection table={proxiesTable} />
|
|
<DataTableActionBarAction
|
|
tooltip={t("syncTooltips.bulkToggle")}
|
|
onClick={() => void handleBulkToggleProxiesSync()}
|
|
size="icon"
|
|
>
|
|
<LuRefreshCw />
|
|
</DataTableActionBarAction>
|
|
<DataTableActionBarAction
|
|
tooltip={t("common.buttons.delete")}
|
|
onClick={() => {
|
|
setShowBulkDeleteProxiesDialog(true);
|
|
}}
|
|
size="icon"
|
|
variant="destructive"
|
|
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
|
|
>
|
|
<LuTrash2 />
|
|
</DataTableActionBarAction>
|
|
</DataTableActionBar>
|
|
)}
|
|
{isOpen && activeTab === "vpns" && (
|
|
<DataTableActionBar table={vpnsTable}>
|
|
<DataTableActionBarSelection table={vpnsTable} />
|
|
<DataTableActionBarAction
|
|
tooltip={t("syncTooltips.bulkToggle")}
|
|
onClick={() => void handleBulkToggleVpnsSync()}
|
|
size="icon"
|
|
>
|
|
<LuRefreshCw />
|
|
</DataTableActionBarAction>
|
|
<DataTableActionBarAction
|
|
tooltip={t("common.buttons.delete")}
|
|
onClick={() => {
|
|
setShowBulkDeleteVpnsDialog(true);
|
|
}}
|
|
size="icon"
|
|
variant="destructive"
|
|
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
|
|
>
|
|
<LuTrash2 />
|
|
</DataTableActionBarAction>
|
|
</DataTableActionBar>
|
|
)}
|
|
<DeleteConfirmationDialog
|
|
isOpen={showBulkDeleteProxiesDialog}
|
|
onClose={() => {
|
|
setShowBulkDeleteProxiesDialog(false);
|
|
}}
|
|
onConfirm={handleBulkDeleteProxies}
|
|
title={t("proxies.bulkDelete.proxiesTitle")}
|
|
description={t("proxies.bulkDelete.proxiesDescription", {
|
|
count: selectedProxies.length,
|
|
names: selectedProxies.map((p) => p.name).join(", "),
|
|
})}
|
|
confirmButtonText={t("proxies.bulkDelete.confirmButton", {
|
|
count: selectedProxies.length,
|
|
})}
|
|
isLoading={isBulkDeletingProxies}
|
|
/>
|
|
<DeleteConfirmationDialog
|
|
isOpen={showBulkDeleteVpnsDialog}
|
|
onClose={() => {
|
|
setShowBulkDeleteVpnsDialog(false);
|
|
}}
|
|
onConfirm={handleBulkDeleteVpns}
|
|
title={t("proxies.bulkDelete.vpnsTitle")}
|
|
description={t("proxies.bulkDelete.vpnsDescription", {
|
|
count: selectedVpns.length,
|
|
names: selectedVpns.map((v) => v.name).join(", "),
|
|
})}
|
|
confirmButtonText={t("proxies.bulkDelete.confirmButton", {
|
|
count: selectedVpns.length,
|
|
})}
|
|
isLoading={isBulkDeletingVpns}
|
|
/>
|
|
</>
|
|
);
|
|
}
|