"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([]); const [extensionGroups, setExtensionGroups] = useState([]); 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(null); const [editGroupName, setEditGroupName] = useState(""); // Delete state const [extensionToDelete, setExtensionToDelete] = useState( null, ); const [groupToDelete, setGroupToDelete] = useState( 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("list_extensions"), invoke("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) => { 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 ( {t("extensions.compatibility.both")} ); } if (compat.includes("chromium")) { return ( {t("extensions.compatibility.chromium")} ); } if (compat.includes("firefox")) { return ( {t("extensions.compatibility.firefox")} ); } return null; }; return ( <> {t("extensions.title")} {limitedMode && } {t("extensions.description")}
{limitedMode && ( <>
{t("extensions.proRequired")}
)}
{/* Tab selector */}
{/* Notice */}
{t("extensions.managedNotice")}
{activeTab === "extensions" && (
{/* Upload form */} {showUploadForm && pendingFile && (
{t("extensions.selectedFile")}:{" "} {pendingFile.name}
setExtensionName(e.target.value)} placeholder={t("extensions.namePlaceholder")} className="flex-1" /> {isUploading ? t("common.buttons.loading") : t("common.buttons.add")}
)} {/* Extensions list */} {isLoading ? (
{t("common.buttons.loading")}
) : extensions.length === 0 ? (
{t("extensions.empty")}
) : (
{t("common.labels.name")} {t("common.labels.type")} {t("extensions.compatibility.label")} {t("common.labels.actions")} {extensions.map((ext) => ( {ext.name} .{ext.file_type} {getCompatibilityBadge( ext.browser_compatibility, )} {t("extensions.delete")} ))}
)}
)} {activeTab === "groups" && (
setShowCreateGroup(true)} className="flex gap-2 items-center" disabled={limitedMode} > {t("extensions.createGroup")}
{/* Create group form */} {showCreateGroup && (
setNewGroupName(e.target.value)} placeholder={t("extensions.groupNamePlaceholder")} className="flex-1" onKeyDown={(e) => { if (e.key === "Enter") void handleCreateGroup(); }} /> {t("common.buttons.create")}
)} {/* Groups list */} {extensionGroups.length === 0 ? (
{t("extensions.noGroups")}
) : (
{extensionGroups.map((group) => (
{editingGroup?.id === group.id ? (
setEditGroupName(e.target.value) } className="flex-1" onKeyDown={(e) => { if (e.key === "Enter") void handleUpdateGroup(); }} /> {t("common.buttons.save")}
) : ( <> {group.name}
{t("common.buttons.edit")} {t("extensions.deleteGroup")}
)}
{/* Extension assignment */}
{group.extension_ids.length > 0 && (
{group.extension_ids.map((extId) => { const ext = extensions.find( (e) => e.id === extId, ); if (!ext) return null; return ( {ext.name} ); })}
)} {extensions.filter( (e) => !group.extension_ids.includes(e.id), ).length > 0 && ( )}
))}
)}
)}
{t("common.buttons.close")}
{/* Delete extension confirmation */} setExtensionToDelete(null)} onConfirm={handleDeleteExtension} title={t("extensions.deleteConfirmTitle")} description={t("extensions.deleteConfirmDescription", { name: extensionToDelete?.name ?? "", })} isLoading={isDeleting} /> {/* Delete group confirmation */} setGroupToDelete(null)} onConfirm={handleDeleteGroup} title={t("extensions.deleteGroupConfirmTitle")} description={t("extensions.deleteGroupConfirmDescription", { name: groupToDelete?.name ?? "", })} isLoading={isDeleting} /> ); }