code formatting with prettier
This commit is contained in:
@@ -20,33 +20,35 @@ export const FileInput: React.FC<Props> = (props) => {
|
||||
const [fileName, setFileName] = useState("");
|
||||
|
||||
// Вся ваша логика для чтения файла остается без изменений
|
||||
const onFileInput = useLockFn(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const onFileInput = useLockFn(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<string>((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<string>((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 для отступов
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
{/* Ручка для перетаскивания */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
|
||||
className={cn(
|
||||
"p-1 text-muted-foreground rounded-sm",
|
||||
isSortable ? "cursor-move hover:bg-accent" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
@@ -82,7 +88,13 @@ export const GroupItem = (props: Props) => {
|
||||
{/* Иконка группы */}
|
||||
{group.icon && (
|
||||
<img
|
||||
src={group.icon.startsWith('data') ? group.icon : group.icon.startsWith('<svg') ? `data:image/svg+xml;base64,${btoa(group.icon ?? "")}` : (iconCachePath || group.icon)}
|
||||
src={
|
||||
group.icon.startsWith("data")
|
||||
? group.icon
|
||||
: group.icon.startsWith("<svg")
|
||||
? `data:image/svg+xml;base64,${btoa(group.icon ?? "")}`
|
||||
: iconCachePath || group.icon
|
||||
}
|
||||
className="w-8 h-8 mx-2 rounded-md"
|
||||
alt={group.name}
|
||||
/>
|
||||
@@ -97,7 +109,12 @@ export const GroupItem = (props: Props) => {
|
||||
</div>
|
||||
|
||||
{/* Кнопка действия */}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onDelete}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{type === "delete" ? (
|
||||
<Undo2 className="h-4 w-4" />
|
||||
) : (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) => {
|
||||
<div className="h-[300px] overflow-y-auto space-y-2 p-1">
|
||||
{logInfo.length > 0 ? (
|
||||
logInfo.map(([level, log], index) => (
|
||||
<div key={index} className="pb-2 border-b border-border last:border-b-0">
|
||||
<div
|
||||
key={index}
|
||||
className="pb-2 border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Badge variant={getLogLevelVariant(level)} className="mt-0.5">
|
||||
{level}
|
||||
@@ -60,7 +65,9 @@ export const LogViewer = (props: Props) => {
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">{t("Close")}</Button>
|
||||
<Button type="button" variant="outline">
|
||||
{t("Close")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -28,14 +28,14 @@ export const ProfileBox = React.forwardRef<HTMLDivElement, ProfileBoxProps>(
|
||||
"data-[selected=true]:text-card-foreground",
|
||||
|
||||
// --- Дополнительные классы от пользователя ---
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ProfileBox.displayName = "ProfileBox";
|
||||
|
||||
@@ -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 (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
@@ -310,7 +332,14 @@ export const ProfileItem = (props: Props) => {
|
||||
<p className="text-sm font-semibold truncate" title={name}>
|
||||
{name}
|
||||
</p>
|
||||
{expireInfo === t("Expired") ? <Badge variant="destructive" className="text-xs bg-red-500 text-white dark:bg-red-500">{t(expireInfo)}</Badge> : null}
|
||||
{expireInfo === t("Expired") ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs bg-red-500 text-white dark:bg-red-500"
|
||||
>
|
||||
{t(expireInfo)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<Badge
|
||||
@@ -334,7 +363,11 @@ export const ProfileItem = (props: Props) => {
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-3 w-3 inline mr-1.5" />
|
||||
<span>
|
||||
{expireInfo === null ? <Infinity className="h-3 w-3 inline mr-1.5"/>: expireInfo}
|
||||
{expireInfo === null ? (
|
||||
<Infinity className="h-3 w-3 inline mr-1.5" />
|
||||
) : (
|
||||
expireInfo
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,7 +384,6 @@ export const ProfileItem = (props: Props) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -369,36 +401,78 @@ export const ProfileItem = (props: Props) => {
|
||||
</Card>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="w-56" onClick={e => e.stopPropagation()}>
|
||||
<ContextMenuContent
|
||||
className="w-56"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Объединяем все части меню */}
|
||||
{[...homeMenuItem, ...mainMenuItems].map(item => (
|
||||
<ContextMenuItem key={item.label} onSelect={item.handler} disabled={item.disabled}>
|
||||
<item.icon className="mr-2 h-4 w-4" /><span>{t(item.label)}</span>
|
||||
{[...homeMenuItem, ...mainMenuItems].map((item) => (
|
||||
<ContextMenuItem
|
||||
key={item.label}
|
||||
onSelect={item.handler}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
<span>{t(item.label)}</span>
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={!hasUrl || isLoading}><DownloadCloud className="mr-2 h-4 w-4" /><span>{t("Update")}</span></ContextMenuSubTrigger>
|
||||
<ContextMenuPortal><ContextMenuSubContent>
|
||||
<ContextMenuItem onSelect={() => onUpdate(0)}>{t("Update")}</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => onUpdate(2)}>{t("Update via proxy")}</ContextMenuItem>
|
||||
</ContextMenuSubContent></ContextMenuPortal>
|
||||
<ContextMenuSubTrigger disabled={!hasUrl || isLoading}>
|
||||
<DownloadCloud className="mr-2 h-4 w-4" />
|
||||
<span>{t("Update")}</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onSelect={() => onUpdate(0)}>
|
||||
{t("Update")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => onUpdate(2)}>
|
||||
{t("Update via proxy")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
{editMenuItems.map(item => (
|
||||
<ContextMenuItem key={item.label} onSelect={item.handler} disabled={item.disabled}>
|
||||
<item.icon className="mr-2 h-4 w-4" /><span>{t(item.label)}</span>
|
||||
{editMenuItems.map((item) => (
|
||||
<ContextMenuItem
|
||||
key={item.label}
|
||||
onSelect={item.handler}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
<span>{t(item.label)}</span>
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={deleteMenuItem.handler} className={cn(deleteMenuItem.isDestructive && "text-destructive focus:text-destructive focus:bg-destructive/10")}>
|
||||
<deleteMenuItem.icon className="mr-2 h-4 w-4" /><span>{t(deleteMenuItem.label)}</span>
|
||||
<ContextMenuItem
|
||||
onSelect={deleteMenuItem.handler}
|
||||
className={cn(
|
||||
deleteMenuItem.isDestructive &&
|
||||
"text-destructive focus:text-destructive focus:bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
<deleteMenuItem.icon className="mr-2 h-4 w-4" />
|
||||
<span>{t(deleteMenuItem.label)}</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* Модальные окна для редактирования */}
|
||||
{fileOpen && <EditorViewer open={true} title={`${t("Edit File")}: ${name}`} onClose={() => setFileOpen(false)} initialData={readProfileFile(uid)} language="yaml" schema="clash" onSave={async (p, c) => { await saveProfileFile(uid, c || ""); onSave?.(p, c); }} />}
|
||||
{fileOpen && (
|
||||
<EditorViewer
|
||||
open={true}
|
||||
title={`${t("Edit File")}: ${name}`}
|
||||
onClose={() => setFileOpen(false)}
|
||||
initialData={readProfileFile(uid)}
|
||||
language="yaml"
|
||||
schema="clash"
|
||||
onSave={async (p, c) => {
|
||||
await saveProfileFile(uid, c || "");
|
||||
onSave?.(p, c);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rulesOpen && (
|
||||
<RulesEditorViewer
|
||||
@@ -407,7 +481,7 @@ export const ProfileItem = (props: Props) => {
|
||||
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.")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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) => {
|
||||
<ProfileBox onDoubleClick={onEditFile}>
|
||||
{/* Верхняя строка: Название и Бейдж */}
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<p className="font-semibold text-base truncate">{t(`Global ${id}`)}</p>
|
||||
<p className="font-semibold text-base truncate">
|
||||
{t(`Global ${id}`)}
|
||||
</p>
|
||||
<Badge variant="secondary">{id}</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<ProfileViewerRef, Props>((props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [openType, setOpenType] = useState<"new" | "edit">("new");
|
||||
const { profiles } = useProfiles();
|
||||
const fileDataRef = useRef<string | null>(null);
|
||||
export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
||||
(props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [openType, setOpenType] = useState<"new" | "edit">("new");
|
||||
const { profiles } = useProfiles();
|
||||
const fileDataRef = useRef<string | null>(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<IProfileItem>({
|
||||
defaultValues: {
|
||||
type: "remote",
|
||||
name: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
option: {
|
||||
with_proxy: false,
|
||||
self_proxy: false,
|
||||
danger_accept_invalid_certs: false,
|
||||
const form = useForm<IProfileItem>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[95vw] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{openType === "new" ? t("Create Profile") : t("Edit Profile")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
const handleSaveAdvanced = useLockFn(
|
||||
handleSubmit(async (formData) => {
|
||||
const form = { ...formData, url: formData.url || importUrl };
|
||||
|
||||
{openType === "new" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1 flex-grow sm:flex-grow-0">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("Profile URL")}
|
||||
value={importUrl}
|
||||
onChange={(e) => 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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[95vw] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{openType === "new" ? t("Create Profile") : t("Edit Profile")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{openType === "new" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1 flex-grow sm:flex-grow-0">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("Profile URL")}
|
||||
value={importUrl}
|
||||
onChange={(e) => 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 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t("Clear")}
|
||||
onClick={() => setImportUrl("")}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t("Paste")}
|
||||
onClick={onCopyLink}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{importUrl ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t("Clear")}
|
||||
onClick={() => setImportUrl("")}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t("Paste")}
|
||||
onClick={onCopyLink}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={
|
||||
!importUrl || isCheckingUrl || !isUrlValid || isImporting
|
||||
}
|
||||
className="flex-shrink-0 min-w-[5.5rem]"
|
||||
>
|
||||
{isCheckingUrl || isImporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
t("Import")
|
||||
)}
|
||||
</Button>
|
||||
{!isUrlValid && importUrl && (
|
||||
<p className="text-sm text-destructive px-1">
|
||||
{t("Please enter a valid URL")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!importUrl || isCheckingUrl || !isUrlValid || isImporting}
|
||||
className="flex-shrink-0 min-w-[5.5rem]"
|
||||
variant="outline"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{(isCheckingUrl || isImporting) ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
t("Import")
|
||||
)}
|
||||
{showAdvanced
|
||||
? t("Hide Advanced Settings")
|
||||
: t("Show Advanced Settings")}
|
||||
</Button>
|
||||
{!isUrlValid && importUrl && (
|
||||
<p className="text-sm text-destructive px-1">
|
||||
{t("Please enter a valid URL")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||
{showAdvanced ? t("Hide Advanced Settings") : t("Show Advanced Settings")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(openType === "edit" || showAdvanced) && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSaveAdvanced();
|
||||
}}
|
||||
className="space-y-4 max-h-[60vh] overflow-y-auto px-1 pt-4"
|
||||
>
|
||||
<FormField
|
||||
control={control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Type")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
disabled={openType === "edit"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="remote">Remote</SelectItem>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("Profile Name")} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(openType === 'edit' || showAdvanced) && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={e => { e.preventDefault(); handleSaveAdvanced(); }} className="space-y-4 max-h-[60vh] overflow-y-auto px-1 pt-4">
|
||||
<FormField control={control} name="type" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Type")}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={openType === "edit"}>
|
||||
<FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="remote">Remote</SelectItem>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="desc"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Descriptions")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("Profile Description")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField control={control} name="name" render={({ field }) => (
|
||||
<FormItem><FormLabel>{t("Name")}</FormLabel><FormControl><Input placeholder={t("Profile Name")} {...field} /></FormControl></FormItem>
|
||||
)}/>
|
||||
{isRemote && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Subscription URL")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t("Leave blank to use the URL above")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField control={control} name="desc" render={({ field }) => (
|
||||
<FormItem><FormLabel>{t("Descriptions")}</FormLabel><FormControl><Input placeholder={t("Profile Description")} {...field} /></FormControl></FormItem>
|
||||
)}/>
|
||||
|
||||
{isRemote && (
|
||||
<FormField control={control} name="url" render={({ field }) => (
|
||||
<FormItem><FormLabel>{t("Subscription URL")}</FormLabel><FormControl><Textarea placeholder={t("Leave blank to use the URL above")} {...field} /></FormControl></FormItem>
|
||||
)}/>
|
||||
)}
|
||||
|
||||
{isLocal && openType === "new" && (
|
||||
{isLocal && openType === "new" && (
|
||||
<FormItem>
|
||||
<FormLabel>{t("File")}</FormLabel>
|
||||
<FormControl><Input type="file" accept=".yml,.yaml" onChange={(e) => {
|
||||
<FormLabel>{t("File")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".yml,.yaml"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setValue("name", form.getValues("name") || file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => { fileDataRef.current = event.target?.result as string; };
|
||||
reader.readAsText(file);
|
||||
setValue(
|
||||
"name",
|
||||
form.getValues("name") || file.name,
|
||||
);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
fileDataRef.current = event.target
|
||||
?.result as string;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}} /></FormControl>
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
)}
|
||||
|
||||
{isRemote && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<FormField control={control} name="option.update_interval" render={({ field }) => (
|
||||
<FormItem><FormLabel>{t("Update Interval (mins)")}</FormLabel><FormControl><Input type="number" placeholder="1440" {...field} onChange={e => field.onChange(+e.target.value)} /></FormControl></FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.user_agent" render={({ field }) => (
|
||||
<FormItem><FormLabel>User Agent</FormLabel><FormControl><Input placeholder={`clash-verge/v${version}`} {...field} /></FormControl></FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.with_proxy" render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between"><FormLabel>{t("Use System Proxy")}</FormLabel><FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl></FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.self_proxy" render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between"><FormLabel>{t("Use Clash Proxy")}</FormLabel><FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl></FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.danger_accept_invalid_certs" render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between"><FormLabel className="text-destructive">{t("Accept Invalid Certs (Danger)")}</FormLabel><FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl></FormItem>
|
||||
)}/>
|
||||
</div>
|
||||
)}
|
||||
{isRemote && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="option.update_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Update Interval (mins)")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1440"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(+e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="option.user_agent"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Agent</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={`clash-verge/v${version}`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="option.with_proxy"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between">
|
||||
<FormLabel>{t("Use System Proxy")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="option.self_proxy"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between">
|
||||
<FormLabel>{t("Use Clash Proxy")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="option.danger_accept_invalid_certs"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between">
|
||||
<FormLabel className="text-destructive">
|
||||
{t("Accept Invalid Certs (Danger)")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="hidden" />
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
<button type="submit" className="hidden" />
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
|
||||
{(openType === 'edit' || showAdvanced) && (
|
||||
<Button type="button" onClick={handleSaveAdvanced} disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("Save")}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
{(openType === "edit" || showAdvanced) && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveAdvanced}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -50,28 +50,38 @@ export const ProxyItem = (props: Props) => {
|
||||
className={cn(
|
||||
"flex items-center p-2 mb-1 rounded-lg transition-shadow",
|
||||
typeStyles[type],
|
||||
isDragging && "shadow-lg"
|
||||
isDragging && "shadow-lg",
|
||||
)}
|
||||
>
|
||||
{/* Ручка для перетаскивания */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
|
||||
className={cn(
|
||||
"p-1 text-muted-foreground rounded-sm",
|
||||
isSortable ? "cursor-move hover:bg-accent" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{/* Название и тип прокси */}
|
||||
<div className="flex-1 min-w-0 ml-2">
|
||||
<p className="text-sm font-semibold truncate" title={proxy.name}>{proxy.name}</p>
|
||||
<p className="text-sm font-semibold truncate" title={proxy.name}>
|
||||
{proxy.name}
|
||||
</p>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<Badge variant="outline">{proxy.type}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка действия */}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onDelete}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{type === "delete" ? (
|
||||
<Undo2 className="h-4 w-4" />
|
||||
) : (
|
||||
|
||||
@@ -22,9 +22,16 @@ const typeStyles = {
|
||||
};
|
||||
|
||||
// Вспомогательная функция для цвета политики прокси
|
||||
const PROXY_COLOR_CLASSES = ["text-sky-500", "text-violet-500", "text-amber-500", "text-lime-500", "text-emerald-500"];
|
||||
const PROXY_COLOR_CLASSES = [
|
||||
"text-sky-500",
|
||||
"text-violet-500",
|
||||
"text-amber-500",
|
||||
"text-lime-500",
|
||||
"text-emerald-500",
|
||||
];
|
||||
const getProxyColorClass = (proxyName: string): string => {
|
||||
if (proxyName === "REJECT" || proxyName === "REJECT-DROP") return "text-destructive";
|
||||
if (proxyName === "REJECT" || proxyName === "REJECT-DROP")
|
||||
return "text-destructive";
|
||||
if (proxyName === "DIRECT") return "text-primary";
|
||||
let sum = 0;
|
||||
for (let i = 0; i < proxyName.length; i++) sum += proxyName.charCodeAt(i);
|
||||
@@ -66,21 +73,27 @@ export const RuleItem = (props: Props) => {
|
||||
className={cn(
|
||||
"flex items-center p-2 mb-1 rounded-lg transition-shadow",
|
||||
typeStyles[type],
|
||||
isDragging && "shadow-lg"
|
||||
isDragging && "shadow-lg",
|
||||
)}
|
||||
>
|
||||
{/* Ручка для перетаскивания */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
|
||||
className={cn(
|
||||
"p-1 text-muted-foreground rounded-sm",
|
||||
isSortable ? "cursor-move hover:bg-accent" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="flex-1 min-w-0 ml-2">
|
||||
<p className="text-sm font-semibold truncate" title={ruleContent || "-"}>
|
||||
<p
|
||||
className="text-sm font-semibold truncate"
|
||||
title={ruleContent || "-"}
|
||||
>
|
||||
{ruleContent || "-"}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
@@ -92,7 +105,12 @@ export const RuleItem = (props: Props) => {
|
||||
</div>
|
||||
|
||||
{/* Кнопка действия */}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 ml-2" onClick={onDelete}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 ml-2"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{type === "delete" ? (
|
||||
<Undo2 className="h-4 w-4" />
|
||||
) : (
|
||||
|
||||
@@ -188,7 +188,6 @@ const rules: {
|
||||
];
|
||||
const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
|
||||
|
||||
|
||||
const Combobox = ({
|
||||
options,
|
||||
value,
|
||||
|
||||
Reference in New Issue
Block a user