From 250e206eef049d73376b18396d812fb6513bd25e Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:00:28 +0400 Subject: [PATCH] refactor: extension cleanup --- next-env.d.ts | 2 +- src/app/page.tsx | 38 ++ .../extension-group-assignment-dialog.tsx | 188 +++++++ src/components/home-header.tsx | 7 + src/components/profile-data-table.tsx | 50 +- src/components/profile-info-dialog.tsx | 515 +++++++++--------- src/i18n/locales/en.json | 9 +- src/i18n/locales/es.json | 9 +- src/i18n/locales/fr.json | 9 +- src/i18n/locales/ja.json | 9 +- src/i18n/locales/pt.json | 9 +- src/i18n/locales/ru.json | 9 +- src/i18n/locales/zh.json | 9 +- 13 files changed, 584 insertions(+), 279 deletions(-) create mode 100644 src/components/extension-group-assignment-dialog.tsx diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..b87975d 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/src/app/page.tsx b/src/app/page.tsx index fc8bb7b..3e1c5ad 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([]); 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} />
@@ -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} /> + { + setExtensionGroupAssignmentDialogOpen(false); + }} + selectedProfiles={selectedProfilesForExtensionGroup} + onAssignmentComplete={handleExtensionGroupAssignmentComplete} + profiles={profiles} + /> + { diff --git a/src/components/extension-group-assignment-dialog.tsx b/src/components/extension-group-assignment-dialog.tsx new file mode 100644 index 0000000..7eebf9b --- /dev/null +++ b/src/components/extension-group-assignment-dialog.tsx @@ -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([]); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isAssigning, setIsAssigning] = useState(false); + const [error, setError] = useState(null); + + const loadGroups = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const groupList = await invoke("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 ( + + + + {t("extensions.assignTitle")} + + {t("extensions.assignDescription", { + count: selectedProfiles.length, + })} + + + +
+
+ +
+
    + {selectedProfiles.map((profileId) => { + const profile = profiles.find( + (p: BrowserProfile) => p.id === profileId, + ); + const displayName = profile ? profile.name : profileId; + return ( +
  • + • {displayName} +
  • + ); + })} +
+
+
+ +
+ + {isLoading ? ( +
+ {t("common.buttons.loading")} +
+ ) : ( + + )} +
+ + {error && ( +
+ {error} +
+ )} +
+ + + + {t("common.buttons.cancel")} + + void handleAssign()} + disabled={isLoading} + > + {t("common.buttons.apply")} + + +
+
+ ); +} diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx index 4beb49f..e87c48d 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -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")} { onExtensionManagementDialogOpen(true); }} > {t("header.menu.extensions")} + {!crossOsUnlocked && } { diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index c331993..784c516 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -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(null); + const [bypassRulesProfile, setBypassRulesProfile] = + React.useState(null); const [launchingProfiles, setLaunchingProfiles] = React.useState>( 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({ )} + {onBulkExtensionGroupAssignment && ( + + + + {!crossOsUnlocked && ( + + PRO + + )} + + + )} {onBulkCopyCookies && ( - + + + {!crossOsUnlocked && ( + + PRO + + )} + )} {onBulkDelete && ( @@ -2568,6 +2608,12 @@ export function ProfilesDataTable({ profileName={trafficDialogProfile.name} /> )} + setBypassRulesProfile(null)} + profileId={bypassRulesProfile?.id ?? null} + initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []} + /> ); } diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index c7f03a2..1559bd4 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -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([]); - 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: , + label: t("profileInfo.actions.assignExtensionGroup"), + onClick: () => handleAction(() => onAssignExtensionGroup?.([profile.id])), + disabled: isDisabled || !crossOsUnlocked, + proBadge: !crossOsUnlocked, + hidden: profile.ephemeral === true, + }, { icon: , label: t("profileInfo.network.bypassRulesTitle"), - onClick: () => setBypassRulesDialogOpen(true), + onClick: () => handleAction(() => onOpenBypassRules?.(profile)), }, { icon: , @@ -322,247 +302,254 @@ export function ProfileInfoDialog({ const visibleActions = actions.filter((a) => !a.hidden); return ( - <> - !open && onClose()}> - - - {t("profileInfo.title")} - - - - - {t("profileInfo.tabs.info")} - - - {t("profileInfo.tabs.settings")} - - - - -
- {/* Hero */} -
-
- -
-
-

- {profile.name} -

-
- - {getBrowserDisplayName(profile.browser)}{" "} - {profile.version} + !open && onClose()}> + + + {t("profileInfo.title")} + + + + + {t("profileInfo.tabs.info")} + + + {t("profileInfo.tabs.settings")} + + + +
+
+ {/* Hero */} +
+
+ +
+
+

+ {profile.name} +

+
+ + {getBrowserDisplayName(profile.browser)}{" "} + {profile.version} + + + {releaseLabel} + + {isRunning && ( + + {t("common.status.running")} + )} + {profile.ephemeral && ( - {releaseLabel} + {t("profiles.ephemeralBadge")} - {isRunning && ( - - {t("common.status.running")} - - )} - {profile.ephemeral && ( - - {t("profiles.ephemeralBadge")} - - )} - {showCrossOs && profile.host_os && ( - - - {getOSDisplayName(profile.host_os)} - - )} -
-
-
- - {/* Profile ID */} -
- - ID - - - {profile.id} - - -
- - {/* Network & Organization */} -
- - - - -
- - {/* Sync */} -
-
-

- {t("profileInfo.fields.syncStatus")} -

-

{syncLabel}

-
- - {syncMode === "Disabled" - ? t("sync.mode.disabled") - : syncStatus?.status === "syncing" - ? t("common.status.syncing") - : t("common.status.synced")} - -
- - {/* Tags */} - {hasTags && ( -
- - {t("profileInfo.fields.tags")} - -
- {profile.tags?.map((tag) => ( - - {tag} - - ))} -
-
- )} - - {/* Note */} - {hasNote && ( -
- - {t("profileInfo.fields.note")} - -

- {profile.note} -

-
- )} - - {/* Team */} - {profile.created_by_email && ( -
-

- {t("sync.team.title")} -

-

- {t("sync.team.createdBy", { - email: profile.created_by_email, - })} -

-
- )} -
- - - - -
- {visibleActions.map((action) => ( - - ))} +
+
- - - - - - - -
- setBypassRulesDialogOpen(false)} - bypassRules={bypassRules} - newRule={newRule} - onNewRuleChange={setNewRule} - onAddRule={handleAddRule} - onRemoveRule={handleRemoveRule} - /> - + + {/* Profile ID */} +
+ + ID + + + {profile.id} + + +
+ + {/* Network & Organization */} +
+ + + + +
+ + {/* Sync */} +
+
+

+ {t("profileInfo.fields.syncStatus")} +

+

{syncLabel}

+
+ + {syncMode === "Disabled" + ? t("sync.mode.disabled") + : syncStatus?.status === "syncing" + ? t("common.status.syncing") + : t("common.status.synced")} + +
+ + {/* Tags */} + {hasTags && ( +
+ + {t("profileInfo.fields.tags")} + +
+ {profile.tags?.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {/* Note */} + {hasNote && ( +
+ + {t("profileInfo.fields.note")} + +

+ {profile.note} +

+
+ )} + + {/* Team */} + {profile.created_by_email && ( +
+

+ {t("sync.team.title")} +

+

+ {t("sync.team.createdBy", { + email: profile.created_by_email, + })} +

+
+ )} +
+ + + +
+
+ {visibleActions.map((action) => ( + + ))} +
+
+
+ + + ); } 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([]); + 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 ( !open && onClose()}> @@ -578,14 +565,18 @@ function ProfileBypassRulesDialog({
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" /> - @@ -604,7 +595,7 @@ function ProfileBypassRulesDialog({ {rule}