mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 09:47:51 +02:00
refactor: extension cleanup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user