mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-25 17:47:48 +02:00
feat: extension management
This commit is contained in:
@@ -74,6 +74,7 @@ interface CreateProfileDialogProps {
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
extensionGroupId?: string;
|
||||
ephemeral?: boolean;
|
||||
}) => Promise<void>;
|
||||
selectedGroupId?: string;
|
||||
@@ -166,6 +167,21 @@ export function CreateProfileDialog({
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
|
||||
useState<string>();
|
||||
const [extensionGroups, setExtensionGroups] = useState<
|
||||
{ id: string; name: string; extension_ids: string[] }[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
invoke<{ id: string; name: string; extension_ids: string[] }[]>(
|
||||
"list_extension_groups",
|
||||
)
|
||||
.then(setExtensionGroups)
|
||||
.catch(() => setExtensionGroups([]));
|
||||
}
|
||||
}, [isOpen]);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
|
||||
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
|
||||
const [releaseTypesError, setReleaseTypesError] = useState<string | null>(
|
||||
@@ -406,6 +422,7 @@ export function CreateProfileDialog({
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
});
|
||||
} else {
|
||||
@@ -430,6 +447,7 @@ export function CreateProfileDialog({
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
});
|
||||
}
|
||||
@@ -1074,6 +1092,37 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extension Group */}
|
||||
{extensionGroups.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.extensionGroup")}</Label>
|
||||
<Select
|
||||
value={selectedExtensionGroupId || "none"}
|
||||
onValueChange={(val) =>
|
||||
setSelectedExtensionGroupId(
|
||||
val === "none" ? undefined : val,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("profileInfo.values.none")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("profileInfo.values.none")}
|
||||
</SelectItem>
|
||||
{extensionGroups.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name} ({g.extension_ids.length})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuPencil, LuPuzzle, LuTrash2, LuUpload } from "react-icons/lu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { Extension, ExtensionGroup } from "@/types";
|
||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ExtensionManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
limitedMode: boolean;
|
||||
}
|
||||
|
||||
export function ExtensionManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
limitedMode,
|
||||
}: ExtensionManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Extension upload state
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [extensionName, setExtensionName] = useState("");
|
||||
const [showUploadForm, setShowUploadForm] = useState(false);
|
||||
const [pendingFile, setPendingFile] = useState<{
|
||||
name: string;
|
||||
data: number[];
|
||||
} | null>(null);
|
||||
|
||||
// Group state
|
||||
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [editingGroup, setEditingGroup] = useState<ExtensionGroup | null>(null);
|
||||
const [editGroupName, setEditGroupName] = useState("");
|
||||
|
||||
// Delete state
|
||||
const [extensionToDelete, setExtensionToDelete] = useState<Extension | null>(
|
||||
null,
|
||||
);
|
||||
const [groupToDelete, setGroupToDelete] = useState<ExtensionGroup | null>(
|
||||
null,
|
||||
);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Tab
|
||||
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
|
||||
"extensions",
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (limitedMode) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [exts, groups] = await Promise.all([
|
||||
invoke<Extension[]>("list_extensions"),
|
||||
invoke<ExtensionGroup[]>("list_extension_groups"),
|
||||
]);
|
||||
setExtensions(exts);
|
||||
setExtensionGroups(groups);
|
||||
} catch {
|
||||
// User may not have pro subscription
|
||||
setExtensions([]);
|
||||
setExtensionGroups([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [limitedMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadData();
|
||||
}
|
||||
}, [isOpen, loadData]);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const validExtensions = [".xpi", ".crx", ".zip"];
|
||||
const isValid = validExtensions.some((ext) =>
|
||||
file.name.toLowerCase().endsWith(ext),
|
||||
);
|
||||
if (!isValid) {
|
||||
showErrorToast(t("extensions.invalidFileType"));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||
const data = Array.from(new Uint8Array(arrayBuffer));
|
||||
const baseName = file.name
|
||||
.replace(/\.(xpi|crx|zip)$/i, "")
|
||||
.replace(/[-_]/g, " ");
|
||||
setExtensionName(baseName);
|
||||
setPendingFile({ name: file.name, data });
|
||||
setShowUploadForm(true);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showErrorToast(t("extensions.readError"));
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
|
||||
// Reset input
|
||||
e.target.value = "";
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (!pendingFile || !extensionName.trim()) return;
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await invoke("add_extension", {
|
||||
name: extensionName.trim(),
|
||||
fileName: pendingFile.name,
|
||||
fileData: pendingFile.data,
|
||||
});
|
||||
showSuccessToast(t("extensions.uploadSuccess"));
|
||||
setShowUploadForm(false);
|
||||
setPendingFile(null);
|
||||
setExtensionName("");
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [pendingFile, extensionName, loadData, t]);
|
||||
|
||||
const handleDeleteExtension = useCallback(async () => {
|
||||
if (!extensionToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await invoke("delete_extension", { extensionId: extensionToDelete.id });
|
||||
showSuccessToast(t("extensions.deleteSuccess"));
|
||||
setExtensionToDelete(null);
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [extensionToDelete, loadData, t]);
|
||||
|
||||
const handleCreateGroup = useCallback(async () => {
|
||||
if (!newGroupName.trim()) return;
|
||||
try {
|
||||
await invoke("create_extension_group", { name: newGroupName.trim() });
|
||||
showSuccessToast(t("extensions.groupCreateSuccess"));
|
||||
setShowCreateGroup(false);
|
||||
setNewGroupName("");
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [newGroupName, loadData, t]);
|
||||
|
||||
const handleUpdateGroup = useCallback(async () => {
|
||||
if (!editingGroup || !editGroupName.trim()) return;
|
||||
try {
|
||||
await invoke("update_extension_group", {
|
||||
groupId: editingGroup.id,
|
||||
name: editGroupName.trim(),
|
||||
});
|
||||
showSuccessToast(t("extensions.groupUpdateSuccess"));
|
||||
setEditingGroup(null);
|
||||
setEditGroupName("");
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [editingGroup, editGroupName, loadData, t]);
|
||||
|
||||
const handleDeleteGroup = useCallback(async () => {
|
||||
if (!groupToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await invoke("delete_extension_group", { groupId: groupToDelete.id });
|
||||
showSuccessToast(t("extensions.groupDeleteSuccess"));
|
||||
setGroupToDelete(null);
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [groupToDelete, loadData, t]);
|
||||
|
||||
const handleAddToGroup = useCallback(
|
||||
async (groupId: string, extensionId: string) => {
|
||||
try {
|
||||
await invoke("add_extension_to_group", { groupId, extensionId });
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[loadData],
|
||||
);
|
||||
|
||||
const handleRemoveFromGroup = useCallback(
|
||||
async (groupId: string, extensionId: string) => {
|
||||
try {
|
||||
await invoke("remove_extension_from_group", { groupId, extensionId });
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[loadData],
|
||||
);
|
||||
|
||||
const getCompatibilityBadge = (compat: string[]) => {
|
||||
if (compat.includes("chromium") && compat.includes("firefox")) {
|
||||
return (
|
||||
<Badge variant="secondary">{t("extensions.compatibility.both")}</Badge>
|
||||
);
|
||||
}
|
||||
if (compat.includes("chromium")) {
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{t("extensions.compatibility.chromium")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (compat.includes("firefox")) {
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{t("extensions.compatibility.firefox")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuPuzzle className="w-5 h-5" />
|
||||
{t("extensions.title")}
|
||||
{limitedMode && <ProBadge />}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("extensions.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("extensions.proRequired")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Tab selector */}
|
||||
<div className="flex gap-2 border-b">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "extensions"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setActiveTab("extensions")}
|
||||
disabled={limitedMode}
|
||||
>
|
||||
{t("extensions.extensionsTab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "groups"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setActiveTab("groups")}
|
||||
disabled={limitedMode}
|
||||
>
|
||||
{t("extensions.groupsTab")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notice */}
|
||||
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
{t("extensions.managedNotice")}
|
||||
</div>
|
||||
|
||||
{activeTab === "extensions" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>{t("extensions.extensionsTab")}</Label>
|
||||
<div>
|
||||
<label htmlFor="ext-file-input">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
disabled={limitedMode}
|
||||
onClick={() =>
|
||||
document.getElementById("ext-file-input")?.click()
|
||||
}
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
{t("extensions.upload")}
|
||||
</RippleButton>
|
||||
</label>
|
||||
<input
|
||||
id="ext-file-input"
|
||||
type="file"
|
||||
accept=".xpi,.crx,.zip"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={limitedMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload form */}
|
||||
{showUploadForm && pendingFile && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("extensions.selectedFile")}:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{pendingFile.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={extensionName}
|
||||
onChange={(e) => setExtensionName(e.target.value)}
|
||||
placeholder={t("extensions.namePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || !extensionName.trim()}
|
||||
>
|
||||
{isUploading
|
||||
? t("common.buttons.loading")
|
||||
: t("common.buttons.add")}
|
||||
</RippleButton>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowUploadForm(false);
|
||||
setPendingFile(null);
|
||||
setExtensionName("");
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extensions list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : extensions.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("extensions.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[200px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("common.labels.type")}
|
||||
</TableHead>
|
||||
<TableHead className="w-32">
|
||||
{t("extensions.compatibility.label")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{extensions.map((ext) => (
|
||||
<TableRow key={ext.id}>
|
||||
<TableCell className="font-medium">
|
||||
{ext.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
.{ext.file_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getCompatibilityBadge(
|
||||
ext.browser_compatibility,
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExtensionToDelete(ext)
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("extensions.delete")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "groups" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>{t("extensions.groupsTab")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setShowCreateGroup(true)}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={limitedMode}
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
{t("extensions.createGroup")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{/* Create group form */}
|
||||
{showCreateGroup && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder={t("extensions.groupNamePlaceholder")}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreateGroup();
|
||||
}}
|
||||
/>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateGroup}
|
||||
disabled={!newGroupName.trim()}
|
||||
>
|
||||
{t("common.buttons.create")}
|
||||
</RippleButton>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateGroup(false);
|
||||
setNewGroupName("");
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
{extensionGroups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("extensions.noGroups")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{extensionGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
{editingGroup?.id === group.id ? (
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<Input
|
||||
value={editGroupName}
|
||||
onChange={(e) =>
|
||||
setEditGroupName(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
void handleUpdateGroup();
|
||||
}}
|
||||
/>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleUpdateGroup}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</RippleButton>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditingGroup(null)}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">
|
||||
{group.name}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingGroup(group);
|
||||
setEditGroupName(group.name);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("common.buttons.edit")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setGroupToDelete(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("extensions.deleteGroup")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extension assignment */}
|
||||
<div className="space-y-1">
|
||||
{group.extension_ids.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{group.extension_ids.map((extId) => {
|
||||
const ext = extensions.find(
|
||||
(e) => e.id === extId,
|
||||
);
|
||||
if (!ext) return null;
|
||||
return (
|
||||
<Badge
|
||||
key={extId}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{ext.name}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 hover:text-destructive"
|
||||
onClick={() =>
|
||||
handleRemoveFromGroup(group.id, extId)
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{extensions.filter(
|
||||
(e) => !group.extension_ids.includes(e.id),
|
||||
).length > 0 && (
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(extId) =>
|
||||
handleAddToGroup(group.id, extId)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue
|
||||
placeholder={t("extensions.addToGroup")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{extensions
|
||||
.filter(
|
||||
(e) =>
|
||||
!group.extension_ids.includes(e.id),
|
||||
)
|
||||
.map((ext) => (
|
||||
<SelectItem key={ext.id} value={ext.id}>
|
||||
{ext.name} (.{ext.file_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete extension confirmation */}
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={extensionToDelete !== null}
|
||||
onClose={() => setExtensionToDelete(null)}
|
||||
onConfirm={handleDeleteExtension}
|
||||
title={t("extensions.deleteConfirmTitle")}
|
||||
description={t("extensions.deleteConfirmDescription", {
|
||||
name: extensionToDelete?.name ?? "",
|
||||
})}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Delete group confirmation */}
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={groupToDelete !== null}
|
||||
onClose={() => setGroupToDelete(null)}
|
||||
onConfirm={handleDeleteGroup}
|
||||
title={t("extensions.deleteGroupConfirmTitle")}
|
||||
description={t("extensions.deleteGroupConfirmDescription", {
|
||||
name: groupToDelete?.name ?? "",
|
||||
})}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { LuCloud, LuPlug, LuSearch, LuUsers, LuX } from "react-icons/lu";
|
||||
import {
|
||||
LuCloud,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuSearch,
|
||||
LuUsers,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Button } from "./ui/button";
|
||||
import { CardTitle } from "./ui/card";
|
||||
@@ -23,6 +30,7 @@ type Props = {
|
||||
onCreateProfileDialogOpen: (open: boolean) => void;
|
||||
onSyncConfigDialogOpen: (open: boolean) => void;
|
||||
onIntegrationsDialogOpen: (open: boolean) => void;
|
||||
onExtensionManagementDialogOpen: (open: boolean) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
};
|
||||
@@ -35,6 +43,7 @@ const HomeHeader = ({
|
||||
onCreateProfileDialogOpen,
|
||||
onSyncConfigDialogOpen,
|
||||
onIntegrationsDialogOpen,
|
||||
onExtensionManagementDialogOpen,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
}: Props) => {
|
||||
@@ -124,6 +133,14 @@ const HomeHeader = ({
|
||||
<LuUsers className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.groups")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onExtensionManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuPuzzle className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.extensions")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onSyncConfigDialogOpen(true);
|
||||
|
||||
@@ -1582,6 +1582,7 @@ export function ProfilesDataTable({
|
||||
const osName = profile.host_os
|
||||
? getOSDisplayName(profile.host_os)
|
||||
: "another OS";
|
||||
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
|
||||
const OsIcon =
|
||||
profile.host_os === "macos"
|
||||
? FaApple
|
||||
@@ -1606,10 +1607,7 @@ export function ProfilesDataTable({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
This profile was created on {osName} and is not supported on
|
||||
this system
|
||||
</p>
|
||||
<p>{crossOsTooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -1620,14 +1618,10 @@ export function ProfilesDataTable({
|
||||
const osName = profile.host_os
|
||||
? getOSDisplayName(profile.host_os)
|
||||
: "another OS";
|
||||
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
|
||||
return (
|
||||
<NonHoverableTooltip
|
||||
content={
|
||||
<p>
|
||||
This profile was created on {osName} and is not supported on
|
||||
this system
|
||||
</p>
|
||||
}
|
||||
content={<p>{crossOsTooltip}</p>}
|
||||
sideOffset={4}
|
||||
horizontalOffset={8}
|
||||
>
|
||||
@@ -2305,7 +2299,7 @@ export function ProfilesDataTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -2362,25 +2356,34 @@ export function ProfilesDataTable({
|
||||
</TableHeader>
|
||||
<TableBody className="overflow-visible">
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn(
|
||||
"overflow-visible hover:bg-accent/50",
|
||||
isCrossOsProfile(row.original) && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="overflow-visible">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const rowIsCrossOs = isCrossOsProfile(row.original);
|
||||
const crossOsTitle = rowIsCrossOs
|
||||
? t("crossOs.viewOnly", {
|
||||
os: getOSDisplayName(row.original.host_os ?? ""),
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
title={crossOsTitle}
|
||||
className={cn(
|
||||
"overflow-visible hover:bg-accent/50",
|
||||
rowIsCrossOs && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="overflow-visible">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
LuPlus,
|
||||
LuRefreshCw,
|
||||
LuSettings,
|
||||
LuTrash2,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
@@ -100,6 +103,11 @@ export function ProfileInfoDialog({
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [groupName, setGroupName] = React.useState<string | null>(null);
|
||||
const [extensionGroupName, setExtensionGroupName] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
|
||||
const [newRule, setNewRule] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !profile?.group_id) {
|
||||
@@ -117,11 +125,33 @@ export function ProfileInfoDialog({
|
||||
})();
|
||||
}, [isOpen, profile?.group_id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !profile?.extension_group_id) {
|
||||
setExtensionGroupName(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const group = await invoke<{ name: string } | null>(
|
||||
"get_extension_group_for_profile",
|
||||
{ profileId: profile.id },
|
||||
);
|
||||
setExtensionGroupName(group?.name ?? null);
|
||||
} catch {
|
||||
setExtensionGroupName(null);
|
||||
}
|
||||
})();
|
||||
}, [isOpen, profile?.extension_group_id, profile?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setCopied(false);
|
||||
setNewRule("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
if (isOpen && profile) {
|
||||
setBypassRules(profile.proxy_bypass_rules ?? []);
|
||||
}
|
||||
}, [isOpen, profile]);
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
@@ -163,6 +193,31 @@ 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 infoFields: { label: string; value: React.ReactNode }[] = [
|
||||
{
|
||||
label: t("profileInfo.fields.profileId"),
|
||||
@@ -203,6 +258,10 @@ export function ProfileInfoDialog({
|
||||
label: t("profileInfo.fields.group"),
|
||||
value: groupName ?? t("profileInfo.values.none"),
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.extensionGroup"),
|
||||
value: extensionGroupName ?? t("profileInfo.values.none"),
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.tags"),
|
||||
value:
|
||||
@@ -349,6 +408,9 @@ export function ProfileInfoDialog({
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
{t("profileInfo.tabs.info")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="network" className="flex-1">
|
||||
{t("profileInfo.tabs.network")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
{t("profileInfo.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
@@ -365,6 +427,63 @@ export function ProfileInfoDialog({
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="network">
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("profileInfo.network.bypassRules")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("profileInfo.network.bypassRulesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newRule}
|
||||
onChange={(e) => setNewRule(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddRule();
|
||||
}}
|
||||
placeholder={t("profileInfo.network.rulePlaceholder")}
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddRule}
|
||||
disabled={!newRule.trim()}
|
||||
>
|
||||
<LuPlus className="w-4 h-4 mr-1" />
|
||||
{t("profileInfo.network.addRule")}
|
||||
</Button>
|
||||
</div>
|
||||
{bypassRules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("profileInfo.network.noRules")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto">
|
||||
{bypassRules.map((rule) => (
|
||||
<div
|
||||
key={rule}
|
||||
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">{rule}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRule(rule)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<LuX className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.network.ruleTypes")}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="flex flex-col py-1">
|
||||
{visibleActions.map((action) => (
|
||||
|
||||
Reference in New Issue
Block a user