profile-item.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. import dayjs from "dayjs";
  2. import { mutate } from "swr";
  3. import { useEffect, useState } from "react";
  4. import { useLockFn } from "ahooks";
  5. import { useRecoilState } from "recoil";
  6. import { useTranslation } from "react-i18next";
  7. import { useSortable } from "@dnd-kit/sortable";
  8. import { CSS } from "@dnd-kit/utilities";
  9. import {
  10. Box,
  11. Typography,
  12. LinearProgress,
  13. IconButton,
  14. keyframes,
  15. MenuItem,
  16. Menu,
  17. CircularProgress,
  18. } from "@mui/material";
  19. import { RefreshRounded, DragIndicator } from "@mui/icons-material";
  20. import { atomLoadingCache } from "@/services/states";
  21. import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds";
  22. import { Notice } from "@/components/base";
  23. import { EditorViewer } from "./editor-viewer";
  24. import { ProfileBox } from "./profile-box";
  25. import parseTraffic from "@/utils/parse-traffic";
  26. import { ConfirmViewer } from "./confirm-viewer";
  27. import { open } from "@tauri-apps/api/shell";
  28. const round = keyframes`
  29. from { transform: rotate(0deg); }
  30. to { transform: rotate(360deg); }
  31. `;
  32. interface Props {
  33. id: string;
  34. selected: boolean;
  35. activating: boolean;
  36. itemData: IProfileItem;
  37. onSelect: (force: boolean) => void;
  38. onEdit: () => void;
  39. }
  40. export const ProfileItem = (props: Props) => {
  41. const { selected, activating, itemData, onSelect, onEdit } = props;
  42. const { attributes, listeners, setNodeRef, transform, transition } =
  43. useSortable({ id: props.id });
  44. const { t } = useTranslation();
  45. const [anchorEl, setAnchorEl] = useState<any>(null);
  46. const [position, setPosition] = useState({ left: 0, top: 0 });
  47. const [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache);
  48. const { uid, name = "Profile", extra, updated = 0 } = itemData;
  49. // local file mode
  50. // remote file mode
  51. const hasUrl = !!itemData.url;
  52. const hasExtra = !!extra; // only subscription url has extra info
  53. const hasHome = !!itemData.home; // only subscription url has home page
  54. const { upload = 0, download = 0, total = 0 } = extra ?? {};
  55. const from = parseUrl(itemData.url);
  56. const description = itemData.desc;
  57. const expire = parseExpire(extra?.expire);
  58. const progress = Math.round(((download + upload) * 100) / (total + 0.1));
  59. const loading = loadingCache[itemData.uid] ?? false;
  60. // interval update fromNow field
  61. const [, setRefresh] = useState({});
  62. useEffect(() => {
  63. if (!hasUrl) return;
  64. let timer: any = null;
  65. const handler = () => {
  66. const now = Date.now();
  67. const lastUpdate = updated * 1000;
  68. // 大于一天的不管
  69. if (now - lastUpdate >= 24 * 36e5) return;
  70. const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4;
  71. timer = setTimeout(() => {
  72. setRefresh({});
  73. handler();
  74. }, wait);
  75. };
  76. handler();
  77. return () => {
  78. if (timer) clearTimeout(timer);
  79. };
  80. }, [hasUrl, updated]);
  81. const [fileOpen, setFileOpen] = useState(false);
  82. const [confirmOpen, setConfirmOpen] = useState(false);
  83. const onOpenHome = () => {
  84. setAnchorEl(null);
  85. open(itemData.home ?? "");
  86. };
  87. const onEditInfo = () => {
  88. setAnchorEl(null);
  89. onEdit();
  90. };
  91. const onEditFile = () => {
  92. setAnchorEl(null);
  93. setFileOpen(true);
  94. };
  95. const onForceSelect = () => {
  96. setAnchorEl(null);
  97. onSelect(true);
  98. };
  99. const onOpenFile = useLockFn(async () => {
  100. setAnchorEl(null);
  101. try {
  102. await viewProfile(itemData.uid);
  103. } catch (err: any) {
  104. Notice.error(err?.message || err.toString());
  105. }
  106. });
  107. /// 0 不使用任何代理
  108. /// 1 使用订阅好的代理
  109. /// 2 至少使用一个代理,根据订阅,如果没订阅,默认使用系统代理
  110. const onUpdate = useLockFn(async (type: 0 | 1 | 2) => {
  111. setAnchorEl(null);
  112. setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
  113. const option: Partial<IProfileOption> = {};
  114. if (type === 0) {
  115. option.with_proxy = false;
  116. option.self_proxy = false;
  117. } else if (type === 1) {
  118. // nothing
  119. } else if (type === 2) {
  120. if (itemData.option?.self_proxy) {
  121. option.with_proxy = false;
  122. option.self_proxy = true;
  123. } else {
  124. option.with_proxy = true;
  125. option.self_proxy = false;
  126. }
  127. }
  128. try {
  129. await updateProfile(itemData.uid, option);
  130. mutate("getProfiles");
  131. } catch (err: any) {
  132. const errmsg = err?.message || err.toString();
  133. Notice.error(
  134. errmsg.replace(/error sending request for url (\S+?): /, "")
  135. );
  136. } finally {
  137. setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
  138. }
  139. });
  140. const onDelete = useLockFn(async () => {
  141. setAnchorEl(null);
  142. try {
  143. await deleteProfile(itemData.uid);
  144. mutate("getProfiles");
  145. } catch (err: any) {
  146. Notice.error(err?.message || err.toString());
  147. }
  148. });
  149. const urlModeMenu = (
  150. hasHome ? [{ label: "Home", handler: onOpenHome }] : []
  151. ).concat([
  152. { label: "Select", handler: onForceSelect },
  153. { label: "Edit Info", handler: onEditInfo },
  154. { label: "Edit File", handler: onEditFile },
  155. { label: "Open File", handler: onOpenFile },
  156. { label: "Update", handler: () => onUpdate(0) },
  157. { label: "Update(Proxy)", handler: () => onUpdate(2) },
  158. {
  159. label: "Delete",
  160. handler: () => {
  161. setAnchorEl(null);
  162. setConfirmOpen(true);
  163. },
  164. },
  165. ]);
  166. const fileModeMenu = [
  167. { label: "Select", handler: onForceSelect },
  168. { label: "Edit Info", handler: onEditInfo },
  169. { label: "Edit File", handler: onEditFile },
  170. { label: "Open File", handler: onOpenFile },
  171. {
  172. label: "Delete",
  173. handler: () => {
  174. setAnchorEl(null);
  175. setConfirmOpen(true);
  176. },
  177. },
  178. ];
  179. const boxStyle = {
  180. height: 26,
  181. display: "flex",
  182. alignItems: "center",
  183. justifyContent: "space-between",
  184. };
  185. return (
  186. <Box
  187. sx={{
  188. transform: CSS.Transform.toString(transform),
  189. transition,
  190. }}
  191. >
  192. <ProfileBox
  193. aria-selected={selected}
  194. onClick={() => onSelect(false)}
  195. onContextMenu={(event) => {
  196. const { clientX, clientY } = event;
  197. setPosition({ top: clientY, left: clientX });
  198. setAnchorEl(event.currentTarget);
  199. event.preventDefault();
  200. }}
  201. >
  202. {activating && (
  203. <Box
  204. sx={{
  205. position: "absolute",
  206. display: "flex",
  207. justifyContent: "center",
  208. alignItems: "center",
  209. top: 10,
  210. left: 10,
  211. right: 10,
  212. bottom: 2,
  213. zIndex: 10,
  214. backdropFilter: "blur(2px)",
  215. }}
  216. >
  217. <CircularProgress size={20} />
  218. </Box>
  219. )}
  220. <Box position="relative">
  221. <Box sx={{ display: "flex", justifyContent: "start" }}>
  222. <Box
  223. ref={setNodeRef}
  224. sx={{ display: "flex", margin: "auto 0" }}
  225. {...attributes}
  226. {...listeners}
  227. >
  228. <DragIndicator
  229. sx={[
  230. { cursor: "move", marginLeft: "-6px" },
  231. ({ palette: { text } }) => {
  232. return { color: text.primary };
  233. },
  234. ]}
  235. />
  236. </Box>
  237. <Typography
  238. width="calc(100% - 36px)"
  239. sx={{ fontSize: "18px", fontWeight: "600", lineHeight: "26px" }}
  240. variant="h6"
  241. component="h2"
  242. noWrap
  243. title={name}
  244. >
  245. {name}
  246. </Typography>
  247. </Box>
  248. {/* only if has url can it be updated */}
  249. {hasUrl && (
  250. <IconButton
  251. sx={{
  252. position: "absolute",
  253. p: "3px",
  254. top: -1,
  255. right: -5,
  256. animation: loading ? `1s linear infinite ${round}` : "none",
  257. }}
  258. size="small"
  259. color="inherit"
  260. disabled={loading}
  261. onClick={(e) => {
  262. e.stopPropagation();
  263. onUpdate(1);
  264. }}
  265. >
  266. <RefreshRounded color="inherit" />
  267. </IconButton>
  268. )}
  269. </Box>
  270. {/* the second line show url's info or description */}
  271. <Box sx={boxStyle}>
  272. {
  273. <>
  274. {description ? (
  275. <Typography
  276. noWrap
  277. title={description}
  278. sx={{ fontSize: "14px" }}
  279. >
  280. {description}
  281. </Typography>
  282. ) : (
  283. hasUrl && (
  284. <Typography noWrap title={`From ${from}`}>
  285. {from}
  286. </Typography>
  287. )
  288. )}
  289. {hasUrl && (
  290. <Typography
  291. noWrap
  292. flex="1 0 auto"
  293. fontSize={14}
  294. textAlign="right"
  295. title={`Updated Time: ${parseExpire(updated)}`}
  296. >
  297. {updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
  298. </Typography>
  299. )}
  300. </>
  301. }
  302. </Box>
  303. {/* the third line show extra info or last updated time */}
  304. {hasExtra ? (
  305. <Box sx={{ ...boxStyle, fontSize: 14 }}>
  306. <span title="Used / Total">
  307. {parseTraffic(upload + download)} / {parseTraffic(total)}
  308. </span>
  309. <span title="Expire Time">{expire}</span>
  310. </Box>
  311. ) : (
  312. <Box sx={{ ...boxStyle, fontSize: 12, justifyContent: "flex-end" }}>
  313. <span title="Updated Time">{parseExpire(updated)}</span>
  314. </Box>
  315. )}
  316. <LinearProgress variant="determinate" value={progress} />
  317. </ProfileBox>
  318. <Menu
  319. open={!!anchorEl}
  320. anchorEl={anchorEl}
  321. onClose={() => setAnchorEl(null)}
  322. anchorPosition={position}
  323. anchorReference="anchorPosition"
  324. transitionDuration={225}
  325. MenuListProps={{ sx: { py: 0.5 } }}
  326. onContextMenu={(e) => {
  327. setAnchorEl(null);
  328. e.preventDefault();
  329. }}
  330. >
  331. {(hasUrl ? urlModeMenu : fileModeMenu).map((item) => (
  332. <MenuItem
  333. key={item.label}
  334. onClick={item.handler}
  335. sx={[
  336. {
  337. minWidth: 120,
  338. },
  339. (theme) => {
  340. return {
  341. color:
  342. item.label === "Delete"
  343. ? theme.palette.error.main
  344. : undefined,
  345. };
  346. },
  347. ]}
  348. dense
  349. >
  350. {t(item.label)}
  351. </MenuItem>
  352. ))}
  353. </Menu>
  354. <EditorViewer
  355. uid={uid}
  356. open={fileOpen}
  357. mode="yaml"
  358. onClose={() => setFileOpen(false)}
  359. />
  360. <ConfirmViewer
  361. title="Confirm deletion"
  362. message="This operation is not reversible"
  363. open={confirmOpen}
  364. onClose={() => setConfirmOpen(false)}
  365. onConfirm={() => {
  366. onDelete();
  367. setConfirmOpen(false);
  368. }}
  369. />
  370. </Box>
  371. );
  372. };
  373. function parseUrl(url?: string) {
  374. if (!url) return "";
  375. const regex = /https?:\/\/(.+?)\//;
  376. const result = url.match(regex);
  377. return result ? result[1] : "local file";
  378. }
  379. function parseExpire(expire?: number) {
  380. if (!expire) return "-";
  381. return dayjs(expire * 1000).format("YYYY-MM-DD");
  382. }