feat: full ui refresh

This commit is contained in:
zhom
2026-05-11 23:12:16 +04:00
parent 739b5e2449
commit ed3c209f35
46 changed files with 5956 additions and 1553 deletions
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCloud, LuLogOut, LuRefreshCw, LuUser } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface AccountPageProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
onOpenSignIn: () => void;
}
export function AccountPage({
isOpen,
onClose,
subPage,
onOpenSignIn,
}: AccountPageProps) {
const { t } = useTranslation();
const { user, isLoggedIn, logout, refreshProfile } = useCloudAuth();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refreshProfile();
showSuccessToast(t("account.refreshed"));
} catch (e) {
showErrorToast(String(e));
} finally {
setIsRefreshing(false);
}
};
const handleLogout = async () => {
try {
await logout();
showSuccessToast(t("account.loggedOut"));
} catch (e) {
showErrorToast(String(e));
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center gap-3">
<div className="grid place-items-center w-12 h-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="w-6 h-6" />
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
<>
<h2 className="text-base font-semibold truncate">
{user.email}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.plan", {
plan: user.plan,
period: user.planPeriod ?? "—",
})}
</p>
</>
) : (
<>
<h2 className="text-base font-semibold">
{t("account.signedOut")}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.signedOutDescription")}
</p>
</>
)}
</div>
</div>
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.plan")}
</p>
<p className="mt-0.5 font-medium uppercase">{user.plan}</p>
</div>
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.status")}
</p>
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
</div>
{user.teamRole && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.teamRole")}
</p>
<p className="mt-0.5">{user.teamRole}</p>
</div>
)}
{user.planPeriod && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.period")}
</p>
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
void handleRefresh();
}}
disabled={isRefreshing}
className="h-8 text-xs gap-1.5"
>
<LuRefreshCw className="w-3 h-3" />
{t("account.refresh")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
void handleLogout();
}}
className="h-8 text-xs gap-1.5"
>
<LuLogOut className="w-3 h-3" />
{t("account.logout")}
</Button>
</>
) : (
<Button
size="sm"
onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5"
>
<LuCloud className="w-3 h-3" />
{t("account.signIn")}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -568,7 +568,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[90vh] flex flex-col">
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
+2 -2
View File
@@ -255,7 +255,7 @@ export function UnifiedToast(props: ToastProps) {
</div>
<div className="w-full bg-muted rounded-full h-1.5">
<div
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
style={{ width: `${progress.percentage}%` }}
/>
</div>
@@ -275,7 +275,7 @@ export function UnifiedToast(props: ToastProps) {
<div className="flex items-center space-x-2">
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
<div
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
+28 -1
View File
@@ -3,7 +3,9 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -16,6 +18,8 @@ import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
interface DeviceCodeVerifyDialogProps {
isOpen: boolean;
onClose: (loginOccurred?: boolean) => void;
@@ -36,6 +40,19 @@ export function DeviceCodeVerifyDialog({
const { exchangeDeviceCode } = useCloudAuth();
const [linkCode, setLinkCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [isOpeningLogin, setIsOpeningLogin] = useState(false);
const handleOpenLogin = async () => {
setIsOpeningLogin(true);
try {
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
} catch (error) {
console.error("Failed to open login link:", error);
showErrorToast(String(error));
} finally {
setIsOpeningLogin(false);
}
};
// Reset the field when the dialog reopens so a stale code from a
// previous attempt doesn't auto-populate.
@@ -75,12 +92,22 @@ export function DeviceCodeVerifyDialog({
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.cloud.verifyAndLogin")}</DialogTitle>
<DialogTitle>{t("sync.cloud.signInTitle")}</DialogTitle>
<DialogDescription>
{t("sync.cloud.deviceLinkInstructions")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<Button
type="button"
variant="outline"
onClick={() => void handleOpenLogin()}
disabled={isOpeningLogin}
className="w-full gap-1.5"
>
<LuExternalLink className="w-3.5 h-3.5" />
{t("sync.cloud.openLogin")}
</Button>
<div className="space-y-2">
<Label htmlFor="device-link-code">
{t("sync.cloud.linkCodeLabel")}
+23 -15
View File
@@ -96,12 +96,14 @@ interface ExtensionManagementDialogProps {
isOpen: boolean;
onClose: () => void;
limitedMode: boolean;
subPage?: boolean;
}
export function ExtensionManagementDialog({
isOpen,
onClose,
limitedMode,
subPage,
}: ExtensionManagementDialogProps) {
const { t } = useTranslation();
const [extensions, setExtensions] = useState<Extension[]>([]);
@@ -526,18 +528,22 @@ export function ExtensionManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuPuzzle className="w-5 h-5" />
{t("extensions.title")}
{limitedMode && <ProBadge />}
</DialogTitle>
<DialogDescription>{t("extensions.description")}</DialogDescription>
</DialogHeader>
{!subPage && (
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuPuzzle className="w-5 h-5" />
{t("extensions.title")}
{limitedMode && <ProBadge />}
</DialogTitle>
<DialogDescription>
{t("extensions.description")}
</DialogDescription>
</DialogHeader>
)}
<ScrollArea className="overflow-y-auto flex-1">
<ScrollArea className="overflow-y-auto flex-1 scroll-fade">
<div className="relative">
{limitedMode && (
<>
@@ -985,11 +991,13 @@ export function ExtensionManagementDialog({
</div>
</ScrollArea>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
{!subPage && (
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
+18 -12
View File
@@ -93,12 +93,14 @@ interface GroupManagementDialogProps {
isOpen: boolean;
onClose: () => void;
onGroupManagementComplete: () => void;
subPage?: boolean;
}
export function GroupManagementDialog({
isOpen,
onClose,
onGroupManagementComplete,
subPage,
}: GroupManagementDialogProps) {
const { t } = useTranslation();
const [groups, setGroups] = useState<GroupWithCount[]>([]);
@@ -249,14 +251,16 @@ export function GroupManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("groups.management")}</DialogTitle>
<DialogDescription>
{t("groups.noGroupDescription")}
</DialogDescription>
</DialogHeader>
{!subPage && (
<DialogHeader>
<DialogTitle>{t("groups.management")}</DialogTitle>
<DialogDescription>
{t("groups.noGroupDescription")}
</DialogDescription>
</DialogHeader>
)}
<div className="space-y-4">
{/* Create new group button */}
@@ -418,11 +422,13 @@ export function GroupManagementDialog({
)}
</div>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
{!subPage && (
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
+277 -311
View File
@@ -1,245 +1,290 @@
import { useCallback, useEffect, useRef, useState } from "react";
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import {
LuCloud,
LuPlug,
LuPuzzle,
LuSearch,
LuUsers,
LuX,
} from "react-icons/lu";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
import { getCurrentOS } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import type { GroupWithCount } from "@/types";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Input } from "./ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
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";
const HOLD_MS = 150;
const DRAG_THRESHOLD_PX = 3;
function useLogoEasterEgg() {
const clickTimestamps = useRef<number[]>([]);
const [isPressed, setIsPressed] = useState(false);
const [wobbleKey, setWobbleKey] = useState(0);
const [isFalling, setIsFalling] = useState(false);
const [isHidden, setIsHidden] = useState(() => {
try {
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
} catch {
return false;
}
});
const logoRef = useRef<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(0);
const isTextInputTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
const el = target.closest(
"input, select, textarea, [contenteditable=''], [contenteditable='true']",
);
return el !== 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 floorY = window.innerHeight;
const leftWall = 0;
const rightWall = window.innerWidth;
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;
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;
vy += GRAVITY * dt;
x += vx * dt;
y += vy * dt;
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
// Floor bounce
const currentBottom = startY + y + rect.height;
if (currentBottom >= floorY && vy > 0) {
y = floorY - startY - rect.height;
if (Math.abs(vy) > MIN_BOUNCE_VELOCITY) {
vy = -Math.abs(vy) * BOUNCE_DAMPING;
} else {
vy = -MIN_BOUNCE_VELOCITY * 3;
}
}
// Left wall bounce only — right wall lets it fly off screen
const currentLeft = startX + x;
if (currentLeft <= leftWall && vx < 0) {
x = leftWall - startX;
vx = Math.abs(vx) * 1.1;
}
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
// Only end when fully off-screen vertically (bounced out the top or flew off bottom somehow)
const currentTop = startY + y;
const offScreenRight = startX + x > rightWall + 50;
const offScreenBottom = currentTop > floorY + 100;
const offScreenTop = currentTop + rect.height < -200;
if (offScreenRight || offScreenBottom || offScreenTop) {
clone.remove();
try {
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
} catch {
// ignore
}
setIsHidden(true);
setIsFalling(false);
return;
}
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
}, [isFalling]);
useEffect(() => {
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, []);
const handleClick = useCallback(() => {
if (isFalling || isHidden) return;
const now = Date.now();
clickTimestamps.current = clickTimestamps.current.filter(
(t) => now - t < CLICK_WINDOW_MS,
);
clickTimestamps.current.push(now);
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
clickTimestamps.current = [];
triggerFall();
} else {
setWobbleKey((k) => k + 1);
}
}, [isFalling, isHidden, triggerFall]);
return {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
};
}
const ALL_FILTER_ID = "__all__";
interface Props {
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
onGroupManagementDialogOpen: (open: boolean) => void;
onImportProfileDialogOpen: (open: boolean) => void;
onCreateProfileDialogOpen: (open: boolean) => void;
onSyncConfigDialogOpen: (open: boolean) => void;
onIntegrationsDialogOpen: (open: boolean) => void;
onExtensionManagementDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
groups: GroupWithCount[];
selectedGroupId: string | null;
onGroupSelect: (groupId: string) => void;
pageTitle?: string;
}
const HomeHeader = ({
onSettingsDialogOpen,
onProxyManagementDialogOpen,
onGroupManagementDialogOpen,
onImportProfileDialogOpen,
onCreateProfileDialogOpen,
onSyncConfigDialogOpen,
onIntegrationsDialogOpen,
onExtensionManagementDialogOpen,
searchQuery,
onSearchQueryChange,
groups,
selectedGroupId,
onGroupSelect,
pageTitle,
}: Props) => {
const { t } = useTranslation();
const {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
} = useLogoEasterEgg();
const [platform, setPlatform] = useState<string>("macos");
useEffect(() => {
setPlatform(getCurrentOS());
}, []);
const isMacOS = platform === "macos";
const showProfileToolbar = !pageTitle;
const totalProfiles = useMemo(
() => groups.reduce((sum, g) => sum + g.count, 0),
[groups],
);
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
const holdTimeoutRef = useRef<number | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const dragStartedRef = useRef(false);
const activePointerIdRef = useRef<number | null>(null);
const dragRootRef = useRef<HTMLDivElement | null>(null);
const clearHold = useCallback(() => {
if (holdTimeoutRef.current !== null) {
window.clearTimeout(holdTimeoutRef.current);
holdTimeoutRef.current = null;
}
}, []);
const beginDrag = useCallback(() => {
if (dragStartedRef.current) return;
dragStartedRef.current = true;
clearHold();
void getCurrentWindow().startDragging();
}, [clearHold]);
useEffect(() => {
return () => {
clearHold();
};
}, [clearHold]);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (isTextInputTarget(e.target)) return;
dragStartedRef.current = false;
dragStartRef.current = { x: e.clientX, y: e.clientY };
activePointerIdRef.current = e.pointerId;
clearHold();
holdTimeoutRef.current = window.setTimeout(() => {
holdTimeoutRef.current = null;
beginDrag();
}, HOLD_MS);
},
[beginDrag, clearHold],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (
dragStartedRef.current ||
dragStartRef.current === null ||
activePointerIdRef.current !== e.pointerId
) {
return;
}
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
if (Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
beginDrag();
}
},
[beginDrag],
);
const handlePointerEnd = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (activePointerIdRef.current !== e.pointerId) return;
clearHold();
dragStartRef.current = null;
activePointerIdRef.current = null;
dragStartedRef.current = false;
},
[clearHold],
);
// Horizontal scroll fades for the group filter strip — when the user
// has more groups than fit, the right edge fades to hint at overflow.
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
const [groupsFadeLeft, setGroupsFadeLeft] = useState(false);
const [groupsFadeRight, setGroupsFadeRight] = useState(false);
useEffect(() => {
const el = groupsScrollRef.current;
if (!el) return;
const update = () => {
setGroupsFadeLeft(el.scrollLeft > 1);
setGroupsFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, []);
return (
<div className="flex justify-between items-center mt-6">
<div className="flex gap-3 items-center">
{!isHidden ? (
<button
ref={logoRef}
type="button"
className="p-1 cursor-pointer select-none"
onClick={handleClick}
onPointerDown={() => {
setIsPressed(true);
}}
onPointerUp={() => {
setIsPressed(false);
}}
onPointerLeave={() => {
setIsPressed(false);
<div
ref={dragRootRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
className="flex items-center gap-2 h-11 px-3 border-b border-border bg-card select-none"
>
{isMacOS && (
<div
aria-hidden="true"
className="flex items-center gap-[7px] mr-1 shrink-0"
>
{/* Reserve space for the macOS native traffic lights — the OS draws
the colored buttons here through the transparent titlebar. */}
<div className="w-[11px] h-[11px] rounded-full" />
<div className="w-[11px] h-[11px] rounded-full" />
<div className="w-[11px] h-[11px] rounded-full" />
</div>
)}
{pageTitle ? (
<span className="text-xs font-semibold text-card-foreground ml-2">
{pageTitle}
</span>
) : null}
{showProfileToolbar && (
<div className="relative flex-1 min-w-0 flex items-center">
{groupsFadeLeft && (
<button
type="button"
aria-label={t("header.scrollGroupsLeft")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: -el.clientWidth * 0.6,
behavior: "smooth",
});
}}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-5 h-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
>
<LuChevronLeft className="w-3 h-3" />
</button>
)}
<div
ref={groupsScrollRef}
className="flex items-center gap-3 ml-2 overflow-x-auto scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
style={{
paddingLeft: groupsFadeLeft ? 22 : 0,
paddingRight: groupsFadeRight ? 22 : 0,
}}
>
<Logo
key={wobbleKey}
className={cn(
"w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110",
isPressed && "scale-90",
!isFalling &&
!isPressed &&
wobbleKey > 0 &&
"animate-[wiggle_0.3s_ease-in-out]",
)}
/>
</button>
) : (
<div className="p-1 w-10 h-10" />
)}
<CardTitle>Donut</CardTitle>
</div>
<div className="flex gap-2 items-center">
<div className="relative">
{/* "All" filter — shows every profile regardless of group. */}
{(() => {
const active = selectedGroupId === ALL_FILTER_ID;
return (
<button
key="__all__"
type="button"
onClick={() => {
onGroupSelect(ALL_FILTER_ID);
}}
className={cn(
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
active
? "text-foreground font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{t("groups.all")}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{totalProfiles}
</span>
</button>
);
})()}
{groups.map((group) => {
const active = selectedGroupId === group.id;
const label =
group.id === "default" ? t("groups.defaultGroup") : group.name;
return (
<button
key={group.id}
type="button"
onClick={() => {
onGroupSelect(active ? ALL_FILTER_ID : group.id);
}}
className={cn(
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
active
? "text-foreground font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{label}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
</button>
);
})}
</div>
{groupsFadeRight && (
<button
type="button"
aria-label={t("header.scrollGroupsRight")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: el.clientWidth * 0.6,
behavior: "smooth",
});
}}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-5 h-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
>
<LuChevronRight className="w-3 h-3" />
</button>
)}
</div>
)}
{!showProfileToolbar && <div className="flex-1" />}
{showProfileToolbar && (
<div className="relative shrink-0">
<Input
type="text"
placeholder={t("header.searchPlaceholder")}
@@ -247,122 +292,43 @@ const HomeHeader = ({
onChange={(e) => {
onSearchQueryChange(e.target.value);
}}
className="pr-8 pl-10 w-48"
className="pr-7 pl-8 w-52 h-7 text-xs"
/>
<LuSearch className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-muted-foreground" />
{searchQuery && (
<LuSearch className="absolute left-2.5 top-1/2 w-3.5 h-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
{searchQuery ? (
<button
type="button"
onClick={() => {
onSearchQueryChange("");
}}
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
className="absolute right-1.5 top-1/2 p-0.5 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label={t("header.clearSearch")}
>
<LuX className="w-4 h-4 text-muted-foreground hover:text-foreground" />
<LuX className="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
)}
) : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center h-[36px] border-foreground/20 hover:text-foreground"
>
<GoKebabHorizontal className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{t("header.moreActions")}</TooltipContent>
</Tooltip>
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onSettingsDialogOpen(true);
}}
>
<GoGear className="mr-2 w-4 h-4" />
{t("header.menu.settings")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onProxyManagementDialogOpen(true);
}}
>
<FiWifi className="mr-2 w-4 h-4" />
{t("header.menu.proxies")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onGroupManagementDialogOpen(true);
}}
>
<LuUsers className="mr-2 w-4 h-4" />
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onExtensionManagementDialogOpen(true);
}}
>
<LuPuzzle className="mr-2 w-4 h-4" />
{t("header.menu.extensions")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onSyncConfigDialogOpen(true);
}}
>
<LuCloud className="mr-2 w-4 h-4" />
{t("header.menu.syncService")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onIntegrationsDialogOpen(true);
}}
>
<LuPlug className="mr-2 w-4 h-4" />
{t("header.menu.integrations")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onImportProfileDialogOpen(true);
}}
>
<FaDownload className="mr-2 w-4 h-4" />
{t("header.menu.importProfile")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{showProfileToolbar && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<span className="shrink-0">
<Button
size="sm"
onClick={() => {
onCreateProfileDialogOpen(true);
}}
className="flex gap-2 items-center h-[36px]"
className="flex gap-1.5 items-center h-7 px-2.5 text-xs"
>
<GoPlus className="w-4 h-4" />
<GoPlus className="w-3.5 h-3.5" />
{t("header.newProfile")}
</Button>
</span>
</TooltipTrigger>
<TooltipContent
arrowOffset={-8}
style={{ transform: "translateX(-8px)" }}
>
{t("header.createProfile")}
</TooltipContent>
<TooltipContent>{t("header.createProfile")}</TooltipContent>
</Tooltip>
</div>
)}
</div>
);
};
+21 -10
View File
@@ -13,7 +13,6 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
@@ -30,6 +29,7 @@ import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -43,12 +43,14 @@ interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
crossOsUnlocked?: boolean;
subPage?: boolean;
}
export function ImportProfileDialog({
isOpen,
onClose,
crossOsUnlocked,
subPage,
}: ImportProfileDialogProps) {
const { t } = useTranslation();
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
@@ -292,11 +294,13 @@ export function ImportProfileDialog({
}, [isOpen, loadDetectedProfiles]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
{!subPage && (
<DialogHeader className="flex-shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{currentStep === "select" && (
@@ -600,12 +604,19 @@ export function ImportProfileDialog({
)}
</div>
<DialogFooter className="flex-shrink-0">
<div
className={cn(
"flex-shrink-0 flex gap-2 items-center justify-end",
subPage ? "pt-2 border-t border-border" : undefined,
)}
>
{currentStep === "select" ? (
<>
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
{!subPage && (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
)}
<RippleButton
disabled={!canProceedToNext}
onClick={() => {
@@ -635,7 +646,7 @@ export function ImportProfileDialog({
</LoadingButton>
</>
)}
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
+8 -3
View File
@@ -36,11 +36,13 @@ interface McpConfig {
interface IntegrationsDialogProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
}
export function IntegrationsDialog({
isOpen,
onClose,
subPage,
}: IntegrationsDialogProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<AppSettings>({
@@ -206,11 +208,14 @@ export function IntegrationsDialog({
onOpenChange={(open) => {
if (!open) onClose();
}}
subPage={subPage}
>
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("integrations.title")}</DialogTitle>
</DialogHeader>
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("integrations.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 min-h-0">
<Tabs defaultValue="api" className="w-full">
+420 -115
View File
@@ -9,6 +9,7 @@ import {
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import type { Dispatch, SetStateAction } from "react";
@@ -23,7 +24,9 @@ import {
LuCookie,
LuInfo,
LuLock,
LuPlay,
LuPuzzle,
LuSquare,
LuTrash2,
LuTriangleAlert,
LuUsers,
@@ -51,7 +54,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
@@ -68,12 +70,12 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useScrollFade } from "@/hooks/use-scroll-fade";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { useTeamLocks } from "@/hooks/use-team-locks";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import {
getBrowserDisplayName,
getCurrentOS,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
@@ -83,6 +85,7 @@ import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
ExtensionGroup,
LocationItem,
ProxyCheckResult,
StoredProxy,
@@ -154,6 +157,15 @@ interface TableMeta {
vpnId: string | null,
) => void | Promise<void>;
// Extension groups (for Ext column lookup)
extensionGroups: ExtensionGroup[];
// Click handlers for inline Ext / DNS cell editing
onAssignExtensionGroup?: (profileIds: string[]) => void;
setDnsBlocklistProfile: React.Dispatch<
React.SetStateAction<BrowserProfile | null>
>;
// Selection helpers
isProfileSelected: (id: string) => boolean;
handleToggleAll: (checked: boolean) => void;
@@ -298,6 +310,187 @@ function getProfileSyncStatusDot(
}
}
// Inline extension-group dropdown for the Ext column. Matches the
// proxy column's Popover-style picker — no nested dialog.
function ExtCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
const [open, setOpen] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const groupId = profile.extension_group_id ?? null;
const group = groupId
? meta.extensionGroups.find((g) => g.id === groupId)
: undefined;
const label = group?.name ?? meta.t("profiles.table.extDefault");
const onPick = async (nextId: string | null) => {
setIsSaving(true);
try {
await invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: nextId,
});
} catch (err) {
console.error("Failed to assign extension group:", err);
} finally {
setIsSaving(false);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
>
<LuPuzzle className="w-3 h-3 shrink-0" />
<span className="truncate flex-1" title={label}>
{label}
</span>
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0" align="start">
<Command>
<CommandInput placeholder={meta.t("profiles.table.extSearch")} />
<CommandList>
<CommandEmpty>{meta.t("profiles.table.extEmpty")}</CommandEmpty>
<CommandGroup>
<CommandItem
value="__default__"
onSelect={() => {
void onPick(null);
}}
>
{groupId === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
<span className={groupId === null ? "" : "ml-5"}>
{meta.t("profiles.table.extDefault")}
</span>
</CommandItem>
{meta.extensionGroups.map((g) => (
<CommandItem
key={g.id}
value={g.name}
onSelect={() => {
void onPick(g.id);
}}
>
{groupId === g.id && <LuCheck className="mr-2 w-3.5 h-3.5" />}
<span className={groupId === g.id ? "" : "ml-5"}>
{g.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// Inline DNS blocklist dropdown — same Popover/Command pattern as Ext.
function DnsCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
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 onPick = async (nextLevel: string | null) => {
setIsSaving(true);
try {
await invoke("update_profile_dns_blocklist", {
profileId: profile.id,
level: nextLevel,
});
} catch (err) {
console.error("Failed to update DNS blocklist:", err);
} finally {
setIsSaving(false);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
title={
level
? meta.t("profiles.table.dnsLevel", { level })
: meta.t("dnsBlocklist.none")
}
>
<FiWifi className="w-3 h-3 shrink-0" />
<span className="flex-1 truncate uppercase text-[10px] font-mono tracking-wide">
{level ?? "—"}
</span>
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
void onPick(null);
}}
>
{level === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
<span className={level === null ? "" : "ml-5"}>
{meta.t("dnsBlocklist.none")}
</span>
</CommandItem>
{LEVELS.map((l) => (
<CommandItem
key={l.value}
value={l.value}
onSelect={() => {
void onPick(l.value);
}}
>
{level === l.value && (
<LuCheck className="mr-2 w-3.5 h-3.5" />
)}
<span className={level === l.value ? "" : "ml-5"}>
{meta.t(l.labelKey)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TagsCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
@@ -1023,6 +1216,36 @@ export function ProfilesDataTable({
// Country proxy creation state (for inline proxy creation in dropdown)
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
// Extension groups for the Ext column lookup. Refreshed when the
// backend emits 'extensions-changed' (group rename/create/delete).
const [extensionGroups, setExtensionGroups] = React.useState<
ExtensionGroup[]
>([]);
React.useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
const load = async () => {
try {
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
if (mounted) setExtensionGroups(data);
} catch (e) {
console.error("Failed to load extension groups:", e);
}
};
void load();
void listen("extensions-changed", () => {
void load();
}).then((u) => {
if (mounted) unlisten = u;
else u();
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
const canCreateLocationProxy = false;
const loadCountries = React.useCallback(async () => {
@@ -1552,6 +1775,11 @@ export function ProfilesDataTable({
vpnOverrides,
handleVpnSelection,
// Extension groups
extensionGroups,
onAssignExtensionGroup,
setDnsBlocklistProfile,
// Selection helpers
isProfileSelected: (id: string) => selectedProfiles.includes(id),
handleToggleAll,
@@ -1643,6 +1871,8 @@ export function ProfilesDataTable({
vpnConfigs,
vpnOverrides,
handleVpnSelection,
extensionGroups,
onAssignExtensionGroup,
handleToggleAll,
handleCheckboxChange,
handleIconClick,
@@ -1743,7 +1973,7 @@ export function ProfilesDataTable({
>
<span className="w-4 h-4 group">
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -1852,7 +2082,7 @@ export function ProfilesDataTable({
{IconComponent && (
<IconComponent className="w-4 h-4 group-hover:hidden" />
)}
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -1861,11 +2091,11 @@ export function ProfilesDataTable({
},
enableSorting: false,
enableHiding: false,
size: 40,
size: 28,
},
{
id: "actions",
size: 100,
size: 48,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
@@ -1983,11 +2213,18 @@ export function ProfilesDataTable({
variant={buttonVariant}
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
aria-label={
isRunning
? meta.t("profiles.actions.stop")
: meta.t("profiles.actions.launch")
}
className={cn(
"min-w-[80px] h-7 px-3",
"h-7 w-7 p-0 grid place-items-center",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
isFollower && "border-accent",
isRunning &&
"bg-destructive/10 text-destructive hover:bg-destructive/20",
)}
onClick={() =>
isRunning
@@ -1996,13 +2233,11 @@ export function ProfilesDataTable({
}
>
{isLaunching || isStopping ? (
<div className="flex gap-1 items-center">
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
</div>
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
) : isRunning ? (
meta.t("profiles.actions.stop")
<LuSquare className="w-3.5 h-3.5 fill-current" />
) : (
meta.t("profiles.actions.launch")
<LuPlay className="w-3.5 h-3.5 fill-current" />
)}
</RippleButton>
</span>
@@ -2092,11 +2327,15 @@ export function ProfilesDataTable({
const display =
name.length < 14 ? (
<div className="font-medium text-left leading-none">{name}</div>
<div className="font-medium text-left leading-none truncate">
{name}
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="leading-none">{trimName(name, 14)}</span>
<span className="leading-none block truncate">
{trimName(name, 14)}
</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
@@ -2114,11 +2353,11 @@ export function ProfilesDataTable({
const isLocked = meta.isProfileLockedByAnother(profile.id);
return (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5 min-w-0 max-w-full overflow-hidden">
<button
type="button"
className={cn(
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none h-6 min-w-0 max-w-full overflow-hidden",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
@@ -2159,7 +2398,7 @@ export function ProfilesDataTable({
},
{
id: "tags",
size: 110,
size: 100,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.tagsHeader");
@@ -2192,7 +2431,7 @@ export function ProfilesDataTable({
},
{
id: "note",
size: 110,
size: 80,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.noteHeader");
@@ -2223,7 +2462,7 @@ export function ProfilesDataTable({
},
{
id: "proxy",
size: 130,
size: 110,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.proxy");
@@ -2282,17 +2521,19 @@ export function ProfilesDataTable({
(snapshot?.current_bytes_received ?? 0);
return (
<BandwidthMiniChart
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
data={bandwidthData}
currentBandwidth={currentBandwidth}
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
/>
<div className="overflow-hidden min-w-0">
<BandwidthMiniChart
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
data={bandwidthData}
currentBandwidth={currentBandwidth}
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
/>
</div>
);
}
return (
<div className="flex gap-2 items-center">
<div className="flex overflow-hidden gap-2 items-center min-w-0">
<Popover
open={isSelectorOpen}
onOpenChange={(open) => {
@@ -2498,10 +2739,36 @@ export function ProfilesDataTable({
);
},
},
{
id: "ext",
size: 95,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.ext");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return <ExtCell profile={profile} meta={meta} />;
},
},
{
id: "dns",
size: 95,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.dns");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return <DnsCell profile={profile} meta={meta} />;
},
},
{
id: "sync",
header: "",
size: 24,
size: 28,
cell: ({ row, table }) => {
const profile = row.original;
const meta = table.options.meta as TableMeta;
@@ -2525,7 +2792,7 @@ export function ProfilesDataTable({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-3 h-3">
<span className="flex justify-center items-center h-9 w-full">
{dot.encrypted ? (
<LuLock
className={`w-3 h-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
@@ -2544,16 +2811,16 @@ export function ProfilesDataTable({
},
{
id: "settings",
size: 40,
size: 32,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return (
<div className="flex justify-end items-center">
<div className="flex justify-end items-center h-9 w-full">
<Button
variant="ghost"
className="p-0 w-8 h-8"
className="p-0 w-7 h-7"
disabled={!meta.isClient}
onClick={() => {
setProfileForInfoDialog(profile);
@@ -2595,98 +2862,136 @@ export function ProfilesDataTable({
meta: tableMeta,
});
const platform = getCurrentOS();
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
const sortedRows = table.getRowModel().rows;
useScrollFade(scrollParentRef);
// Compact 36px row from the redesign spec; estimateSize must match the
// actual rendered row height or virtualizer placement drifts under scroll.
const ROW_HEIGHT = 36;
const rowVirtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? totalSize - virtualRows[virtualRows.length - 1].end
: 0;
return (
<>
<ScrollArea
className={cn(
"rounded-md border [&>div[data-slot='scroll-area-viewport']>div]:overflow-visible",
platform === "macos" ? "h-[340px]" : "h-[280px]",
)}
>
<Table className="overflow-visible table-fixed">
<TableHeader className="overflow-visible">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="overflow-visible">
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="overflow-visible">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(
row.original.host_os ||
row.original.camoufox_config?.os ||
row.original.wayfern_config?.os ||
"",
),
})
: undefined;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
className={cn(
"overflow-visible hover:bg-accent/50",
rowIsCrossOs && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="overflow-visible"
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={scrollParentRef}
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
>
<Table className="table-fixed">
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="overflow-visible !border-0"
>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="overflow-visible">
{sortedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("profiles.table.empty")}
</TableCell>
</TableRow>
) : (
<>
{paddingTop > 0 && (
<tr style={{ height: `${paddingTop}px` }}>
<td colSpan={columns.length} />
</tr>
)}
{virtualRows.map((virtualRow) => {
const row = sortedRows[virtualRow.index];
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(
row.original.host_os ||
row.original.camoufox_config?.os ||
row.original.wayfern_config?.os ||
"",
),
})
: undefined;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
style={{ height: `${ROW_HEIGHT}px` }}
className={cn(
"overflow-visible hover:bg-accent/50 !border-0",
rowIsCrossOs && "opacity-60",
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("profiles.table.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="overflow-visible py-0"
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})}
{paddingBottom > 0 && (
<tr style={{ height: `${paddingBottom}px` }}>
<td colSpan={columns.length} />
</tr>
)}
</>
)}
</TableBody>
</Table>
</div>
</div>
<DeleteConfirmationDialog
isOpen={profileToDelete !== null}
onClose={() => {
File diff suppressed because it is too large Load Diff
+47 -17
View File
@@ -40,6 +40,7 @@ import {
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
import { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple";
@@ -100,11 +101,16 @@ function getSyncStatusDot(
interface ProxyManagementDialogProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
/** Which tab to display first when the dialog mounts; defaults to "proxies". */
initialTab?: "proxies" | "vpns";
}
export function ProxyManagementDialog({
isOpen,
onClose,
subPage,
initialTab = "proxies",
}: ProxyManagementDialogProps) {
const { t } = useTranslation();
// Proxy state
@@ -391,22 +397,44 @@ export function ProxyManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
<DialogDescription>
{t("proxies.management.description")}
</DialogDescription>
</DialogHeader>
{!subPage && (
<DialogHeader>
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
<DialogDescription>
{t("proxies.management.description")}
</DialogDescription>
</DialogHeader>
)}
<ScrollArea className="overflow-y-auto flex-1">
<Tabs defaultValue="proxies">
<TabsList className="w-full">
<TabsTrigger value="proxies" className="flex-1">
<ScrollArea className="overflow-y-auto flex-1 scroll-fade">
<Tabs key={initialTab} defaultValue={initialTab}>
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="proxies"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
{t("proxies.management.tabProxies")}
</TabsTrigger>
<TabsTrigger value="vpns" className="flex-1">
<TabsTrigger
value="vpns"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
{t("proxies.management.tabVpns")}
</TabsTrigger>
</TabsList>
@@ -844,11 +872,13 @@ export function ProxyManagementDialog({
</Tabs>
</ScrollArea>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
{!subPage && (
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
+472
View File
@@ -0,0 +1,472 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal } from "react-icons/go";
import {
LuCloud,
LuPlug,
LuPuzzle,
LuShieldCheck,
LuUser,
LuUsers,
} from "react-icons/lu";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export type AppPage =
| "profiles"
| "proxies"
| "extensions"
| "groups"
| "vpns"
| "settings"
| "integrations"
| "account"
| "import";
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({
currentPage,
onNavigate,
}: {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
}) {
const clickTimestamps = useRef<number[]>([]);
const [isPressed, setIsPressed] = useState(false);
const [wobbleKey, setWobbleKey] = useState(0);
const [isFalling, setIsFalling] = useState(false);
/**
* Click count toward the bounce trigger while the user is on the profiles
* page. Capped at 4: each click here grows the logo by 25%, so step 4 has
* doubled the original size. Click 5 fires `triggerFall` and resets.
*/
const [growStep, setGrowStep] = useState(0);
const resetTimeoutRef = useRef<number | null>(null);
const [isHidden, setIsHidden] = useState(() => {
try {
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
} catch {
return false;
}
});
const logoRef = useRef<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(0);
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 floorY = window.innerHeight;
const rightWall = window.innerWidth;
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;
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();
try {
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
} catch {
// ignore — sessionStorage unavailable in some Tauri WebViews
}
setIsHidden(true);
setIsFalling(false);
return;
}
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
}, [isFalling]);
useEffect(() => {
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, []);
const handleClick = useCallback(() => {
if (isFalling || isHidden) return;
// First behaviour: any click from elsewhere in the app just routes the
// user back to the profiles list. Growing the donut requires the user
// to already be home — that keeps the easter egg from accidentally
// firing during normal navigation.
if (currentPage !== "profiles") {
onNavigate("profiles");
clickTimestamps.current = [];
setGrowStep(0);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
return;
}
const now = Date.now();
clickTimestamps.current = clickTimestamps.current.filter(
(t) => now - t < CLICK_WINDOW_MS,
);
clickTimestamps.current.push(now);
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
clickTimestamps.current = [];
setGrowStep(0);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
triggerFall();
} else {
setGrowStep(
Math.min(clickTimestamps.current.length, CLICK_THRESHOLD - 1),
);
setWobbleKey((k) => k + 1);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
}
resetTimeoutRef.current = window.setTimeout(() => {
clickTimestamps.current = [];
setGrowStep(0);
resetTimeoutRef.current = null;
}, CLICK_WINDOW_MS);
}
}, [currentPage, isFalling, isHidden, onNavigate, triggerFall]);
// Leaving the profiles page mid-streak cancels growth so we never end up
// with an outsized logo when the user returns later.
useEffect(() => {
if (currentPage !== "profiles") {
clickTimestamps.current = [];
setGrowStep(0);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
}
}, [currentPage]);
useEffect(() => {
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
}
};
}, []);
return {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
growStep,
handleClick,
};
}
interface RailNavProps {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
}
interface RailItem {
page: AppPage;
Icon: React.ComponentType<{ className?: string }>;
labelKey: string;
}
const TOP_ITEMS: RailItem[] = [
{ page: "profiles", Icon: LuUser, labelKey: "rail.profiles" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.proxies" },
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
];
interface MoreMenuItem {
page: AppPage;
Icon: React.ComponentType<{ className?: string }>;
labelKey: string;
hintKey: string;
}
const MORE_ITEMS: MoreMenuItem[] = [
{
page: "import",
Icon: FaDownload,
labelKey: "rail.more.importProfile",
hintKey: "rail.more.importProfileHint",
},
{
page: "vpns",
Icon: LuShieldCheck,
labelKey: "rail.more.vpns",
hintKey: "rail.more.vpnsHint",
},
{
page: "integrations",
Icon: LuPlug,
labelKey: "rail.more.integrations",
hintKey: "rail.more.integrationsHint",
},
{
page: "account",
Icon: LuCloud,
labelKey: "rail.more.account",
hintKey: "rail.more.accountHint",
},
];
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
const {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
growStep,
handleClick,
} = useLogoEasterEgg({ currentPage, onNavigate });
return (
<nav className="flex flex-col items-center w-10 py-2 gap-1 bg-background border-r border-border shrink-0 relative">
{!isHidden ? (
<button
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
className="grid place-items-center w-7 h-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
onClick={handleClick}
onPointerDown={() => {
setIsPressed(true);
}}
onPointerUp={() => {
setIsPressed(false);
}}
onPointerLeave={() => {
setIsPressed(false);
}}
>
{/* Inner wrapper survives clicks (no `key`) so the scale change
animates smoothly across the wiggle layer's remounts. */}
<span
style={{
transform: isPressed
? `scale(${(1 + growStep * 0.25) * 0.9})`
: `scale(${1 + growStep * 0.25})`,
}}
className="inline-grid place-items-center transition-transform duration-300 ease-out will-change-transform"
>
<span
key={wobbleKey}
className={cn(
"inline-grid place-items-center",
!isFalling &&
!isPressed &&
wobbleKey > 0 &&
"animate-[wiggle_0.3s_ease-in-out]",
)}
>
<Logo className="w-5 h-5 will-change-transform" />
</span>
</span>
</button>
) : (
<div className="w-7 h-7" />
)}
<div className="w-5 h-px bg-border my-1" />
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
const active = currentPage === page;
return (
<Tooltip key={page} delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onNavigate(page);
}}
aria-label={t(labelKey)}
aria-current={active ? "page" : undefined}
className={cn(
"relative grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
active
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<Icon className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
</Tooltip>
);
})}
<div className="flex-1" />
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
setMoreOpen((v) => !v);
}}
aria-label={t("rail.more.label")}
aria-expanded={moreOpen}
className={cn(
"grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
moreOpen
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
<GoKebabHorizontal className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.more.label")}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onNavigate("settings");
}}
aria-label={t("rail.settings")}
aria-current={currentPage === "settings" ? "page" : undefined}
className={cn(
"relative grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
currentPage === "settings"
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
{currentPage === "settings" && (
<span
aria-hidden="true"
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<GoGear className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.settings")}</TooltipContent>
</Tooltip>
{moreOpen && (
<>
<button
type="button"
aria-label={t("rail.more.closeAriaLabel")}
className="fixed inset-0 z-30 bg-transparent cursor-default"
onClick={() => {
setMoreOpen(false);
}}
/>
<div className="absolute bottom-14 left-11 w-56 bg-card border border-border rounded-lg shadow-2xl p-1 z-40 animate-in fade-in-0 slide-in-from-bottom-1 duration-100">
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
<button
key={page}
type="button"
onClick={() => {
setMoreOpen(false);
onNavigate(page);
}}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-accent transition-colors duration-100 text-left"
>
<span className="grid place-items-center w-5 h-5 rounded bg-muted text-muted-foreground shrink-0">
<Icon className="w-3 h-3" />
</span>
<span className="flex flex-col min-w-0">
<span className="text-xs font-medium text-foreground truncate">
{t(labelKey)}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{t(hintKey)}
</span>
</span>
</button>
))}
</div>
</>
)}
</nav>
);
}
+85 -21
View File
@@ -1,6 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
import Color from "color";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -53,6 +54,7 @@ import {
THEMES,
} from "@/lib/themes";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
@@ -83,12 +85,14 @@ interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
onIntegrationsOpen?: () => void;
subPage?: boolean;
}
export function SettingsDialog({
isOpen,
onClose,
onIntegrationsOpen,
subPage,
}: SettingsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
set_as_default_browser: false,
@@ -603,13 +607,20 @@ export function SettingsDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}>
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
)}
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
<div
className={cn(
"grid overflow-y-auto flex-1 gap-6 min-h-0",
subPage ? "py-2" : "py-4",
)}
>
{/* Appearance Section */}
<div className="space-y-4">
<Label className="text-base font-medium">
@@ -1000,6 +1011,7 @@ export function SettingsDialog({
await invoke("delete_e2e_password");
setHasE2ePassword(false);
showSuccessToast(t("settings.encryption.removed"));
void invoke("rollover_encryption_for_all_entities");
} catch (error) {
showErrorToast(String(error));
}
@@ -1056,6 +1068,7 @@ export function SettingsDialog({
showSuccessToast(
t("settings.encryption.passwordSaved"),
);
void invoke("rollover_encryption_for_all_entities");
} catch (error) {
showErrorToast(String(error));
} finally {
@@ -1170,6 +1183,40 @@ export function SettingsDialog({
<p className="text-xs text-muted-foreground">
{t("settings.advanced.clearCacheDescription")}
</p>
<div className="grid grid-cols-2 gap-2 pt-2">
<RippleButton
variant="outline"
className="text-xs"
onClick={async () => {
try {
const content = await invoke<string>("read_log_files");
await writeClipboardText(content);
showSuccessToast(t("settings.advanced.copyLogsSuccess"));
} catch (err) {
showErrorToast(String(err));
}
}}
>
{t("settings.advanced.copyLogs")}
</RippleButton>
<RippleButton
variant="outline"
className="text-xs"
onClick={async () => {
try {
await invoke("open_log_directory");
} catch (err) {
showErrorToast(String(err));
}
}}
>
{t("settings.advanced.openLogDir")}
</RippleButton>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.copyLogsDescription")}
</p>
</div>
{/* System Info */}
@@ -1182,22 +1229,39 @@ export function SettingsDialog({
)}
</div>
<DialogFooter className="shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={() => {
handleSave().catch((err: unknown) => {
console.error(err);
});
}}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</DialogFooter>
{subPage ? (
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border">
<LoadingButton
size="sm"
isLoading={isSaving}
onClick={() => {
handleSave().catch((err: unknown) => {
console.error(err);
});
}}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</div>
) : (
<DialogFooter className="shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={() => {
handleSave().catch((err: unknown) => {
console.error(err);
});
}}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
<DnsBlocklistDialog
+2 -2
View File
@@ -55,12 +55,12 @@ export function CopyToClipboard({
{copied ? t("common.srOnly.copied") : t("common.srOnly.copy")}
</span>
<LuCopy
className={`h-4 w-4 transition-all duration-300 ${
className={`h-4 w-4 transition-all duration-150 ${
copied ? "scale-0" : "scale-100"
}`}
/>
<LuCheck
className={`absolute inset-0 m-auto h-4 w-4 text-foreground transition-all duration-300 ${
className={`absolute inset-0 m-auto h-4 w-4 text-foreground transition-all duration-150 ${
copied ? "scale-100" : "scale-0"
}`}
/>
+93 -11
View File
@@ -14,14 +14,21 @@ import { WindowDragArea } from "../window-drag-area";
type DialogContextType = {
isOpen: boolean;
setIsOpen: DialogProps["onOpenChange"];
subPage: boolean;
container: HTMLElement | null | undefined;
};
const [DialogProvider, useDialog] =
getStrictContext<DialogContextType>("DialogContext");
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root> & {
/** Render in a portal container as an in-flow sub-page instead of a centered modal. */
subPage?: boolean;
/** Portal container target. Required when subPage=true; ignored otherwise. */
container?: HTMLElement | null;
};
function Dialog(props: DialogProps) {
function Dialog({ subPage, container, children, ...props }: DialogProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
@@ -29,12 +36,27 @@ function Dialog(props: DialogProps) {
});
return (
<DialogProvider value={{ isOpen, setIsOpen }}>
<DialogProvider
value={{
isOpen,
setIsOpen,
subPage: !!subPage,
container: container ?? undefined,
}}
>
{/* In sub-page mode the Dialog isn't a modal — it's an in-flow page.
Forcing `modal={false}` prevents Radix from locking pointer-events
and aria-hiding everything outside the dialog. Children are passed
explicitly (not via spread) so React doesn't have to guess where
the JSX subtree should mount. */}
<DialogPrimitive.Root
data-slot="dialog"
{...props}
modal={subPage ? false : props.modal}
onOpenChange={setIsOpen}
/>
>
{children}
</DialogPrimitive.Root>
</DialogProvider>
);
}
@@ -51,7 +73,7 @@ type DialogPortalProps = Omit<
>;
function DialogPortal(props: DialogPortalProps) {
const { isOpen } = useDialog();
const { isOpen, container } = useDialog();
return (
<AnimatePresence>
@@ -59,6 +81,7 @@ function DialogPortal(props: DialogPortalProps) {
<DialogPrimitive.Portal
data-slot="dialog-portal"
forceMount
container={container ?? props.container}
{...props}
/>
)}
@@ -102,8 +125,54 @@ type DialogContentProps = Omit<
> &
HTMLMotionProps<"div"> & {
from?: DialogFlipDirection;
/**
* Suppress the built-in top-right close X. Use when the dialog renders
* its own header bar with a custom close control to avoid two X buttons
* stacking near the corner.
*/
hideClose?: boolean;
};
function SubPageContent({
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
const { isOpen } = useDialog();
if (!isOpen) return null;
// Inline styles deliberately override any className the caller passed
// for the modal mode (max-w-*, max-h-*, my-*). tailwind-merge inside the
// shared dialog wrappers turned out to be unreliable when both classnames
// and !important variants competed — inline styles guarantee the layout.
return (
<motion.div
data-slot="sub-page"
data-sub-page="true"
initial={false}
animate={{ opacity: 1 }}
style={{
position: "relative",
display: "flex",
flexDirection: "column",
flex: "1 1 0%",
minHeight: 0,
width: "100%",
maxWidth: "none",
height: "100%",
maxHeight: "none",
margin: 0,
padding: 12,
gap: 12,
overflow: "auto",
background: "var(--background)",
}}
>
{children}
</motion.div>
);
}
function DialogContent({
className,
children,
@@ -113,14 +182,25 @@ function DialogContent({
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
transition = { type: "spring", stiffness: 150, damping: 25 },
transition,
hideClose,
...props
}: DialogContentProps) {
const { t } = useTranslation();
const { subPage } = useDialog();
const initialRotation =
from === "bottom" || from === "left" ? "20deg" : "-20deg";
const isVertical = from === "top" || from === "bottom";
const rotateAxis = isVertical ? "rotateX" : "rotateY";
const finalTransition = transition ?? {
type: "spring",
stiffness: 220,
damping: 26,
};
if (subPage) {
return <SubPageContent>{children}</SubPageContent>;
}
return (
<DialogPortal data-slot="dialog-portal">
@@ -158,7 +238,7 @@ function DialogContent({
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
transition={finalTransition}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
className,
@@ -166,10 +246,12 @@ function DialogContent({
{...props}
>
{children}
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">{t("common.buttons.close")}</span>
</DialogPrimitive.Close>
{!hideClose && (
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">{t("common.buttons.close")}</span>
</DialogPrimitive.Close>
)}
</motion.div>
</DialogPrimitive.Content>
</DialogPortal>
+50 -63
View File
@@ -43,23 +43,20 @@ export function WindowDragArea() {
return null;
}
// macOS: transparent drag area overlay
// macOS: nothing to render here. The transparent native titlebar (set via
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
// handle dragging directly, and the sys-bar inside `home-header.tsx`
// declares its own `data-tauri-drag-region` overlay for the WebView area.
// The previous full-width fixed z-[999999] button was stealing every
// click in the top 40px of the window.
if (platform === "macos") {
return (
<button
type="button"
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[999999] select-none"
data-window-drag-area="true"
onPointerDown={handlePointerDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
);
return null;
}
// Windows: custom title bar with drag area + minimize/close buttons
// Windows: minimize/close controls anchored at the top-right corner of
// the sys-bar. The HomeHeader's own drag-region overlay handles window
// dragging via Tauri 2, so we don't need a separate draggable spacer
// covering the whole width.
const handleMinimize = async () => {
try {
await getCurrentWindow().minimize();
@@ -75,64 +72,54 @@ export function WindowDragArea() {
console.error("Failed to close window:", error);
}
};
void handlePointerDown; // kept for backwards-compat; not used on Windows now
return (
<div
className="fixed top-0 right-0 left-0 h-10 z-[999999] flex items-center select-none"
data-window-drag-area="true"
className="fixed top-0 right-0 z-50 flex items-center h-11 select-none"
aria-hidden="false"
>
{/* Draggable area */}
<button
type="button"
className="flex-1 h-full bg-transparent border-0 cursor-default"
onPointerDown={handlePointerDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onClick={() => {
void handleMinimize();
}}
/>
{/* Window control buttons */}
<div className="flex items-center h-full">
<button
type="button"
onClick={() => {
void handleMinimize();
}}
className="flex items-center justify-center w-12 h-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
className="flex items-center justify-center w-11 h-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
aria-label={t("common.window.minimize")}
>
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="currentColor"
role="img"
aria-label={t("common.window.minimize")}
>
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="currentColor"
role="img"
aria-label={t("common.window.minimize")}
>
<rect width="10" height="1" />
</svg>
</button>
<button
type="button"
onClick={() => {
void handleClose();
}}
className="flex items-center justify-center w-12 h-full hover:bg-destructive/90 transition-colors text-muted-foreground hover:text-destructive-foreground"
<rect width="10" height="1" />
</svg>
</button>
<button
type="button"
onClick={() => {
void handleClose();
}}
className="flex items-center justify-center w-11 h-full hover:bg-destructive/90 transition-colors text-muted-foreground hover:text-destructive-foreground"
aria-label={t("common.buttons.close")}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.buttons.close")}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.buttons.close")}
>
<line x1="1" y1="1" x2="9" y2="9" />
<line x1="9" y1="1" x2="1" y2="9" />
</svg>
</button>
</div>
<line x1="1" y1="1" x2="9" y2="9" />
<line x1="9" y1="1" x2="1" y2="9" />
</svg>
</button>
</div>
);
}