clash-verge-rev/src/pages/profiles.tsx
cismous ec9852eb98
Style filter input (#724)
* refactor: reduce duplicate code

* style: add a white background to the light color theme to avoid the gray text being too light
2024-03-30 01:14:03 +08:00

419 lines
12 KiB
TypeScript

import useSWR, { mutate } from "swr";
import { useMemo, useRef, useState } from "react";
import { useLockFn } from "ahooks";
import { useSetRecoilState } from "recoil";
import { Box, Button, Grid, IconButton, Stack, Divider } from "@mui/material";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { LoadingButton } from "@mui/lab";
import {
ClearRounded,
ContentPasteRounded,
LocalFireDepartmentRounded,
RefreshRounded,
TextSnippetOutlined,
} from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import {
getProfiles,
importProfile,
enhanceProfiles,
getRuntimeLogs,
deleteProfile,
updateProfile,
reorderProfile,
} from "@/services/cmds";
import { atomLoadingCache } from "@/services/states";
import { closeAllConnections } from "@/services/api";
import { BasePage, DialogRef, Notice } from "@/components/base";
import {
ProfileViewer,
ProfileViewerRef,
} from "@/components/profile/profile-viewer";
import { ProfileItem } from "@/components/profile/profile-item";
import { ProfileMore } from "@/components/profile/profile-more";
import { useProfiles } from "@/hooks/use-profiles";
import { ConfigViewer } from "@/components/setting/mods/config-viewer";
import { throttle } from "lodash-es";
import { useRecoilState } from "recoil";
import { atomThemeMode } from "@/services/states";
import { BaseStyledTextField } from "@/components/base/base-styled-text-field";
const ProfilePage = () => {
const { t } = useTranslation();
const [url, setUrl] = useState("");
const [disabled, setDisabled] = useState(false);
const [activating, setActivating] = useState("");
const [loading, setLoading] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const {
profiles = {},
activateSelected,
patchProfiles,
mutateProfiles,
} = useProfiles();
const { data: chainLogs = {}, mutate: mutateLogs } = useSWR(
"getRuntimeLogs",
getRuntimeLogs
);
const chain = profiles.chain || [];
const viewerRef = useRef<ProfileViewerRef>(null);
const configRef = useRef<DialogRef>(null);
// distinguish type
const { regularItems, enhanceItems } = useMemo(() => {
const items = profiles.items || [];
const chain = profiles.chain || [];
const type1 = ["local", "remote"];
const type2 = ["merge", "script"];
const regularItems = items.filter((i) => i && type1.includes(i.type!));
const restItems = items.filter((i) => i && type2.includes(i.type!));
const restMap = Object.fromEntries(restItems.map((i) => [i.uid, i]));
const enhanceItems = chain
.map((i) => restMap[i]!)
.filter(Boolean)
.concat(restItems.filter((i) => !chain.includes(i.uid)));
return { regularItems, enhanceItems };
}, [profiles]);
const onImport = async () => {
if (!url) return;
setLoading(true);
try {
await importProfile(url);
Notice.success("Successfully import profile.");
setUrl("");
setLoading(false);
getProfiles().then((newProfiles) => {
mutate("getProfiles", newProfiles);
const remoteItem = newProfiles.items?.find((e) => e.type === "remote");
if (!newProfiles.current && remoteItem) {
const current = remoteItem.uid;
patchProfiles({ current });
mutateLogs();
setTimeout(() => activateSelected(), 2000);
}
});
} catch (err: any) {
Notice.error(err.message || err.toString());
setLoading(false);
} finally {
setDisabled(false);
setLoading(false);
}
};
const onDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (active.id !== over.id) {
await reorderProfile(active.id.toString(), over.id.toString());
mutateProfiles();
}
}
};
const onSelect = useLockFn(async (current: string, force: boolean) => {
if (!force && current === profiles.current) return;
// 避免大多数情况下loading态闪烁
const reset = setTimeout(() => setActivating(current), 100);
try {
await patchProfiles({ current });
mutateLogs();
closeAllConnections();
setTimeout(() => activateSelected(), 2000);
Notice.success("Refresh clash config", 1000);
} catch (err: any) {
Notice.error(err?.message || err.toString(), 4000);
} finally {
clearTimeout(reset);
setActivating("");
}
});
const onEnhance = useLockFn(async () => {
try {
await enhanceProfiles();
mutateLogs();
Notice.success("Refresh clash config", 1000);
} catch (err: any) {
Notice.error(err.message || err.toString(), 3000);
}
});
const onEnable = useLockFn(async (uid: string) => {
if (chain.includes(uid)) return;
const newChain = [...chain, uid];
await patchProfiles({ chain: newChain });
mutateLogs();
});
const onDisable = useLockFn(async (uid: string) => {
if (!chain.includes(uid)) return;
const newChain = chain.filter((i) => i !== uid);
await patchProfiles({ chain: newChain });
mutateLogs();
});
const onDelete = useLockFn(async (uid: string) => {
try {
await onDisable(uid);
await deleteProfile(uid);
mutateProfiles();
mutateLogs();
} catch (err: any) {
Notice.error(err?.message || err.toString());
}
});
const onMoveTop = useLockFn(async (uid: string) => {
if (!chain.includes(uid)) return;
const newChain = [uid].concat(chain.filter((i) => i !== uid));
await patchProfiles({ chain: newChain });
mutateLogs();
});
const onMoveEnd = useLockFn(async (uid: string) => {
if (!chain.includes(uid)) return;
const newChain = chain.filter((i) => i !== uid).concat([uid]);
await patchProfiles({ chain: newChain });
mutateLogs();
});
// 更新所有订阅
const setLoadingCache = useSetRecoilState(atomLoadingCache);
const onUpdateAll = useLockFn(async () => {
const throttleMutate = throttle(mutateProfiles, 2000, {
trailing: true,
});
const updateOne = async (uid: string) => {
try {
await updateProfile(uid);
throttleMutate();
} finally {
setLoadingCache((cache) => ({ ...cache, [uid]: false }));
}
};
return new Promise((resolve) => {
setLoadingCache((cache) => {
// 获取没有正在更新的订阅
const items = regularItems.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 };
});
});
});
const onCopyLink = async () => {
const text = await navigator.clipboard.readText();
if (text) setUrl(text);
};
const [mode] = useRecoilState(atomThemeMode);
const islight = mode === "light" ? true : false;
const dividercolor = islight
? "rgba(0, 0, 0, 0.06)"
: "rgba(255, 255, 255, 0.06)";
return (
<BasePage
full
title={t("Profiles")}
contentStyle={{ height: "100%" }}
header={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton
size="small"
color="inherit"
title={t("Update All Profiles")}
onClick={onUpdateAll}
>
<RefreshRounded />
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("View Runtime Config")}
onClick={() => configRef.current?.open()}
>
<TextSnippetOutlined />
</IconButton>
<IconButton
size="small"
color="primary"
title={t("Reactivate Profiles")}
onClick={onEnhance}
>
<LocalFireDepartmentRounded />
</IconButton>
</Box>
}
>
<Stack
direction="row"
spacing={1}
sx={{
pt: 1,
mb: 0.5,
mx: "10px",
height: "36px",
display: "flex",
alignItems: "center",
}}
>
<BaseStyledTextField
value={url}
variant="outlined"
onChange={(e) => setUrl(e.target.value)}
placeholder={t("Profile URL")}
InputProps={{
sx: { pr: 1 },
endAdornment: !url ? (
<IconButton
size="small"
sx={{ p: 0.5 }}
title={t("Paste")}
onClick={onCopyLink}
>
<ContentPasteRounded fontSize="inherit" />
</IconButton>
) : (
<IconButton
size="small"
sx={{ p: 0.5 }}
title={t("Clear")}
onClick={() => setUrl("")}
>
<ClearRounded fontSize="inherit" />
</IconButton>
),
}}
/>
<LoadingButton
disabled={!url || disabled}
loading={loading}
variant="contained"
size="small"
sx={{ borderRadius: "6px" }}
onClick={onImport}
>
{t("Import")}
</LoadingButton>
<Button
variant="contained"
size="small"
sx={{ borderRadius: "6px" }}
onClick={() => viewerRef.current?.create()}
>
{t("New")}
</Button>
</Stack>
<Box
sx={{
pt: 1,
mb: 0.5,
pl: "10px",
mr: "10px",
height: "calc(100% - 68px)",
overflowY: "auto",
}}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<Box sx={{ mb: 1.5 }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<SortableContext
items={regularItems.map((x) => {
return x.uid;
})}
>
{regularItems.map((item) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
<ProfileItem
id={item.uid}
selected={profiles.current === item.uid}
activating={activating === item.uid}
itemData={item}
onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}
/>
</Grid>
))}
</SortableContext>
</Grid>
</Box>
</DndContext>
{enhanceItems.length > 0 && (
<Divider
variant="middle"
flexItem
sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }}
></Divider>
)}
{enhanceItems.length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
{enhanceItems.map((item) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
<ProfileMore
selected={!!chain.includes(item.uid)}
itemData={item}
enableNum={chain.length || 0}
logInfo={chainLogs[item.uid]}
onEnable={() => onEnable(item.uid)}
onDisable={() => onDisable(item.uid)}
onDelete={() => onDelete(item.uid)}
onMoveTop={() => onMoveTop(item.uid)}
onMoveEnd={() => onMoveEnd(item.uid)}
onEdit={() => viewerRef.current?.edit(item)}
/>
</Grid>
))}
</Grid>
</Box>
)}
</Box>
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
<ConfigViewer ref={configRef} />
</BasePage>
);
};
export default ProfilePage;