mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-26 22:06:23 +02:00
refactor: extension cleanup
This commit is contained in:
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user