code formatting with prettier

This commit is contained in:
coolcoala
2025-07-14 05:23:32 +03:00
parent eb1e4fe0c3
commit 5cdc5075f8
58 changed files with 5163 additions and 1846 deletions

View File

@@ -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 для отступов

View File

@@ -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

View File

@@ -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>

View File

@@ -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";

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
},
);

View File

@@ -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" />
) : (

View File

@@ -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" />
) : (

View File

@@ -188,7 +188,6 @@ const rules: {
];
const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
const Combobox = ({
options,
value,