refactor: extension cleanup

This commit is contained in:
zhom
2026-03-03 01:00:28 +04:00
parent dd6834a4af
commit 250e206eef
13 changed files with 584 additions and 279 deletions
@@ -0,0 +1,188 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BrowserProfile, ExtensionGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ExtensionGroupAssignmentDialogProps {
isOpen: boolean;
onClose: () => void;
selectedProfiles: string[];
onAssignmentComplete: () => void;
profiles?: BrowserProfile[];
}
export function ExtensionGroupAssignmentDialog({
isOpen,
onClose,
selectedProfiles,
onAssignmentComplete,
profiles = [],
}: ExtensionGroupAssignmentDialogProps) {
const { t } = useTranslation();
const [groups, setGroups] = useState<ExtensionGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isAssigning, setIsAssigning] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadGroups = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const groupList = await invoke<ExtensionGroup[]>("list_extension_groups");
setGroups(groupList);
} catch (err) {
console.error("Failed to load extension groups:", err);
setError(
err instanceof Error ? err.message : "Failed to load extension groups",
);
} finally {
setIsLoading(false);
}
}, []);
const handleAssign = useCallback(async () => {
setIsAssigning(true);
setError(null);
try {
for (const profileId of selectedProfiles) {
await invoke("assign_extension_group_to_profile", {
profileId,
extensionGroupId: selectedGroupId,
});
}
toast.success(t("extensions.assignSuccess"));
onAssignmentComplete();
onClose();
} catch (err) {
console.error("Failed to assign extension group:", err);
const errorMessage =
err instanceof Error ? err.message : "Failed to assign extension group";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsAssigning(false);
}
}, [selectedProfiles, selectedGroupId, onAssignmentComplete, onClose, t]);
useEffect(() => {
if (isOpen) {
void loadGroups();
setSelectedGroupId(null);
setError(null);
}
}, [isOpen, loadGroups]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("extensions.assignTitle")}</DialogTitle>
<DialogDescription>
{t("extensions.assignDescription", {
count: selectedProfiles.length,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("extensions.assignTitle")}:</Label>
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
<ul className="text-sm space-y-1">
{selectedProfiles.map((profileId) => {
const profile = profiles.find(
(p: BrowserProfile) => p.id === profileId,
);
const displayName = profile ? profile.name : profileId;
return (
<li key={profileId} className="truncate">
{displayName}
</li>
);
})}
</ul>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="extension-group-select">
{t("extensions.extensionGroup")}:
</Label>
{isLoading ? (
<div className="text-sm text-muted-foreground">
{t("common.buttons.loading")}
</div>
) : (
<Select
value={selectedGroupId || "none"}
onValueChange={(value) => {
setSelectedGroupId(value === "none" ? null : value);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("extensions.noGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
</div>
<DialogFooter>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isAssigning}
>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isAssigning}
onClick={() => void handleAssign()}
disabled={isLoading}
>
{t("common.buttons.apply")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+7
View File
@@ -10,6 +10,7 @@ import {
LuUsers,
LuX,
} from "react-icons/lu";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
@@ -20,6 +21,7 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Input } from "./ui/input";
import { ProBadge } from "./ui/pro-badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type Props = {
@@ -33,6 +35,7 @@ type Props = {
onExtensionManagementDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
crossOsUnlocked?: boolean;
};
const HomeHeader = ({
@@ -46,6 +49,7 @@ const HomeHeader = ({
onExtensionManagementDialogOpen,
searchQuery,
onSearchQueryChange,
crossOsUnlocked = false,
}: Props) => {
const { t } = useTranslation();
const handleLogoClick = () => {
@@ -134,12 +138,15 @@ const HomeHeader = ({
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!crossOsUnlocked}
className={cn(!crossOsUnlocked && "opacity-50")}
onClick={() => {
onExtensionManagementDialogOpen(true);
}}
>
<LuPuzzle className="mr-2 w-4 h-4" />
{t("header.menu.extensions")}
{!crossOsUnlocked && <ProBadge className="ml-auto" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
+48 -2
View File
@@ -23,11 +23,15 @@ import {
LuCookie,
LuInfo,
LuLock,
LuPuzzle,
LuTrash2,
LuUsers,
} from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ProfileInfoDialog } from "@/components/profile-info-dialog";
import {
ProfileBypassRulesDialog,
ProfileInfoDialog,
} from "@/components/profile-info-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -791,6 +795,8 @@ interface ProfilesDataTableProps {
onBulkGroupAssignment?: () => void;
onBulkProxyAssignment?: () => void;
onBulkCopyCookies?: () => void;
onBulkExtensionGroupAssignment?: () => void;
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
@@ -816,6 +822,8 @@ export function ProfilesDataTable({
onBulkGroupAssignment,
onBulkProxyAssignment,
onBulkCopyCookies,
onBulkExtensionGroupAssignment,
onAssignExtensionGroup,
onOpenProfileSyncDialog,
onToggleProfileSync,
crossOsUnlocked = false,
@@ -886,6 +894,8 @@ export function ProfilesDataTable({
const [isDeleting, setIsDeleting] = React.useState(false);
const [profileForInfoDialog, setProfileForInfoDialog] =
React.useState<BrowserProfile | null>(null);
const [bypassRulesProfile, setBypassRulesProfile] =
React.useState<BrowserProfile | null>(null);
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
new Set(),
);
@@ -2505,6 +2515,8 @@ export function ProfilesDataTable({
onConfigureCamoufox={onConfigureCamoufox}
onCopyCookiesToProfile={onCopyCookiesToProfile}
onOpenCookieManagement={onOpenCookieManagement}
onAssignExtensionGroup={onAssignExtensionGroup}
onOpenBypassRules={(profile) => setBypassRulesProfile(profile)}
onCloneProfile={onCloneProfile}
onDeleteProfile={(profile) => {
setProfileForInfoDialog(null);
@@ -2538,6 +2550,27 @@ export function ProfilesDataTable({
<FiWifi />
</DataTableActionBarAction>
)}
{onBulkExtensionGroupAssignment && (
<DataTableActionBarAction
tooltip={
crossOsUnlocked
? "Assign Extension Group"
: "Assign Extension Group (Pro)"
}
onClick={onBulkExtensionGroupAssignment}
size="icon"
disabled={!crossOsUnlocked}
>
<span className="relative">
<LuPuzzle />
{!crossOsUnlocked && (
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
PRO
</span>
)}
</span>
</DataTableActionBarAction>
)}
{onBulkCopyCookies && (
<DataTableActionBarAction
tooltip={crossOsUnlocked ? "Copy Cookies" : "Copy Cookies (Pro)"}
@@ -2545,7 +2578,14 @@ export function ProfilesDataTable({
size="icon"
disabled={!crossOsUnlocked}
>
<LuCookie />
<span className="relative">
<LuCookie />
{!crossOsUnlocked && (
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
PRO
</span>
)}
</span>
</DataTableActionBarAction>
)}
{onBulkDelete && (
@@ -2568,6 +2608,12 @@ export function ProfilesDataTable({
profileName={trafficDialogProfile.name}
/>
)}
<ProfileBypassRulesDialog
isOpen={bypassRulesProfile !== null}
onClose={() => setBypassRulesProfile(null)}
profileId={bypassRulesProfile?.id ?? null}
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
/>
</>
);
}
+253 -262
View File
@@ -14,6 +14,7 @@ import {
LuGlobe,
LuGroup,
LuPlus,
LuPuzzle,
LuRefreshCw,
LuSettings,
LuShieldCheck,
@@ -60,6 +61,8 @@ interface ProfileInfoDialogProps {
onConfigureCamoufox?: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenBypassRules?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
@@ -103,6 +106,8 @@ export function ProfileInfoDialog({
onConfigureCamoufox,
onCopyCookiesToProfile,
onOpenCookieManagement,
onAssignExtensionGroup,
onOpenBypassRules,
onCloneProfile,
onDeleteProfile,
crossOsUnlocked = false,
@@ -117,10 +122,6 @@ export function ProfileInfoDialog({
const [extensionGroupName, setExtensionGroupName] = React.useState<
string | null
>(null);
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
const [newRule, setNewRule] = React.useState("");
const [bypassRulesDialogOpen, setBypassRulesDialogOpen] =
React.useState(false);
React.useEffect(() => {
if (!isOpen || !profile?.group_id) {
@@ -159,12 +160,8 @@ export function ProfileInfoDialog({
React.useEffect(() => {
if (!isOpen) {
setCopied(false);
setNewRule("");
}
if (isOpen && profile) {
setBypassRules(profile.proxy_bypass_rules ?? []);
}
}, [isOpen, profile]);
}, [isOpen]);
if (!profile) return null;
@@ -206,31 +203,6 @@ export function ProfileInfoDialog({
action();
};
const updateBypassRules = async (rules: string[]) => {
if (!profile) return;
try {
await invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
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));
};
const releaseLabel =
profile.release_type.charAt(0).toUpperCase() +
profile.release_type.slice(1);
@@ -305,10 +277,18 @@ export function ProfileInfoDialog({
disabled: isDisabled,
hidden: profile.ephemeral === true,
},
{
icon: <LuPuzzle className="w-4 h-4" />,
label: t("profileInfo.actions.assignExtensionGroup"),
onClick: () => handleAction(() => onAssignExtensionGroup?.([profile.id])),
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
hidden: profile.ephemeral === true,
},
{
icon: <LuShieldCheck className="w-4 h-4" />,
label: t("profileInfo.network.bypassRulesTitle"),
onClick: () => setBypassRulesDialogOpen(true),
onClick: () => handleAction(() => onOpenBypassRules?.(profile)),
},
{
icon: <LuTrash2 className="w-4 h-4" />,
@@ -322,247 +302,254 @@ export function ProfileInfoDialog({
const visibleActions = actions.filter((a) => !a.hidden);
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="info" className="flex-1 min-h-0 flex flex-col">
<TabsList className="w-full shrink-0">
<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" className="flex-1 min-h-0 flex flex-col">
<ScrollArea className="flex-1 min-h-0">
<div className="flex flex-col gap-4 py-3 pr-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}
<Dialog open={isOpen} onOpenChange={(open) => !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">
{releaseLabel}
{t("profiles.ephemeralBadge")}
</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>
)}
{showCrossOs && profile.host_os && (
<Badge variant="outline" className="text-xs gap-1">
<OSIcon os={profile.host_os} />
{getOSDisplayName(profile.host_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")
}
/>
</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>
</ScrollArea>
</TabsContent>
<TabsContent
value="settings"
className="flex-1 min-h-0 flex flex-col"
>
<ScrollArea className="flex-1 min-h-0">
<div className="flex flex-col py-1">
{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",
{showCrossOs && profile.host_os && (
<Badge variant="outline" className="text-xs gap-1">
<OSIcon os={profile.host_os} />
{getOSDisplayName(profile.host_os)}
</Badge>
)}
>
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.proBadge && <ProBadge />}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ProfileBypassRulesDialog
isOpen={bypassRulesDialogOpen}
onClose={() => setBypassRulesDialogOpen(false)}
bypassRules={bypassRules}
newRule={newRule}
onNewRuleChange={setNewRule}
onAddRule={handleAddRule}
onRemoveRule={handleRemoveRule}
/>
</>
{/* 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")
}
/>
</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 py-1">
{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.proBadge && <ProBadge />}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
interface ProfileBypassRulesDialogProps {
isOpen: boolean;
onClose: () => void;
bypassRules: string[];
newRule: string;
onNewRuleChange: (value: string) => void;
onAddRule: () => void;
onRemoveRule: (rule: string) => void;
profileId: string | null;
initialRules?: string[];
}
function ProfileBypassRulesDialog({
export function ProfileBypassRulesDialog({
isOpen,
onClose,
bypassRules,
newRule,
onNewRuleChange,
onAddRule,
onRemoveRule,
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) => !open && onClose()}>
@@ -578,14 +565,18 @@ function ProfileBypassRulesDialog({
<div className="flex gap-2">
<Input
value={newRule}
onChange={(e) => onNewRuleChange(e.target.value)}
onChange={(e) => setNewRule(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onAddRule();
if (e.key === "Enter") handleAddRule();
}}
placeholder={t("profileInfo.network.rulePlaceholder")}
className="flex-1 text-sm"
/>
<Button size="sm" onClick={onAddRule} disabled={!newRule.trim()}>
<Button
size="sm"
onClick={handleAddRule}
disabled={!newRule.trim()}
>
<LuPlus className="w-4 h-4 mr-1" />
{t("profileInfo.network.addRule")}
</Button>
@@ -604,7 +595,7 @@ function ProfileBypassRulesDialog({
<span className="font-mono text-xs truncate">{rule}</span>
<button
type="button"
onClick={() => onRemoveRule(rule)}
onClick={() => handleRemoveRule(rule)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<LuX className="w-3.5 h-3.5" />