Files
donutbrowser/src/components/profile-info-dialog.tsx
T
2026-05-10 04:32:59 +04:00

952 lines
30 KiB
TypeScript

"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
import {
LuChevronRight,
LuClipboard,
LuClipboardCheck,
LuCookie,
LuCopy,
LuFingerprint,
LuGlobe,
LuGroup,
LuKey,
LuLink,
LuLock,
LuLockOpen,
LuPlus,
LuPuzzle,
LuRefreshCw,
LuSettings,
LuShield,
LuShieldCheck,
LuTrash2,
LuUsers,
LuX,
} from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ProBadge } from "@/components/ui/pro-badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
getBrowserDisplayName,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
ProfileGroup,
StoredProxy,
VpnConfig,
} from "@/types";
interface ProfileInfoDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
storedProxies: StoredProxy[];
vpnConfigs: VpnConfig[];
onOpenTrafficDialog?: (profileId: string) => void;
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onAssignProfilesToGroup?: (profileIds: string[]) => void;
onConfigureCamoufox?: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenBypassRules?: (profile: BrowserProfile) => void;
onOpenDnsBlocklist?: (profile: BrowserProfile) => void;
onOpenLaunchHook?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
onSetPassword?: (profile: BrowserProfile) => void;
onChangePassword?: (profile: BrowserProfile) => void;
onRemovePassword?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
isRunning?: boolean;
isDisabled?: boolean;
isCrossOs?: boolean;
syncStatuses: Record<string, { status: string; error?: string }>;
}
function OSIcon({ os }: { os: string }) {
switch (os) {
case "macos":
return <FaApple className="w-3.5 h-3.5" />;
case "windows":
return <FaWindows className="w-3.5 h-3.5" />;
case "linux":
return <FaLinux className="w-3.5 h-3.5" />;
default:
return null;
}
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-muted/50 border px-3 py-2.5">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm mt-0.5 truncate">{value}</p>
</div>
);
}
export function ProfileInfoDialog({
isOpen,
onClose,
profile,
storedProxies,
vpnConfigs,
onOpenTrafficDialog,
onOpenProfileSyncDialog,
onAssignProfilesToGroup,
onConfigureCamoufox,
onCopyCookiesToProfile,
onOpenCookieManagement,
onAssignExtensionGroup,
onOpenBypassRules,
onOpenDnsBlocklist,
onOpenLaunchHook,
onCloneProfile,
onDeleteProfile,
onLaunchWithSync,
onSetPassword,
onChangePassword,
onRemovePassword,
crossOsUnlocked = false,
isRunning = false,
isDisabled = false,
isCrossOs = false,
syncStatuses,
}: ProfileInfoDialogProps) {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const [groupName, setGroupName] = React.useState<string | null>(null);
const [extensionGroupName, setExtensionGroupName] = React.useState<
string | null
>(null);
React.useEffect(() => {
if (!isOpen || !profile?.group_id) {
setGroupName(null);
return;
}
void (async () => {
try {
const groups = await invoke<ProfileGroup[]>("get_groups");
const group = groups.find((g) => g.id === profile.group_id);
setGroupName(group?.name ?? null);
} catch {
setGroupName(null);
}
})();
}, [isOpen, profile?.group_id]);
React.useEffect(() => {
if (!isOpen || !profile?.extension_group_id) {
setExtensionGroupName(null);
return;
}
void (async () => {
try {
const group = await invoke<{ name: string } | null>(
"get_extension_group_for_profile",
{ profileId: profile.id },
);
setExtensionGroupName(group?.name ?? null);
} catch {
setExtensionGroupName(null);
}
})();
}, [isOpen, profile?.extension_group_id, profile?.id]);
React.useEffect(() => {
if (!isOpen) {
setCopied(false);
}
}, [isOpen]);
if (!profile) return null;
const ProfileIcon = getProfileIcon(profile);
const isCamoufoxOrWayfern =
profile.browser === "camoufox" || profile.browser === "wayfern";
const isDeleteDisabled = isRunning;
const proxyName = profile.proxy_id
? storedProxies.find((p) => p.id === profile.proxy_id)?.name
: null;
const vpnName = profile.vpn_id
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
: null;
const networkLabel = vpnName
? `VPN: ${vpnName}`
: proxyName
? `Proxy: ${proxyName}`
: t("profileInfo.values.none");
const syncStatus = syncStatuses[profile.id];
const syncMode = profile.sync_mode ?? "Disabled";
const syncLabel = syncStatus
? `${syncMode} (${syncStatus.status})`
: syncMode;
const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(profile.id);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
} catch {
// ignore
}
};
const handleAction = (action: () => void) => {
onClose();
action();
};
const releaseLabel =
profile.release_type.charAt(0).toUpperCase() +
profile.release_type.slice(1);
const hasTags = profile.tags && profile.tags.length > 0;
const hasNote = !!profile.note;
const showCrossOs = isCrossOsProfile(profile);
// Items in the settings tab `actions` list MUST only open another dialog
// (or trigger a navigation/action that closes this one). Do NOT put inline
// settings UI — inputs, toggles, save buttons — directly in this dialog's
// settings tab. Each setting belongs in its own focused dialog (see
// `ProfileLaunchHookDialog`, `ProfileBypassRulesDialog`,
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
// a navigation hub.
interface ActionItem {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
proBadge?: boolean;
runningBadge?: boolean;
hidden?: boolean;
}
const actions: ActionItem[] = [
{
icon: <LuGlobe className="w-4 h-4" />,
label: t("profiles.actions.viewNetwork"),
onClick: () => {
handleAction(() => onOpenTrafficDialog?.(profile.id));
},
disabled: isCrossOs,
},
{
icon: <LuRefreshCw className="w-4 h-4" />,
label: t("profiles.actions.syncSettings"),
onClick: () => {
handleAction(() => onOpenProfileSyncDialog?.(profile));
},
disabled: isCrossOs,
hidden: profile.ephemeral === true,
},
{
icon: <LuGroup className="w-4 h-4" />,
label: t("profiles.actions.assignToGroup"),
onClick: () => {
handleAction(() => onAssignProfilesToGroup?.([profile.id]));
},
disabled: isDisabled,
runningBadge: isRunning,
},
{
icon: <LuFingerprint className="w-4 h-4" />,
label: t("profiles.actions.changeFingerprint"),
onClick: () => {
handleAction(() => onConfigureCamoufox?.(profile));
},
disabled: isDisabled,
runningBadge: isRunning,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
{
icon: <LuUsers className="w-4 h-4" />,
label: t("profiles.synchronizer.launchWithSync"),
onClick: () => {
handleAction(() => onLaunchWithSync?.(profile));
},
disabled: isDisabled || isRunning || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
},
{
icon: <LuCopy className="w-4 h-4" />,
label: t("profiles.actions.copyCookiesToProfile"),
onClick: () => {
handleAction(() => onCopyCookiesToProfile?.(profile));
},
disabled: isDisabled,
runningBadge: isRunning,
hidden:
!isCamoufoxOrWayfern ||
profile.ephemeral === true ||
!onCopyCookiesToProfile,
},
{
icon: <LuCookie className="w-4 h-4" />,
label: t("profileInfo.actions.manageCookies"),
onClick: () => {
handleAction(() => onOpenCookieManagement?.(profile));
},
disabled: isDisabled,
runningBadge: isRunning,
hidden:
!isCamoufoxOrWayfern ||
profile.ephemeral === true ||
!onOpenCookieManagement,
},
{
icon: <LuSettings className="w-4 h-4" />,
label: t("profiles.actions.clone"),
onClick: () => {
handleAction(() => onCloneProfile?.(profile));
},
disabled: isDisabled,
runningBadge: isRunning,
hidden: profile.ephemeral === true,
},
{
icon: <LuPuzzle className="w-4 h-4" />,
label: t("profileInfo.actions.assignExtensionGroup"),
onClick: () => {
handleAction(() => onAssignExtensionGroup?.([profile.id]));
},
disabled: isDisabled,
runningBadge: isRunning,
hidden: profile.ephemeral === true,
},
{
icon: <LuShieldCheck className="w-4 h-4" />,
label: t("profileInfo.network.bypassRulesTitle"),
onClick: () => {
handleAction(() => onOpenBypassRules?.(profile));
},
},
{
icon: <LuShield className="w-4 h-4" />,
label: t("dnsBlocklist.title"),
onClick: () => {
handleAction(() => onOpenDnsBlocklist?.(profile));
},
},
{
icon: <LuLink className="w-4 h-4" />,
label: t("profiles.actions.launchHook"),
onClick: () => {
handleAction(() => onOpenLaunchHook?.(profile));
},
hidden: !onOpenLaunchHook,
},
{
icon: <LuKey className="w-4 h-4" />,
label: t("profiles.actions.setPassword"),
onClick: () => {
handleAction(() => onSetPassword?.(profile));
},
disabled: isDisabled || isRunning,
runningBadge: isRunning,
hidden:
profile.password_protected === true ||
profile.ephemeral === true ||
!onSetPassword,
},
{
icon: <LuKey className="w-4 h-4" />,
label: t("profiles.actions.changePassword"),
onClick: () => {
handleAction(() => onChangePassword?.(profile));
},
disabled: isDisabled || isRunning,
runningBadge: isRunning,
hidden: profile.password_protected !== true || !onChangePassword,
},
{
icon: <LuLockOpen className="w-4 h-4" />,
label: t("profiles.actions.removePassword"),
onClick: () => {
handleAction(() => onRemovePassword?.(profile));
},
disabled: isDisabled || isRunning,
runningBadge: isRunning,
hidden: profile.password_protected !== true || !onRemovePassword,
destructive: true,
},
{
icon: <LuTrash2 className="w-4 h-4" />,
label: t("profiles.actions.delete"),
onClick: () => {
handleAction(() => onDeleteProfile?.(profile));
},
disabled: isDeleteDisabled,
destructive: true,
},
];
const visibleActions = actions.filter((a) => !a.hidden);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="info">
<TabsList className="w-full">
<TabsTrigger value="info" className="flex-1">
{t("profileInfo.tabs.info")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("profileInfo.tabs.settings")}
</TabsTrigger>
</TabsList>
<TabsContent value="info">
<div className="overflow-y-auto max-h-[calc(80vh-12rem)] pr-1">
<div className="flex flex-col gap-4 py-3">
{/* Hero */}
<div className="flex items-center gap-3">
<div className="rounded-lg bg-muted p-2.5 shrink-0">
<ProfileIcon className="w-8 h-8 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold truncate">
{profile.name}
</h3>
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<Badge variant="secondary" className="text-xs">
{getBrowserDisplayName(profile.browser)}{" "}
{profile.version}
</Badge>
<Badge variant="outline" className="text-xs">
{releaseLabel}
</Badge>
{isRunning && (
<Badge className="text-xs bg-primary/15 text-primary border-primary/25">
{t("common.status.running")}
</Badge>
)}
{profile.ephemeral && (
<Badge variant="outline" className="text-xs">
{t("profiles.ephemeralBadge")}
</Badge>
)}
{profile.password_protected && (
<Badge variant="outline" className="text-xs gap-1">
<LuLock className="w-3 h-3" />
{t("profiles.passwordProtectedBadge")}
</Badge>
)}
{showCrossOs && (
<Badge variant="outline" className="text-xs gap-1">
<OSIcon
os={
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
""
}
/>
{getOSDisplayName(
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
"",
)}
</Badge>
)}
</div>
</div>
</div>
{/* Profile ID */}
<div className="flex items-center gap-2 rounded-md bg-muted/50 border px-3 py-2">
<span className="text-xs text-muted-foreground shrink-0">
ID
</span>
<span className="font-mono text-xs truncate flex-1">
{profile.id}
</span>
<button
type="button"
onClick={() => void handleCopyId()}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
{copied ? (
<LuClipboardCheck className="w-3.5 h-3.5" />
) : (
<LuClipboard className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Network & Organization */}
<div className="grid grid-cols-2 gap-2">
<InfoCard
label={t("profileInfo.fields.proxyVpn")}
value={networkLabel}
/>
<InfoCard
label={t("profileInfo.fields.group")}
value={groupName ?? t("profileInfo.values.none")}
/>
<InfoCard
label={t("profileInfo.fields.extensionGroup")}
value={extensionGroupName ?? t("profileInfo.values.none")}
/>
<InfoCard
label={t("profileInfo.fields.lastLaunched")}
value={
profile.last_launch
? formatRelativeTime(profile.last_launch)
: t("profileInfo.values.never")
}
/>
<InfoCard
label={t("dnsBlocklist.title")}
value={
profile.dns_blocklist
? t(
`dnsBlocklist.${profile.dns_blocklist === "pro_plus" ? "proPlus" : profile.dns_blocklist}`,
)
: t("dnsBlocklist.none")
}
/>
<InfoCard
label={t("profileInfo.fields.launchHook")}
value={profile.launch_hook || t("profileInfo.values.none")}
/>
</div>
{/* Sync */}
<div className="rounded-md bg-muted/50 border px-3 py-2.5 flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">
{t("profileInfo.fields.syncStatus")}
</p>
<p className="text-sm mt-0.5">{syncLabel}</p>
</div>
<Badge
variant={syncMode === "Disabled" ? "outline" : "secondary"}
className="text-xs shrink-0"
>
{syncMode === "Disabled"
? t("sync.mode.disabled")
: syncStatus.status === "syncing"
? t("common.status.syncing")
: t("common.status.synced")}
</Badge>
</div>
{/* Tags */}
{hasTags && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("profileInfo.fields.tags")}
</span>
<div className="flex flex-wrap gap-1.5">
{profile.tags?.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-xs"
>
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Note */}
{hasNote && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("profileInfo.fields.note")}
</span>
<p className="text-sm rounded-md bg-muted/50 border px-3 py-2 whitespace-pre-wrap break-words">
{profile.note}
</p>
</div>
)}
{/* Team */}
{profile.created_by_email && (
<div className="rounded-md bg-muted/50 border px-3 py-2.5">
<p className="text-xs text-muted-foreground">
{t("sync.team.title")}
</p>
<p className="text-sm mt-0.5">
{t("sync.team.createdBy", {
email: profile.created_by_email,
})}
</p>
</div>
)}
</div>
</div>
</TabsContent>
<TabsContent value="settings">
<div className="overflow-y-auto max-h-[calc(80vh-12rem)]">
<div className="flex flex-col gap-3 py-1">
<div className="flex flex-col">
{visibleActions.map((action) => (
<button
key={action.label}
type="button"
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
action.destructive &&
"text-destructive hover:bg-destructive/10",
)}
>
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.runningBadge && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-primary/15 text-primary uppercase">
{t("common.status.running")}
</span>
)}
{action.proBadge && !action.runningBadge && (
<ProBadge />
)}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
interface ProfileLaunchHookDialogProps {
isOpen: boolean;
onClose: () => void;
profileId: string | null;
currentLaunchHook: string | null;
}
export function ProfileLaunchHookDialog({
isOpen,
onClose,
profileId,
currentLaunchHook,
}: ProfileLaunchHookDialogProps) {
const { t } = useTranslation();
const [value, setValue] = React.useState(currentLaunchHook ?? "");
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
if (isOpen) {
setValue(currentLaunchHook ?? "");
}
}, [isOpen, currentLaunchHook]);
const trimmed = value.trim();
const saved = currentLaunchHook ?? "";
const isDirty = trimmed !== saved;
const handleSave = async () => {
if (!profileId) return;
setIsSaving(true);
try {
await invoke("update_profile_launch_hook", {
profileId,
launchHook: trimmed || null,
});
onClose();
} catch (err) {
console.error("Failed to update launch hook:", err);
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("profileInfo.launchHook.title")}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
{t("profileInfo.launchHook.description")}
</p>
<Input
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
placeholder={t("profileInfo.launchHook.placeholder")}
disabled={isSaving}
/>
<DialogFooter>
<Button
onClick={() => void handleSave()}
disabled={isSaving || !isDirty}
className="w-full"
>
{t("common.buttons.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface ProfileDnsBlocklistDialogProps {
isOpen: boolean;
onClose: () => void;
profileId: string | null;
currentLevel: string | null;
}
export function ProfileDnsBlocklistDialog({
isOpen,
onClose,
profileId,
currentLevel,
}: ProfileDnsBlocklistDialogProps) {
const { t } = useTranslation();
const [level, setLevel] = React.useState(currentLevel ?? "");
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
if (isOpen) {
setLevel(currentLevel ?? "");
}
}, [isOpen, currentLevel]);
const handleSave = async () => {
if (!profileId) return;
setIsSaving(true);
try {
await invoke("update_profile_dns_blocklist", {
profileId,
dnsBlocklist: level || null,
});
onClose();
} catch (err) {
console.error("Failed to update DNS blocklist:", err);
} finally {
setIsSaving(false);
}
};
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") },
];
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xs">
<DialogHeader>
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
{t("dnsBlocklist.settingsDescription")}{" "}
<a
href="https://github.com/hagezi/dns-blocklists"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t("common.buttons.moreInfo")}
</a>
</p>
<div className="space-y-1">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setLevel(option.value)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
level === option.value
? "bg-primary/10 text-primary border border-primary/30"
: "hover:bg-accent border border-transparent"
}`}
>
{option.label}
</button>
))}
</div>
<Button
onClick={() => void handleSave()}
disabled={isSaving || level === (currentLevel ?? "")}
className="w-full"
>
{t("common.buttons.save")}
</Button>
</DialogContent>
</Dialog>
);
}
interface ProfileBypassRulesDialogProps {
isOpen: boolean;
onClose: () => void;
profileId: string | null;
initialRules?: string[];
}
export function ProfileBypassRulesDialog({
isOpen,
onClose,
profileId,
initialRules,
}: ProfileBypassRulesDialogProps) {
const { t } = useTranslation();
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
const [newRule, setNewRule] = React.useState("");
React.useEffect(() => {
if (isOpen) {
setBypassRules(initialRules ?? []);
setNewRule("");
}
}, [isOpen, initialRules]);
const updateBypassRules = async (rules: string[]) => {
if (!profileId) return;
try {
await invoke("update_profile_proxy_bypass_rules", {
profileId,
rules,
});
setBypassRules(rules);
} catch {
// ignore
}
};
const handleAddRule = () => {
const trimmed = newRule.trim();
if (!trimmed || bypassRules.includes(trimmed)) return;
const updated = [...bypassRules, trimmed];
setNewRule("");
void updateBypassRules(updated);
};
const handleRemoveRule = (rule: string) => {
void updateBypassRules(bypassRules.filter((r) => r !== rule));
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("profileInfo.network.bypassRulesTitle")}</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 min-h-0">
<div className="flex flex-col gap-3 py-2">
<p className="text-sm text-muted-foreground">
{t("profileInfo.network.bypassRulesDescription")}
</p>
<div className="flex gap-2">
<Input
value={newRule}
onChange={(e) => {
setNewRule(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddRule();
}}
placeholder={t("profileInfo.network.rulePlaceholder")}
className="flex-1 text-sm"
/>
<Button
size="sm"
onClick={handleAddRule}
disabled={!newRule.trim()}
>
<LuPlus className="w-4 h-4 mr-1" />
{t("profileInfo.network.addRule")}
</Button>
</div>
{bypassRules.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("profileInfo.network.noRules")}
</p>
) : (
<div className="flex flex-col gap-1.5">
{bypassRules.map((rule) => (
<div
key={rule}
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
>
<span className="font-mono text-xs truncate">{rule}</span>
<button
type="button"
onClick={() => {
handleRemoveRule(rule);
}}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<LuX className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("profileInfo.network.ruleTypes")}
</p>
</div>
</ScrollArea>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}