diff --git a/README.md b/README.md index ed7c41ca..93078c73 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A Clash Meta GUI based on Tauri +

{t("Something went wrong")}

diff --git a/src/components/base/base-fieldset.tsx b/src/components/base/base-fieldset.tsx index 9e88b810..8acaa257 100644 --- a/src/components/base/base-fieldset.tsx +++ b/src/components/base/base-fieldset.tsx @@ -15,7 +15,7 @@ export const BaseFieldset: React.FC = (props) => {
{/* 2. Используем legend. Он абсолютно спозиционирован относительно fieldset. */} diff --git a/src/components/base/base-loading-overlay.tsx b/src/components/base/base-loading-overlay.tsx index 05f82ca2..d9500215 100644 --- a/src/components/base/base-loading-overlay.tsx +++ b/src/components/base/base-loading-overlay.tsx @@ -18,7 +18,7 @@ export const BaseLoadingOverlay: React.FC = ({
{/* 3. Используем наш BaseLoading и делаем его немного больше */} diff --git a/src/components/base/base-page.tsx b/src/components/base/base-page.tsx index b24f2703..6ed4145e 100644 --- a/src/components/base/base-page.tsx +++ b/src/components/base/base-page.tsx @@ -3,10 +3,10 @@ import { BaseErrorBoundary } from "./base-error-boundary"; import { cn } from "@root/lib/utils"; interface Props { - title?: ReactNode; // Заголовок страницы - header?: ReactNode; // Элементы в правой части шапки (кнопки и т.д.) - children?: ReactNode; // Основное содержимое страницы - className?: string; // Дополнительные классы для основной области контента + title?: ReactNode; // Заголовок страницы + header?: ReactNode; // Элементы в правой части шапки (кнопки и т.д.) + children?: ReactNode; // Основное содержимое страницы + className?: string; // Дополнительные классы для основной области контента } export const BasePage: React.FC = (props) => { @@ -16,7 +16,6 @@ export const BasePage: React.FC = (props) => { {/* 1. Корневой контейнер: flex-колонка на всю высоту */}
- {/* 2. Шапка: не растягивается, имеет фиксированную высоту и нижнюю границу */}
= (props) => {

{title}

-
- {header} -
+
{header}
{/* 3. Основная область: занимает все оставшееся место и прокручивается */}
{children}
-
); diff --git a/src/components/base/base-search-box.tsx b/src/components/base/base-search-box.tsx index 2987eec7..33c1e227 100644 --- a/src/components/base/base-search-box.tsx +++ b/src/components/base/base-search-box.tsx @@ -5,7 +5,12 @@ import { cn } from "@root/lib/utils"; // Новые импорты import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { CaseSensitive, WholeWord, Regex } from "lucide-react"; // Иконки из lucide-react export type SearchState = { @@ -40,7 +45,7 @@ export const BaseSearchBox = (props: SearchProps) => { return new RegExp(searchText, flags).test(content); } - let pattern = searchText.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); // Экранируем спецсимволы + let pattern = searchText.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); // Экранируем спецсимволы if (matchWholeWord) { pattern = `\\b${pattern}\\b`; } @@ -55,16 +60,27 @@ export const BaseSearchBox = (props: SearchProps) => { }, [matchCase, matchWholeWord, useRegularExpression]); useEffect(() => { - props.onSearch(createMatcher(text), { text, matchCase, matchWholeWord, useRegularExpression }); + props.onSearch(createMatcher(text), { + text, + matchCase, + matchWholeWord, + useRegularExpression, + }); }, [matchCase, matchWholeWord, useRegularExpression, createMatcher]); // Убрали text из зависимостей const handleChange = (e: ChangeEvent) => { const value = e.target.value; setText(value); - props.onSearch(createMatcher(value), { text: value, matchCase, matchWholeWord, useRegularExpression }); + props.onSearch(createMatcher(value), { + text: value, + matchCase, + matchWholeWord, + useRegularExpression, + }); }; - const getToggleVariant = (isActive: boolean) => (isActive ? "secondary" : "ghost"); + const getToggleVariant = (isActive: boolean) => + isActive ? "secondary" : "ghost"; return (
@@ -81,33 +97,56 @@ export const BaseSearchBox = (props: SearchProps) => { - -

{t("Match Case")}

+ +

{t("Match Case")}

+
- -

{t("Match Whole Word")}

+ +

{t("Match Whole Word")}

+
- -

{t("Use Regular Expression")}

+ +

{t("Use Regular Expression")}

+
{/* Отображение ошибки под полем ввода */} - {errorMessage &&

{errorMessage}

} + {errorMessage && ( +

{errorMessage}

+ )}
); }; diff --git a/src/components/base/base-styled-select.tsx b/src/components/base/base-styled-select.tsx index 0b186b9a..2f89c255 100644 --- a/src/components/base/base-styled-select.tsx +++ b/src/components/base/base-styled-select.tsx @@ -26,7 +26,7 @@ export const BaseStyledSelect: React.FC = (props) => { diff --git a/src/components/base/base-styled-text-field.tsx b/src/components/base/base-styled-text-field.tsx index 3af4538e..9cf13b6b 100644 --- a/src/components/base/base-styled-text-field.tsx +++ b/src/components/base/base-styled-text-field.tsx @@ -19,7 +19,7 @@ export const BaseStyledTextField = React.forwardRef< ref={ref} className={cn( "h-9", // Задаем стандартную компактную высоту - className + className, )} placeholder={props.placeholder ?? t("Filter conditions")} autoComplete="off" diff --git a/src/components/base/base-switch.tsx b/src/components/base/base-switch.tsx index 58146d40..82b9552d 100644 --- a/src/components/base/base-switch.tsx +++ b/src/components/base/base-switch.tsx @@ -5,18 +5,11 @@ import { cn } from "@root/lib/utils"; // Тип пропсов остается без изменений export type SwitchProps = React.ComponentPropsWithoutRef; -const Switch = React.forwardRef< - HTMLButtonElement, - SwitchProps ->(({ className, ...props }, ref) => { - return ( - - ); -}); +const Switch = React.forwardRef( + ({ className, ...props }, ref) => { + return ; + }, +); Switch.displayName = "Switch"; diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx index 1996ee7a..d7577c72 100644 --- a/src/components/connection/connection-table.tsx +++ b/src/components/connection/connection-table.tsx @@ -52,7 +52,6 @@ interface Props { scrollerRef: (element: HTMLElement | Window | null) => void; } - export const ConnectionTable = (props: Props) => { const { connections, onShowDetail, scrollerRef } = props; @@ -60,11 +59,16 @@ export const ConnectionTable = (props: Props) => { try { const saved = localStorage.getItem("connection-table-widths"); return saved ? JSON.parse(saved) : {}; - } catch { return {}; } + } catch { + return {}; + } }); useEffect(() => { - localStorage.setItem("connection-table-widths", JSON.stringify(columnSizing)); + localStorage.setItem( + "connection-table-widths", + JSON.stringify(columnSizing), + ); }, [columnSizing]); const connRows = useMemo((): ConnectionRow[] => { @@ -86,7 +90,7 @@ export const ConnectionTable = (props: Props) => { ulSpeed: each.curUpload ?? 0, chains, rule, - process: truncateStr(metadata.process || metadata.processPath) ?? '', + process: truncateStr(metadata.process || metadata.processPath) ?? "", time: each.start, source: `${metadata.sourceIP}:${metadata.sourcePort}`, remoteDestination: Destination, @@ -96,20 +100,104 @@ export const ConnectionTable = (props: Props) => { }); }, [connections]); - const columns = useMemo[]>(() => [ - { accessorKey: "host", header: () => t("Host"), size: columnSizing?.host || 220, minSize: 180 }, - { accessorKey: "download", header: () => t("Downloaded"), size: columnSizing?.download || 88, cell: ({ getValue }) =>
{parseTraffic(getValue()).join(" ")}
}, - { accessorKey: "upload", header: () => t("Uploaded"), size: columnSizing?.upload || 88, cell: ({ getValue }) =>
{parseTraffic(getValue()).join(" ")}
}, - { accessorKey: "dlSpeed", header: () => t("DL Speed"), size: columnSizing?.dlSpeed || 88, cell: ({ getValue }) =>
{parseTraffic(getValue()).join(" ")}/s
}, - { accessorKey: "ulSpeed", header: () => t("UL Speed"), size: columnSizing?.ulSpeed || 88, cell: ({ getValue }) =>
{parseTraffic(getValue()).join(" ")}/s
}, - { accessorKey: "chains", header: () => t("Chains"), size: columnSizing?.chains || 340, minSize: 180 }, - { accessorKey: "rule", header: () => t("Rule"), size: columnSizing?.rule || 280, minSize: 180 }, - { accessorKey: "process", header: () => t("Process"), size: columnSizing?.process || 220, minSize: 180 }, - { accessorKey: "time", header: () => t("Time"), size: columnSizing?.time || 120, minSize: 100, cell: ({ getValue }) =>
{dayjs(getValue()).fromNow()}
}, - { accessorKey: "source", header: () => t("Source"), size: columnSizing?.source || 200, minSize: 130 }, - { accessorKey: "remoteDestination", header: () => t("Destination"), size: columnSizing?.remoteDestination || 200, minSize: 130 }, - { accessorKey: "type", header: () => t("Type"), size: columnSizing?.type || 160, minSize: 100 }, - ], [columnSizing]); + const columns = useMemo[]>( + () => [ + { + accessorKey: "host", + header: () => t("Host"), + size: columnSizing?.host || 220, + minSize: 180, + }, + { + accessorKey: "download", + header: () => t("Downloaded"), + size: columnSizing?.download || 88, + cell: ({ getValue }) => ( +
+ {parseTraffic(getValue()).join(" ")} +
+ ), + }, + { + accessorKey: "upload", + header: () => t("Uploaded"), + size: columnSizing?.upload || 88, + cell: ({ getValue }) => ( +
+ {parseTraffic(getValue()).join(" ")} +
+ ), + }, + { + accessorKey: "dlSpeed", + header: () => t("DL Speed"), + size: columnSizing?.dlSpeed || 88, + cell: ({ getValue }) => ( +
+ {parseTraffic(getValue()).join(" ")}/s +
+ ), + }, + { + accessorKey: "ulSpeed", + header: () => t("UL Speed"), + size: columnSizing?.ulSpeed || 88, + cell: ({ getValue }) => ( +
+ {parseTraffic(getValue()).join(" ")}/s +
+ ), + }, + { + accessorKey: "chains", + header: () => t("Chains"), + size: columnSizing?.chains || 340, + minSize: 180, + }, + { + accessorKey: "rule", + header: () => t("Rule"), + size: columnSizing?.rule || 280, + minSize: 180, + }, + { + accessorKey: "process", + header: () => t("Process"), + size: columnSizing?.process || 220, + minSize: 180, + }, + { + accessorKey: "time", + header: () => t("Time"), + size: columnSizing?.time || 120, + minSize: 100, + cell: ({ getValue }) => ( +
+ {dayjs(getValue()).fromNow()} +
+ ), + }, + { + accessorKey: "source", + header: () => t("Source"), + size: columnSizing?.source || 200, + minSize: 130, + }, + { + accessorKey: "remoteDestination", + header: () => t("Destination"), + size: columnSizing?.remoteDestination || 200, + minSize: 130, + }, + { + accessorKey: "type", + header: () => t("Type"), + size: columnSizing?.type || 160, + minSize: 100, + }, + ], + [columnSizing], + ); const table = useReactTable({ data: connRows, @@ -117,37 +205,42 @@ export const ConnectionTable = (props: Props) => { state: { columnSizing }, onColumnSizingChange: setColumnSizing, getCoreRowModel: getCoreRowModel(), - columnResizeMode: 'onChange', + columnResizeMode: "onChange", }); - const VirtuosoTableComponents = useMemo>>(() => ({ - // Явно типизируем `ref` для каждого компонента - Scroller: React.forwardRef((props, ref) => ( + const VirtuosoTableComponents = useMemo>>( + () => ({ + // Явно типизируем `ref` для каждого компонента + Scroller: React.forwardRef((props, ref) => (
- )), - Table: (props) => ( - - ), - TableHead: React.forwardRef((props, ref) => ( - - )), - // Явно типизируем пропсы и `ref` для TableRow - TableRow: React.forwardRef } & React.HTMLAttributes>( - ({ item: row, ...props }, ref) => { - // `Virtuoso` передает нам готовую строку `row` в пропсе `item`. - // Больше не нужно искать ее по индексу! - return ( - onShowDetail(row.original.connectionData)} - /> - ); + )), + Table: (props) =>
, + TableHead: React.forwardRef((props, ref) => ( + + )), + // Явно типизируем пропсы и `ref` для TableRow + TableRow: React.forwardRef< + HTMLTableRowElement, + { item: Row } & React.HTMLAttributes + >(({ item: row, ...props }, ref) => { + // `Virtuoso` передает нам готовую строку `row` в пропсе `item`. + // Больше не нужно искать ее по индексу! + return ( + onShowDetail(row.original.connectionData)} + /> + ); + }), + TableBody: React.forwardRef((props, ref) => ( + + )), }), - TableBody: React.forwardRef((props, ref) => ) - }), []); + [], + ); return (
@@ -156,17 +249,29 @@ export const ConnectionTable = (props: Props) => { scrollerRef={scrollerRef} data={table.getRowModel().rows} components={VirtuosoTableComponents} - fixedHeaderContent={() => ( + fixedHeaderContent={() => table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} ))} )) - )} + } itemContent={(index, row) => ( <> {row.getVisibleCells().map((cell) => ( diff --git a/src/components/home/proxy-selectors.tsx b/src/components/home/proxy-selectors.tsx index 9cd1cf91..cf1e326e 100644 --- a/src/components/home/proxy-selectors.tsx +++ b/src/components/home/proxy-selectors.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { cn } from "@root/lib/utils"; // Компоненты и иконки @@ -9,23 +9,28 @@ import { SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { ChevronsUpDown, Timer, WholeWord } from 'lucide-react'; +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { ChevronsUpDown, Timer, WholeWord } from "lucide-react"; // Логика -import { useVerge } from '@/hooks/use-verge'; -import { useAppData } from '@/providers/app-data-provider'; -import delayManager from '@/services/delay'; -import { updateProxy, deleteConnection } from '@/services/api'; +import { useVerge } from "@/hooks/use-verge"; +import { useAppData } from "@/providers/app-data-provider"; +import delayManager from "@/services/delay"; +import { updateProxy, deleteConnection } from "@/services/api"; // --- Типы и константы --- -const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group'; -const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type'; +const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group"; +const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type"; const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"]; -type ProxySortType = 'default' | 'delay' | 'name'; +type ProxySortType = "default" | "delay" | "name"; interface IProxyGroup { name: string; type: string; @@ -35,15 +40,25 @@ interface IProxyGroup { } // --- Вспомогательная функция для цвета задержки --- -function getDelayBadgeVariant(delayValue: number): 'default' | 'secondary' | 'destructive' | 'outline' { - if (delayValue < 0) return 'secondary'; - if (delayValue >= 150) return 'destructive'; - return 'default'; +function getDelayBadgeVariant( + delayValue: number, +): "default" | "secondary" | "destructive" | "outline" { + if (delayValue < 0) return "secondary"; + if (delayValue >= 150) return "destructive"; + return "default"; } // --- Дочерний компонент для элемента списка с "живым" обновлением пинга --- -const ProxySelectItem = ({ proxyName, groupName }: { proxyName: string, groupName: string }) => { - const [delay, setDelay] = useState(() => delayManager.getDelay(proxyName, groupName)); +const ProxySelectItem = ({ + proxyName, + groupName, +}: { + proxyName: string; + groupName: string; +}) => { + const [delay, setDelay] = useState(() => + delayManager.getDelay(proxyName, groupName), + ); const [isJustUpdated, setIsJustUpdated] = useState(false); useEffect(() => { @@ -71,44 +86,62 @@ const ProxySelectItem = ({ proxyName, groupName }: { proxyName: string, groupNam variant={getDelayBadgeVariant(delay)} className={cn( "ml-4 flex-shrink-0 px-2 h-5 justify-center transition-colors duration-300", - isJustUpdated && "bg-primary/20 border-primary/50" + isJustUpdated && "bg-primary/20 border-primary/50", )} > - {(delay < 0) || (delay > 10000) ? '---' : delay} + {delay < 0 || delay > 10000 ? "---" : delay}
); }; - export const ProxySelectors: React.FC = () => { const { t } = useTranslation(); const { verge } = useVerge(); const { proxies, connections, clashConfig, refreshProxy } = useAppData(); - const mode = clashConfig?.mode?.toLowerCase() || 'rule'; - const isGlobalMode = mode === 'global'; - const isDirectMode = mode ==='direct'; + const mode = clashConfig?.mode?.toLowerCase() || "rule"; + const isGlobalMode = mode === "global"; + const isDirectMode = mode === "direct"; - const [selectedGroup, setSelectedGroup] = useState(''); - const [selectedProxy, setSelectedProxy] = useState(''); - const [sortType, setSortType] = useState(() => (localStorage.getItem(STORAGE_KEY_SORT_TYPE) as ProxySortType) || 'default'); + const [selectedGroup, setSelectedGroup] = useState(""); + const [selectedProxy, setSelectedProxy] = useState(""); + const [sortType, setSortType] = useState( + () => + (localStorage.getItem(STORAGE_KEY_SORT_TYPE) as ProxySortType) || + "default", + ); useEffect(() => { if (!proxies?.groups) return; - if (isGlobalMode) { setSelectedGroup('GLOBAL'); return; } - if (isDirectMode) { setSelectedGroup('DIRECT'); return; } + if (isGlobalMode) { + setSelectedGroup("GLOBAL"); + return; + } + if (isDirectMode) { + setSelectedGroup("DIRECT"); + return; + } const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP); - const primaryGroup = proxies.groups.find((g: IProxyGroup) => g.type === 'Selector' && g.name.toLowerCase().includes('auto')) || proxies.groups.find((g: IProxyGroup) => g.type === 'Selector'); + const primaryGroup = + proxies.groups.find( + (g: IProxyGroup) => + g.type === "Selector" && g.name.toLowerCase().includes("auto"), + ) || proxies.groups.find((g: IProxyGroup) => g.type === "Selector"); - if (savedGroup && proxies.groups.some((g: IProxyGroup) => g.name === savedGroup)) { + if ( + savedGroup && + proxies.groups.some((g: IProxyGroup) => g.name === savedGroup) + ) { setSelectedGroup(savedGroup); } else if (primaryGroup) { setSelectedGroup(primaryGroup.name); } else if (proxies.groups.length > 0) { - const firstSelector = proxies.groups.find((g: IProxyGroup) => g.type === 'Selector'); + const firstSelector = proxies.groups.find( + (g: IProxyGroup) => g.type === "Selector", + ); if (firstSelector) { setSelectedGroup(firstSelector.name); } @@ -117,38 +150,56 @@ export const ProxySelectors: React.FC = () => { useEffect(() => { if (!selectedGroup || !proxies) return; - if (isGlobalMode) { setSelectedProxy(proxies.global?.now || ''); return; } - if (isDirectMode) { setSelectedProxy('DIRECT'); return; } - const group = proxies.groups.find((g: IProxyGroup) => g.name === selectedGroup); + if (isGlobalMode) { + setSelectedProxy(proxies.global?.now || ""); + return; + } + if (isDirectMode) { + setSelectedProxy("DIRECT"); + return; + } + const group = proxies.groups.find( + (g: IProxyGroup) => g.name === selectedGroup, + ); if (group) { const current = group.now; - const firstInList = typeof group.all?.[0] === 'string' ? group.all[0] : group.all?.[0]?.name; - setSelectedProxy(current || firstInList || ''); + const firstInList = + typeof group.all?.[0] === "string" + ? group.all[0] + : group.all?.[0]?.name; + setSelectedProxy(current || firstInList || ""); } }, [selectedGroup, proxies, isGlobalMode, isDirectMode]); - const handleProxyListOpen = useCallback((isOpen: boolean) => { - if (!isOpen || isDirectMode) return; + const handleProxyListOpen = useCallback( + (isOpen: boolean) => { + if (!isOpen || isDirectMode) return; - const timeout = verge?.default_latency_timeout || 5000; + const timeout = verge?.default_latency_timeout || 5000; - if (isGlobalMode) { - const proxyList = proxies?.global?.all; - if (proxyList) { - const proxyNames = proxyList - .map((p: any) => (typeof p === 'string' ? p : p.name)) - .filter((name: string) => name && !presetList.includes(name)); + if (isGlobalMode) { + const proxyList = proxies?.global?.all; + if (proxyList) { + const proxyNames = proxyList + .map((p: any) => (typeof p === "string" ? p : p.name)) + .filter((name: string) => name && !presetList.includes(name)); - delayManager.checkListDelay(proxyNames, 'GLOBAL', timeout); + delayManager.checkListDelay(proxyNames, "GLOBAL", timeout); + } + } else { + const group = proxies?.groups?.find( + (g: IProxyGroup) => g.name === selectedGroup, + ); + if (group && group.all) { + const proxyNames = group.all + .map((p: any) => (typeof p === "string" ? p : p.name)) + .filter(Boolean); + delayManager.checkListDelay(proxyNames, selectedGroup, timeout); + } } - } else { - const group = proxies?.groups?.find((g: IProxyGroup) => g.name === selectedGroup); - if (group && group.all) { - const proxyNames = group.all.map((p: any) => typeof p === 'string' ? p : p.name).filter(Boolean); - delayManager.checkListDelay(proxyNames, selectedGroup, timeout); - } - } - }, [selectedGroup, proxies, isGlobalMode, isDirectMode, verge]); + }, + [selectedGroup, proxies, isGlobalMode, isDirectMode, verge], + ); const handleGroupChange = (newGroup: string) => { if (isGlobalMode || isDirectMode) return; @@ -176,7 +227,11 @@ export const ProxySelectors: React.FC = () => { }; const handleSortChange = () => { - const nextSort: Record = { default: 'delay', delay: 'name', name: 'default' }; + const nextSort: Record = { + default: "delay", + delay: "name", + name: "default", + }; const newSortType = nextSort[sortType]; setSortType(newSortType); localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType); @@ -187,27 +242,31 @@ export const ProxySelectors: React.FC = () => { const allowedTypes = ["Selector", "URLTest", "Fallback"]; - return proxies.groups.filter((g: IProxyGroup) => - allowedTypes.includes(g.type) && - !g.hidden + return proxies.groups.filter( + (g: IProxyGroup) => allowedTypes.includes(g.type) && !g.hidden, ); }, [proxies]); - const proxyOptions = useMemo(() => { let options: { name: string }[] = []; if (isDirectMode) return [{ name: "DIRECT" }]; - const sourceList = isGlobalMode ? proxies?.global?.all : proxies?.groups?.find((g: IProxyGroup) => g.name === selectedGroup)?.all; + const sourceList = isGlobalMode + ? proxies?.global?.all + : proxies?.groups?.find((g: IProxyGroup) => g.name === selectedGroup) + ?.all; if (sourceList) { - options = sourceList.map((proxy: any) => ({ - name: typeof proxy === 'string' ? proxy : proxy.name, - })).filter((p: { name: string }) => p.name); + options = sourceList + .map((proxy: any) => ({ + name: typeof proxy === "string" ? proxy : proxy.name, + })) + .filter((p: { name: string }) => p.name); } - if (sortType === 'name') return options.sort((a, b) => a.name.localeCompare(b.name)); - if (sortType === 'delay') { + if (sortType === "name") + return options.sort((a, b) => a.name.localeCompare(b.name)); + if (sortType === "delay") { return options.sort((a, b) => { const delayA = delayManager.getDelay(a.name, selectedGroup); const delayB = delayManager.getDelay(b.name, selectedGroup); @@ -223,8 +282,14 @@ export const ProxySelectors: React.FC = () => {
- - @@ -239,7 +304,9 @@ export const ProxySelectors: React.FC = () => { {selectorGroups.map((group: IProxyGroup) => ( - {group.name} + + {group.name} + ))} @@ -247,26 +314,39 @@ export const ProxySelectors: React.FC = () => {
- + - - - - - - - {sortType === 'default' &&

{t("Sort by default")}

} - {sortType === 'delay' &&

{t("Sort by delay")}

} - {sortType === 'name' &&

{t("Sort by name")}

} -
-
- + + + + + + + {sortType === "default" &&

{t("Sort by default")}

} + {sortType === "delay" &&

{t("Sort by delay")}

} + {sortType === "name" &&

{t("Sort by name")}

} +
+
- @@ -280,11 +360,11 @@ export const ProxySelectors: React.FC = () => { - {proxyOptions.map(proxy => ( + {proxyOptions.map((proxy) => ( ))} diff --git a/src/components/layout/theme-provider.tsx b/src/components/layout/theme-provider.tsx index 77987421..ed55c095 100644 --- a/src/components/layout/theme-provider.tsx +++ b/src/components/layout/theme-provider.tsx @@ -10,33 +10,45 @@ type ThemeProviderProps = { }; function hexToHsl(hex?: string): string | undefined { - if (!hex) return undefined; - let r = 0, g = 0, b = 0; - hex = hex.replace("#", ""); - if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - - r = parseInt(hex.substring(0, 2), 16) / 255; - g = parseInt(hex.substring(2, 4), 16) / 255; - b = parseInt(hex.substring(4, 6), 16) / 255; - - const max = Math.max(r, g, b), min = Math.min(r, g, b); - let h = 0, s = 0, l = (max + min) / 2; - if (max !== min) { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { - case r: h = (g - b) / d + (g < b ? 6 : 0); break; - case g: h = (b - r) / d + 2; break; - case b: h = (r - g) / d + 4; break; - } - h /= 6; + if (!hex) return undefined; + let r = 0, + g = 0, + b = 0; + hex = hex.replace("#", ""); + if (hex.length === 3) + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + + r = parseInt(hex.substring(0, 2), 16) / 255; + g = parseInt(hex.substring(2, 4), 16) / 255; + b = parseInt(hex.substring(4, 6), 16) / 255; + + const max = Math.max(r, g, b), + min = Math.min(r, g, b); + let h = 0, + s = 0, + l = (max + min) / 2; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; } - return `${(h * 360).toFixed(1)} ${s.toFixed(3)} ${l.toFixed(3)}`; + h /= 6; + } + return `${(h * 360).toFixed(1)} ${s.toFixed(3)} ${l.toFixed(3)}`; } export function ThemeProvider({ children }: ThemeProviderProps) { const { verge } = useVerge(); - + const themeModeSetting = verge?.theme_mode || "system"; const customThemeSettings = verge?.theme_setting || {}; @@ -44,32 +56,50 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const root = window.document.documentElement; // тег const appWindow = getCurrentWebviewWindow(); - const applyTheme = (mode: 'light' | 'dark') => { + const applyTheme = (mode: "light" | "dark") => { root.classList.remove("light", "dark"); root.classList.add(mode); - + appWindow.setTheme(mode as TauriTheme).catch(console.error); - const basePalette = mode === 'light' ? defaultTheme : defaultDarkTheme; + const basePalette = mode === "light" ? defaultTheme : defaultDarkTheme; const variables = { "--background": hexToHsl(basePalette.background_color), - "--foreground": hexToHsl(customThemeSettings.primary_text || basePalette.primary_text), + "--foreground": hexToHsl( + customThemeSettings.primary_text || basePalette.primary_text, + ), "--card": hexToHsl(basePalette.background_color), // Используем тот же фон - "--card-foreground": hexToHsl(customThemeSettings.primary_text || basePalette.primary_text), + "--card-foreground": hexToHsl( + customThemeSettings.primary_text || basePalette.primary_text, + ), "--popover": hexToHsl(basePalette.background_color), - "--popover-foreground": hexToHsl(customThemeSettings.primary_text || basePalette.primary_text), - "--primary": hexToHsl(customThemeSettings.primary_color || basePalette.primary_color), + "--popover-foreground": hexToHsl( + customThemeSettings.primary_text || basePalette.primary_text, + ), + "--primary": hexToHsl( + customThemeSettings.primary_color || basePalette.primary_color, + ), "--primary-foreground": hexToHsl("#ffffff"), // Предполагаем белый текст на основном цвете - "--secondary": hexToHsl(customThemeSettings.secondary_color || basePalette.secondary_color), - "--secondary-foreground": hexToHsl(customThemeSettings.primary_text || basePalette.primary_text), - "--muted-foreground": hexToHsl(customThemeSettings.secondary_text || basePalette.secondary_text), - "--destructive": hexToHsl(customThemeSettings.error_color || basePalette.error_color), - "--ring": hexToHsl(customThemeSettings.primary_color || basePalette.primary_color), + "--secondary": hexToHsl( + customThemeSettings.secondary_color || basePalette.secondary_color, + ), + "--secondary-foreground": hexToHsl( + customThemeSettings.primary_text || basePalette.primary_text, + ), + "--muted-foreground": hexToHsl( + customThemeSettings.secondary_text || basePalette.secondary_text, + ), + "--destructive": hexToHsl( + customThemeSettings.error_color || basePalette.error_color, + ), + "--ring": hexToHsl( + customThemeSettings.primary_color || basePalette.primary_color, + ), }; - + for (const [key, value] of Object.entries(variables)) { - if(value) root.style.setProperty(key, value); + if (value) root.style.setProperty(key, value); } if (customThemeSettings.font_family) { @@ -77,7 +107,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { } else { root.style.removeProperty("--font-sans"); } - + let styleElement = document.querySelector("style#verge-theme"); if (!styleElement) { styleElement = document.createElement("style"); @@ -90,12 +120,17 @@ export function ThemeProvider({ children }: ThemeProviderProps) { }; if (themeModeSetting === "system") { - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; applyTheme(systemTheme); const unlistenPromise = appWindow.onThemeChanged(({ payload }) => { - if (verge?.theme_mode === 'system') applyTheme(payload); + if (verge?.theme_mode === "system") applyTheme(payload); }); - return () => { unlistenPromise.then(f => f()); }; + return () => { + unlistenPromise.then((f) => f()); + }; } else { applyTheme(themeModeSetting); } diff --git a/src/components/layout/use-custom-theme.ts b/src/components/layout/use-custom-theme.ts index 27a029c1..9513022f 100644 --- a/src/components/layout/use-custom-theme.ts +++ b/src/components/layout/use-custom-theme.ts @@ -13,20 +13,24 @@ export const useCustomTheme = () => { const setMode = useSetThemeMode(); useEffect(() => { - setMode(theme_mode === "light" || theme_mode === "dark" ? theme_mode : "system"); + setMode( + theme_mode === "light" || theme_mode === "dark" ? theme_mode : "system", + ); }, [theme_mode, setMode]); useEffect(() => { const root = document.documentElement; - const activeTheme = mode === "system" - ? window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" - : mode; + const activeTheme = + mode === "system" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : mode; root.classList.remove("light", "dark"); root.classList.add(activeTheme); appWindow.setTheme(activeTheme as Theme).catch(console.error); - }, [mode, appWindow]); useEffect(() => { @@ -34,7 +38,9 @@ export const useCustomTheme = () => { const unlistenPromise = appWindow.onThemeChanged(({ payload }) => { setMode(payload); }); - return () => { unlistenPromise.then(f => f()); }; + return () => { + unlistenPromise.then((f) => f()); + }; }, [theme_mode, appWindow, setMode]); return {}; diff --git a/src/components/profile/file-input.tsx b/src/components/profile/file-input.tsx index 20eb2bca..abff2598 100644 --- a/src/components/profile/file-input.tsx +++ b/src/components/profile/file-input.tsx @@ -20,33 +20,35 @@ export const FileInput: React.FC = (props) => { const [fileName, setFileName] = useState(""); // Вся ваша логика для чтения файла остается без изменений - const onFileInput = useLockFn(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; + const onFileInput = useLockFn( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; - setFileName(file.name); - setLoading(true); + setFileName(file.name); + setLoading(true); - try { - const value = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (event) => { - resolve(event.target?.result as string); - }; - reader.onerror = (err) => reject(err); - reader.readAsText(file); - }); - onChange(file, value); - } catch (error) { - console.error("File reading error:", error); - } finally { - setLoading(false); - // Очищаем value у input, чтобы можно было выбрать тот же файл еще раз - if (inputRef.current) { - inputRef.current.value = ""; + try { + const value = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + resolve(event.target?.result as string); + }; + reader.onerror = (err) => reject(err); + reader.readAsText(file); + }); + onChange(file, value); + } catch (error) { + console.error("File reading error:", error); + } finally { + setLoading(false); + // Очищаем value у input, чтобы можно было выбрать тот же файл еще раз + if (inputRef.current) { + inputRef.current.value = ""; + } } - } - }); + }, + ); return ( // Заменяем Box на div с flex и gap для отступов diff --git a/src/components/profile/group-item.tsx b/src/components/profile/group-item.tsx index d52d2fa0..30c5e5bd 100644 --- a/src/components/profile/group-item.tsx +++ b/src/components/profile/group-item.tsx @@ -45,13 +45,16 @@ export const GroupItem = (props: Props) => { async function initIconCachePath() { if (group.icon && group.icon.trim().startsWith("http")) { - const fileName = group.name.replaceAll(" ", "") + "-" + getFileName(group.icon); + const fileName = + group.name.replaceAll(" ", "") + "-" + getFileName(group.icon); const iconPath = await downloadIconCache(group.icon, fileName); setIconCachePath(convertFileSrc(iconPath)); } } - useEffect(() => { initIconCachePath(); }, [group.icon, group.name]); + useEffect(() => { + initIconCachePath(); + }, [group.icon, group.name]); const style = { transform: CSS.Transform.toString(transform), @@ -67,14 +70,17 @@ export const GroupItem = (props: Props) => { className={cn( "flex items-center p-2 mb-1 rounded-lg transition-shadow", typeStyles[type], - isDragging && "shadow-lg" + isDragging && "shadow-lg", )} > {/* Ручка для перетаскивания */}
@@ -82,7 +88,13 @@ export const GroupItem = (props: Props) => { {/* Иконка группы */} {group.icon && ( {group.name} @@ -97,7 +109,12 @@ export const GroupItem = (props: Props) => {
{/* Кнопка действия */} - - - - - - No results found. - - {options.map((option) => ( - { onSelect(options.find(opt => opt.toLowerCase() === currentValue) || ''); setOpen(false); }}> - - {option} - - ))} - - - - - ); +const Combobox = ({ + options, + value, + onSelect, + placeholder, +}: { + options: string[]; + value: string; + onSelect: (value: string) => void; + placeholder?: string; +}) => { + const [open, setOpen] = useState(false); + return ( + + + + + + + + No results found. + + {options.map((option) => ( + { + onSelect( + options.find((opt) => opt.toLowerCase() === currentValue) || + "", + ); + setOpen(false); + }} + > + + {option} + + ))} + + + + + ); }; // --- Новый компонент MultiSelectCombobox (множественный выбор) --- -const MultiSelectCombobox = ({ options, value, onChange, placeholder }: { options: string[], value: string[], onChange: (value: string[]) => void, placeholder?: string }) => { - const [open, setOpen] = useState(false); - const selectedSet = new Set(value); +const MultiSelectCombobox = ({ + options, + value, + onChange, + placeholder, +}: { + options: string[]; + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; +}) => { + const [open, setOpen] = useState(false); + const selectedSet = new Set(value); - const handleSelect = (currentValue: string) => { - const newSet = new Set(selectedSet); - if (newSet.has(currentValue)) { - newSet.delete(currentValue); - } else { - newSet.add(currentValue); - } - onChange(Array.from(newSet)); - }; + const handleSelect = (currentValue: string) => { + const newSet = new Set(selectedSet); + if (newSet.has(currentValue)) { + newSet.delete(currentValue); + } else { + newSet.add(currentValue); + } + onChange(Array.from(newSet)); + }; - return ( - - - - - - - - No results found. - - {options.map((option) => ( - handleSelect(option)} className="cursor-pointer"> - - {option} - - ))} - - - - - ); + return ( + + + + + + + + No results found. + + {options.map((option) => ( + handleSelect(option)} + className="cursor-pointer" + > + + {option} + + ))} + + + + + ); }; // --- Новый компонент для элемента списка групп --- -const EditorGroupItem = ({ type, group, onDelete, id }: { type: string, group: IProxyGroupConfig, onDelete: () => void, id: string }) => { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); - const style = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 100 : undefined }; - const isDelete = type === 'delete'; - return ( -
-
-

{group.name}

- -
- ) +const EditorGroupItem = ({ + type, + group, + onDelete, + id, +}: { + type: string; + group: IProxyGroupConfig; + onDelete: () => void; + id: string; +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 100 : undefined, + }; + const isDelete = type === "delete"; + return ( +
+
+ +
+

+ {group.name} +

+ +
+ ); }; export const GroupsEditorViewer = (props: Props) => { - const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } = props; - const { t } = useTranslation(); - const themeMode = useThemeMode(); - const [prevData, setPrevData] = useState(""); - const [currData, setCurrData] = useState(""); - const [visualization, setVisualization] = useState(true); - const [match, setMatch] = useState(() => (_: string) => true); - const [interfaceNameList, setInterfaceNameList] = useState([]); - - const form = useForm({ - defaultValues: { type: "select", name: "", interval: 300, timeout: 5000, "max-failed-times": 5, lazy: true }, - }); - const { control, watch, handleSubmit, getValues } = form; + const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } = + props; + const { t } = useTranslation(); + const themeMode = useThemeMode(); + const [prevData, setPrevData] = useState(""); + const [currData, setCurrData] = useState(""); + const [visualization, setVisualization] = useState(true); + const [match, setMatch] = useState(() => (_: string) => true); + const [interfaceNameList, setInterfaceNameList] = useState([]); - const [groupList, setGroupList] = useState([]); - const [proxyPolicyList, setProxyPolicyList] = useState([]); - const [proxyProviderList, setProxyProviderList] = useState([]); - const [prependSeq, setPrependSeq] = useState([]); - const [appendSeq, setAppendSeq] = useState([]); - const [deleteSeq, setDeleteSeq] = useState([]); + const form = useForm({ + defaultValues: { + type: "select", + name: "", + interval: 300, + timeout: 5000, + "max-failed-times": 5, + lazy: true, + }, + }); + const { control, watch, handleSubmit, getValues } = form; - const filteredPrependSeq = useMemo(() => prependSeq.filter((group) => match(group.name)), [prependSeq, match]); - const filteredGroupList = useMemo(() => groupList.filter((group) => match(group.name)), [groupList, match]); - const filteredAppendSeq = useMemo(() => appendSeq.filter((group) => match(group.name)), [appendSeq, match]); + const [groupList, setGroupList] = useState([]); + const [proxyPolicyList, setProxyPolicyList] = useState([]); + const [proxyProviderList, setProxyProviderList] = useState([]); + const [prependSeq, setPrependSeq] = useState([]); + const [appendSeq, setAppendSeq] = useState([]); + const [deleteSeq, setDeleteSeq] = useState([]); - const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })); + const filteredPrependSeq = useMemo( + () => prependSeq.filter((group) => match(group.name)), + [prependSeq, match], + ); + const filteredGroupList = useMemo( + () => groupList.filter((group) => match(group.name)), + [groupList, match], + ); + const filteredAppendSeq = useMemo( + () => appendSeq.filter((group) => match(group.name)), + [appendSeq, match], + ); - const reorder = (list: IProxyGroupConfig[], startIndex: number, endIndex: number) => { - const result = Array.from(list); - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - return result; - }; - - const onPrependDragEnd = async (event: DragEndEvent) => { - const { active, over } = event; - if (over && active.id !== over.id) { - let activeIndex = 0; - let overIndex = 0; - prependSeq.forEach((item, index) => { - if (item.name === active.id) activeIndex = index; - if (item.name === over.id) overIndex = index; - }); - setPrependSeq(reorder(prependSeq, activeIndex, overIndex)); - } - }; - - const onAppendDragEnd = async (event: DragEndEvent) => { - const { active, over } = event; - if (over && active.id !== over.id) { - let activeIndex = 0; - let overIndex = 0; - appendSeq.forEach((item, index) => { - if (item.name === active.id) activeIndex = index; - if (item.name === over.id) overIndex = index; - }); - setAppendSeq(reorder(appendSeq, activeIndex, overIndex)); - } - }; - - const fetchContent = async () => { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const reorder = ( + list: IProxyGroupConfig[], + startIndex: number, + endIndex: number, + ) => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + return result; + }; + + const onPrependDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + let activeIndex = 0; + let overIndex = 0; + prependSeq.forEach((item, index) => { + if (item.name === active.id) activeIndex = index; + if (item.name === over.id) overIndex = index; + }); + setPrependSeq(reorder(prependSeq, activeIndex, overIndex)); + } + }; + + const onAppendDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + let activeIndex = 0; + let overIndex = 0; + appendSeq.forEach((item, index) => { + if (item.name === active.id) activeIndex = index; + if (item.name === over.id) overIndex = index; + }); + setAppendSeq(reorder(appendSeq, activeIndex, overIndex)); + } + }; + + const fetchContent = async () => { + try { + let data = await readProfileFile(property); + let obj = yaml.load(data) as ISeqProfileConfig | null; + setPrependSeq(obj?.prepend || []); + setAppendSeq(obj?.append || []); + setDeleteSeq(obj?.delete || []); + setPrevData(data); + setCurrData(data); + } catch (error) { + console.error("Failed to fetch or parse content:", error); + } + }; + + useEffect(() => { + if (currData === "" || !visualization) return; + try { + let obj = yaml.load(currData) as { + prepend: []; + append: []; + delete: []; + } | null; + setPrependSeq(obj?.prepend || []); + setAppendSeq(obj?.append || []); + setDeleteSeq(obj?.delete || []); + } catch (e) { + /* Ignore parsing errors while typing */ + } + }, [visualization, currData]); + + useEffect(() => { + if (prependSeq && appendSeq && deleteSeq && visualization) { + const serialize = () => { try { - let data = await readProfileFile(property); - let obj = yaml.load(data) as ISeqProfileConfig | null; - setPrependSeq(obj?.prepend || []); - setAppendSeq(obj?.append || []); - setDeleteSeq(obj?.delete || []); - setPrevData(data); - setCurrData(data); - } catch (error) { console.error("Failed to fetch or parse content:", error); } - }; - - useEffect(() => { - if (currData === "" || !visualization) return; - try { - let obj = yaml.load(currData) as { prepend: [], append: [], delete: [] } | null; - setPrependSeq(obj?.prepend || []); - setAppendSeq(obj?.append || []); - setDeleteSeq(obj?.delete || []); - } catch (e) { /* Ignore parsing errors while typing */ } - }, [visualization, currData]); - - useEffect(() => { - if (prependSeq && appendSeq && deleteSeq && visualization) { - const serialize = () => { - try { - setCurrData(yaml.dump({ prepend: prependSeq, append: appendSeq, delete: deleteSeq }, { forceQuotes: true })); - } catch (e: any) { showNotice("error", e?.message || e?.toString() || "YAML dump error"); } - }; - if (window.requestIdleCallback) { window.requestIdleCallback(serialize); } else { setTimeout(serialize, 0); } + setCurrData( + yaml.dump( + { prepend: prependSeq, append: appendSeq, delete: deleteSeq }, + { forceQuotes: true }, + ), + ); + } catch (e: any) { + showNotice("error", e?.message || e?.toString() || "YAML dump error"); } - }, [prependSeq, appendSeq, deleteSeq, visualization]); - - const fetchProxyPolicy = async () => { - try { - let data = await readProfileFile(profileUid); - let proxiesData = await readProfileFile(proxiesUid); - let originGroupsObj = yaml.load(data) as { "proxy-groups": IProxyGroupConfig[] } | null; - let originProxiesObj = yaml.load(data) as { proxies: [] } | null; - let originProxies = originProxiesObj?.proxies || []; - let moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null; - let morePrependProxies = moreProxiesObj?.prepend || []; - let moreAppendProxies = moreProxiesObj?.append || []; - let moreDeleteProxies = moreProxiesObj?.delete || ([] as string[] | { name: string }[]); - let proxies = morePrependProxies.concat(originProxies.filter((proxy: any) => !moreDeleteProxies.some((del: any) => (del.name || del) === proxy.name)), moreAppendProxies); + }; + if (window.requestIdleCallback) { + window.requestIdleCallback(serialize); + } else { + setTimeout(serialize, 0); + } + } + }, [prependSeq, appendSeq, deleteSeq, visualization]); - setProxyPolicyList(Array.from(new Set(builtinProxyPolicies.concat( - prependSeq.map((group) => group.name), - originGroupsObj?.["proxy-groups"].map((group) => group.name).filter((name) => !deleteSeq.includes(name)) || [], - appendSeq.map((group) => group.name), - proxies.map((proxy: any) => proxy.name), - )))); - } catch(error) { console.error("Failed to fetch proxy policy:", error) } - }; + const fetchProxyPolicy = async () => { + try { + let data = await readProfileFile(profileUid); + let proxiesData = await readProfileFile(proxiesUid); + let originGroupsObj = yaml.load(data) as { + "proxy-groups": IProxyGroupConfig[]; + } | null; + let originProxiesObj = yaml.load(data) as { proxies: [] } | null; + let originProxies = originProxiesObj?.proxies || []; + let moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null; + let morePrependProxies = moreProxiesObj?.prepend || []; + let moreAppendProxies = moreProxiesObj?.append || []; + let moreDeleteProxies = + moreProxiesObj?.delete || ([] as string[] | { name: string }[]); + let proxies = morePrependProxies.concat( + originProxies.filter( + (proxy: any) => + !moreDeleteProxies.some( + (del: any) => (del.name || del) === proxy.name, + ), + ), + moreAppendProxies, + ); - const fetchProfile = async () => { - try { - let data = await readProfileFile(profileUid); - let mergeData = await readProfileFile(mergeUid); - let globalMergeData = await readProfileFile("Merge"); + setProxyPolicyList( + Array.from( + new Set( + builtinProxyPolicies.concat( + prependSeq.map((group) => group.name), + originGroupsObj?.["proxy-groups"] + .map((group) => group.name) + .filter((name) => !deleteSeq.includes(name)) || [], + appendSeq.map((group) => group.name), + proxies.map((proxy: any) => proxy.name), + ), + ), + ), + ); + } catch (error) { + console.error("Failed to fetch proxy policy:", error); + } + }; - let originGroupsObj = yaml.load(data) as { "proxy-groups": IProxyGroupConfig[] } | null; - let originProviderObj = yaml.load(data) as { "proxy-providers": {} } | null; - let originProvider = originProviderObj?.["proxy-providers"] || {}; - let moreProviderObj = yaml.load(mergeData) as { "proxy-providers": {} } | null; - let moreProvider = moreProviderObj?.["proxy-providers"] || {}; - let globalProviderObj = yaml.load(globalMergeData) as { "proxy-providers": {} } | null; - let globalProvider = globalProviderObj?.["proxy-providers"] || {}; - let provider = { ...originProvider, ...moreProvider, ...globalProvider }; + const fetchProfile = async () => { + try { + let data = await readProfileFile(profileUid); + let mergeData = await readProfileFile(mergeUid); + let globalMergeData = await readProfileFile("Merge"); - setProxyProviderList(Object.keys(provider)); - setGroupList(originGroupsObj?.["proxy-groups"] || []); - } catch(error) { console.error("Failed to fetch profile:", error) } - }; - - const getInterfaceNameList = async () => { - try { - let list = await getNetworkInterfaces(); - setInterfaceNameList(list); - } catch (error) { console.error("Failed to get network interfaces:", error) } - }; - - useEffect(() => { fetchProxyPolicy(); }, [prependSeq, appendSeq, deleteSeq]); - - useEffect(() => { - if (open) { - fetchContent(); - fetchProxyPolicy(); - fetchProfile(); - getInterfaceNameList(); - } - }, [open]); + let originGroupsObj = yaml.load(data) as { + "proxy-groups": IProxyGroupConfig[]; + } | null; + let originProviderObj = yaml.load(data) as { + "proxy-providers": {}; + } | null; + let originProvider = originProviderObj?.["proxy-providers"] || {}; + let moreProviderObj = yaml.load(mergeData) as { + "proxy-providers": {}; + } | null; + let moreProvider = moreProviderObj?.["proxy-providers"] || {}; + let globalProviderObj = yaml.load(globalMergeData) as { + "proxy-providers": {}; + } | null; + let globalProvider = globalProviderObj?.["proxy-providers"] || {}; + let provider = { ...originProvider, ...moreProvider, ...globalProvider }; - const validateGroup = () => { - let group = getValues(); - if (group.name === "") { - throw new Error(t("Group Name Required")); - } - }; - - const handleSave = useLockFn(async () => { - try { - await saveProfileFile(property, currData); - showNotice("success", t("Saved Successfully")); - onSave?.(prevData, currData); - onClose(); - } catch (err: any) { - showNotice("error", err.toString()); - } - }); + setProxyProviderList(Object.keys(provider)); + setGroupList(originGroupsObj?.["proxy-groups"] || []); + } catch (error) { + console.error("Failed to fetch profile:", error); + } + }; - const groupType = watch("type"); + const getInterfaceNameList = async () => { + try { + let list = await getNetworkInterfaces(); + setInterfaceNameList(list); + } catch (error) { + console.error("Failed to get network interfaces:", error); + } + }; - return ( - - - -
- {t("Edit Groups")} - -
-
+ useEffect(() => { + fetchProxyPolicy(); + }, [prependSeq, appendSeq, deleteSeq]); -
- {visualization ? ( -
- - {/* Левая панель: Конструктор групп */} -
-

Constructor

- -
- ({t("Group Type")})}/> - ({t("Group Name")})}/> - ({t("Proxy Group Icon")})}/> - ({t("Use Proxies")})}/> - ({t("Use Provider")})}/> - {(groupType === "url-test" || groupType === "fallback") && <> - ({t("Health Check Url")})}/> - ({t("Interval")}
field.onChange(parseInt(e.target.value, 10) || 0)}/>{t("seconds")}
)}/> - ({t("Timeout")}
field.onChange(parseInt(e.target.value, 10) || 0)}/>{t("millis")}
)}/> - ({t("Max Failed Times")} field.onChange(parseInt(e.target.value, 10) || 0)}/>)}/> - } - ({t("Interface Name")})}/> - ({t("Routing Mark")} field.onChange(parseInt(e.target.value, 10) || 0)}/>)}/> - {(groupType === "url-test" || groupType === "fallback" || groupType === "load-balance") && <> - ({t("Lazy")})} /> - ({t("Disable UDP")})} /> - } - ({t("Hidden")})} /> -
-
- - -
+ useEffect(() => { + if (open) { + fetchContent(); + fetchProxyPolicy(); + fetchProfile(); + getInterfaceNameList(); + } + }, [open]); + + const validateGroup = () => { + let group = getValues(); + if (group.name === "") { + throw new Error(t("Group Name Required")); + } + }; + + const handleSave = useLockFn(async () => { + try { + await saveProfileFile(property, currData); + showNotice("success", t("Saved Successfully")); + onSave?.(prevData, currData); + onClose(); + } catch (err: any) { + showNotice("error", err.toString()); + } + }); + + const groupType = watch("type"); + + return ( + + + +
+ {t("Edit Groups")} + +
+
+ +
+ {visualization ? ( + + + {/* Левая панель: Конструктор групп */} +
+

Constructor

+ +
+ ( + + {t("Group Type")} + + + )} + /> + ( + + {t("Group Name")} + + + + + )} + /> + ( + + {t("Proxy Group Icon")} + + + + + )} + /> + ( + + {t("Use Proxies")} + + + )} + /> + ( + + {t("Use Provider")} + + + )} + /> + {(groupType === "url-test" || groupType === "fallback") && ( + <> + ( + + {t("Health Check Url")} + + + + + )} + /> + ( + + {t("Interval")} + +
+ + field.onChange( + parseInt(e.target.value, 10) || 0, + ) + } + /> + + {t("seconds")} +
- - - -
- setMatch(() => matcher)} /> -
- 0 ? 1 : 0) + (filteredAppendSeq.length > 0 ? 1 : 0)} - itemContent={(index) => { - let shift = filteredPrependSeq.length > 0 ? 1 : 0; - if (filteredPrependSeq.length > 0 && index === 0) { - return ( x.name)}>{filteredPrependSeq.map((item) => ( setPrependSeq(prependSeq.filter(v => v.name !== item.name))} />))}); - } else if (index < filteredGroupList.length + shift) { - const newIndex = index - shift; - const currentGroup = filteredGroupList[newIndex]; - return ( { if (deleteSeq.includes(currentGroup.name)) { setDeleteSeq(deleteSeq.filter(v => v !== currentGroup.name)); } else { setDeleteSeq((prev) => [...prev, currentGroup.name]); }}} />); - } else { - return ( x.name)}>{filteredAppendSeq.map((item) => ( setAppendSeq(appendSeq.filter(v => v.name !== item.name))} />))}); - } - }} - /> -
+ + + )} + /> + ( + + {t("Timeout")} + +
+ + field.onChange( + parseInt(e.target.value, 10) || 0, + ) + } + /> + + {t("millis")} +
- - - ) : ( -
- = 1500 }, mouseWheelZoom: true, quickSuggestions: { strings: true, comments: true, other: true }, padding: { top: 16 }, fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""}`, fontLigatures: false, smoothScrolling: true }} onChange={(value) => setCurrData(value)} /> -
+
+
+ )} + /> + ( + + {t("Max Failed Times")} + + + field.onChange( + parseInt(e.target.value, 10) || 0, + ) + } + /> + + + )} + /> + )} + ( + + {t("Interface Name")} + + + )} + /> + ( + + {t("Routing Mark")} + + + field.onChange( + parseInt(e.target.value, 10) || 0, + ) + } + /> + + + )} + /> + {(groupType === "url-test" || + groupType === "fallback" || + groupType === "load-balance") && ( + <> + ( + + {t("Lazy")} + + + + + )} + /> + ( + + {t("Disable UDP")} + + + + + )} + /> + + )} + ( + + {t("Hidden")} + + + + + )} + /> +
+
+ + +
- - - - - -
- ); + + +
+ setMatch(() => matcher)} + /> +
+ 0 ? 1 : 0) + + (filteredAppendSeq.length > 0 ? 1 : 0) + } + itemContent={(index) => { + let shift = filteredPrependSeq.length > 0 ? 1 : 0; + if (filteredPrependSeq.length > 0 && index === 0) { + return ( + + x.name)} + > + {filteredPrependSeq.map((item) => ( + + setPrependSeq( + prependSeq.filter( + (v) => v.name !== item.name, + ), + ) + } + /> + ))} + + + ); + } else if (index < filteredGroupList.length + shift) { + const newIndex = index - shift; + const currentGroup = filteredGroupList[newIndex]; + return ( + { + if (deleteSeq.includes(currentGroup.name)) { + setDeleteSeq( + deleteSeq.filter( + (v) => v !== currentGroup.name, + ), + ); + } else { + setDeleteSeq((prev) => [ + ...prev, + currentGroup.name, + ]); + } + }} + /> + ); + } else { + return ( + + x.name)} + > + {filteredAppendSeq.map((item) => ( + + setAppendSeq( + appendSeq.filter( + (v) => v.name !== item.name, + ), + ) + } + /> + ))} + + + ); + } + }} + /> +
+
+ + + ) : ( +
+ = 1500, + }, + mouseWheelZoom: true, + quickSuggestions: { + strings: true, + comments: true, + other: true, + }, + padding: { top: 16 }, + fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""}`, + fontLigatures: false, + smoothScrolling: true, + }} + onChange={(value) => setCurrData(value)} + /> +
+ )} +
+ + + + + + + + +
+ ); }; diff --git a/src/components/profile/log-viewer.tsx b/src/components/profile/log-viewer.tsx index c4796194..f9f74144 100644 --- a/src/components/profile/log-viewer.tsx +++ b/src/components/profile/log-viewer.tsx @@ -27,7 +27,9 @@ export const LogViewer = (props: Props) => { // Вспомогательная функция для определения варианта Badge const getLogLevelVariant = (level: string): "destructive" | "secondary" => { - return level === "error" || level === "exception" ? "destructive" : "secondary"; + return level === "error" || level === "exception" + ? "destructive" + : "secondary"; }; return ( @@ -41,7 +43,10 @@ export const LogViewer = (props: Props) => {
{logInfo.length > 0 ? ( logInfo.map(([level, log], index) => ( -
+
{level} @@ -60,7 +65,9 @@ export const LogViewer = (props: Props) => { - + diff --git a/src/components/profile/profile-box.tsx b/src/components/profile/profile-box.tsx index f11bfb3f..ca2d4e66 100644 --- a/src/components/profile/profile-box.tsx +++ b/src/components/profile/profile-box.tsx @@ -28,14 +28,14 @@ export const ProfileBox = React.forwardRef( "data-[selected=true]:text-card-foreground", // --- Дополнительные классы от пользователя --- - className + className, )} {...props} > {children}
); - } + }, ); ProfileBox.displayName = "ProfileBox"; diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index f5059df9..d8ce8788 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -66,9 +66,9 @@ import { ListTree, CheckCircle, Infinity, - RefreshCw + RefreshCw, } from "lucide-react"; -import {t} from "i18next"; +import { t } from "i18next"; // Активируем плагин для dayjs dayjs.extend(relativeTime); @@ -93,7 +93,7 @@ const parseExpire = (expire?: number | string): string | null => { if (!expireDate.isValid()) return null; const now = dayjs(); if (expireDate.isBefore(now)) return t("Expired"); - return t('Expires in', { duration: expireDate.fromNow(true) }); + return t("Expires in", { duration: expireDate.fromNow(true) }); }; type MenuItemAction = { @@ -262,7 +262,9 @@ export const ProfileItem = (props: Props) => { zIndex: isDragging ? 100 : undefined, }; - const homeMenuItem: MenuItemAction[] = hasHome ? [{ label: "Home", handler: onOpenHome, icon: ExternalLink }] : []; + const homeMenuItem: MenuItemAction[] = hasHome + ? [{ label: "Home", handler: onOpenHome, icon: ExternalLink }] + : []; const mainMenuItems: MenuItemAction[] = [ { label: "Select", handler: onForceSelect, icon: CheckCircle }, @@ -272,12 +274,32 @@ export const ProfileItem = (props: Props) => { ]; const editMenuItems: MenuItemAction[] = [ - { label: "Edit Rules", handler: onEditRules, disabled: !option?.rules, icon: ListChecks }, - { label: "Edit Proxies", handler: onEditProxies, disabled: !option?.proxies, icon: ListFilter }, - { label: "Edit Groups", handler: onEditGroups, disabled: !option?.groups, icon: ListTree }, + { + label: "Edit Rules", + handler: onEditRules, + disabled: !option?.rules, + icon: ListChecks, + }, + { + label: "Edit Proxies", + handler: onEditProxies, + disabled: !option?.proxies, + icon: ListFilter, + }, + { + label: "Edit Groups", + handler: onEditGroups, + disabled: !option?.groups, + icon: ListTree, + }, ]; - const deleteMenuItem: MenuItemAction = { label: "Delete", handler: () => setConfirmOpen(true), icon: Trash2, isDestructive: true }; + const deleteMenuItem: MenuItemAction = { + label: "Delete", + handler: () => setConfirmOpen(true), + icon: Trash2, + isDestructive: true, + }; return (
@@ -310,7 +332,14 @@ export const ProfileItem = (props: Props) => {

{name}

- {expireInfo === t("Expired") ? {t(expireInfo)} : null} + {expireInfo === t("Expired") ? ( + + {t(expireInfo)} + + ) : null}
{
- {expireInfo === null ? : expireInfo} + {expireInfo === null ? ( + + ) : ( + expireInfo + )}
@@ -351,7 +384,6 @@ export const ProfileItem = (props: Props) => { )}
-
@@ -369,36 +401,78 @@ export const ProfileItem = (props: Props) => { - e.stopPropagation()}> + e.stopPropagation()} + > {/* Объединяем все части меню */} - {[...homeMenuItem, ...mainMenuItems].map(item => ( - - {t(item.label)} + {[...homeMenuItem, ...mainMenuItems].map((item) => ( + + + {t(item.label)} ))} - {t("Update")} - - onUpdate(0)}>{t("Update")} - onUpdate(2)}>{t("Update via proxy")} - + + + {t("Update")} + + + + onUpdate(0)}> + {t("Update")} + + onUpdate(2)}> + {t("Update via proxy")} + + + - {editMenuItems.map(item => ( - - {t(item.label)} + {editMenuItems.map((item) => ( + + + {t(item.label)} ))} - - {t(deleteMenuItem.label)} + + + {t(deleteMenuItem.label)} {/* Модальные окна для редактирования */} - {fileOpen && setFileOpen(false)} initialData={readProfileFile(uid)} language="yaml" schema="clash" onSave={async (p, c) => { await saveProfileFile(uid, c || ""); onSave?.(p, c); }} />} + {fileOpen && ( + setFileOpen(false)} + initialData={readProfileFile(uid)} + language="yaml" + schema="clash" + onSave={async (p, c) => { + await saveProfileFile(uid, c || ""); + onSave?.(p, c); + }} + /> + )} {rulesOpen && ( { profileUid={uid} // <-- Был 'uid', стал 'profileUid' property={option?.rules ?? ""} groupsUid={option?.groups ?? ""} // <-- Добавлен недостающий пропс - mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс + mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс onSave={onSave} /> )} @@ -429,7 +503,7 @@ export const ProfileItem = (props: Props) => { profileUid={uid} // <-- Был 'uid', стал 'profileUid' property={option?.groups ?? ""} proxiesUid={option?.proxies ?? ""} // <-- Добавлен недостающий пропс - mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс + mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс onSave={onSave} /> )} @@ -438,7 +512,7 @@ export const ProfileItem = (props: Props) => { open={confirmOpen} onOpenChange={setConfirmOpen} onConfirm={onDelete} - title={t('Delete Profile', { name })} + title={t("Delete Profile", { name })} description={t("This action cannot be undone.")} />
diff --git a/src/components/profile/profile-more.tsx b/src/components/profile/profile-more.tsx index a83fa9bc..65554202 100644 --- a/src/components/profile/profile-more.tsx +++ b/src/components/profile/profile-more.tsx @@ -6,12 +6,17 @@ import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; import { EditorViewer } from "@/components/profile/editor-viewer"; import { ProfileBox } from "./profile-box"; // Наш рефакторенный компонент -import { LogViewer } from "./log-viewer"; // Наш рефакторенный компонент +import { LogViewer } from "./log-viewer"; // Наш рефакторенный компонент // Новые импорты import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Tooltip, TooltipContent, @@ -60,7 +65,9 @@ export const ProfileMore = (props: Props) => { {/* Верхняя строка: Название и Бейдж */}
-

{t(`Global ${id}`)}

+

+ {t(`Global ${id}`)} +

{id}
diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 4fe07ab1..66729df5 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -1,8 +1,19 @@ -import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; import { useLockFn } from "ahooks"; import { useTranslation } from "react-i18next"; import { useForm } from "react-hook-form"; -import { createProfile, patchProfile, importProfile, enhanceProfiles } from "@/services/cmds"; +import { + createProfile, + patchProfile, + importProfile, + enhanceProfiles, +} from "@/services/cmds"; import { useProfiles } from "@/hooks/use-profiles"; import { showNotice } from "@/services/noticeService"; import { version } from "@root/package.json"; @@ -34,11 +45,10 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import {ClipboardPaste, Loader2, X} from "lucide-react"; -import {readText} from "@tauri-apps/plugin-clipboard-manager"; +import { ClipboardPaste, Loader2, X } from "lucide-react"; +import { readText } from "@tauri-apps/plugin-clipboard-manager"; import { cn } from "@root/lib/utils"; - interface Props { onChange: (isActivating?: boolean) => void; } @@ -48,300 +58,469 @@ export interface ProfileViewerRef { edit: (item: IProfileItem) => void; } -export const ProfileViewer = forwardRef((props, ref) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [openType, setOpenType] = useState<"new" | "edit">("new"); - const { profiles } = useProfiles(); - const fileDataRef = useRef(null); +export const ProfileViewer = forwardRef( + (props, ref) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [openType, setOpenType] = useState<"new" | "edit">("new"); + const { profiles } = useProfiles(); + const fileDataRef = useRef(null); - const [showAdvanced, setShowAdvanced] = useState(false); - const [importUrl, setImportUrl] = useState(""); - const [isUrlValid, setIsUrlValid] = useState(true); - const [isCheckingUrl, setIsCheckingUrl] = useState(false); - const [isImporting, setIsImporting] = useState(false); - const [loading, setLoading] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [importUrl, setImportUrl] = useState(""); + const [isUrlValid, setIsUrlValid] = useState(true); + const [isCheckingUrl, setIsCheckingUrl] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [loading, setLoading] = useState(false); - const form = useForm({ - defaultValues: { - type: "remote", - name: "", - desc: "", - url: "", - option: { - with_proxy: false, - self_proxy: false, - danger_accept_invalid_certs: false, + const form = useForm({ + defaultValues: { + type: "remote", + name: "", + desc: "", + url: "", + option: { + with_proxy: false, + self_proxy: false, + danger_accept_invalid_certs: false, + }, }, - }, - }); + }); - const { control, watch, handleSubmit, reset, setValue } = form; + const { control, watch, handleSubmit, reset, setValue } = form; - useImperativeHandle(ref, () => ({ - create: () => { - reset({ type: "remote", name: "", desc: "", url: "", option: { with_proxy: false, self_proxy: false, danger_accept_invalid_certs: false } }); - fileDataRef.current = null; - setImportUrl(""); - setShowAdvanced(false); - setOpenType("new"); - setOpen(true); - }, - edit: (item) => { - reset(item); - fileDataRef.current = null; - setImportUrl(item.url || ""); - setShowAdvanced(true); - setOpenType("edit"); - setOpen(true); - }, - })); + useImperativeHandle(ref, () => ({ + create: () => { + reset({ + type: "remote", + name: "", + desc: "", + url: "", + option: { + with_proxy: false, + self_proxy: false, + danger_accept_invalid_certs: false, + }, + }); + fileDataRef.current = null; + setImportUrl(""); + setShowAdvanced(false); + setOpenType("new"); + setOpen(true); + }, + edit: (item) => { + reset(item); + fileDataRef.current = null; + setImportUrl(item.url || ""); + setShowAdvanced(true); + setOpenType("edit"); + setOpen(true); + }, + })); - const selfProxy = watch("option.self_proxy"); - const withProxy = watch("option.with_proxy"); - useEffect(() => { if (selfProxy) setValue("option.with_proxy", false); }, [selfProxy, setValue]); - useEffect(() => { if (withProxy) setValue("option.self_proxy", false); }, [withProxy, setValue]); + const selfProxy = watch("option.self_proxy"); + const withProxy = watch("option.with_proxy"); + useEffect(() => { + if (selfProxy) setValue("option.with_proxy", false); + }, [selfProxy, setValue]); + useEffect(() => { + if (withProxy) setValue("option.self_proxy", false); + }, [withProxy, setValue]); - useEffect(() => { - if (!importUrl) { - setIsUrlValid(true); - setIsCheckingUrl(false); - return; - } - setIsCheckingUrl(true); - - const handler = setTimeout(() => { - try { - new URL(importUrl); + useEffect(() => { + if (!importUrl) { setIsUrlValid(true); - } catch (error) { - setIsUrlValid(false); - } finally { setIsCheckingUrl(false); + return; } - }, 500); - return () => { - clearTimeout(handler); - }; - }, [importUrl]); + setIsCheckingUrl(true); - const handleImport = useLockFn(async () => { - if (!importUrl) return; - setIsImporting(true); - try { - await importProfile(importUrl); - showNotice("success", t("Profile Imported Successfully")); - props.onChange(); - await enhanceProfiles(); - setOpen(false); - } catch (err) { - showNotice("info", t("Import failed, retrying with Clash proxy...")); + const handler = setTimeout(() => { + try { + new URL(importUrl); + setIsUrlValid(true); + } catch (error) { + setIsUrlValid(false); + } finally { + setIsCheckingUrl(false); + } + }, 500); + return () => { + clearTimeout(handler); + }; + }, [importUrl]); + + const handleImport = useLockFn(async () => { + if (!importUrl) return; + setIsImporting(true); try { - await importProfile(importUrl, { with_proxy: false, self_proxy: true }); - showNotice("success", t("Profile Imported with Clash proxy")); + await importProfile(importUrl); + showNotice("success", t("Profile Imported Successfully")); props.onChange(); await enhanceProfiles(); setOpen(false); - } catch (retryErr: any) { - showNotice("error", `${t("Import failed even with Clash proxy")}: ${retryErr?.message || retryErr.toString()}`); - } - } finally { - setIsImporting(false); - } - }); - - const onCopyLink = async () => { - const text = await readText(); - if (text) setImportUrl(text); - }; - - const handleSaveAdvanced = useLockFn( - handleSubmit(async (formData) => { - const form = { ...formData, url: formData.url || importUrl }; - - setLoading(true); - try { - if (!form.type) throw new Error("`Type` should not be null"); - if (form.type === "remote" && !form.url) throw new Error("The URL should not be null"); - if (form.option?.update_interval) form.option.update_interval = +form.option.update_interval; - else delete form.option?.update_interval; - if (form.option?.user_agent === "") delete form.option.user_agent; - - const name = form.name || `${form.type} file`; - const item = { ...form, name }; - const isUpdate = openType === "edit"; - const isActivating = isUpdate && form.uid === (profiles?.current ?? ""); - - if (openType === "new") { - await createProfile(item, fileDataRef.current); - } else { - if (!form.uid) throw new Error("UID not found"); - await patchProfile(form.uid, item); + } catch (err) { + showNotice("info", t("Import failed, retrying with Clash proxy...")); + try { + await importProfile(importUrl, { + with_proxy: false, + self_proxy: true, + }); + showNotice("success", t("Profile Imported with Clash proxy")); + props.onChange(); + await enhanceProfiles(); + setOpen(false); + } catch (retryErr: any) { + showNotice( + "error", + `${t("Import failed even with Clash proxy")}: ${retryErr?.message || retryErr.toString()}`, + ); } - - setOpen(false); - props.onChange(isActivating); - } catch (err: any) { - showNotice("error", err.message || err.toString()); } finally { - setLoading(false); + setIsImporting(false); } - }), - ); + }); - const formType = watch("type"); - const isRemote = formType === "remote"; - const isLocal = formType === "local"; + const onCopyLink = async () => { + const text = await readText(); + if (text) setImportUrl(text); + }; - return ( - - - - {openType === "new" ? t("Create Profile") : t("Edit Profile")} - + const handleSaveAdvanced = useLockFn( + handleSubmit(async (formData) => { + const form = { ...formData, url: formData.url || importUrl }; - {openType === "new" && ( -
-
-
- setImportUrl(e.target.value)} - disabled={isImporting} - className={cn( - "h-9 min-w-[200px] flex-grow sm:w-65", - !isUrlValid && "border-destructive focus-visible:ring-destructive" + setLoading(true); + try { + if (!form.type) throw new Error("`Type` should not be null"); + if (form.type === "remote" && !form.url) + throw new Error("The URL should not be null"); + if (form.option?.update_interval) + form.option.update_interval = +form.option.update_interval; + else delete form.option?.update_interval; + if (form.option?.user_agent === "") delete form.option.user_agent; + + const name = form.name || `${form.type} file`; + const item = { ...form, name }; + const isUpdate = openType === "edit"; + const isActivating = + isUpdate && form.uid === (profiles?.current ?? ""); + + if (openType === "new") { + await createProfile(item, fileDataRef.current); + } else { + if (!form.uid) throw new Error("UID not found"); + await patchProfile(form.uid, item); + } + + setOpen(false); + props.onChange(isActivating); + } catch (err: any) { + showNotice("error", err.message || err.toString()); + } finally { + setLoading(false); + } + }), + ); + + const formType = watch("type"); + const isRemote = formType === "remote"; + const isLocal = formType === "local"; + + return ( + + + + + {openType === "new" ? t("Create Profile") : t("Edit Profile")} + + + + {openType === "new" && ( +
+
+
+ setImportUrl(e.target.value)} + disabled={isImporting} + className={cn( + "h-9 min-w-[200px] flex-grow sm:w-65", + !isUrlValid && + "border-destructive focus-visible:ring-destructive", + )} + /> + {importUrl ? ( + + ) : ( + )} - /> - {importUrl ? ( - - ) : ( - +
+ + {!isUrlValid && importUrl && ( +

+ {t("Please enter a valid URL")} +

)}
+ - {!isUrlValid && importUrl && ( -

- {t("Please enter a valid URL")} -

- )}
+ )} - -
- )} + {(openType === "edit" || showAdvanced) && ( +
+ { + e.preventDefault(); + handleSaveAdvanced(); + }} + className="space-y-4 max-h-[60vh] overflow-y-auto px-1 pt-4" + > + ( + + {t("Type")} + + + )} + /> + ( + + {t("Name")} + + + + + )} + /> - {(openType === 'edit' || showAdvanced) && ( - - { e.preventDefault(); handleSaveAdvanced(); }} className="space-y-4 max-h-[60vh] overflow-y-auto px-1 pt-4"> - ( - - {t("Type")} - - - )}/> + ( + + {t("Descriptions")} + + + + + )} + /> - ( - {t("Name")} - )}/> + {isRemote && ( + ( + + {t("Subscription URL")} + +