mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 09:47:51 +02:00
refactor: cleanup
This commit is contained in:
@@ -164,6 +164,8 @@ export function CamoufoxConfigDialog({
|
||||
readOnly={isRunning}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={profile.version}
|
||||
profileBrowser="wayfern"
|
||||
/>
|
||||
) : (
|
||||
<SharedCamoufoxConfigForm
|
||||
@@ -174,6 +176,8 @@ export function CamoufoxConfigDialog({
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={profile.version}
|
||||
profileBrowser="camoufox"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,22 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,13 +29,16 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -34,6 +48,7 @@ import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserReleaseTypes,
|
||||
CamoufoxConfig,
|
||||
@@ -127,6 +142,7 @@ export function CreateProfileDialog({
|
||||
const [selectedBrowser, setSelectedBrowser] =
|
||||
useState<BrowserTypeString | null>(null);
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
|
||||
@@ -557,8 +573,13 @@ export function CreateProfileDialog({
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>
|
||||
{currentStep === "browser-selection"
|
||||
? "Create New Profile"
|
||||
: "Configure Profile"}
|
||||
? t("createProfile.title")
|
||||
: t("createProfile.configureTitle", {
|
||||
browser:
|
||||
selectedBrowser === "wayfern"
|
||||
? t("createProfile.chromiumLabel")
|
||||
: t("createProfile.firefoxLabel"),
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -576,62 +597,54 @@ export function CreateProfileDialog({
|
||||
<>
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
{/* Anti-Detect Browser Selection */}
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
Anti-Detect Browser
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose a browser with anti-detection capabilities
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 pt-8">
|
||||
{/* Wayfern (Chromium) - First */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("wayfern")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon("wayfern");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{t("createProfile.chromiumLabel")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("createProfile.chromiumSubtitle")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Wayfern (Chromium) - First */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("wayfern")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon("wayfern");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
) : null;
|
||||
})()}
|
||||
{/* Camoufox (Firefox) - Second */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("camoufox")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon("camoufox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{t("createProfile.firefoxLabel")}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Wayfern</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Anti-Detect Browser
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("createProfile.firefoxSubtitle")}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Camoufox (Firefox) - Second */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("camoufox")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{(() => {
|
||||
const IconComponent =
|
||||
getBrowserIcon("camoufox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Camoufox</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Anti-Detect Browser
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -823,6 +836,10 @@ export function CreateProfileDialog({
|
||||
isCreating
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={
|
||||
getBestAvailableVersion("wayfern")?.version
|
||||
}
|
||||
profileBrowser="wayfern"
|
||||
/>
|
||||
</div>
|
||||
) : selectedBrowser === "camoufox" ? (
|
||||
@@ -915,6 +932,14 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crossOsUnlocked && (
|
||||
<Alert className="border-yellow-500/50 bg-yellow-500/10">
|
||||
<AlertDescription className="text-sm">
|
||||
{t("createProfile.camoufoxWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
@@ -922,6 +947,10 @@ export function CreateProfileDialog({
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={
|
||||
getBestAvailableVersion("camoufox")?.version
|
||||
}
|
||||
profileBrowser="camoufox"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1039,52 +1068,125 @@ export function CreateProfileDialog({
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(
|
||||
value === "none" ? undefined : value,
|
||||
)
|
||||
}
|
||||
<Popover
|
||||
open={proxyPopoverOpen}
|
||||
onOpenChange={setProxyPopoverOpen}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy / VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
No proxy / VPN
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem
|
||||
key={proxy.id}
|
||||
value={proxy.id}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={proxyPopoverOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
v.id === selectedProxyId.slice(4),
|
||||
);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[240px] p-0"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(undefined);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{vpnConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>VPNs</SelectLabel>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<SelectItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.id}`}
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}{" "}
|
||||
— {vpn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedProxyId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(proxy.id);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedProxyId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.name}`}
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(
|
||||
`vpn-${vpn.id}`,
|
||||
);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedProxyId ===
|
||||
`vpn-${vpn.id}`
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
@@ -1257,52 +1359,125 @@ export function CreateProfileDialog({
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(
|
||||
value === "none" ? undefined : value,
|
||||
)
|
||||
}
|
||||
<Popover
|
||||
open={proxyPopoverOpen}
|
||||
onOpenChange={setProxyPopoverOpen}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy / VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
No proxy / VPN
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem
|
||||
key={proxy.id}
|
||||
value={proxy.id}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={proxyPopoverOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
v.id === selectedProxyId.slice(4),
|
||||
);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[240px] p-0"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(undefined);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{vpnConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>VPNs</SelectLabel>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<SelectItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.id}`}
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}{" "}
|
||||
— {vpn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedProxyId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(proxy.id);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedProxyId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.name}`}
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(
|
||||
`vpn-${vpn.id}`,
|
||||
);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedProxyId ===
|
||||
`vpn-${vpn.id}`
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
|
||||
@@ -116,6 +116,20 @@ interface TwilightUpdateToastProps extends BaseToastProps {
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
|
||||
interface SyncProgressToastProps extends BaseToastProps {
|
||||
type: "sync-progress";
|
||||
progress?: {
|
||||
completed_files: number;
|
||||
total_files: number;
|
||||
completed_bytes: number;
|
||||
total_bytes: number;
|
||||
speed_bytes_per_sec: number;
|
||||
eta_seconds: number;
|
||||
failed_count: number;
|
||||
phase: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ToastProps =
|
||||
| LoadingToastProps
|
||||
| SuccessToastProps
|
||||
@@ -123,7 +137,38 @@ type ToastProps =
|
||||
| DownloadToastProps
|
||||
| VersionUpdateToastProps
|
||||
| FetchingToastProps
|
||||
| TwilightUpdateToastProps;
|
||||
| TwilightUpdateToastProps
|
||||
| SyncProgressToastProps;
|
||||
|
||||
function formatBytesCompact(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
const value = bytes / 1024 ** i;
|
||||
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatSpeedCompact(bytesPerSec: number): string {
|
||||
if (bytesPerSec >= 1024 * 1024) {
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
}
|
||||
return `${(bytesPerSec / 1024).toFixed(0)} KB/s`;
|
||||
}
|
||||
|
||||
function formatEtaCompact(seconds: number): string {
|
||||
if (seconds >= 3600) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
if (seconds >= 60) {
|
||||
return `${Math.floor(seconds / 60)} min`;
|
||||
}
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
@@ -153,6 +198,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "sync-progress":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
@@ -237,6 +286,39 @@ export function UnifiedToast(props: ToastProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync progress */}
|
||||
{type === "sync-progress" &&
|
||||
progress &&
|
||||
"completed_files" in progress && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "}
|
||||
{progress.completed_files}/{progress.total_files} files
|
||||
{" \u2022 "}
|
||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||
{formatBytesCompact(progress.total_bytes)}
|
||||
{progress.speed_bytes_per_sec > 0 && (
|
||||
<>
|
||||
{" \u2022 "}
|
||||
{formatSpeedCompact(progress.speed_bytes_per_sec)}
|
||||
</>
|
||||
)}
|
||||
{progress.eta_seconds > 0 &&
|
||||
progress.completed_files < progress.total_files && (
|
||||
<>
|
||||
{" \u2022 ~"}
|
||||
{formatEtaCompact(progress.eta_seconds)} remaining
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{progress.failed_count > 0 && (
|
||||
<p className="text-xs text-destructive mt-0.5">
|
||||
{progress.failed_count} file(s) failed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Twilight update progress */}
|
||||
{type === "twilight-update" && (
|
||||
<div className="mt-2">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
group: GroupWithCount,
|
||||
liveStatus: SyncStatus | undefined,
|
||||
errorMessage?: string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
@@ -64,7 +65,11 @@ function getSyncStatusDot(
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
|
||||
return {
|
||||
color: "bg-red-500",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
|
||||
}
|
||||
@@ -95,6 +100,9 @@ export function GroupManagementDialog({
|
||||
const [groupSyncStatus, setGroupSyncStatus] = useState<
|
||||
Record<string, SyncStatus>
|
||||
>({});
|
||||
const [groupSyncErrors, setGroupSyncErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [groupInUse, setGroupInUse] = useState<Record<string, boolean>>({});
|
||||
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
@@ -105,14 +113,17 @@ export function GroupManagementDialog({
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
unlisten = await listen<{ id: string; status: string; error?: string }>(
|
||||
"group-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
const { id, status, error } = event.payload;
|
||||
setGroupSyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
if (error) {
|
||||
setGroupSyncErrors((prev) => ({ ...prev, [id]: error }));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -216,7 +227,7 @@ export function GroupManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Profile Groups</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -225,149 +236,152 @@ export function GroupManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No groups created yet. Create your first group using the
|
||||
button above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-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>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No groups created yet. Create your first group using the button
|
||||
above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-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>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,26 +30,31 @@ export function LocationProxyDialog({
|
||||
onClose,
|
||||
}: LocationProxyDialogProps) {
|
||||
const [countries, setCountries] = useState<LocationItem[]>([]);
|
||||
const [states, setStates] = useState<LocationItem[]>([]);
|
||||
const [regions, setRegions] = useState<LocationItem[]>([]);
|
||||
const [cities, setCities] = useState<LocationItem[]>([]);
|
||||
const [isps, setIsps] = useState<LocationItem[]>([]);
|
||||
|
||||
const [selectedCountry, setSelectedCountry] = useState("");
|
||||
const [selectedState, setSelectedState] = useState("");
|
||||
const [selectedRegion, setSelectedRegion] = useState("");
|
||||
const [selectedCity, setSelectedCity] = useState("");
|
||||
const [selectedIsp, setSelectedIsp] = useState("");
|
||||
const [proxyName, setProxyName] = useState("");
|
||||
|
||||
const [isLoadingCountries, setIsLoadingCountries] = useState(false);
|
||||
const [isLoadingStates, setIsLoadingStates] = useState(false);
|
||||
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
|
||||
const [isLoadingCities, setIsLoadingCities] = useState(false);
|
||||
const [isLoadingIsps, setIsLoadingIsps] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedCountry("");
|
||||
setSelectedState("");
|
||||
setSelectedRegion("");
|
||||
setSelectedCity("");
|
||||
setSelectedIsp("");
|
||||
setProxyName("");
|
||||
setStates([]);
|
||||
setRegions([]);
|
||||
setCities([]);
|
||||
setIsps([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -65,52 +71,87 @@ export function LocationProxyDialog({
|
||||
.finally(() => setIsLoadingCountries(false));
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch states when country changes
|
||||
// Fetch regions when country changes
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) {
|
||||
setStates([]);
|
||||
setRegions([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingStates(true);
|
||||
setSelectedState("");
|
||||
setIsLoadingRegions(true);
|
||||
setSelectedRegion("");
|
||||
setSelectedCity("");
|
||||
setSelectedIsp("");
|
||||
setCities([]);
|
||||
invoke<LocationItem[]>("cloud_get_states", { country: selectedCountry })
|
||||
.then((data) => setStates(data))
|
||||
.catch((err) => console.error("Failed to fetch states:", err))
|
||||
.finally(() => setIsLoadingStates(false));
|
||||
setIsps([]);
|
||||
invoke<LocationItem[]>("cloud_get_regions", { country: selectedCountry })
|
||||
.then((data) => setRegions(data))
|
||||
.catch((err) => console.error("Failed to fetch regions:", err))
|
||||
.finally(() => setIsLoadingRegions(false));
|
||||
}, [selectedCountry]);
|
||||
|
||||
// Fetch cities when state changes
|
||||
// Fetch cities when country or region changes (cities can be loaded without region)
|
||||
useEffect(() => {
|
||||
if (!selectedCountry || !selectedState) {
|
||||
if (!selectedCountry) {
|
||||
setCities([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingCities(true);
|
||||
setSelectedCity("");
|
||||
invoke<LocationItem[]>("cloud_get_cities", {
|
||||
const args: { country: string; region?: string } = {
|
||||
country: selectedCountry,
|
||||
state: selectedState,
|
||||
})
|
||||
};
|
||||
if (selectedRegion) {
|
||||
args.region = selectedRegion;
|
||||
}
|
||||
invoke<LocationItem[]>("cloud_get_cities", args)
|
||||
.then((data) => setCities(data))
|
||||
.catch((err) => console.error("Failed to fetch cities:", err))
|
||||
.finally(() => setIsLoadingCities(false));
|
||||
}, [selectedCountry, selectedState]);
|
||||
}, [selectedCountry, selectedRegion]);
|
||||
|
||||
// Fetch ISPs when country/region/city changes
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) {
|
||||
setIsps([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingIsps(true);
|
||||
setSelectedIsp("");
|
||||
const args: { country: string; region?: string; city?: string } = {
|
||||
country: selectedCountry,
|
||||
};
|
||||
if (selectedRegion) args.region = selectedRegion;
|
||||
if (selectedCity) args.city = selectedCity;
|
||||
invoke<LocationItem[]>("cloud_get_isps", args)
|
||||
.then((data) => setIsps(data))
|
||||
.catch((err) => console.error("Failed to fetch ISPs:", err))
|
||||
.finally(() => setIsLoadingIsps(false));
|
||||
}, [selectedCountry, selectedRegion, selectedCity]);
|
||||
|
||||
// Auto-generate name from selections
|
||||
useEffect(() => {
|
||||
const parts: string[] = [];
|
||||
const countryItem = countries.find((c) => c.code === selectedCountry);
|
||||
if (countryItem) parts.push(countryItem.name);
|
||||
const stateItem = states.find((s) => s.code === selectedState);
|
||||
if (stateItem) parts.push(stateItem.name);
|
||||
const regionItem = regions.find((s) => s.code === selectedRegion);
|
||||
if (regionItem) parts.push(regionItem.name);
|
||||
const cityItem = cities.find((c) => c.code === selectedCity);
|
||||
if (cityItem) parts.push(cityItem.name);
|
||||
const ispItem = isps.find((i) => i.code === selectedIsp);
|
||||
if (ispItem) parts.push(ispItem.name);
|
||||
if (parts.length > 0) {
|
||||
setProxyName(parts.join(" - "));
|
||||
}
|
||||
}, [selectedCountry, selectedState, selectedCity, countries, states, cities]);
|
||||
}, [
|
||||
selectedCountry,
|
||||
selectedRegion,
|
||||
selectedCity,
|
||||
selectedIsp,
|
||||
countries,
|
||||
regions,
|
||||
cities,
|
||||
isps,
|
||||
]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!selectedCountry || !proxyName.trim()) return;
|
||||
@@ -119,8 +160,9 @@ export function LocationProxyDialog({
|
||||
await invoke("create_cloud_location_proxy", {
|
||||
name: proxyName.trim(),
|
||||
country: selectedCountry,
|
||||
state: selectedState || null,
|
||||
region: selectedRegion || null,
|
||||
city: selectedCity || null,
|
||||
isp: selectedIsp || null,
|
||||
});
|
||||
toast.success("Location proxy created");
|
||||
await emit("stored-proxies-changed");
|
||||
@@ -133,14 +175,26 @@ export function LocationProxyDialog({
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [selectedCountry, selectedState, selectedCity, proxyName, handleClose]);
|
||||
}, [
|
||||
selectedCountry,
|
||||
selectedRegion,
|
||||
selectedCity,
|
||||
selectedIsp,
|
||||
proxyName,
|
||||
handleClose,
|
||||
]);
|
||||
|
||||
const countryOptions = countries.map((c) => ({
|
||||
value: c.code,
|
||||
label: c.name,
|
||||
}));
|
||||
const stateOptions = states.map((s) => ({ value: s.code, label: s.name }));
|
||||
const regionOptions = regions.map((s) => ({ value: s.code, label: s.name }));
|
||||
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
|
||||
const ispOptions = isps.map((i) => ({ value: i.code, label: i.name }));
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -148,48 +202,102 @@ export function LocationProxyDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Location Proxy</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a geo-targeted proxy from your cloud credentials
|
||||
Create a geo-targeted proxy with a 24-hour sticky session
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Country - always visible */}
|
||||
<div className="space-y-2">
|
||||
<Label>Country (required)</Label>
|
||||
<Label className="flex items-center gap-2">
|
||||
Country (required)
|
||||
{isLoadingCountries && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={countryOptions}
|
||||
value={selectedCountry}
|
||||
onValueChange={setSelectedCountry}
|
||||
placeholder={isLoadingCountries ? "Loading..." : "Select country"}
|
||||
placeholder={
|
||||
isLoadingCountries ? "Loading countries..." : "Select country"
|
||||
}
|
||||
searchPlaceholder="Search countries..."
|
||||
disabled={isLoadingCountries}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedCountry && stateOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>State (optional)</Label>
|
||||
<Combobox
|
||||
options={stateOptions}
|
||||
value={selectedState}
|
||||
onValueChange={setSelectedState}
|
||||
placeholder={isLoadingStates ? "Loading..." : "Select state"}
|
||||
searchPlaceholder="Search states..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Region - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
Region (optional)
|
||||
{isLoadingRegions && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={regionOptions}
|
||||
value={selectedRegion}
|
||||
onValueChange={setSelectedRegion}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
: isLoadingRegions
|
||||
? "Loading regions..."
|
||||
: regionOptions.length === 0
|
||||
? "No regions available"
|
||||
: "Select region"
|
||||
}
|
||||
searchPlaceholder="Search regions..."
|
||||
disabled={!selectedCountry || isLoadingRegions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedState && cityOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>City (optional)</Label>
|
||||
<Combobox
|
||||
options={cityOptions}
|
||||
value={selectedCity}
|
||||
onValueChange={setSelectedCity}
|
||||
placeholder={isLoadingCities ? "Loading..." : "Select city"}
|
||||
searchPlaceholder="Search cities..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* City - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
City (optional)
|
||||
{isLoadingCities && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={cityOptions}
|
||||
value={selectedCity}
|
||||
onValueChange={setSelectedCity}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
: isLoadingCities
|
||||
? "Loading cities..."
|
||||
: cityOptions.length === 0
|
||||
? "No cities available"
|
||||
: "Select city"
|
||||
}
|
||||
searchPlaceholder="Search cities..."
|
||||
disabled={!selectedCountry || isLoadingCities}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ISP - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
ISP (optional)
|
||||
{isLoadingIsps && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={ispOptions}
|
||||
value={selectedIsp}
|
||||
onValueChange={setSelectedIsp}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
: isLoadingIsps
|
||||
? "Loading ISPs..."
|
||||
: ispOptions.length === 0
|
||||
? "No ISPs available"
|
||||
: "Select ISP"
|
||||
}
|
||||
searchPlaceholder="Search ISPs..."
|
||||
disabled={!selectedCountry || isLoadingIsps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
|
||||
@@ -1048,8 +1048,9 @@ export function ProfilesDataTable({
|
||||
await invoke("create_cloud_location_proxy", {
|
||||
name: country.name,
|
||||
country: country.code,
|
||||
state: null,
|
||||
region: null,
|
||||
city: null,
|
||||
isp: null,
|
||||
});
|
||||
await emit("stored-proxies-changed");
|
||||
// Wait briefly for proxy list to update, then find and assign the new proxy
|
||||
|
||||
@@ -217,6 +217,7 @@ export function ProfileInfoDialog({
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
proBadge?: boolean;
|
||||
runningBadge?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
@@ -240,12 +241,14 @@ export function ProfileInfoDialog({
|
||||
onClick: () =>
|
||||
handleAction(() => onAssignProfilesToGroup?.([profile.id])),
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
},
|
||||
{
|
||||
icon: <LuFingerprint className="w-4 h-4" />,
|
||||
label: t("profiles.actions.changeFingerprint"),
|
||||
onClick: () => handleAction(() => onConfigureCamoufox?.(profile)),
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
|
||||
},
|
||||
{
|
||||
@@ -254,6 +257,7 @@ export function ProfileInfoDialog({
|
||||
onClick: () => handleAction(() => onCopyCookiesToProfile?.(profile)),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning && crossOsUnlocked,
|
||||
hidden:
|
||||
!isCamoufoxOrWayfern ||
|
||||
profile.ephemeral === true ||
|
||||
@@ -265,6 +269,7 @@ export function ProfileInfoDialog({
|
||||
onClick: () => handleAction(() => onOpenCookieManagement?.(profile)),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning && crossOsUnlocked,
|
||||
hidden:
|
||||
!isCamoufoxOrWayfern ||
|
||||
profile.ephemeral === true ||
|
||||
@@ -275,6 +280,7 @@ export function ProfileInfoDialog({
|
||||
label: t("profiles.actions.clone"),
|
||||
onClick: () => handleAction(() => onCloneProfile?.(profile)),
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
@@ -283,6 +289,7 @@ export function ProfileInfoDialog({
|
||||
onClick: () => handleAction(() => onAssignExtensionGroup?.([profile.id])),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning && crossOsUnlocked,
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
@@ -488,7 +495,12 @@ export function ProfileInfoDialog({
|
||||
{action.icon}
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{action.label}
|
||||
{action.proBadge && <ProBadge />}
|
||||
{action.runningBadge && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-primary/15 text-primary uppercase">
|
||||
{t("common.status.running")}
|
||||
</span>
|
||||
)}
|
||||
{action.proBadge && !action.runningBadge && <ProBadge />}
|
||||
</span>
|
||||
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -53,6 +53,7 @@ export function ProfileSyncDialog({
|
||||
const [hasSelfHostedConfig, setHasSelfHostedConfig] = useState(false);
|
||||
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
||||
const [isCheckingConfig, setIsCheckingConfig] = useState(false);
|
||||
const [userChangedMode, setUserChangedMode] = useState(false);
|
||||
|
||||
const hasConfig = isCloudSyncEligible || hasSelfHostedConfig;
|
||||
|
||||
@@ -72,17 +73,21 @@ export function ProfileSyncDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && profile) {
|
||||
setSyncMode(profile.sync_mode ?? "Disabled");
|
||||
setUserChangedMode(false);
|
||||
void checkSyncConfig();
|
||||
}
|
||||
}, [isOpen, profile, checkSyncConfig]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open && profile) {
|
||||
setSyncMode(profile.sync_mode ?? "Disabled");
|
||||
void checkSyncConfig();
|
||||
}
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[profile, onClose, checkSyncConfig],
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
@@ -113,6 +118,7 @@ export function ProfileSyncDialog({
|
||||
syncMode: newMode,
|
||||
});
|
||||
setSyncMode(newMode as SyncMode);
|
||||
setUserChangedMode(true);
|
||||
showSuccessToast(
|
||||
newMode !== "Disabled"
|
||||
? t("sync.mode.enabledToast")
|
||||
@@ -273,14 +279,16 @@ export function ProfileSyncDialog({
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{syncMode === "Encrypted" && !hasE2ePassword && (
|
||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||
{t(
|
||||
"sync.mode.noPasswordWarning",
|
||||
"E2E password not set. Please set a password in Settings.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{syncMode === "Encrypted" &&
|
||||
!hasE2ePassword &&
|
||||
userChangedMode && (
|
||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||
{t(
|
||||
"sync.mode.noPasswordWarning",
|
||||
"E2E password not set. Please set a password in Settings.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label>
|
||||
|
||||
@@ -3,9 +3,19 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,14 +26,11 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile, StoredProxy, VpnConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -51,6 +58,7 @@ export function ProxyAssignmentDialog({
|
||||
"none",
|
||||
);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleValueChange = useCallback((value: string) => {
|
||||
@@ -126,13 +134,6 @@ export function ProxyAssignmentDialog({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selectValue =
|
||||
selectionType === "none"
|
||||
? "none"
|
||||
: selectionType === "vpn"
|
||||
? `vpn-${selectedId}`
|
||||
: (selectedId ?? "none");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -166,43 +167,112 @@ export function ProxyAssignmentDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
|
||||
<Select value={selectValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a proxy or VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
{proxy.is_cloud_managed ? " (Included)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{vpnConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>VPNs</SelectLabel>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<SelectItem key={vpn.id} value={`vpn-${vpn.id}`}>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight"
|
||||
<Popover open={proxyPopoverOpen} onOpenChange={setProxyPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={proxyPopoverOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{(() => {
|
||||
if (selectionType === "none") return "None";
|
||||
if (selectionType === "vpn") {
|
||||
const vpn = vpnConfigs.find((v) => v.id === selectedId);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "None";
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedId,
|
||||
);
|
||||
return proxy
|
||||
? `${proxy.name}${proxy.is_cloud_managed ? " (Included)" : ""}`
|
||||
: "None";
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
handleValueChange("none");
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectionType === "none"
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() => {
|
||||
handleValueChange(proxy.id);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectionType === "proxy" &&
|
||||
selectedId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
{proxy.is_cloud_managed ? " (Included)" : ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.name}`}
|
||||
onSelect={() => {
|
||||
handleValueChange(`vpn-${vpn.id}`);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectionType === "vpn" && selectedId === vpn.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -53,6 +53,7 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
item: { sync_enabled?: boolean; last_sync?: number },
|
||||
liveStatus: SyncStatus | undefined,
|
||||
errorMessage?: string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
@@ -74,7 +75,11 @@ function getSyncStatusDot(
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
|
||||
return {
|
||||
color: "bg-red-500",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
|
||||
}
|
||||
@@ -104,6 +109,9 @@ export function ProxyManagementDialog({
|
||||
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>>(
|
||||
{},
|
||||
@@ -119,6 +127,9 @@ export function ProxyManagementDialog({
|
||||
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>
|
||||
@@ -126,50 +137,30 @@ export function ProxyManagementDialog({
|
||||
|
||||
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
|
||||
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
|
||||
const [cloudProxyUsage, setCloudProxyUsage] = useState<{
|
||||
used_mb: number;
|
||||
limit_mb: number;
|
||||
} | null>(null);
|
||||
|
||||
// Sort cloud-managed proxies first
|
||||
const storedProxies = [...rawProxies].sort((a, b) => {
|
||||
if (a.is_cloud_managed && !b.is_cloud_managed) return -1;
|
||||
if (!a.is_cloud_managed && b.is_cloud_managed) return 1;
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
// Fetch cloud proxy usage
|
||||
useEffect(() => {
|
||||
const fetchUsage = async () => {
|
||||
try {
|
||||
const usage = await invoke<{
|
||||
used_mb: number;
|
||||
limit_mb: number;
|
||||
remaining_mb: number;
|
||||
} | null>("cloud_get_proxy_usage");
|
||||
setCloudProxyUsage(usage);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
void fetchUsage();
|
||||
}
|
||||
}, [isOpen]);
|
||||
// Filter out the base cloud-managed proxy (it's an internal indicator, not user-facing)
|
||||
// Keep cloud-derived location proxies
|
||||
const storedProxies = rawProxies
|
||||
.filter((p) => !p.is_cloud_managed)
|
||||
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
||||
const hasCloudProxy = rawProxies.some((p) => p.is_cloud_managed);
|
||||
|
||||
// Listen for proxy sync status events
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
unlisten = await listen<{ id: string; status: string; error?: string }>(
|
||||
"proxy-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
const { id, status, error } = event.payload;
|
||||
setProxySyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
if (error) {
|
||||
setProxySyncErrors((prev) => ({ ...prev, [id]: error }));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -185,14 +176,17 @@ export function ProxyManagementDialog({
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
unlisten = await listen<{ id: string; status: string; error?: string }>(
|
||||
"vpn-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
const { id, status, error } = event.payload;
|
||||
setVpnSyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
if (error) {
|
||||
setVpnSyncErrors((prev) => ({ ...prev, [id]: error }));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -370,7 +364,7 @@ export function ProxyManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Proxies & VPNs</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -378,96 +372,96 @@ export function ProxyManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="proxies">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="proxies" className="flex-1">
|
||||
Proxies
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vpns" className="flex-1">
|
||||
VPNs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<Tabs defaultValue="proxies">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="proxies" className="flex-1">
|
||||
Proxies
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vpns" className="flex-1">
|
||||
VPNs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="proxies">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Export
|
||||
</RippleButton>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{storedProxies.some((p) => p.is_cloud_managed) && (
|
||||
<TabsContent value="proxies">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowLocationDialog(true)}
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoGlobe className="w-4 h-4" />
|
||||
Location
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Export
|
||||
</RippleButton>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{hasCloudProxy && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowLocationDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoGlobe className="w-4 h-4" />
|
||||
Location
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No proxies created yet. Create your first proxy using the
|
||||
button above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => {
|
||||
const isCloud = proxy.is_cloud_managed === true;
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
);
|
||||
const isDerived = proxy.is_cloud_derived === true;
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No proxies created yet. Create your first proxy using the
|
||||
button above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
proxySyncErrors[proxy.id],
|
||||
);
|
||||
const isDerived = proxy.is_cloud_derived === true;
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDerived && proxy.geo_country && (
|
||||
<FlagIcon
|
||||
@@ -475,7 +469,7 @@ export function ProxyManagementDialog({
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
{!isCloud && !isDerived && (
|
||||
{!isDerived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -493,23 +487,13 @@ export function ProxyManagementDialog({
|
||||
)}
|
||||
{proxy.name}
|
||||
</div>
|
||||
{isCloud && cloudProxyUsage && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cloudProxyUsage.used_mb} /{" "}
|
||||
{cloudProxyUsage.limit_mb} MB used
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isCloud ? (
|
||||
<Badge variant="outline">Cloud</Badge>
|
||||
) : (
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
@@ -540,48 +524,50 @@ export function ProxyManagementDialog({
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!isCloud && !isDerived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleEditProxy(proxy)
|
||||
}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isCloud && (
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!isDerived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleEditProxy(proxy)
|
||||
}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
@@ -613,199 +599,202 @@ export function ProxyManagementDialog({
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vpns">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<TabsContent value="vpns">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowVpnImportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
</RippleButton>
|
||||
</div>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowVpnImportDialog(true)}
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading VPNs...
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No VPN configs created yet. Import or create one using the
|
||||
buttons above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-16">Type</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnConfigs.map((vpn) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
vpn,
|
||||
vpnSyncStatus[vpn.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={vpn.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading VPNs...
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No VPN configs created yet. Import or create one using the
|
||||
buttons above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-16">Type</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnConfigs.map((vpn) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
vpn,
|
||||
vpnSyncStatus[vpn.id],
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={vpn.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{vpn.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{vpnUsage[vpn.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{vpn.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{vpnUsage[vpn.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={vpn.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleVpnSync(vpn)
|
||||
}
|
||||
disabled={
|
||||
isTogglingVpnSync[vpn.id] ||
|
||||
vpnInUse[vpn.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this VPN
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit VPN</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteVpn(vpn)}
|
||||
disabled={
|
||||
(vpnUsage[vpn.id] ?? 0) > 0
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={vpn.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleVpnSync(vpn)
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
disabled={
|
||||
isTogglingVpnSync[vpn.id] ||
|
||||
vpnInUse[vpn.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{vpnUsage[vpn.id]} profile
|
||||
{vpnUsage[vpn.id] > 1 ? "s" : ""}
|
||||
Sync cannot be disabled while this
|
||||
VPN is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete VPN</p>
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit VPN</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteVpn(vpn)
|
||||
}
|
||||
disabled={
|
||||
(vpnUsage[vpn.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{vpnUsage[vpn.id]} profile
|
||||
{vpnUsage[vpn.id] > 1 ? "s" : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete VPN</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ColorPicker,
|
||||
ColorPickerAlpha,
|
||||
@@ -60,6 +61,7 @@ interface AppSettings {
|
||||
api_enabled: boolean;
|
||||
api_port: number;
|
||||
api_token?: string;
|
||||
disable_auto_updates?: boolean;
|
||||
}
|
||||
|
||||
interface CustomThemeState {
|
||||
@@ -116,6 +118,7 @@ export function SettingsDialog({
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [isLinux, setIsLinux] = useState(false);
|
||||
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
||||
const [e2ePassword, setE2ePassword] = useState("");
|
||||
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
|
||||
@@ -486,6 +489,8 @@ export function SettingsDialog({
|
||||
const userAgent = navigator.userAgent;
|
||||
const isMac = userAgent.includes("Mac");
|
||||
setIsMacOS(isMac);
|
||||
const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win");
|
||||
setIsLinux(isLin);
|
||||
|
||||
if (isMac) {
|
||||
loadPermissions().catch(console.error);
|
||||
@@ -547,7 +552,8 @@ export function SettingsDialog({
|
||||
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
||||
(settings.theme !== "custom" &&
|
||||
JSON.stringify(settings.custom_theme ?? {}) !==
|
||||
JSON.stringify(originalSettings.custom_theme ?? {}));
|
||||
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
||||
settings.disable_auto_updates !== originalSettings.disable_auto_updates;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -1028,6 +1034,29 @@ export function SettingsDialog({
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
|
||||
{!isLinux && (
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg border">
|
||||
<Checkbox
|
||||
id="disable-auto-updates"
|
||||
checked={settings.disable_auto_updates || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting("disable_auto_updates", checked as boolean)
|
||||
}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="disable-auto-updates"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("settings.disableAutoUpdates")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.disableAutoUpdatesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoadingButton
|
||||
isLoading={isClearingCache}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import MultipleSelector, { type Option } from "@/components/multiple-selector";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -33,6 +35,8 @@ interface SharedCamoufoxConfigFormProps {
|
||||
browserType?: "camoufox" | "wayfern"; // Browser type to customize form options
|
||||
crossOsUnlocked?: boolean; // Allow selecting non-current OS (paid feature)
|
||||
limitedMode?: boolean; // Blur and disable advanced fields while keeping basic options accessible
|
||||
profileVersion?: string;
|
||||
profileBrowser?: string;
|
||||
}
|
||||
|
||||
// Determine if fingerprint editing should be disabled
|
||||
@@ -124,6 +128,8 @@ export function SharedCamoufoxConfigForm({
|
||||
browserType = "camoufox",
|
||||
crossOsUnlocked = false,
|
||||
limitedMode = false,
|
||||
profileVersion,
|
||||
profileBrowser,
|
||||
}: SharedCamoufoxConfigFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
@@ -132,6 +138,26 @@ export function SharedCamoufoxConfigForm({
|
||||
const [fingerprintConfig, setFingerprintConfig] =
|
||||
useState<CamoufoxFingerprintConfig>({});
|
||||
const [currentOS] = useState<CamoufoxOS>(getCurrentOS);
|
||||
const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false);
|
||||
|
||||
const handleGenerateFingerprint = async () => {
|
||||
if (!profileVersion) return;
|
||||
const browser = profileBrowser || browserType || "camoufox";
|
||||
setIsGeneratingFingerprint(true);
|
||||
try {
|
||||
const configJson = JSON.stringify(config);
|
||||
const result = await invoke<string>("generate_sample_fingerprint", {
|
||||
browser,
|
||||
version: profileVersion,
|
||||
configJson,
|
||||
});
|
||||
onConfigChange("fingerprint", result);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate fingerprint:", error);
|
||||
} finally {
|
||||
setIsGeneratingFingerprint(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get selected OS (defaults to current OS)
|
||||
const selectedOS = config.os || currentOS;
|
||||
@@ -223,7 +249,22 @@ export function SharedCamoufoxConfigForm({
|
||||
<div className="space-y-6">
|
||||
{/* Operating System Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.osLabel")}</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("fingerprint.osLabel")}</Label>
|
||||
{profileVersion && (!isCreating || crossOsUnlocked) && (
|
||||
<LoadingButton
|
||||
isLoading={isGeneratingFingerprint}
|
||||
onClick={handleGenerateFingerprint}
|
||||
disabled={readOnly}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isCreating
|
||||
? t("fingerprint.generateFingerprint")
|
||||
: t("fingerprint.refreshFingerprint")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={selectedOS}
|
||||
onValueChange={(value: CamoufoxOS) => onConfigChange("os", value)}
|
||||
|
||||
@@ -32,6 +32,14 @@ interface SyncConfigDialogProps {
|
||||
onClose: (loginOccurred?: boolean) => void;
|
||||
}
|
||||
|
||||
interface ProxyUsage {
|
||||
used_mb: number;
|
||||
limit_mb: number;
|
||||
remaining_mb: number;
|
||||
recurring_limit_mb: number;
|
||||
extra_limit_mb: number;
|
||||
}
|
||||
|
||||
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -59,6 +67,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("cloud");
|
||||
const [liveProxyUsage, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
"unknown" | "testing" | "connected" | "error"
|
||||
@@ -99,6 +108,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setCodeSent(false);
|
||||
setOtpCode("");
|
||||
setEmail("");
|
||||
invoke<ProxyUsage | null>("cloud_get_proxy_usage")
|
||||
.then(setLiveProxyUsage)
|
||||
.catch(() => setLiveProxyUsage(null));
|
||||
}
|
||||
}, [isOpen, loadSettings]);
|
||||
|
||||
@@ -288,26 +300,39 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{user.proxyBandwidthLimitMb > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Proxy Bandwidth</span>
|
||||
<span>
|
||||
{user.proxyBandwidthUsedMb} /{" "}
|
||||
{user.proxyBandwidthLimitMb +
|
||||
(user.proxyBandwidthExtraMb || 0)}{" "}
|
||||
MB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(user.proxyBandwidthExtraMb || 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Extra Bandwidth</span>
|
||||
<span>
|
||||
{user.proxyBandwidthExtraMb >= 1000
|
||||
? `${(user.proxyBandwidthExtraMb / 1000).toFixed(1)} GB`
|
||||
: `${user.proxyBandwidthExtraMb} MB`}
|
||||
</span>
|
||||
</div>
|
||||
{liveProxyUsage && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Recurring Proxy Bandwidth
|
||||
</span>
|
||||
<span>
|
||||
{Math.max(
|
||||
0,
|
||||
liveProxyUsage.recurring_limit_mb -
|
||||
liveProxyUsage.used_mb,
|
||||
)}{" "}
|
||||
/ {liveProxyUsage.recurring_limit_mb} MB remaining
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Extra Proxy Bandwidth
|
||||
</span>
|
||||
<span>
|
||||
{Math.max(
|
||||
0,
|
||||
liveProxyUsage.remaining_mb -
|
||||
Math.max(
|
||||
0,
|
||||
liveProxyUsage.recurring_limit_mb -
|
||||
liveProxyUsage.used_mb,
|
||||
),
|
||||
)}{" "}
|
||||
/ {liveProxyUsage.extra_limit_mb} MB remaining
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{user.teamName && (
|
||||
<>
|
||||
|
||||
@@ -32,6 +32,7 @@ interface ComboboxProps {
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
@@ -41,16 +42,18 @@ export function Combobox({
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search...",
|
||||
className,
|
||||
disabled,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between", className)}
|
||||
>
|
||||
{value
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -31,6 +33,8 @@ interface WayfernConfigFormProps {
|
||||
readOnly?: boolean;
|
||||
crossOsUnlocked?: boolean;
|
||||
limitedMode?: boolean;
|
||||
profileVersion?: string;
|
||||
profileBrowser?: string;
|
||||
}
|
||||
|
||||
const isFingerprintEditingDisabled = (config: WayfernConfig): boolean => {
|
||||
@@ -62,6 +66,8 @@ export function WayfernConfigForm({
|
||||
readOnly = false,
|
||||
crossOsUnlocked = false,
|
||||
limitedMode = false,
|
||||
profileVersion,
|
||||
profileBrowser,
|
||||
}: WayfernConfigFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
@@ -70,6 +76,25 @@ export function WayfernConfigForm({
|
||||
const [fingerprintConfig, setFingerprintConfig] =
|
||||
useState<WayfernFingerprintConfig>({});
|
||||
const [currentOS] = useState<WayfernOS>(getCurrentOS);
|
||||
const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false);
|
||||
|
||||
const handleGenerateFingerprint = async () => {
|
||||
if (!profileVersion) return;
|
||||
setIsGeneratingFingerprint(true);
|
||||
try {
|
||||
const configJson = JSON.stringify(config);
|
||||
const result = await invoke<string>("generate_sample_fingerprint", {
|
||||
browser: profileBrowser || "wayfern",
|
||||
version: profileVersion,
|
||||
configJson,
|
||||
});
|
||||
onConfigChange("fingerprint", result);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate fingerprint:", error);
|
||||
} finally {
|
||||
setIsGeneratingFingerprint(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedOS = config.os || currentOS;
|
||||
|
||||
@@ -150,7 +175,22 @@ export function WayfernConfigForm({
|
||||
<div className="space-y-6">
|
||||
{/* Operating System Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.osLabel")}</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("fingerprint.osLabel")}</Label>
|
||||
{profileVersion && (!isCreating || crossOsUnlocked) && (
|
||||
<LoadingButton
|
||||
isLoading={isGeneratingFingerprint}
|
||||
onClick={handleGenerateFingerprint}
|
||||
disabled={readOnly}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isCreating
|
||||
? t("fingerprint.generateFingerprint")
|
||||
: t("fingerprint.refreshFingerprint")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={selectedOS}
|
||||
onValueChange={(value: WayfernOS) => onConfigChange("os", value)}
|
||||
|
||||
Reference in New Issue
Block a user