mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
517 lines
18 KiB
TypeScript
517 lines
18 KiB
TypeScript
import {
|
|
AlertTriangle,
|
|
Check,
|
|
ChevronDown,
|
|
Copy,
|
|
Globe,
|
|
LayoutGrid,
|
|
List as ListIcon,
|
|
Pencil,
|
|
Plus,
|
|
Search,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import React, { useMemo, useState } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
|
import { removeProxyProfileReferences } from "../domain/proxyProfiles";
|
|
import {
|
|
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
|
} from "../infrastructure/config/storageKeys";
|
|
import { cn } from "../lib/utils";
|
|
import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "../types";
|
|
import {
|
|
AsidePanel,
|
|
AsidePanelContent,
|
|
AsidePanelFooter,
|
|
} from "./ui/aside-panel";
|
|
import { Badge } from "./ui/badge";
|
|
import { Button } from "./ui/button";
|
|
import { Card } from "./ui/card";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from "./ui/context-menu";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "./ui/dialog";
|
|
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
|
import { Input } from "./ui/input";
|
|
import { toast } from "./ui/toast";
|
|
|
|
interface ProxyProfilesManagerProps {
|
|
proxyProfiles: ProxyProfile[];
|
|
hosts: Host[];
|
|
groupConfigs: GroupConfig[];
|
|
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
|
|
onUpdateHosts: (hosts: Host[]) => void;
|
|
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
|
|
}
|
|
|
|
const createDraftProfile = (): ProxyProfile => {
|
|
const now = Date.now();
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
label: "",
|
|
config: {
|
|
type: "http",
|
|
host: "",
|
|
port: 8080,
|
|
},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
};
|
|
|
|
const getProfileUsageCount = (
|
|
profileId: string,
|
|
hosts: Host[],
|
|
groupConfigs: GroupConfig[],
|
|
): number =>
|
|
hosts.filter((host) => host.proxyProfileId === profileId).length +
|
|
groupConfigs.filter((config) => config.proxyProfileId === profileId).length;
|
|
|
|
type ProxyProfilesViewMode = "grid" | "list";
|
|
|
|
interface ProxyProfileCardProps {
|
|
profile: ProxyProfile;
|
|
usageCount: number;
|
|
viewMode: ProxyProfilesViewMode;
|
|
isSelected: boolean;
|
|
onClick: () => void;
|
|
onEdit: () => void;
|
|
onDuplicate: () => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
|
profile,
|
|
usageCount,
|
|
viewMode,
|
|
isSelected,
|
|
onClick,
|
|
onEdit,
|
|
onDuplicate,
|
|
onDelete,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
"group cursor-pointer",
|
|
viewMode === "grid"
|
|
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
|
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
|
isSelected && "ring-2 ring-primary",
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<div className="flex items-center gap-3 h-full">
|
|
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
|
<Globe size={18} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
|
<Badge variant="secondary" className="text-[10px] shrink-0">
|
|
{profile.config.type.toUpperCase()}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
|
{profile.config.host}:{profile.config.port} -{" "}
|
|
{t("proxyProfiles.usage", { count: usageCount })}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{viewMode === "list" && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onEdit();
|
|
}}
|
|
>
|
|
<Pencil size={14} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem onClick={onEdit}>
|
|
<Pencil size={14} className="mr-2" />
|
|
{t("action.edit")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={onDuplicate}>
|
|
<Copy size={14} className="mr-2" />
|
|
{t("action.duplicate")}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
|
<Trash2 size={14} className="mr-2" />
|
|
{t("action.delete")}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
};
|
|
|
|
export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
|
proxyProfiles,
|
|
hosts,
|
|
groupConfigs,
|
|
onUpdateProxyProfiles,
|
|
onUpdateHosts,
|
|
onUpdateGroupConfigs,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const [search, setSearch] = useState("");
|
|
const [viewMode, setViewMode] = useStoredViewMode(
|
|
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
|
"grid",
|
|
);
|
|
const proxyProfilesViewMode: ProxyProfilesViewMode =
|
|
viewMode === "list" ? "list" : "grid";
|
|
const [draft, setDraft] = useState<ProxyProfile | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
|
|
|
|
const usageByProfileId = useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
for (const profile of proxyProfiles) {
|
|
map.set(profile.id, getProfileUsageCount(profile.id, hosts, groupConfigs));
|
|
}
|
|
return map;
|
|
}, [groupConfigs, hosts, proxyProfiles]);
|
|
|
|
const filteredProfiles = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return proxyProfiles;
|
|
return proxyProfiles.filter((profile) =>
|
|
profile.label.toLowerCase().includes(q) ||
|
|
profile.config.host.toLowerCase().includes(q) ||
|
|
profile.config.type.toLowerCase().includes(q),
|
|
);
|
|
}, [proxyProfiles, search]);
|
|
|
|
const updateDraftConfig = (field: keyof ProxyConfig, value: string | number) => {
|
|
setDraft((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
config: {
|
|
...prev.config,
|
|
[field]: value,
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const openCreate = () => {
|
|
setDraft(createDraftProfile());
|
|
};
|
|
|
|
const openEdit = (profile: ProxyProfile) => {
|
|
setDraft({
|
|
...profile,
|
|
config: { ...profile.config },
|
|
});
|
|
};
|
|
|
|
const duplicateProfile = (profile: ProxyProfile) => {
|
|
const now = Date.now();
|
|
onUpdateProxyProfiles([
|
|
...proxyProfiles,
|
|
{
|
|
...profile,
|
|
id: crypto.randomUUID(),
|
|
label: t("proxyProfiles.copyName", { name: profile.label }),
|
|
config: { ...profile.config },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
]);
|
|
};
|
|
|
|
const saveDraft = () => {
|
|
if (!draft) return;
|
|
const label = draft.label.trim();
|
|
const host = draft.config.host.trim();
|
|
if (!label || !host || !draft.config.port) {
|
|
toast.error(t("proxyProfiles.error.required"));
|
|
return;
|
|
}
|
|
|
|
const saved: ProxyProfile = {
|
|
...draft,
|
|
label,
|
|
config: {
|
|
...draft.config,
|
|
host,
|
|
port: Number(draft.config.port),
|
|
username: draft.config.username?.trim() || undefined,
|
|
password: draft.config.password || undefined,
|
|
},
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
onUpdateProxyProfiles(
|
|
proxyProfiles.some((profile) => profile.id === saved.id)
|
|
? proxyProfiles.map((profile) => profile.id === saved.id ? saved : profile)
|
|
: [...proxyProfiles, saved],
|
|
);
|
|
setDraft(null);
|
|
};
|
|
|
|
const confirmDelete = () => {
|
|
if (!deleteTarget) return;
|
|
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
|
|
hosts,
|
|
groupConfigs,
|
|
});
|
|
onUpdateProxyProfiles(proxyProfiles.filter((profile) => profile.id !== deleteTarget.id));
|
|
onUpdateHosts(cleaned.hosts);
|
|
onUpdateGroupConfigs(cleaned.groupConfigs);
|
|
setDeleteTarget(null);
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex relative">
|
|
<div className={cn("flex-1 flex flex-col min-h-0 transition-all duration-200", draft && "mr-[380px]")}>
|
|
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0">
|
|
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
|
<Button onClick={openCreate} size="sm" className="h-10">
|
|
<Plus size={14} className="mr-2" />
|
|
{t("proxyProfiles.action.add")}
|
|
</Button>
|
|
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
|
|
<div className="relative flex-shrink min-w-[100px]">
|
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder={t("proxyProfiles.search.placeholder")}
|
|
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
|
|
/>
|
|
</div>
|
|
<Dropdown>
|
|
<DropdownTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-10 w-10 flex-shrink-0"
|
|
>
|
|
{proxyProfilesViewMode === "grid" ? (
|
|
<LayoutGrid size={16} />
|
|
) : (
|
|
<ListIcon size={16} />
|
|
)}
|
|
<ChevronDown size={10} className="ml-0.5" />
|
|
</Button>
|
|
</DropdownTrigger>
|
|
<DropdownContent className="w-32" align="end">
|
|
<Button
|
|
variant={proxyProfilesViewMode === "grid" ? "secondary" : "ghost"}
|
|
className="w-full justify-start gap-2 h-9"
|
|
onClick={() => setViewMode("grid")}
|
|
>
|
|
<LayoutGrid size={14} /> {t("keychain.view.grid")}
|
|
</Button>
|
|
<Button
|
|
variant={proxyProfilesViewMode === "list" ? "secondary" : "ghost"}
|
|
className="w-full justify-start gap-2 h-9"
|
|
onClick={() => setViewMode("list")}
|
|
>
|
|
<ListIcon size={14} /> {t("keychain.view.list")}
|
|
</Button>
|
|
</DropdownContent>
|
|
</Dropdown>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="space-y-3 p-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-base font-semibold text-muted-foreground">
|
|
{t("proxyProfiles.section.proxies")}
|
|
</h2>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t("proxyProfiles.count.items", { count: filteredProfiles.length })}
|
|
</span>
|
|
</div>
|
|
|
|
{filteredProfiles.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
|
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
|
<Globe size={32} className="opacity-60" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
|
{t("proxyProfiles.empty.title")}
|
|
</h3>
|
|
<p className="text-sm text-center max-w-sm mb-4">
|
|
{t("proxyProfiles.empty.desc")}
|
|
</p>
|
|
<Button onClick={openCreate}>
|
|
<Plus size={14} className="mr-2" />
|
|
{t("proxyProfiles.action.add")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={
|
|
proxyProfilesViewMode === "grid"
|
|
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
|
: "flex flex-col gap-0"
|
|
}
|
|
>
|
|
{filteredProfiles.map((profile) => (
|
|
<ProxyProfileCard
|
|
key={profile.id}
|
|
profile={profile}
|
|
usageCount={usageByProfileId.get(profile.id) ?? 0}
|
|
viewMode={proxyProfilesViewMode}
|
|
isSelected={draft?.id === profile.id}
|
|
onClick={() => openEdit(profile)}
|
|
onEdit={() => openEdit(profile)}
|
|
onDuplicate={() => duplicateProfile(profile)}
|
|
onDelete={() => setDeleteTarget(profile)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{draft && (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={() => setDraft(null)}
|
|
title={draft.label || t("proxyProfiles.panel.newTitle")}
|
|
>
|
|
<AsidePanelContent>
|
|
<Card className="p-3 space-y-3 bg-card border-border/80">
|
|
<Input
|
|
value={draft.label}
|
|
onChange={(event) => setDraft({ ...draft, label: event.target.value })}
|
|
placeholder={t("proxyProfiles.field.name")}
|
|
className="h-10"
|
|
/>
|
|
</Card>
|
|
|
|
<Card className="p-3 space-y-3 bg-card border-border/80">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-semibold">{t("field.type")}</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant={draft.config.type === "http" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
className={cn("h-8", draft.config.type === "http" && "bg-primary/15")}
|
|
onClick={() => updateDraftConfig("type", "http")}
|
|
>
|
|
<Check size={14} className={cn("mr-1", draft.config.type !== "http" && "opacity-0")} />
|
|
HTTP
|
|
</Button>
|
|
<Button
|
|
variant={draft.config.type === "socks5" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
className={cn("h-8", draft.config.type === "socks5" && "bg-primary/15")}
|
|
onClick={() => updateDraftConfig("type", "socks5")}
|
|
>
|
|
<Check size={14} className={cn("mr-1", draft.config.type !== "socks5" && "opacity-0")} />
|
|
SOCKS5
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={draft.config.host}
|
|
onChange={(event) => updateDraftConfig("host", event.target.value)}
|
|
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
|
|
className="h-10 flex-1"
|
|
/>
|
|
<Input
|
|
type="number"
|
|
value={draft.config.port || ""}
|
|
onChange={(event) => updateDraftConfig("port", parseInt(event.target.value) || 0)}
|
|
placeholder="3128"
|
|
className="h-10 w-24 text-center"
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-3 space-y-3 bg-card border-border/80">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-semibold">{t("hostDetails.proxyPanel.credentials")}</p>
|
|
<Badge variant="secondary" className="text-xs">{t("common.optional")}</Badge>
|
|
</div>
|
|
<Input
|
|
value={draft.config.username || ""}
|
|
onChange={(event) => updateDraftConfig("username", event.target.value)}
|
|
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
|
className="h-10"
|
|
/>
|
|
<Input
|
|
type="password"
|
|
value={draft.config.password || ""}
|
|
onChange={(event) => updateDraftConfig("password", event.target.value)}
|
|
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
|
className="h-10"
|
|
/>
|
|
</Card>
|
|
</AsidePanelContent>
|
|
<AsidePanelFooter>
|
|
<Button className="w-full" onClick={saveDraft}>
|
|
{t("common.save")}
|
|
</Button>
|
|
</AsidePanelFooter>
|
|
</AsidePanel>
|
|
)}
|
|
|
|
<Dialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<AlertTriangle size={18} className="text-destructive" />
|
|
{t("proxyProfiles.delete.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{deleteTarget
|
|
? t("proxyProfiles.delete.desc", {
|
|
name: deleteTarget.label,
|
|
count: usageByProfileId.get(deleteTarget.id) ?? 0,
|
|
})
|
|
: ""}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button variant="destructive" onClick={confirmDelete}>
|
|
{t("action.delete")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProxyProfilesManager;
|