Files
clash-verge-rev-lite/src/pages/profiles.tsx
2025-07-19 03:57:07 +03:00

461 lines
14 KiB
TypeScript

import React, {
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from "react";
import { useLockFn } from "ahooks";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { useTranslation } from "react-i18next";
import {
importProfile,
enhanceProfiles,
deleteProfile,
updateProfile,
reorderProfile,
createProfile,
} from "@/services/cmds";
import { useSetLoadingCache } from "@/services/states";
import { closeAllConnections } from "@/services/api";
import { DialogRef } from "@/components/base";
import {
ProfileViewer,
ProfileViewerRef,
} from "@/components/profile/profile-viewer";
import { ProfileItem } from "@/components/profile/profile-item";
import { useProfiles } from "@/hooks/use-profiles";
import { ConfigViewer } from "@/components/setting/mods/config-viewer";
import { throttle } from "lodash-es";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { useLocation } from "react-router-dom";
import { useListen } from "@/hooks/use-listen";
import { listen, TauriEvent } from "@tauri-apps/api/event";
import { showNotice } from "@/services/noticeService";
import { cn } from "@root/lib/utils";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
PlusCircle,
RefreshCw,
Zap,
FileText,
Loader2,
} from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
const ProfilePage = () => {
const { t } = useTranslation();
const location = useLocation();
const { addListener } = useListen();
const [url, setUrl] = useState("");
const [disabled, setDisabled] = useState(false);
const [activatings, setActivatings] = useState<string[]>([]);
const [importLoading, setImportLoading] = useState(false);
const [updateAllLoading, setUpdateAllLoading] = useState(false);
const [enhanceLoading, setEnhanceLoading] = useState(false);
const scrollerRef = useRef<HTMLDivElement>(null);
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const currentScroller = scrollerRef.current;
if (!currentScroller) return;
const handleScroll = () => setIsScrolled(currentScroller.scrollTop > 5);
currentScroller.addEventListener("scroll", handleScroll);
return () => currentScroller.removeEventListener("scroll", handleScroll);
}, []);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
let currentProfileFromLocation: string | undefined;
if (
location.state &&
typeof location.state === "object" &&
location.state !== null
) {
const stateAsObject = location.state as { current?: unknown };
if (typeof stateAsObject.current === "string") {
currentProfileFromLocation = stateAsObject.current;
}
}
const profilesHookData = useProfiles();
const profiles = profilesHookData.profiles || {};
const activateSelected = profilesHookData.activateSelected;
const patchProfiles = profilesHookData.patchProfiles;
const mutateProfiles = profilesHookData.mutateProfiles;
const viewerRef = useRef<ProfileViewerRef>(null);
const configRef = useRef<DialogRef>(null);
const profileItems = useMemo(() => {
const items =
profiles && Array.isArray(profiles.items) ? profiles.items : [];
const type1 = ["local", "remote"];
return items.filter((i) => i && type1.includes(i.type!));
}, [profiles]);
const currentActivatings = () => {
const currentProfileValue =
profiles && typeof profiles.current === "string" ? profiles.current : "";
return [...new Set([currentProfileValue])].filter(Boolean);
};
useEffect(() => {
const handleFileDrop = async () => {
const unlisten = await addListener(
TauriEvent.DRAG_DROP,
async (event: any) => {
const paths = event.payload.paths;
for (let file of paths) {
if (!file.endsWith(".yaml") && !file.endsWith(".yml")) {
showNotice("error", t("Only YAML Files Supported"));
continue;
}
const item = {
type: "local",
name: file.split(/\\|\//).pop() ?? "New Profile",
desc: "",
url: "",
option: { with_proxy: false, self_proxy: false },
} as IProfileItem;
let data = await readTextFile(file);
await createProfile(item, data);
await mutateProfiles();
}
},
);
return unlisten;
};
const unsubscribe = handleFileDrop();
return () => {
unsubscribe.then((cleanup) => cleanup());
};
}, [addListener, mutateProfiles, t]);
const activateProfile = useCallback(
async (profile: string, notifySuccess: boolean) => {
const reset = setTimeout(
() => setActivatings((prev) => [...prev, profile]),
100,
);
try {
const success = await patchProfiles({ current: profile });
closeAllConnections();
await activateSelected();
if (notifySuccess && success) {
showNotice("success", t("Profile Switched"), 1000);
}
} catch (err: any) {
showNotice("error", err?.message || err.toString(), 4000);
} finally {
clearTimeout(reset);
setActivatings([]);
}
},
[patchProfiles, activateSelected, t],
);
useEffect(() => {
(async () => {
if (currentProfileFromLocation) {
await activateProfile(currentProfileFromLocation, false);
}
})();
}, [currentProfileFromLocation]);
const onSelect = useLockFn(
async (selectedProfileId: string, force: boolean) => {
if (!force && selectedProfileId === profiles.current) return;
await activateProfile(selectedProfileId, true);
},
);
const onImport = useLockFn(async () => {
if (!url) return;
setImportLoading(true);
setDisabled(true);
try {
await importProfile(url);
showNotice("success", t("Profile Imported Successfully"));
setUrl("");
mutateProfiles();
await onEnhance(false, false);
} catch (err: any) {
showNotice("info", t("Import failed, retrying with Clash proxy..."));
try {
await importProfile(url, { with_proxy: false, self_proxy: true });
showNotice("success", t("Profile Imported with Clash proxy"));
setUrl("");
mutateProfiles();
await onEnhance(false, false);
} catch (retryErr: any) {
const retryErrmsg = retryErr?.message || retryErr.toString();
showNotice(
"error",
`${t("Import failed even with Clash proxy")}: ${retryErrmsg}`,
);
}
} finally {
setDisabled(false);
setImportLoading(false);
}
});
const onDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
await reorderProfile(active.id.toString(), over.id.toString());
mutateProfiles();
}
};
const onEnhance = useLockFn(
async (notifySuccess: boolean = true, showLoading: boolean = true) => {
if (showLoading) setEnhanceLoading(true);
setActivatings(currentActivatings());
try {
await enhanceProfiles();
if (notifySuccess) {
showNotice("success", t("Profile Reactivated"), 1000);
}
} catch (err: any) {
showNotice("error", err.message || err.toString(), 3000);
} finally {
setActivatings([]);
if (showLoading) setEnhanceLoading(false);
}
},
);
const onDelete = useLockFn(async (uid: string) => {
const currentProfile = profiles.current === uid;
try {
setActivatings([...(currentProfile ? currentActivatings() : []), uid]);
await deleteProfile(uid);
mutateProfiles();
if (currentProfile) await onEnhance(false, false);
} catch (err: any) {
showNotice("error", err?.message || err.toString());
} finally {
setActivatings([]);
}
});
const setLoadingCache = useSetLoadingCache();
const onUpdateAll = useLockFn(async () => {
setUpdateAllLoading(true);
const throttleMutate = throttle(mutateProfiles, 2000, { trailing: true });
const updateOne = async (uid: string) => {
try {
await updateProfile(uid);
throttleMutate();
} catch (err: any) {
console.error(`Update subscription ${uid} failed:`, err);
} finally {
setLoadingCache((cache) => ({ ...cache, [uid]: false }));
}
};
return new Promise((resolve) => {
setLoadingCache((cache) => {
const items = profileItems.filter(
(e) => e.type === "remote" && !cache[e.uid],
);
const change = Object.fromEntries(items.map((e) => [e.uid, true]));
Promise.allSettled(items.map((e) => updateOne(e.uid))).then(resolve);
return { ...cache, ...change };
});
}).finally(() => setUpdateAllLoading(false));
});
const onCopyLink = async () => {
const text = await readText();
if (text) setUrl(text);
};
useEffect(() => {
let unlistenPromise: Promise<() => void> | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const setupListener = async () => {
unlistenPromise = listen<string>("profile-changed", () => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
mutateProfiles();
timeoutId = undefined;
}, 300);
});
};
setupListener();
return () => {
if (timeoutId) clearTimeout(timeoutId);
unlistenPromise?.then((unlisten) => unlisten());
};
}, [mutateProfiles]);
return (
<div className="h-full w-full relative">
<div
className={cn(
"absolute top-0 left-0 right-0 z-10 p-4 space-y-4 transition-all duration-200",
{ "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled },
)}
>
<div className="flex justify-between items-center">
<div className="w-10">
<SidebarTrigger />
</div>
<h2 className="text-2xl font-semibold tracking-tight">
{t("Profiles")}
</h2>
<TooltipProvider delayDuration={100}>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => viewerRef.current?.create()}
>
<PlusCircle className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("New")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onUpdateAll}
disabled={updateAllLoading}
>
{updateAllLoading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<RefreshCw className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Update All Profiles")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEnhance(true, true)}
disabled={enhanceLoading}
>
{enhanceLoading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Zap className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Reactivate Profiles")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => configRef.current?.open()}
>
<FileText className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("View Runtime Config")}</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
</div>
<div
ref={scrollerRef}
className="absolute top-0 left-0 right-0 bottom-0 pt-25 overflow-y-auto"
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<div className="p-4 pt-0">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
<SortableContext items={profileItems.map((x) => x.uid)}>
{profileItems.map((item) => (
<ProfileItem
key={item.uid}
id={item.uid}
selected={profiles.current === item.uid}
activating={activatings.includes(item.uid)}
itemData={item}
onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}
onSave={async (prev, curr) => {
if (prev !== curr && profiles.current === item.uid) {
await onEnhance(false, false);
}
}}
onDelete={() => onDelete(item.uid)}
/>
))}
</SortableContext>
</div>
</div>
</DndContext>
</div>
<ProfileViewer
ref={viewerRef}
onChange={async (isActivating) => {
mutateProfiles();
if (isActivating) {
await onEnhance(false, false);
}
}}
/>
<ConfigViewer ref={configRef} />
</div>
);
};
export default ProfilePage;