profile-item.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import dayjs from "dayjs";
  2. import { useLockFn } from "ahooks";
  3. import { useSWRConfig } from "swr";
  4. import { useEffect, useState } from "react";
  5. import {
  6. alpha,
  7. Box,
  8. styled,
  9. Typography,
  10. LinearProgress,
  11. IconButton,
  12. keyframes,
  13. MenuItem,
  14. Menu,
  15. } from "@mui/material";
  16. import { RefreshRounded } from "@mui/icons-material";
  17. import { CmdType } from "../../services/types";
  18. import { updateProfile, deleteProfile, viewProfile } from "../../services/cmds";
  19. import relativeTime from "dayjs/plugin/relativeTime";
  20. import parseTraffic from "../../utils/parse-traffic";
  21. import ProfileEdit from "./profile-edit";
  22. import Notice from "../base/base-notice";
  23. dayjs.extend(relativeTime);
  24. const Wrapper = styled(Box)(({ theme }) => ({
  25. width: "100%",
  26. display: "block",
  27. cursor: "pointer",
  28. textAlign: "left",
  29. borderRadius: theme.shape.borderRadius,
  30. boxShadow: theme.shadows[2],
  31. padding: "8px 16px",
  32. boxSizing: "border-box",
  33. }));
  34. const round = keyframes`
  35. from { transform: rotate(0deg); }
  36. to { transform: rotate(360deg); }
  37. `;
  38. // save the state of each item loading
  39. const loadingCache: Record<string, boolean> = {};
  40. interface Props {
  41. selected: boolean;
  42. itemData: CmdType.ProfileItem;
  43. onSelect: (force: boolean) => void;
  44. }
  45. const ProfileItem = (props: Props) => {
  46. const { selected, itemData, onSelect } = props;
  47. const { mutate } = useSWRConfig();
  48. const [loading, setLoading] = useState(loadingCache[itemData.uid] ?? false);
  49. const [anchorEl, setAnchorEl] = useState<any>(null);
  50. const [position, setPosition] = useState({ left: 0, top: 0 });
  51. const { name = "Profile", extra, updated = 0 } = itemData;
  52. const { upload = 0, download = 0, total = 0 } = extra ?? {};
  53. const from = parseUrl(itemData.url);
  54. const expire = parseExpire(extra?.expire);
  55. const progress = Math.round(((download + upload) * 100) / (total + 0.1));
  56. const fromnow = updated > 0 ? dayjs(updated * 1000).fromNow() : "";
  57. // local file mode
  58. // remote file mode
  59. // subscription url mode
  60. const hasUrl = !!itemData.url;
  61. const hasExtra = !!extra; // only subscription url has extra info
  62. useEffect(() => {
  63. loadingCache[itemData.uid] = loading;
  64. }, [itemData, loading]);
  65. const [editOpen, setEditOpen] = useState(false);
  66. const onEdit = () => {
  67. setAnchorEl(null);
  68. setEditOpen(true);
  69. };
  70. const onView = async () => {
  71. setAnchorEl(null);
  72. try {
  73. await viewProfile(itemData.uid);
  74. } catch (err: any) {
  75. Notice.error(err?.message || err.toString());
  76. }
  77. };
  78. const onForceSelect = () => {
  79. setAnchorEl(null);
  80. onSelect(true);
  81. };
  82. const onUpdateWrapper = (withProxy: boolean) => async () => {
  83. setAnchorEl(null);
  84. if (loading) return;
  85. setLoading(true);
  86. try {
  87. await updateProfile(itemData.uid, { with_proxy: withProxy });
  88. setLoading(false);
  89. mutate("getProfiles");
  90. } catch (err: any) {
  91. setLoading(false);
  92. Notice.error(err?.message || err.toString());
  93. }
  94. };
  95. const onDelete = useLockFn(async () => {
  96. setAnchorEl(null);
  97. try {
  98. await deleteProfile(itemData.uid);
  99. mutate("getProfiles");
  100. } catch (err: any) {
  101. Notice.error(err?.message || err.toString());
  102. }
  103. });
  104. const boxStyle = {
  105. height: 26,
  106. display: "flex",
  107. alignItems: "center",
  108. justifyContent: "space-between",
  109. };
  110. const urlModeMenu = [
  111. { label: "Select", handler: onForceSelect },
  112. { label: "Edit", handler: onEdit },
  113. { label: "File", handler: onView },
  114. { label: "Update", handler: onUpdateWrapper(false) },
  115. { label: "Update(Proxy)", handler: onUpdateWrapper(true) },
  116. { label: "Delete", handler: onDelete },
  117. ];
  118. const fileModeMenu = [
  119. { label: "Select", handler: onForceSelect },
  120. { label: "Edit", handler: onEdit },
  121. { label: "File", handler: onView },
  122. { label: "Delete", handler: onDelete },
  123. ];
  124. return (
  125. <>
  126. <Wrapper
  127. sx={({ palette }) => {
  128. const { mode, primary, text, grey } = palette;
  129. const key = `${mode}-${selected}`;
  130. const bgcolor = {
  131. "light-true": alpha(primary.main, 0.15),
  132. "light-false": palette.background.paper,
  133. "dark-true": alpha(primary.main, 0.35),
  134. "dark-false": alpha(grey[700], 0.35),
  135. }[key]!;
  136. const color = {
  137. "light-true": text.secondary,
  138. "light-false": text.secondary,
  139. "dark-true": alpha(text.secondary, 0.6),
  140. "dark-false": alpha(text.secondary, 0.6),
  141. }[key]!;
  142. const h2color = {
  143. "light-true": primary.main,
  144. "light-false": text.primary,
  145. "dark-true": primary.light,
  146. "dark-false": text.primary,
  147. }[key]!;
  148. return { bgcolor, color, "& h2": { color: h2color } };
  149. }}
  150. onClick={() => onSelect(false)}
  151. onContextMenu={(event) => {
  152. const { clientX, clientY } = event;
  153. setPosition({ top: clientY, left: clientX });
  154. setAnchorEl(event.currentTarget);
  155. event.preventDefault();
  156. }}
  157. >
  158. <Box display="flex" justifyContent="space-between">
  159. <Typography
  160. width="calc(100% - 40px)"
  161. variant="h6"
  162. component="h2"
  163. noWrap
  164. title={name}
  165. >
  166. {name}
  167. </Typography>
  168. {/* only if has url can it be updated */}
  169. {hasUrl && (
  170. <IconButton
  171. sx={{
  172. width: 26,
  173. height: 26,
  174. animation: loading ? `1s linear infinite ${round}` : "none",
  175. }}
  176. color="inherit"
  177. disabled={loading}
  178. onClick={(e) => {
  179. e.stopPropagation();
  180. onUpdateWrapper(false)();
  181. }}
  182. >
  183. <RefreshRounded />
  184. </IconButton>
  185. )}
  186. </Box>
  187. {/* the second line show url's info or description */}
  188. {hasUrl ? (
  189. <Box sx={boxStyle}>
  190. <Typography noWrap title={`From: ${from}`}>
  191. {from}
  192. </Typography>
  193. <Typography
  194. noWrap
  195. flex="1 0 auto"
  196. fontSize={14}
  197. textAlign="right"
  198. title="updated time"
  199. >
  200. {fromnow}
  201. </Typography>
  202. </Box>
  203. ) : (
  204. <Box sx={boxStyle}>
  205. <Typography noWrap title={itemData.desc}>
  206. {itemData.desc}
  207. </Typography>
  208. </Box>
  209. )}
  210. {/* the third line show extra info or last updated time */}
  211. {hasExtra ? (
  212. <Box sx={{ ...boxStyle, fontSize: 14 }}>
  213. <span title="used / total">
  214. {parseTraffic(upload + download)} / {parseTraffic(total)}
  215. </span>
  216. <span title="expire time">{expire}</span>
  217. </Box>
  218. ) : (
  219. <Box sx={{ ...boxStyle, fontSize: 14, justifyContent: "flex-end" }}>
  220. <span title="updated time">{parseExpire(updated)}</span>
  221. </Box>
  222. )}
  223. <LinearProgress
  224. variant="determinate"
  225. value={progress}
  226. color="inherit"
  227. />
  228. </Wrapper>
  229. <Menu
  230. open={!!anchorEl}
  231. anchorEl={anchorEl}
  232. onClose={() => setAnchorEl(null)}
  233. anchorPosition={position}
  234. anchorReference="anchorPosition"
  235. onContextMenu={(e) => {
  236. setAnchorEl(null);
  237. e.preventDefault();
  238. }}
  239. >
  240. {(hasUrl ? urlModeMenu : fileModeMenu).map((item) => (
  241. <MenuItem
  242. key={item.label}
  243. onClick={item.handler}
  244. sx={{ minWidth: 133 }}
  245. >
  246. {item.label}
  247. </MenuItem>
  248. ))}
  249. </Menu>
  250. {editOpen && (
  251. <ProfileEdit
  252. open={editOpen}
  253. itemData={itemData}
  254. onClose={() => setEditOpen(false)}
  255. />
  256. )}
  257. </>
  258. );
  259. };
  260. function parseUrl(url?: string) {
  261. if (!url) return "";
  262. const regex = /https?:\/\/(.+?)\//;
  263. const result = url.match(regex);
  264. return result ? result[1] : "local file";
  265. }
  266. function parseExpire(expire?: number) {
  267. if (!expire) return "-";
  268. return dayjs(expire * 1000).format("YYYY-MM-DD");
  269. }
  270. export default ProfileItem;