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
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./dist/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+38
View File
@@ -11,6 +11,7 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
import { GroupBadges } from "@/components/group-badges";
@@ -146,6 +147,14 @@ export default function Home() {
useState(false);
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
useState(false);
const [
extensionGroupAssignmentDialogOpen,
setExtensionGroupAssignmentDialogOpen,
] = useState(false);
const [
selectedProfilesForExtensionGroup,
setSelectedProfilesForExtensionGroup,
] = useState<string[]>([]);
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
useState(false);
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
@@ -701,6 +710,22 @@ export default function Home() {
setSelectedProfiles([]);
}, [selectedProfiles, handleAssignProfilesToGroup]);
const handleAssignExtensionGroup = useCallback((profileIds: string[]) => {
setSelectedProfilesForExtensionGroup(profileIds);
setExtensionGroupAssignmentDialogOpen(true);
}, []);
const handleBulkExtensionGroupAssignment = useCallback(() => {
if (selectedProfiles.length === 0) return;
handleAssignExtensionGroup(selectedProfiles);
setSelectedProfiles([]);
}, [selectedProfiles, handleAssignExtensionGroup]);
const handleExtensionGroupAssignmentComplete = useCallback(() => {
setExtensionGroupAssignmentDialogOpen(false);
setSelectedProfilesForExtensionGroup([]);
}, []);
const handleAssignProfilesToProxy = useCallback((profileIds: string[]) => {
setSelectedProfilesForProxy(profileIds);
setProxyAssignmentDialogOpen(true);
@@ -1042,6 +1067,7 @@ export default function Home() {
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
crossOsUnlocked={crossOsUnlocked}
/>
</div>
<div className="w-full mt-2.5">
@@ -1072,6 +1098,8 @@ export default function Home() {
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
onBulkCopyCookies={handleBulkCopyCookies}
onBulkExtensionGroupAssignment={handleBulkExtensionGroupAssignment}
onAssignExtensionGroup={handleAssignExtensionGroup}
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
@@ -1192,6 +1220,16 @@ export default function Home() {
profiles={profiles}
/>
<ExtensionGroupAssignmentDialog
isOpen={extensionGroupAssignmentDialogOpen}
onClose={() => {
setExtensionGroupAssignmentDialogOpen(false);
}}
selectedProfiles={selectedProfilesForExtensionGroup}
onAssignmentComplete={handleExtensionGroupAssignmentComplete}
profiles={profiles}
/>
<ProxyAssignmentDialog
isOpen={proxyAssignmentDialogOpen}
onClose={() => {
@@ -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" />
+7 -2
View File
@@ -730,7 +730,8 @@
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
},
"actions": {
"manageCookies": "Manage Cookies"
"manageCookies": "Manage Cookies",
"assignExtensionGroup": "Assign Extension Group"
},
"clone": {
"title": "Clone Profile",
@@ -774,7 +775,11 @@
"deleteGroupConfirmTitle": "Delete Extension Group",
"deleteGroupConfirmDescription": "Are you sure you want to delete the group \"{{name}}\"? This action cannot be undone.",
"invalidFileType": "Invalid file type. Please upload a .crx, .xpi, or .zip file.",
"readError": "Failed to read the extension file."
"readError": "Failed to read the extension file.",
"assignTitle": "Assign Extension Group",
"assignDescription": "Assign {{count}} selected profile(s) to an extension group.",
"noGroup": "None (No Extension Group)",
"assignSuccess": "Extension group assigned successfully"
},
"pro": {
"badge": "PRO",
+7 -2
View File
@@ -730,7 +730,8 @@
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
},
"actions": {
"manageCookies": "Administrar Cookies"
"manageCookies": "Administrar Cookies",
"assignExtensionGroup": "Asignar Grupo de Extensiones"
},
"clone": {
"title": "Clonar Perfil",
@@ -774,7 +775,11 @@
"deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones",
"deleteGroupConfirmDescription": "¿Estás seguro de que deseas eliminar el grupo \"{{name}}\"? Esta acción no se puede deshacer.",
"invalidFileType": "Tipo de archivo no válido. Suba un archivo .crx, .xpi o .zip.",
"readError": "No se pudo leer el archivo de extensión."
"readError": "No se pudo leer el archivo de extensión.",
"assignTitle": "Asignar Grupo de Extensiones",
"assignDescription": "Asignar {{count}} perfil(es) seleccionado(s) a un grupo de extensiones.",
"noGroup": "Ninguno (Sin Grupo de Extensiones)",
"assignSuccess": "Grupo de extensiones asignado exitosamente"
},
"pro": {
"badge": "PRO",
+7 -2
View File
@@ -730,7 +730,8 @@
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
},
"actions": {
"manageCookies": "Gérer les Cookies"
"manageCookies": "Gérer les Cookies",
"assignExtensionGroup": "Assigner un Groupe d'Extensions"
},
"clone": {
"title": "Cloner le Profil",
@@ -774,7 +775,11 @@
"deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions",
"deleteGroupConfirmDescription": "Êtes-vous sûr de vouloir supprimer le groupe \"{{name}}\" ? Cette action est irréversible.",
"invalidFileType": "Type de fichier non valide. Veuillez télécharger un fichier .crx, .xpi ou .zip.",
"readError": "Impossible de lire le fichier d'extension."
"readError": "Impossible de lire le fichier d'extension.",
"assignTitle": "Assigner un Groupe d'Extensions",
"assignDescription": "Assigner {{count}} profil(s) sélectionné(s) à un groupe d'extensions.",
"noGroup": "Aucun (Pas de Groupe d'Extensions)",
"assignSuccess": "Groupe d'extensions assigné avec succès"
},
"pro": {
"badge": "PRO",
+7 -2
View File
@@ -730,7 +730,8 @@
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
},
"actions": {
"manageCookies": "Cookieを管理"
"manageCookies": "Cookieを管理",
"assignExtensionGroup": "拡張機能グループを割り当て"
},
"clone": {
"title": "プロフィールを複製",
@@ -774,7 +775,11 @@
"deleteGroupConfirmTitle": "拡張機能グループを削除",
"deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"invalidFileType": "無効なファイルタイプです。.crx、.xpi、または .zip ファイルをアップロードしてください。",
"readError": "拡張機能ファイルの読み取りに失敗しました。"
"readError": "拡張機能ファイルの読み取りに失敗しました。",
"assignTitle": "拡張機能グループの割り当て",
"assignDescription": "選択した{{count}}件のプロファイルを拡張機能グループに割り当てます。",
"noGroup": "なし(拡張機能グループなし)",
"assignSuccess": "拡張機能グループが正常に割り当てられました"
},
"pro": {
"badge": "PRO",
+7 -2
View File
@@ -730,7 +730,8 @@
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
},
"actions": {
"manageCookies": "Gerenciar Cookies"
"manageCookies": "Gerenciar Cookies",
"assignExtensionGroup": "Atribuir Grupo de Extensões"
},
"clone": {
"title": "Clonar Perfil",
@@ -774,7 +775,11 @@
"deleteGroupConfirmTitle": "Excluir Grupo de Extensões",
"deleteGroupConfirmDescription": "Tem certeza de que deseja excluir o grupo \"{{name}}\"? Esta ação não pode ser desfeita.",
"invalidFileType": "Tipo de arquivo inválido. Envie um arquivo .crx, .xpi ou .zip.",
"readError": "Falha ao ler o arquivo de extensão."
"readError": "Falha ao ler o arquivo de extensão.",
"assignTitle": "Atribuir Grupo de Extensões",
"assignDescription": "Atribuir {{count}} perfil(is) selecionado(s) a um grupo de extensões.",
"noGroup": "Nenhum (Sem Grupo de Extensões)",
"assignSuccess": "Grupo de extensões atribuído com sucesso"
},
"pro": {
"badge": "PRO",
+7 -2
View File
@@ -730,7 +730,8 @@
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
},
"actions": {
"manageCookies": "Управление Cookie"
"manageCookies": "Управление Cookie",
"assignExtensionGroup": "Назначить группу расширений"
},
"clone": {
"title": "Клонировать профиль",
@@ -774,7 +775,11 @@
"deleteGroupConfirmTitle": "Удалить группу расширений",
"deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.",
"invalidFileType": "Недопустимый тип файла. Загрузите файл .crx, .xpi или .zip.",
"readError": "Не удалось прочитать файл расширения."
"readError": "Не удалось прочитать файл расширения.",
"assignTitle": "Назначить группу расширений",
"assignDescription": "Назначить {{count}} выбранных профилей в группу расширений.",
"noGroup": "Нет (Без группы расширений)",
"assignSuccess": "Группа расширений успешно назначена"
},
"pro": {
"badge": "PRO",
+7 -2
View File
@@ -730,7 +730,8 @@
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
},
"actions": {
"manageCookies": "管理 Cookie"
"manageCookies": "管理 Cookie",
"assignExtensionGroup": "分配扩展程序组"
},
"clone": {
"title": "克隆配置文件",
@@ -774,7 +775,11 @@
"deleteGroupConfirmTitle": "删除扩展程序组",
"deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。",
"invalidFileType": "无效的文件类型。请上传 .crx、.xpi 或 .zip 文件。",
"readError": "读取扩展程序文件失败。"
"readError": "读取扩展程序文件失败。",
"assignTitle": "分配扩展程序组",
"assignDescription": "将 {{count}} 个选定的配置文件分配到扩展程序组。",
"noGroup": "无(不使用扩展程序组)",
"assignSuccess": "扩展程序组分配成功"
},
"pro": {
"badge": "PRO",