refactor: ui refresh

This commit is contained in:
zhom
2026-07-18 11:42:07 +04:00
parent 4f7910dd23
commit f1664b2950
64 changed files with 7472 additions and 2827 deletions
+204
View File
@@ -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
View File
@@ -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>
+12 -5
View File
@@ -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>
);
+22
View File
@@ -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>
);
}
+9 -15
View File
@@ -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>
+282 -54
View File
@@ -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")} &middot;{" "}
{formatSize(status.file_size_bytes)} &middot;{" "}
{formatDate(status.last_updated)}
</div>
)}
</div>
{status.is_cached && (
<div className="text-xs text-muted-foreground">
{status.entry_count.toLocaleString()}{" "}
{t("dnsBlocklist.domains")} &middot;{" "}
{formatSize(status.file_size_bytes)} &middot;{" "}
{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>
);
+434 -340
View File
@@ -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,
)}
>
+299 -289
View File
@@ -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>
+143 -24
View File
@@ -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 && (
+61 -5
View File
@@ -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
View File
@@ -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>
</>
)}
File diff suppressed because it is too large Load Diff
+12 -6
View File
@@ -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
+494 -359
View File
@@ -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>
);
+12 -4
View File
@@ -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}
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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,
+13
View File
@@ -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 };