mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-18 00:47:19 +02:00
refactor: ui refresh
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { launchDonutClone } from "@/lib/donut-physics";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AboutDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
app_version: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
portable: boolean;
|
||||
}
|
||||
|
||||
// Flywheel: each click adds spin; past this speed the donut escapes the
|
||||
// dialog and bounces around the window (shared physics with the rail egg).
|
||||
const SPIN_PER_CLICK = 540; // deg/s
|
||||
const ESCAPE_VELOCITY = 2200; // deg/s
|
||||
const SPIN_FRICTION = 1.1; // fraction of velocity lost per second
|
||||
|
||||
export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
||||
const [logoFlown, setLogoFlown] = useState(false);
|
||||
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
const rotationRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
const lastTimeRef = useRef(0);
|
||||
const cancelLaunchRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
invoke<SystemInfo>("get_system_info")
|
||||
.then(setSystemInfo)
|
||||
.catch(() => {
|
||||
setSystemInfo(null);
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
const stopSpin = useCallback(() => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = 0;
|
||||
velocityRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const spinFrame = useCallback(
|
||||
(time: number) => {
|
||||
const el = logoRef.current;
|
||||
if (!el) {
|
||||
rafRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const dt = Math.min((time - lastTimeRef.current) / 1000, 0.05);
|
||||
lastTimeRef.current = time;
|
||||
|
||||
rotationRef.current += velocityRef.current * dt;
|
||||
velocityRef.current *= Math.max(0, 1 - SPIN_FRICTION * dt);
|
||||
el.style.transform = `rotate(${rotationRef.current}deg)`;
|
||||
|
||||
if (velocityRef.current >= ESCAPE_VELOCITY) {
|
||||
// The flywheel wins: the donut tears loose and joins the bounce sim,
|
||||
// keeping its spin.
|
||||
stopSpin();
|
||||
setLogoFlown(true);
|
||||
cancelLaunchRef.current = launchDonutClone(el, {
|
||||
initialVX: Math.random() > 0.5 ? 420 : -420,
|
||||
initialVY: -750,
|
||||
spinSpeed: ESCAPE_VELOCITY,
|
||||
onExit: () => {
|
||||
cancelLaunchRef.current = null;
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (velocityRef.current > 5) {
|
||||
rafRef.current = requestAnimationFrame(spinFrame);
|
||||
} else {
|
||||
rafRef.current = 0;
|
||||
velocityRef.current = 0;
|
||||
}
|
||||
},
|
||||
[stopSpin],
|
||||
);
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
if (reducedMotion || logoFlown) return;
|
||||
velocityRef.current += SPIN_PER_CLICK;
|
||||
if (!rafRef.current) {
|
||||
lastTimeRef.current = performance.now();
|
||||
rafRef.current = requestAnimationFrame(spinFrame);
|
||||
}
|
||||
}, [reducedMotion, logoFlown, spinFrame]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
stopSpin();
|
||||
cancelLaunchRef.current?.();
|
||||
cancelLaunchRef.current = null;
|
||||
rotationRef.current = 0;
|
||||
if (logoRef.current) {
|
||||
logoRef.current.style.transform = "";
|
||||
logoRef.current.style.visibility = "";
|
||||
}
|
||||
setLogoFlown(false);
|
||||
onClose();
|
||||
}, [stopSpin, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
cancelLaunchRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("about.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<button
|
||||
ref={logoRef}
|
||||
type="button"
|
||||
aria-label={t("header.donutLogo")}
|
||||
onClick={handleLogoClick}
|
||||
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
|
||||
style={logoFlown ? { visibility: "hidden" } : undefined}
|
||||
>
|
||||
<Logo className="size-14" />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold">Donut Browser</p>
|
||||
{systemInfo && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("about.version", { version: systemInfo.app_version })}
|
||||
{systemInfo.portable && (
|
||||
<span className="ml-1.5 rounded border border-border bg-muted px-1 py-px text-[10px] align-middle">
|
||||
{t("about.portableBadge")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{systemInfo.os} {systemInfo.arch}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("about.licenseNotice")}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void openUrl("https://donutbrowser.com")}
|
||||
>
|
||||
{t("about.website")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void openUrl("https://github.com/zhom/donutbrowser")
|
||||
}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+272
-270
@@ -199,314 +199,316 @@ export function AccountPage({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="account">
|
||||
{t("account.tabs.account")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
title={
|
||||
selfHostedDisabled
|
||||
? t("account.selfHosted.disabledWhileLoggedIn")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("account.tabs.selfHosted")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="account">
|
||||
{t("account.tabs.account")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
title={
|
||||
selfHostedDisabled
|
||||
? t("account.selfHosted.disabledWhileLoggedIn")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("account.tabs.selfHosted")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
|
||||
<LuUser className="size-6" />
|
||||
<AnimatedTabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
|
||||
<LuUser className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
})}
|
||||
{user.subscriptionStatus ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
</p>
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
{t("account.automationActiveHere")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
</p>
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
{t("account.automationActiveHere")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent value="self-hosted" className="mt-4">
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
// but if state flips mid-render (e.g. a cloud login finishes
|
||||
// while the tab is open), show the explanation instead of
|
||||
// a silent empty card.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedTabsContent value="self-hosted" className="mt-4">
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
// but if state flips mid-render (e.g. a cloud login finishes
|
||||
// while the tab is open), show the explanation instead of
|
||||
// a silent empty card.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-server-url" className="text-xs">
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="self-hosted-server-url"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
variant="outline"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MotionConfig } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { I18nProvider } from "@/components/i18n-provider";
|
||||
import { OnboardingProvider } from "@/components/onboarding-provider";
|
||||
@@ -17,11 +18,17 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>
|
||||
<OnboardingProvider>{children}</OnboardingProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
{/* reducedMotion="user" makes every motion/react animation honor the
|
||||
OS prefers-reduced-motion setting: transforms are skipped, opacity
|
||||
cross-fades are kept. The CSS-side media query in globals.css only
|
||||
covers CSS transitions — this covers the JS-driven ones. */}
|
||||
<MotionConfig reducedMotion="user">
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>
|
||||
<OnboardingProvider>{children}</OnboardingProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
</MotionConfig>
|
||||
</CustomThemeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,14 @@ import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear } from "react-icons/go";
|
||||
import {
|
||||
LuBadgeInfo,
|
||||
LuCircleStop,
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPlus,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
@@ -53,6 +55,8 @@ interface CommandPaletteProps {
|
||||
onLaunchProfile: (profile: BrowserProfile) => void;
|
||||
onKillProfile: (profile: BrowserProfile) => void;
|
||||
onShowProfileInfo: (profile: BrowserProfile) => void;
|
||||
onCreateProfile: () => void;
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
@@ -122,6 +126,8 @@ export function CommandPalette({
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onShowProfileInfo,
|
||||
onCreateProfile,
|
||||
onOpenAbout,
|
||||
}: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -251,6 +257,14 @@ export function CommandPalette({
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.actions")}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
dispatch(onCreateProfile);
|
||||
}}
|
||||
>
|
||||
<LuPlus />
|
||||
<span>{t("commandPalette.actions.createProfile")}</span>
|
||||
</CommandItem>
|
||||
{byGroup("actions").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
@@ -268,6 +282,14 @@ export function CommandPalette({
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
dispatch(onOpenAbout);
|
||||
}}
|
||||
>
|
||||
<LuBadgeInfo />
|
||||
<span>{t("commandPalette.actions.about")}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export interface ConsistencyResult {
|
||||
consistent: boolean;
|
||||
checked: boolean;
|
||||
exit_ip: string | null;
|
||||
exit_country_code: string | null;
|
||||
exit_timezone: string | null;
|
||||
fingerprint_timezone: string | null;
|
||||
fingerprint_language: string | null;
|
||||
mismatches: string[];
|
||||
}
|
||||
|
||||
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
|
||||
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
|
||||
|
||||
export function isConsistencyWarningEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(GLOBAL_DISABLE_KEY) !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function isConsistencyWarningSuppressed(profileId: string): boolean {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" ||
|
||||
localStorage.getItem(perProfileKey(profileId)) === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ConsistencyWarningDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileName: string;
|
||||
profileId: string;
|
||||
result: ConsistencyResult | null;
|
||||
}
|
||||
|
||||
export function ConsistencyWarningDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileName,
|
||||
profileId,
|
||||
result,
|
||||
}: ConsistencyWarningDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dontWarnAgain, setDontWarnAgain] = useState(false);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontWarnAgain) {
|
||||
try {
|
||||
localStorage.setItem(perProfileKey(profileId), "1");
|
||||
} catch {
|
||||
// localStorage unavailable — nothing to persist
|
||||
}
|
||||
}
|
||||
setDontWarnAgain(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const mismatches = result?.mismatches ?? [];
|
||||
const exitIp = result?.exit_ip ?? null;
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!exitIp) {
|
||||
return;
|
||||
}
|
||||
setIsMatching(true);
|
||||
try {
|
||||
await invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId,
|
||||
exitIp,
|
||||
});
|
||||
showSuccessToast(t("consistencyWarning.matchSuccess"));
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning" />
|
||||
{t("consistencyWarning.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{t("consistencyWarning.intro", { name: profileName })}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/10 p-3">
|
||||
{mismatches.includes("timezone") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.timezoneTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.timezoneDetail", {
|
||||
exit: result?.exit_timezone ?? "?",
|
||||
fingerprint: result?.fingerprint_timezone ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mismatches.includes("language") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.languageTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.languageDetail", {
|
||||
country: result?.exit_country_code ?? "?",
|
||||
fingerprint: result?.fingerprint_language ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.explainer")}
|
||||
</p>
|
||||
|
||||
<label
|
||||
htmlFor="consistency-dont-warn"
|
||||
className="flex cursor-pointer items-center gap-2 text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
id="consistency-dont-warn"
|
||||
checked={dontWarnAgain}
|
||||
onCheckedChange={(v) => setDontWarnAgain(v === true)}
|
||||
/>
|
||||
{t("consistencyWarning.dontWarnAgain")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isMatching}
|
||||
>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
{exitIp && (
|
||||
<RippleButton onClick={handleMatch} disabled={isMatching}>
|
||||
{isMatching
|
||||
? t("consistencyWarning.matching")
|
||||
: t("consistencyWarning.matchToProxy")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,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 { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
|
||||
|
||||
@@ -1183,21 +1184,14 @@ export function CreateProfileDialog({
|
||||
<SelectItem value="none">
|
||||
{t("dnsBlocklist.none")}
|
||||
</SelectItem>
|
||||
<SelectItem value="light">
|
||||
{t("dnsBlocklist.light")}
|
||||
</SelectItem>
|
||||
<SelectItem value="normal">
|
||||
{t("dnsBlocklist.normal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pro">
|
||||
{t("dnsBlocklist.pro")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pro_plus">
|
||||
{t("dnsBlocklist.proPlus")}
|
||||
</SelectItem>
|
||||
<SelectItem value="ultimate">
|
||||
{t("dnsBlocklist.ultimate")}
|
||||
</SelectItem>
|
||||
{DNS_BLOCKLIST_LEVELS.map((level) => (
|
||||
<SelectItem
|
||||
key={level.value}
|
||||
value={level.value}
|
||||
>
|
||||
{t(level.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
open as openDialog,
|
||||
save as saveDialog,
|
||||
} from "@tauri-apps/plugin-dialog";
|
||||
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
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 {
|
||||
@@ -12,6 +25,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { dnsBlocklistLabelKey } from "@/lib/dns-blocklist-levels";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
|
||||
interface BlocklistCacheStatus {
|
||||
level: string;
|
||||
@@ -23,11 +41,25 @@ interface BlocklistCacheStatus {
|
||||
is_cached: boolean;
|
||||
}
|
||||
|
||||
interface CustomDnsConfig {
|
||||
sources: string[];
|
||||
block_domains: string[];
|
||||
allow_domains: string[];
|
||||
allowlist_mode: boolean;
|
||||
updated_at: number | null;
|
||||
}
|
||||
|
||||
interface DnsBlocklistDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const linesToArray = (v: string) =>
|
||||
v
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export function DnsBlocklistDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -36,6 +68,12 @@ export function DnsBlocklistDialog({
|
||||
const [statuses, setStatuses] = useState<BlocklistCacheStatus[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const [sources, setSources] = useState("");
|
||||
const [blockDomains, setBlockDomains] = useState("");
|
||||
const [allowDomains, setAllowDomains] = useState("");
|
||||
const [allowlistMode, setAllowlistMode] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const loadStatuses = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<BlocklistCacheStatus[]>(
|
||||
@@ -47,11 +85,24 @@ export function DnsBlocklistDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCustomConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await invoke<CustomDnsConfig>("get_custom_dns_config");
|
||||
setSources(config.sources.join("\n"));
|
||||
setBlockDomains(config.block_domains.join("\n"));
|
||||
setAllowDomains(config.allow_domains.join("\n"));
|
||||
setAllowlistMode(config.allowlist_mode);
|
||||
} catch (e) {
|
||||
console.error("Failed to load custom DNS config:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadStatuses();
|
||||
void loadCustomConfig();
|
||||
}
|
||||
}, [isOpen, loadStatuses]);
|
||||
}, [isOpen, loadStatuses, loadCustomConfig]);
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -65,6 +116,67 @@ export function DnsBlocklistDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustom = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const config = await invoke<CustomDnsConfig>("set_custom_dns_config", {
|
||||
sources: linesToArray(sources),
|
||||
blockDomains: linesToArray(blockDomains),
|
||||
allowDomains: linesToArray(allowDomains),
|
||||
allowlistMode,
|
||||
});
|
||||
setSources(config.sources.join("\n"));
|
||||
setBlockDomains(config.block_domains.join("\n"));
|
||||
setAllowDomains(config.allow_domains.join("\n"));
|
||||
setAllowlistMode(config.allowlist_mode);
|
||||
toast.success(t("dnsBlocklist.custom.saved"));
|
||||
} catch (e) {
|
||||
toast.error(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: "Rules", extensions: ["json", "txt"] }],
|
||||
});
|
||||
if (!selected || typeof selected !== "string") return;
|
||||
const content = await readTextFile(selected);
|
||||
const format = selected.toLowerCase().endsWith(".json") ? "json" : "txt";
|
||||
const config = await invoke<CustomDnsConfig>("import_custom_dns_rules", {
|
||||
content,
|
||||
format,
|
||||
});
|
||||
setSources(config.sources.join("\n"));
|
||||
setBlockDomains(config.block_domains.join("\n"));
|
||||
setAllowDomains(config.allow_domains.join("\n"));
|
||||
setAllowlistMode(config.allowlist_mode);
|
||||
toast.success(t("dnsBlocklist.custom.imported"));
|
||||
} catch (e) {
|
||||
toast.error(translateBackendError(t, e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (format: "json" | "txt") => {
|
||||
try {
|
||||
const content = await invoke<string>("export_custom_dns_rules", {
|
||||
format,
|
||||
});
|
||||
const path = await saveDialog({
|
||||
defaultPath: `donut-dns-rules.${format}`,
|
||||
filters: [{ name: format.toUpperCase(), extensions: [format] }],
|
||||
});
|
||||
if (!path) return;
|
||||
await writeTextFile(path, content);
|
||||
toast.success(t("dnsBlocklist.custom.exported"));
|
||||
} catch (e) {
|
||||
toast.error(translateBackendError(t, e));
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -78,69 +190,185 @@ export function DnsBlocklistDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-lg flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
<AnimatedTabs
|
||||
defaultValue="blocklists"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
<AnimatedTabsTrigger value="blocklists">
|
||||
{t("dnsBlocklist.tabBlocklists")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="custom">
|
||||
{t("dnsBlocklist.tabCustom")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<div className="max-h-[40vh] min-h-0 space-y-3 overflow-y-auto">
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
className="flex items-center justify-between rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{status.display_name}
|
||||
</span>
|
||||
{status.is_cached ? (
|
||||
status.is_fresh ? (
|
||||
<Badge variant="default" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.fresh")}
|
||||
</Badge>
|
||||
<AnimatedTabsContent
|
||||
value="blocklists"
|
||||
className="min-h-0 flex-1 space-y-3 overflow-y-auto"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
className="flex items-center justify-between rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t(dnsBlocklistLabelKey(status.level))}
|
||||
</span>
|
||||
{status.is_cached ? (
|
||||
status.is_fresh ? (
|
||||
<Badge variant="default" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.fresh")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1.5 text-[10px]"
|
||||
>
|
||||
{t("dnsBlocklist.stale")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="secondary" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.stale")}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{t("dnsBlocklist.notCached")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{t("dnsBlocklist.notCached")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{status.is_cached && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status.entry_count.toLocaleString()}{" "}
|
||||
{t("dnsBlocklist.domains")} ·{" "}
|
||||
{formatSize(status.file_size_bytes)} ·{" "}
|
||||
{formatDate(status.last_updated)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{status.is_cached && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status.entry_count.toLocaleString()}{" "}
|
||||
{t("dnsBlocklist.domains")} ·{" "}
|
||||
{formatSize(status.file_size_bytes)} ·{" "}
|
||||
{formatDate(status.last_updated)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<LuRefreshCw
|
||||
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("dnsBlocklist.refreshAll")}
|
||||
</Button>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<Button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<LuRefreshCw
|
||||
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("dnsBlocklist.refreshAll")}
|
||||
</Button>
|
||||
<AnimatedTabsContent
|
||||
value="custom"
|
||||
className="min-h-0 flex-1 space-y-4 overflow-y-auto"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.custom.description")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("dnsBlocklist.custom.allowlistModeLabel")}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{allowlistMode
|
||||
? t("dnsBlocklist.custom.allowlistModeOn")
|
||||
: t("dnsBlocklist.custom.allowlistModeOff")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={allowlistMode}
|
||||
onCheckedChange={(v) => setAllowlistMode(v === true)}
|
||||
aria-label={t("dnsBlocklist.custom.allowlistModeLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!allowlistMode && (
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
{t("dnsBlocklist.custom.sourcesLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={sources}
|
||||
onChange={(e) => setSources(e.target.value)}
|
||||
placeholder={t("dnsBlocklist.custom.sourcesPlaceholder")}
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!allowlistMode && (
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
{t("dnsBlocklist.custom.blockLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={blockDomains}
|
||||
onChange={(e) => setBlockDomains(e.target.value)}
|
||||
placeholder={t("dnsBlocklist.custom.blockPlaceholder")}
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
{allowlistMode
|
||||
? t("dnsBlocklist.custom.allowedOnlyLabel")
|
||||
: t("dnsBlocklist.custom.allowLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={allowDomains}
|
||||
onChange={(e) => setAllowDomains(e.target.value)}
|
||||
placeholder={t("dnsBlocklist.custom.allowPlaceholder")}
|
||||
rows={allowlistMode ? 6 : 3}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{allowlistMode
|
||||
? t("dnsBlocklist.custom.allowedOnlyHint")
|
||||
: t("dnsBlocklist.custom.allowHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LoadingButton isLoading={isSaving} onClick={handleSaveCustom}>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
<Button variant="outline" onClick={handleImport}>
|
||||
{t("common.buttons.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleExport("txt")}
|
||||
>
|
||||
{t("dnsBlocklist.custom.exportTxt")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleExport("json")}
|
||||
>
|
||||
{t("dnsBlocklist.custom.exportJson")}
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFileArchive, FaFolder } from "react-icons/fa";
|
||||
import { LuChevronRight } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AnimatedDisclosureChevron,
|
||||
AnimatedDisclosureContent,
|
||||
} from "@/components/ui/animated-disclosure";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
@@ -36,8 +42,10 @@ import {
|
||||
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||
import { useGroupEvents } from "@/hooks/use-group-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { fireSprinkleConfetti } from "@/lib/confetti";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ArchiveScanResult,
|
||||
@@ -89,7 +97,13 @@ export function ImportProfileDialog({
|
||||
useState<DuplicateStrategy>("rename");
|
||||
// "none" | "round-robin" | a stored proxy id
|
||||
const [proxyAssignment, setProxyAssignment] = useState<string>("none");
|
||||
// "none" | a VPN config id (applied to every imported profile)
|
||||
const [vpnAssignment, setVpnAssignment] = useState<string>("none");
|
||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
|
||||
// Fingerprint + advanced options collapse behind disclosures — the default
|
||||
// path is just names + proxy/VPN.
|
||||
const [showFingerprint, setShowFingerprint] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [progress, setProgress] = useState<ProfileImportProgress | null>(null);
|
||||
@@ -97,6 +111,8 @@ export function ImportProfileDialog({
|
||||
|
||||
const { storedProxies } = useProxyEvents();
|
||||
const { groups } = useGroupEvents();
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const activeProfiles =
|
||||
importMode === "auto-detect" ? detectedProfiles : scannedProfiles;
|
||||
@@ -284,6 +300,7 @@ export function ImportProfileDialog({
|
||||
browser_type: p.browser,
|
||||
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
|
||||
proxy_id: proxyIdForIndex(index),
|
||||
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
|
||||
}));
|
||||
|
||||
setCurrentStep("importing");
|
||||
@@ -308,6 +325,9 @@ export function ImportProfileDialog({
|
||||
failed: batchResult.failed_count,
|
||||
}),
|
||||
);
|
||||
if (batchResult.imported_count > 0 && !reducedMotion) {
|
||||
fireSprinkleConfetti();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to import profiles:", error);
|
||||
toast.error(translateBackendError(t, error));
|
||||
@@ -319,9 +339,11 @@ export function ImportProfileDialog({
|
||||
selectedProfiles,
|
||||
profileNames,
|
||||
proxyIdForIndex,
|
||||
vpnAssignment,
|
||||
selectedGroupId,
|
||||
duplicateStrategy,
|
||||
wayfernConfig,
|
||||
reducedMotion,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -339,7 +361,10 @@ export function ImportProfileDialog({
|
||||
setNewGroupName("");
|
||||
setDuplicateStrategy("rename");
|
||||
setProxyAssignment("none");
|
||||
setVpnAssignment("none");
|
||||
setWayfernConfig({});
|
||||
setShowFingerprint(false);
|
||||
setShowAdvanced(false);
|
||||
setProgress(null);
|
||||
setResult(null);
|
||||
onClose();
|
||||
@@ -430,365 +455,434 @@ export function ImportProfileDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 space-y-6 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-2xl",
|
||||
)}
|
||||
>
|
||||
{currentStep === "select" && (
|
||||
<AnimatedTabs
|
||||
value={importMode}
|
||||
onValueChange={(v) => {
|
||||
setImportMode(v as ImportMode);
|
||||
setSelectedPaths(new Set());
|
||||
}}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
|
||||
{t("importProfile.autoDetect")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
|
||||
{t("importProfile.manualImport")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent value="auto-detect">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.detectedProfilesTitle")}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{t("importProfile.scanning")}
|
||||
</p>
|
||||
</div>
|
||||
) : detectedProfiles.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{t("importProfile.noneFound")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("importProfile.noneFoundHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderProfileList(detectedProfiles)
|
||||
)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent value="manual">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.manualTitle")}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-path" className="mb-2">
|
||||
{t("importProfile.profileFolderPath")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="manual-profile-path"
|
||||
value={manualPath}
|
||||
onChange={(e) => {
|
||||
setManualPath(e.target.value);
|
||||
}}
|
||||
placeholder={t(
|
||||
"importProfile.profileFolderPlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseFolder()}
|
||||
title={t("importProfile.browseFolderTitle")}
|
||||
>
|
||||
<FaFolder className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseArchive()}
|
||||
title={t("importProfile.selectArchiveTitle")}
|
||||
>
|
||||
<FaFileArchive className="size-4" />
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
isLoading={isScanning}
|
||||
disabled={!manualPath.trim()}
|
||||
onClick={() => void scanPath(manualPath.trim())}
|
||||
>
|
||||
{t("importProfile.scanButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("importProfile.manualHint")}
|
||||
</p>
|
||||
<p className="mt-2 text-xs break-all text-muted-foreground">
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application Support/Google/Chrome/Default
|
||||
<br />
|
||||
Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default
|
||||
<br />
|
||||
Linux: ~/.config/google-chrome/Default
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scannedProfiles.length > 0 &&
|
||||
renderProfileList(scannedProfiles)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
)}
|
||||
|
||||
{currentStep === "configure" && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("importProfile.importedAs", {
|
||||
browser: getBrowserDisplayName("wayfern"),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.profilesToImport")}
|
||||
</Label>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{selectedProfiles.map((profile) => (
|
||||
<div key={profile.path} className="flex items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||
title={profile.path}
|
||||
>
|
||||
{profile.name}
|
||||
</span>
|
||||
<Input
|
||||
className="flex-1"
|
||||
aria-label={t("importProfile.newProfileName")}
|
||||
value={profileNames[profile.path] ?? profile.name}
|
||||
onChange={(e) => {
|
||||
setProfileNames((prev) => ({
|
||||
...prev,
|
||||
[profile.path]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.groupOptional")}
|
||||
</Label>
|
||||
{isCreatingGroup ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder={t("importProfile.newGroupNamePlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!newGroupName.trim()}
|
||||
onClick={() => void handleCreateGroup()}
|
||||
>
|
||||
{t("common.buttons.create")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId}
|
||||
onValueChange={(value) => {
|
||||
if (value === "create-new") {
|
||||
setIsCreatingGroup(true);
|
||||
} else {
|
||||
setSelectedGroupId(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("importProfile.noGroup")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noGroup")}
|
||||
</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="create-new">
|
||||
{t("importProfile.createNewGroup")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.duplicateStrategyLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={duplicateStrategy}
|
||||
onValueChange={(value) => {
|
||||
setDuplicateStrategy(value as DuplicateStrategy);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rename">
|
||||
{t("importProfile.duplicateRename")}
|
||||
</SelectItem>
|
||||
<SelectItem value="skip">
|
||||
{t("importProfile.duplicateSkip")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.proxyOptional")}
|
||||
</Label>
|
||||
<Select
|
||||
value={proxyAssignment}
|
||||
onValueChange={setProxyAssignment}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("importProfile.noProxy")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noProxy")}
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectItem value="round-robin">
|
||||
{t("importProfile.proxyRoundRobin")}
|
||||
</SelectItem>
|
||||
)}
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
className={cn("space-y-6", subPage && "mx-auto w-full max-w-3xl")}
|
||||
>
|
||||
{currentStep === "select" && (
|
||||
<AnimatedTabs
|
||||
value={importMode}
|
||||
onValueChange={(v) => {
|
||||
setImportMode(v as ImportMode);
|
||||
setSelectedPaths(new Set());
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
|
||||
{t("importProfile.autoDetect")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
|
||||
{t("importProfile.manualImport")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
{currentStep === "importing" && (
|
||||
<div className="space-y-4">
|
||||
{isImporting && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.importingTitle")}
|
||||
</h3>
|
||||
<Progress value={progressPercent} />
|
||||
{progress && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("importProfile.importProgress", {
|
||||
completed: progress.completed,
|
||||
total: progress.total,
|
||||
})}
|
||||
{progress.status === "importing" && (
|
||||
<> — {progress.name}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AnimatedTabsContent value="auto-detect">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.detectedProfilesTitle")}
|
||||
</h3>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.resultsSummary", {
|
||||
imported: result.imported_count,
|
||||
skipped: result.skipped_count,
|
||||
failed: result.failed_count,
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{t("importProfile.scanning")}
|
||||
</p>
|
||||
</div>
|
||||
) : detectedProfiles.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{t("importProfile.noneFound")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("importProfile.noneFoundHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderProfileList(detectedProfiles)
|
||||
)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent value="manual">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.manualTitle")}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-path" className="mb-2">
|
||||
{t("importProfile.profileFolderPath")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="manual-profile-path"
|
||||
value={manualPath}
|
||||
onChange={(e) => {
|
||||
setManualPath(e.target.value);
|
||||
}}
|
||||
placeholder={t(
|
||||
"importProfile.profileFolderPlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseFolder()}
|
||||
title={t("importProfile.browseFolderTitle")}
|
||||
>
|
||||
<FaFolder className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseArchive()}
|
||||
title={t("importProfile.selectArchiveTitle")}
|
||||
>
|
||||
<FaFileArchive className="size-4" />
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
isLoading={isScanning}
|
||||
disabled={!manualPath.trim()}
|
||||
onClick={() => void scanPath(manualPath.trim())}
|
||||
>
|
||||
{t("importProfile.scanButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("importProfile.manualHint")}
|
||||
</p>
|
||||
<p className="mt-2 text-xs break-all text-muted-foreground">
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application
|
||||
Support/Google/Chrome/Default
|
||||
<br />
|
||||
Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default
|
||||
<br />
|
||||
Linux: ~/.config/google-chrome/Default
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scannedProfiles.length > 0 &&
|
||||
renderProfileList(scannedProfiles)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
)}
|
||||
|
||||
{currentStep === "configure" && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("importProfile.importedAs", {
|
||||
browser: getBrowserDisplayName("wayfern"),
|
||||
})}
|
||||
</h3>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{result.results.map((item) => (
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.profilesToImport")}
|
||||
</Label>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{selectedProfiles.map((profile) => (
|
||||
<div
|
||||
key={item.source_path}
|
||||
className="flex items-center gap-2 p-1 text-sm"
|
||||
key={profile.path}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium",
|
||||
item.status === "imported" && "text-success",
|
||||
item.status === "skipped" &&
|
||||
"text-muted-foreground",
|
||||
item.status === "failed" && "text-destructive",
|
||||
)}
|
||||
className="min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||
title={profile.path}
|
||||
>
|
||||
{item.status === "imported" &&
|
||||
t("importProfile.statusImported")}
|
||||
{item.status === "skipped" &&
|
||||
t("importProfile.statusSkipped")}
|
||||
{item.status === "failed" &&
|
||||
t("importProfile.statusFailed")}
|
||||
{profile.name}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.name || item.source_path}
|
||||
</span>
|
||||
{item.error && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-destructive">
|
||||
{translateBackendError(t, new Error(item.error))}
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
className="flex-1"
|
||||
aria-label={t("importProfile.newProfileName")}
|
||||
value={profileNames[profile.path] ?? profile.name}
|
||||
onChange={(e) => {
|
||||
setProfileNames((prev) => ({
|
||||
...prev,
|
||||
[profile.path]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.proxyOptional")}
|
||||
</Label>
|
||||
<Select
|
||||
value={proxyAssignment}
|
||||
onValueChange={setProxyAssignment}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("importProfile.noProxy")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noProxy")}
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectItem value="round-robin">
|
||||
{t("importProfile.proxyRoundRobin")}
|
||||
</SelectItem>
|
||||
)}
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{vpnConfigs.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.vpnOptional")}
|
||||
</Label>
|
||||
<Select
|
||||
value={vpnAssignment}
|
||||
onValueChange={setVpnAssignment}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("importProfile.noVpn")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noVpn")}
|
||||
</SelectItem>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<SelectItem key={vpn.id} value={vpn.id}>
|
||||
{vpn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
aria-expanded={showAdvanced}
|
||||
>
|
||||
<AnimatedDisclosureChevron open={showAdvanced}>
|
||||
<LuChevronRight className="size-3.5" />
|
||||
</AnimatedDisclosureChevron>
|
||||
{t("importProfile.advancedOptions")}
|
||||
</button>
|
||||
<AnimatedDisclosureContent
|
||||
open={showAdvanced}
|
||||
className="mt-3 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.groupOptional")}
|
||||
</Label>
|
||||
{isCreatingGroup ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder={t(
|
||||
"importProfile.newGroupNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!newGroupName.trim()}
|
||||
onClick={() => void handleCreateGroup()}
|
||||
>
|
||||
{t("common.buttons.create")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId}
|
||||
onValueChange={(value) => {
|
||||
if (value === "create-new") {
|
||||
setIsCreatingGroup(true);
|
||||
} else {
|
||||
setSelectedGroupId(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("importProfile.noGroup")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noGroup")}
|
||||
</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="create-new">
|
||||
{t("importProfile.createNewGroup")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.duplicateStrategyLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={duplicateStrategy}
|
||||
onValueChange={(value) => {
|
||||
setDuplicateStrategy(value as DuplicateStrategy);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rename">
|
||||
{t("importProfile.duplicateRename")}
|
||||
</SelectItem>
|
||||
<SelectItem value="skip">
|
||||
{t("importProfile.duplicateSkip")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowFingerprint((v) => !v)}
|
||||
aria-expanded={showFingerprint}
|
||||
>
|
||||
<AnimatedDisclosureChevron open={showFingerprint}>
|
||||
<LuChevronRight className="size-3.5" />
|
||||
</AnimatedDisclosureChevron>
|
||||
{t("importProfile.configureFingerprint")}
|
||||
</button>
|
||||
<AnimatedDisclosureContent
|
||||
open={showFingerprint}
|
||||
className="mt-3"
|
||||
>
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
</AnimatedDisclosureContent>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === "importing" && (
|
||||
<div className="space-y-4">
|
||||
{isImporting && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.importingTitle")}
|
||||
</h3>
|
||||
<Progress value={progressPercent} />
|
||||
{progress && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("importProfile.importProgress", {
|
||||
completed: progress.completed,
|
||||
total: progress.total,
|
||||
})}
|
||||
{progress.status === "importing" && (
|
||||
<> — {progress.name}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.resultsSummary", {
|
||||
imported: result.imported_count,
|
||||
skipped: result.skipped_count,
|
||||
failed: result.failed_count,
|
||||
})}
|
||||
</h3>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{result.results.map((item) => (
|
||||
<div
|
||||
key={item.source_path}
|
||||
className="flex items-center gap-2 p-1 text-sm"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium",
|
||||
item.status === "imported" && "text-success",
|
||||
item.status === "skipped" &&
|
||||
"text-muted-foreground",
|
||||
item.status === "failed" && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{item.status === "imported" &&
|
||||
t("importProfile.statusImported")}
|
||||
{item.status === "skipped" &&
|
||||
t("importProfile.statusSkipped")}
|
||||
{item.status === "failed" &&
|
||||
t("importProfile.statusFailed")}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.name || item.source_path}
|
||||
</span>
|
||||
{item.error && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-destructive">
|
||||
{translateBackendError(t, new Error(item.error))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-end gap-2",
|
||||
subPage
|
||||
? "mx-auto w-full max-w-2xl border-t border-border pt-2"
|
||||
? "mx-auto w-full max-w-3xl border-t border-border pt-2"
|
||||
: undefined,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -315,167 +315,261 @@ export function IntegrationsDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="mcp">
|
||||
{t("integrations.tabMcp")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="mcp">
|
||||
{t("integrations.tabMcp")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={(checked) =>
|
||||
void handleApiToggle(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={(checked) => void handleApiToggle(checked)}
|
||||
/>
|
||||
|
||||
{apiServerPort && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("integrations.apiRunningOn")}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
http://127.0.0.1:{apiServerPort}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apiServerPort && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("integrations.apiRunningOn")}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
http://127.0.0.1:{apiServerPort}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="w-24 font-mono"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isApiStarting ||
|
||||
apiServerPort === settings.api_port
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="w-24 font-mono"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isApiStarting || apiServerPort === settings.api_port
|
||||
}
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(t("integrations.apiInvalidPort"), {
|
||||
description: t(
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings },
|
||||
);
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>(
|
||||
"start_api_server",
|
||||
{ port },
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
t("integrations.apiInvalidPort"),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(t("integrations.apiStartFailed"), {
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings },
|
||||
);
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>(
|
||||
"start_api_server",
|
||||
{ port },
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(
|
||||
t("integrations.apiStartFailed"),
|
||||
{
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("integrations.apiUnknownError"),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
readOnly
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
}}
|
||||
>
|
||||
{showApiToken ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
{t("integrations.apiExampleRequest")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="mcp"
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpEnableDescription")}
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-warning">
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={settings.mcp_enabled && mcpConfig !== null}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={(checked) =>
|
||||
void handleMcpToggle(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpConfig && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.url")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
type={showMcpUrl ? "text" : "password"}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
className="pr-10 font-mono"
|
||||
className="pr-10 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -483,10 +577,10 @@ export function IntegrationsDialog({
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
setShowMcpUrl(!showMcpUrl);
|
||||
}}
|
||||
>
|
||||
{showApiToken ? (
|
||||
{showMcpUrl ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
@@ -494,164 +588,80 @@ export function IntegrationsDialog({
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
text={mcpUrl}
|
||||
successMessage={t("integrations.mcp.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiExampleRequest")}
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="mcp"
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpEnableDescription")}
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-warning">
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={settings.mcp_enabled && mcpConfig !== null}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={(checked) => void handleMcpToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpConfig && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.url")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showMcpUrl ? "text" : "password"}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
className="pr-10 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowMcpUrl(!showMcpUrl);
|
||||
}}
|
||||
>
|
||||
{showMcpUrl ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={mcpUrl}
|
||||
successMessage={t("integrations.mcp.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
|
||||
<AgentIcon category={agent.category} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{agent.display_name}
|
||||
</p>
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{categoryLabel(t, agent.category)}
|
||||
</p>
|
||||
</div>
|
||||
{agent.connected ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
|
||||
<LuCheck className="size-3" />
|
||||
{t("integrations.mcp.connected")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void handleRemoveAgent(agent)}
|
||||
aria-label={t(
|
||||
"integrations.mcp.removeAriaLabel",
|
||||
{
|
||||
name: agent.display_name,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</Button>
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
|
||||
<AgentIcon category={agent.category} />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void handleAddAgent(agent)}
|
||||
>
|
||||
{t("integrations.mcp.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{agent.display_name}
|
||||
</p>
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{categoryLabel(t, agent.category)}
|
||||
</p>
|
||||
</div>
|
||||
{agent.connected ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
|
||||
<LuCheck className="size-3" />
|
||||
{t("integrations.mcp.connected")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void handleRemoveAgent(agent)
|
||||
}
|
||||
aria-label={t(
|
||||
"integrations.mcp.removeAriaLabel",
|
||||
{
|
||||
name: agent.display_name,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void handleAddAgent(agent)}
|
||||
>
|
||||
{t("integrations.mcp.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
LuSquare,
|
||||
LuTrash2,
|
||||
LuTriangleAlert,
|
||||
LuUserSearch,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
@@ -89,6 +91,7 @@ import {
|
||||
getProfileIcon,
|
||||
isCrossOsProfile,
|
||||
} from "@/lib/browser-utils";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -107,11 +110,13 @@ import {
|
||||
DataTableActionBarAction,
|
||||
DataTableActionBarSelection,
|
||||
} from "./data-table-action-bar";
|
||||
import { Logo } from "./icons/logo";
|
||||
import MultipleSelector, { type Option } from "./multiple-selector";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { TrafficDetailsDialog } from "./traffic-details-dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
@@ -424,15 +429,7 @@ function DnsCell({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const level = profile.dns_blocklist ?? null;
|
||||
// Backend levels are: light, normal, pro, pro_plus, ultimate (+ null).
|
||||
// Keep the list ordered from least to most restrictive.
|
||||
const LEVELS: { value: string; labelKey: string }[] = [
|
||||
{ value: "light", labelKey: "dnsBlocklist.light" },
|
||||
{ value: "normal", labelKey: "dnsBlocklist.normal" },
|
||||
{ value: "pro", labelKey: "dnsBlocklist.pro" },
|
||||
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
|
||||
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
|
||||
];
|
||||
const LEVELS = DNS_BLOCKLIST_LEVELS;
|
||||
const currentLabel =
|
||||
level === null
|
||||
? null
|
||||
@@ -1168,6 +1165,12 @@ interface ProfilesDataTableProps {
|
||||
*/
|
||||
infoDialogProfile?: BrowserProfile | null;
|
||||
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
|
||||
/** Initial data load in flight — renders skeleton rows instead of "empty". */
|
||||
isLoading?: boolean;
|
||||
/** True when the app has zero profiles overall (not just a filtered view). */
|
||||
showOnboardingEmptyState?: boolean;
|
||||
onCreateProfile?: () => void;
|
||||
onImportProfiles?: () => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -1205,6 +1208,10 @@ export function ProfilesDataTable({
|
||||
onRemovePassword,
|
||||
infoDialogProfile,
|
||||
onInfoDialogProfileChange,
|
||||
isLoading = false,
|
||||
showOnboardingEmptyState = false,
|
||||
onCreateProfile,
|
||||
onImportProfiles,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -2391,13 +2398,42 @@ export function ProfilesDataTable({
|
||||
: void handleProfileLaunch(profile)
|
||||
}
|
||||
>
|
||||
{isLaunching || isStopping ? (
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : isRunning ? (
|
||||
<LuSquare className="size-3.5 fill-current" />
|
||||
) : (
|
||||
<LuPlay className="size-3.5 fill-current" />
|
||||
)}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isLaunching || isStopping ? (
|
||||
<motion.span
|
||||
key="spinner"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
</motion.span>
|
||||
) : isRunning ? (
|
||||
<motion.span
|
||||
key="stop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<LuSquare className="size-3.5 fill-current" />
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="play"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<LuPlay className="size-3.5 fill-current" />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</RippleButton>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -3172,14 +3208,97 @@ export function ProfilesDataTable({
|
||||
</TableHeader>
|
||||
<TableBody className="overflow-visible">
|
||||
{sortedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("profiles.table.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
isLoading ? (
|
||||
Array.from({ length: 8 }, (_, i) => (
|
||||
<TableRow
|
||||
key={`skeleton-${i}`}
|
||||
className="border-0!"
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="py-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-7 shrink-0 rounded-md" />
|
||||
<Skeleton
|
||||
className="h-3"
|
||||
style={{ width: `${30 + ((i * 17) % 40)}%` }}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : showOnboardingEmptyState ? (
|
||||
<TableRow className="border-0! hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="py-16"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<Logo className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("profiles.table.emptyTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("profiles.table.emptyHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{onCreateProfile && (
|
||||
<RippleButton size="sm" onClick={onCreateProfile}>
|
||||
{t("profiles.table.emptyCreate")}
|
||||
</RippleButton>
|
||||
)}
|
||||
{onImportProfiles && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onImportProfiles}
|
||||
>
|
||||
{t("profiles.table.emptyImport")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow className="border-0! hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="py-16"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="grid size-12 place-items-center rounded-full bg-muted/60">
|
||||
<LuUserSearch className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("profiles.table.emptyFilteredTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("profiles.table.emptyFilteredHint")}
|
||||
</p>
|
||||
</div>
|
||||
{onCreateProfile && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1"
|
||||
onClick={onCreateProfile}
|
||||
>
|
||||
{t("profiles.table.emptyCreate")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LuCookie,
|
||||
LuCopy,
|
||||
LuDownload,
|
||||
LuEraser,
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { SharedFingerprintConfigForm } from "@/components/shared-fingerprint-config-form";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ColorPicker,
|
||||
@@ -73,6 +75,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getProfileIcon } from "@/lib/browser-utils";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -126,6 +129,56 @@ function _OSIcon({ os }: { os: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function ClearOnCloseToggle({
|
||||
profile,
|
||||
isDisabled,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = React.useState(profile.clear_on_close === true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEnabled(profile.clear_on_close === true);
|
||||
}, [profile.clear_on_close]);
|
||||
|
||||
const toggle = async (next: boolean) => {
|
||||
setEnabled(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
await invoke("update_profile_clear_on_close", {
|
||||
profileId: profile.id,
|
||||
clearOnClose: next,
|
||||
});
|
||||
} catch (error) {
|
||||
setEnabled(!next);
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<LuEraser className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{t("clearOnClose.label")}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("clearOnClose.description")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={enabled}
|
||||
disabled={saving || isDisabled}
|
||||
onCheckedChange={(v) => void toggle(v === true)}
|
||||
aria-label={t("clearOnClose.label")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/50 px-3 py-2.5">
|
||||
@@ -976,6 +1029,10 @@ function ProfileInfoLayout({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!profile.ephemeral && !profile.password_protected && (
|
||||
<ClearOnCloseToggle profile={profile} isDisabled={isDisabled} />
|
||||
)}
|
||||
|
||||
{profile.created_by_email && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
@@ -2320,11 +2377,10 @@ export function ProfileDnsBlocklistDialog({
|
||||
|
||||
const options = [
|
||||
{ value: "", label: t("dnsBlocklist.none") },
|
||||
{ value: "light", label: t("dnsBlocklist.light") },
|
||||
{ value: "normal", label: t("dnsBlocklist.normal") },
|
||||
{ value: "pro", label: t("dnsBlocklist.pro") },
|
||||
{ value: "pro_plus", label: t("dnsBlocklist.proPlus") },
|
||||
{ value: "ultimate", label: t("dnsBlocklist.ultimate") },
|
||||
...DNS_BLOCKLIST_LEVELS.map((l) => ({
|
||||
value: l.value as string,
|
||||
label: t(l.labelKey),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
+51
-87
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
@@ -7,12 +8,15 @@ import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
||||
import {
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { launchDonutClone } from "@/lib/donut-physics";
|
||||
import { MOTION_SPRING_POSITION } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -31,11 +35,6 @@ export type AppPage =
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
const CLICK_WINDOW_MS = 2000;
|
||||
const GRAVITY = 2200;
|
||||
const BOUNCE_DAMPING = 0.6;
|
||||
const INITIAL_HORIZONTAL_SPEED = 350;
|
||||
const SPIN_SPEED = 720;
|
||||
const MIN_BOUNCE_VELOCITY = 60;
|
||||
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
|
||||
|
||||
function useLogoEasterEgg({
|
||||
@@ -64,74 +63,15 @@ function useLogoEasterEgg({
|
||||
}
|
||||
});
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const cancelFallRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const triggerFall = useCallback(() => {
|
||||
const el = logoRef.current;
|
||||
if (!el || isFalling) return;
|
||||
setIsFalling(true);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.left = `${startX}px`;
|
||||
clone.style.top = `${startY}px`;
|
||||
clone.style.zIndex = "9999";
|
||||
clone.style.pointerEvents = "none";
|
||||
clone.style.margin = "0";
|
||||
document.body.appendChild(clone);
|
||||
el.style.visibility = "hidden";
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vy = -500;
|
||||
// Roll right first, bounce off the right wall, then escape the left.
|
||||
let vx = INITIAL_HORIZONTAL_SPEED;
|
||||
let rotation = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const animate = (time: number) => {
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
|
||||
// Read live so a mid-animation window resize moves the floor/wall.
|
||||
const floorY = window.innerHeight;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
|
||||
|
||||
const currentBottom = startY + y + rect.height;
|
||||
if (currentBottom >= floorY && vy > 0) {
|
||||
y = floorY - startY - rect.height;
|
||||
vy =
|
||||
Math.abs(vy) > MIN_BOUNCE_VELOCITY
|
||||
? -Math.abs(vy) * BOUNCE_DAMPING
|
||||
: -MIN_BOUNCE_VELOCITY * 3;
|
||||
}
|
||||
|
||||
// Right-wall bounce: hit, reverse horizontal velocity (with a tiny
|
||||
// damping), and keep rolling. Left wall has no bounce — the donut
|
||||
// exits the window off the left edge.
|
||||
const currentRight = startX + x + rect.width;
|
||||
if (currentRight >= rightWall && vx > 0) {
|
||||
x = rightWall - startX - rect.width;
|
||||
vx = -Math.abs(vx) * 0.9;
|
||||
}
|
||||
|
||||
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
|
||||
|
||||
const offScreenLeft = startX + x + rect.width < -200;
|
||||
const offScreenBottom = startY + y > floorY + 100;
|
||||
const offScreenTop = startY + y + rect.height < -200;
|
||||
|
||||
if (offScreenLeft || offScreenBottom || offScreenTop) {
|
||||
clone.remove();
|
||||
cancelFallRef.current = launchDonutClone(el, {
|
||||
onExit: () => {
|
||||
try {
|
||||
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
|
||||
} catch {
|
||||
@@ -139,16 +79,13 @@ function useLogoEasterEgg({
|
||||
}
|
||||
setIsHidden(true);
|
||||
setIsFalling(false);
|
||||
return;
|
||||
}
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
},
|
||||
});
|
||||
}, [isFalling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
cancelFallRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -236,6 +173,19 @@ function useLogoEasterEgg({
|
||||
interface RailNavProps {
|
||||
currentPage: AppPage;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
/** Shared-element indicator that slides between the active rail items. */
|
||||
function ActiveIndicator() {
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
layoutId="rail-indicator"
|
||||
transition={MOTION_SPRING_POSITION}
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RailItem {
|
||||
@@ -275,7 +225,11 @@ const MORE_ITEMS: MoreMenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
export function RailNav({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
onOpenAbout,
|
||||
}: RailNavProps) {
|
||||
const { t } = useTranslation();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const {
|
||||
@@ -358,12 +312,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
{active && <ActiveIndicator />}
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -413,12 +362,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{currentPage === "settings" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
{currentPage === "settings" && <ActiveIndicator />}
|
||||
<GoGear className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -435,7 +379,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border bg-card p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
|
||||
<div className="surface-material-card absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
|
||||
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
|
||||
<button
|
||||
key={page}
|
||||
@@ -459,6 +403,26 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
onOpenAbout();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent"
|
||||
>
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded bg-muted text-muted-foreground">
|
||||
<LuInfo className="size-3" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{t("rail.more.about")}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{t("rail.more.aboutHint")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
+753
-611
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,11 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { applyThemeColors, clearThemeColors } from "@/lib/themes";
|
||||
import {
|
||||
applyThemeColors,
|
||||
clearThemeColors,
|
||||
withThemeTransition,
|
||||
} from "@/lib/themes";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
@@ -54,11 +58,13 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
|
||||
const setTheme = useCallback((newTheme: string) => {
|
||||
setThemeState(newTheme);
|
||||
if (newTheme === "custom") {
|
||||
applyClassToHtml("dark");
|
||||
} else {
|
||||
applyClassToHtml(newTheme);
|
||||
}
|
||||
withThemeTransition(() => {
|
||||
if (newTheme === "custom") {
|
||||
applyClassToHtml("dark");
|
||||
} else {
|
||||
applyClassToHtml(newTheme);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load initial theme from Tauri settings
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSearch, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -17,6 +18,13 @@ import type {
|
||||
ValueType,
|
||||
} from "recharts/types/component/DefaultTooltipContent";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +32,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
@@ -37,7 +46,10 @@ import {
|
||||
TooltipTrigger,
|
||||
Tooltip as UITooltip,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { FilteredTrafficStats } from "@/types";
|
||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
type TimePeriod =
|
||||
| "1m"
|
||||
@@ -157,24 +169,28 @@ export function TrafficDetailsDialog({
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
|
||||
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
|
||||
const [showClearConfirm, setShowClearConfirm] = React.useState(false);
|
||||
const [isClearing, setIsClearing] = React.useState(false);
|
||||
const [domainSearch, setDomainSearch] = React.useState("");
|
||||
|
||||
const fetchStats = React.useCallback(async () => {
|
||||
if (!profileId) return;
|
||||
try {
|
||||
const seconds = getSecondsForPeriod(timePeriod);
|
||||
const filteredStats = await invoke<FilteredTrafficStats | null>(
|
||||
"get_traffic_stats_for_period",
|
||||
{ profileId, seconds },
|
||||
);
|
||||
setStats(filteredStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch traffic stats:", error);
|
||||
}
|
||||
}, [profileId, timePeriod]);
|
||||
|
||||
// Fetch stats periodically - now uses filtered API
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !profileId) return;
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const seconds = getSecondsForPeriod(timePeriod);
|
||||
const filteredStats = await invoke<FilteredTrafficStats | null>(
|
||||
"get_traffic_stats_for_period",
|
||||
{ profileId, seconds },
|
||||
);
|
||||
setStats(filteredStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch traffic stats:", error);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchStats();
|
||||
const interval = setInterval(() => {
|
||||
void fetchStats();
|
||||
@@ -185,7 +201,22 @@ export function TrafficDetailsDialog({
|
||||
// Clear stats from memory when dialog closes to free up memory
|
||||
setStats(null);
|
||||
};
|
||||
}, [isOpen, profileId, timePeriod]);
|
||||
}, [isOpen, profileId, fetchStats]);
|
||||
|
||||
const handleClearHistory = React.useCallback(async () => {
|
||||
if (!profileId) return;
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await invoke("clear_profile_traffic_stats", { profileId });
|
||||
setStats(null);
|
||||
setShowClearConfirm(false);
|
||||
await fetchStats();
|
||||
} catch (error) {
|
||||
toast.error(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [profileId, fetchStats, t]);
|
||||
|
||||
// Transform data for chart (already filtered by backend)
|
||||
const chartData = React.useMemo(() => {
|
||||
@@ -231,24 +262,31 @@ export function TrafficDetailsDialog({
|
||||
[t],
|
||||
);
|
||||
|
||||
// Top domains sorted by total traffic
|
||||
const topDomainsByTraffic = React.useMemo(() => {
|
||||
// Domains matching the search query (empty query = all).
|
||||
const filteredDomains = React.useMemo(() => {
|
||||
if (!stats?.domains) return [];
|
||||
return Object.values(stats.domains)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
|
||||
)
|
||||
.slice(0, 10);
|
||||
}, [stats]);
|
||||
const q = domainSearch.trim().toLowerCase();
|
||||
const all = Object.values(stats.domains);
|
||||
return q ? all.filter((d) => d.domain.toLowerCase().includes(q)) : all;
|
||||
}, [stats, domainSearch]);
|
||||
|
||||
// Top domains sorted by total traffic. When searching, show every match
|
||||
// (not just the top 10) so the queried domain is never hidden below the cut.
|
||||
const topDomainsByTraffic = React.useMemo(() => {
|
||||
const sorted = [...filteredDomains].sort(
|
||||
(a, b) =>
|
||||
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
|
||||
);
|
||||
return domainSearch.trim() ? sorted : sorted.slice(0, 10);
|
||||
}, [filteredDomains, domainSearch]);
|
||||
|
||||
// Top domains sorted by request count
|
||||
const topDomainsByRequests = React.useMemo(() => {
|
||||
if (!stats?.domains) return [];
|
||||
return Object.values(stats.domains)
|
||||
.sort((a, b) => b.request_count - a.request_count)
|
||||
.slice(0, 10);
|
||||
}, [stats]);
|
||||
const sorted = [...filteredDomains].sort(
|
||||
(a, b) => b.request_count - a.request_count,
|
||||
);
|
||||
return domainSearch.trim() ? sorted : sorted.slice(0, 10);
|
||||
}, [filteredDomains, domainSearch]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -257,364 +295,461 @@ export function TrafficDetailsDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[min(56rem,calc(100%-4rem))]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
— {profileName}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[min(56rem,calc(100%-4rem))] flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<DialogTitle>
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
— {profileName}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!stats}
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
<LuTrash2 className="mr-1.5 size-3.5" />
|
||||
{t("traffic.clearHistory")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[60vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => {
|
||||
setTimePeriod(v as TimePeriod);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1m">{t("traffic.last1m")}</SelectItem>
|
||||
<SelectItem value="5m">{t("traffic.last5m")}</SelectItem>
|
||||
<SelectItem value="30m">{t("traffic.last30m")}</SelectItem>
|
||||
<SelectItem value="1h">{t("traffic.last1h")}</SelectItem>
|
||||
<SelectItem value="2h">{t("traffic.last2h")}</SelectItem>
|
||||
<SelectItem value="4h">{t("traffic.last4h")}</SelectItem>
|
||||
<SelectItem value="1d">{t("traffic.last1d")}</SelectItem>
|
||||
<SelectItem value="7d">{t("traffic.last7d")}</SelectItem>
|
||||
<SelectItem value="30d">{t("traffic.last30d")}</SelectItem>
|
||||
<SelectItem value="all">{t("traffic.allTime")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<AnimatedTabs
|
||||
defaultValue="overview"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
<AnimatedTabsTrigger value="overview">
|
||||
{t("traffic.tabOverview")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="domains">
|
||||
{t("traffic.tabTopDomains")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<div className="h-[clamp(200px,28vh,360px)] w-full">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sentGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
<AnimatedTabsContent
|
||||
value="overview"
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="h-[56vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => {
|
||||
setTimePeriod(v as TimePeriod);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1m">
|
||||
{t("traffic.last1m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="5m">
|
||||
{t("traffic.last5m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30m">
|
||||
{t("traffic.last30m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="1h">
|
||||
{t("traffic.last1h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="2h">
|
||||
{t("traffic.last2h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="4h">
|
||||
{t("traffic.last4h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="1d">
|
||||
{t("traffic.last1d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
{t("traffic.last7d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
{t("traffic.last30d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
{t("traffic.allTime")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="h-[clamp(200px,28vh,360px)] w-full">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.5}
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sentGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="receivedGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.1}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={(t) =>
|
||||
new Date(t * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="receivedGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.5}
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytesPerSecond(v)}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
width={60}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.1}
|
||||
<Tooltip content={renderTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sent"
|
||||
stackId="1"
|
||||
stroke="var(--chart-1)"
|
||||
fill="url(#sentGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={(t) =>
|
||||
new Date(t * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytesPerSecond(v)}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip content={renderTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sent"
|
||||
stackId="1"
|
||||
stroke="var(--chart-1)"
|
||||
fill="url(#sentGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="received"
|
||||
stackId="1"
|
||||
stroke="var(--chart-2)"
|
||||
fill="url(#receivedGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="received"
|
||||
stackId="1"
|
||||
stroke="var(--chart-2)"
|
||||
fill="url(#receivedGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLegend")}
|
||||
</span>
|
||||
<div className="mt-2 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLegend")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLegend")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLegend")}
|
||||
</span>
|
||||
|
||||
{/* Period Stats - now uses backend-computed values */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-1">
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-2">
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period Stats - now uses backend-computed values */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-1">
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-2">
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeTraffic")}
|
||||
</span>{" "}
|
||||
{formatBytes(
|
||||
(stats?.total_bytes_sent ?? 0) +
|
||||
(stats?.total_bytes_received ?? 0),
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeRequests")}
|
||||
</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeTraffic")}
|
||||
</span>{" "}
|
||||
{formatBytes(
|
||||
(stats?.total_bytes_sent ?? 0) +
|
||||
(stats?.total_bytes_received ?? 0),
|
||||
{/* Disclaimer about proxy/VPN traffic calculation */}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t("traffic.proxyDisclaimer")}
|
||||
</p>
|
||||
|
||||
{/* No data state (overview) */}
|
||||
{!stats && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeRequests")}
|
||||
</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
{/* Disclaimer about proxy/VPN traffic calculation */}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t("traffic.proxyDisclaimer")}
|
||||
</p>
|
||||
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnSent")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnReceived")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right text-chart-1">
|
||||
{formatBytes(domain.bytes_sent)}
|
||||
</span>
|
||||
<span className="text-right text-chart-2">
|
||||
{formatBytes(domain.bytes_received)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AnimatedTabsContent
|
||||
value="domains"
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="h-[56vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
<div className="relative">
|
||||
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={domainSearch}
|
||||
onChange={(e) => setDomainSearch(e.target.value)}
|
||||
placeholder={t("traffic.searchDomains")}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Domains by Requests */}
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnTotal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_100px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
{domainSearch.trim() && topDomainsByTraffic.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("traffic.noDomainMatch")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatBytes(
|
||||
domain.bytes_sent + domain.bytes_received,
|
||||
)}
|
||||
{t("traffic.columnSent")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnReceived")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right text-chart-1">
|
||||
{formatBytes(domain.bytes_sent)}
|
||||
</span>
|
||||
<span className="text-right text-chart-2">
|
||||
{formatBytes(domain.bytes_received)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Unique IPs */}
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
||||
</h3>
|
||||
<FadingScrollArea className="max-h-[clamp(120px,15vh,240px)] p-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.unique_ips.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
{/* Top Domains by Requests */}
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnTotal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_100px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatBytes(
|
||||
domain.bytes_sent + domain.bytes_received,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadingScrollArea>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* No data state */}
|
||||
{!stats && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
{/* Unique IPs */}
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.uniqueIps", {
|
||||
count: stats.unique_ips.length,
|
||||
})}
|
||||
</h3>
|
||||
<FadingScrollArea className="max-h-[clamp(120px,15vh,240px)] p-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.unique_ips.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</FadingScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No data state (domains) */}
|
||||
{!stats && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showClearConfirm}
|
||||
onClose={() => setShowClearConfirm(false)}
|
||||
onConfirm={handleClearHistory}
|
||||
title={t("traffic.clearHistoryTitle")}
|
||||
description={t("traffic.clearHistoryDescription", {
|
||||
name: profileName ?? "",
|
||||
})}
|
||||
confirmButtonText={t("traffic.clearHistory")}
|
||||
isLoading={isClearing}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -162,8 +162,12 @@ function SubPageContent({
|
||||
<motion.div
|
||||
data-slot="sub-page"
|
||||
data-sub-page="true"
|
||||
initial={false}
|
||||
animate={{ opacity: 1 }}
|
||||
// Sub-pages enter with a short rise+fade so rail navigation reads as a
|
||||
// transition instead of a hard cut. Same axis for every page (spatial
|
||||
// consistency); the outgoing page unmounts under the incoming fade.
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
@@ -177,7 +181,11 @@ function SubPageContent({
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
overflow: "auto",
|
||||
// The sub-page wrapper never scrolls itself — exactly one inner
|
||||
// element per page owns scrolling (full-width, so the wheel works
|
||||
// over side gutters too). "auto" here created a competing,
|
||||
// never-engaged scroll container.
|
||||
overflow: "hidden",
|
||||
background: "var(--background)",
|
||||
containerType: "inline-size",
|
||||
}}
|
||||
@@ -258,7 +266,7 @@ function DialogContent({
|
||||
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
|
||||
// gutter even when callers override max-w-*: tailwind-merge drops
|
||||
// a base max-w in favor of the caller's, but leaves width alone.
|
||||
"fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6 shadow-lg",
|
||||
"surface-material fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md surface-material-popover border p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -232,7 +232,7 @@ function DropdownMenuSubContent({
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -32,7 +32,7 @@ function PopoverContent({
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -61,7 +61,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md surface-material-popover border text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
Reference in New Issue
Block a user