diff --git a/package.json b/package.json index 1a934a48d553c0a315205dea0bb4a51c86a3fdd2..adf63a95d8a5da184ffe22b54a39d67204b2f9a0 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,13 @@ "@mui/material": "^5.2.3", "@tauri-apps/api": "^1.0.0-beta.8", "axios": "^0.24.0", + "dayjs": "^1.10.7", "react": "^17.0.0", "react-dom": "^17.0.0", "react-router-dom": "^6.0.2", "react-virtuoso": "^2.3.1", - "recoil": "^0.5.2" + "recoil": "^0.5.2", + "swr": "^1.1.2-beta.0" }, "devDependencies": { "@tauri-apps/cli": "^1.0.0-beta.10", diff --git a/src/components/profile-item.tsx b/src/components/profile-item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a6c19975349a4600cb2c8b320099d26c6ae528d4 --- /dev/null +++ b/src/components/profile-item.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import dayjs from "dayjs"; +import { + alpha, + Box, + ButtonBase, + styled, + Typography, + LinearProgress, + IconButton, +} from "@mui/material"; +import { MenuRounded } from "@mui/icons-material"; +import { ProfileItem } from "../services/command"; +import parseTraffic from "../utils/parse-traffic"; + +const Wrapper = styled(Box)(({ theme }) => ({ + width: "100%", + display: "block", + cursor: "pointer", + textAlign: "left", + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[2], + padding: "8px 16px", + boxSizing: "border-box", +})); + +interface Props { + selected: boolean; + itemData: ProfileItem; + onClick: () => void; +} + +const ProfileItemComp: React.FC<Props> = (props) => { + const { selected, itemData, onClick } = props; + + const { name = "Profile", extra } = itemData; + const { upload = 0, download = 0, total = 0 } = extra ?? {}; + const from = parseUrl(itemData.url); + const expire = parseExpire(extra?.expire); + const progress = Math.round(((download + upload) * 100) / (total + 0.1)); + + return ( + <Wrapper + sx={({ palette }) => { + const { mode, primary, text, grey } = palette; + const isDark = mode === "dark"; + + if (selected) { + const bgcolor = isDark + ? alpha(primary.main, 0.35) + : alpha(primary.main, 0.15); + + return { + bgcolor, + color: isDark ? alpha(text.secondary, 0.6) : text.secondary, + "& h2": { + color: isDark ? primary.light : primary.main, + }, + }; + } + const bgcolor = isDark + ? alpha(grey[700], 0.35) + : palette.background.paper; + return { + bgcolor, + color: isDark ? alpha(text.secondary, 0.6) : text.secondary, + "& h2": { + color: isDark ? text.primary : text.primary, + }, + }; + }} + onClick={onClick} + > + <Box display="flex" justifyContent="space-between"> + <Typography + width="calc(100% - 40px)" + variant="h6" + component="h2" + noWrap + title={name} + > + {name} + </Typography> + + <IconButton + sx={{ width: 30, height: 30 }} + color="inherit" + onClick={(e) => { + e.stopPropagation(); + }} + > + <MenuRounded /> + </IconButton> + </Box> + + <Typography noWrap title={from}> + {from} + </Typography> + + <Box + sx={{ + my: 0.5, + fontSize: 14, + display: "flex", + justifyContent: "space-between", + }} + > + <span> + {parseTraffic(upload + download)} / {parseTraffic(total)} + </span> + <span>{expire}</span> + </Box> + + <LinearProgress variant="determinate" value={progress} color="inherit" /> + </Wrapper> + ); +}; + +function parseUrl(url?: string) { + if (!url) return ""; + const regex = /https?:\/\/(.+?)\//; + const result = url.match(regex); + return result ? result[1] : "local file"; +} + +function parseExpire(expire?: number) { + if (!expire) return "-"; + return dayjs(expire * 1000).format("YYYY-MM-DD"); +} + +export default ProfileItemComp; diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index e081c98037453277415f86bdab5189f343de45d9..143871e1c7ced12e835c6146eab0b32118d9cc2f 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { SWRConfig } from "swr"; import { Route, Routes } from "react-router-dom"; import { useRecoilValue } from "recoil"; import { createTheme, List, Paper, ThemeProvider } from "@mui/material"; @@ -55,6 +56,15 @@ const Layout = () => { } return createTheme({ + breakpoints: { + values: { + xs: 0, + sm: 650, + md: 900, + lg: 1200, + xl: 1536, + }, + }, palette: { mode: paletteMode, primary: { @@ -69,38 +79,40 @@ const Layout = () => { }, [paletteMode]); return ( - <ThemeProvider theme={theme}> - <Paper square elevation={0} className="layout"> - <div className="layout__sidebar"> - <div className="layout__logo"> - <img src={LogoSvg} width="100%" alt="" /> - </div> + <SWRConfig value={{}}> + <ThemeProvider theme={theme}> + <Paper square elevation={0} className="layout"> + <div className="layout__sidebar"> + <div className="layout__logo"> + <img src={LogoSvg} width="100%" alt="" /> + </div> - <List sx={{ userSelect: "none" }}> - {routers.map((router) => ( - <ListItemLink key={router.label} to={router.link}> - {router.label} - </ListItemLink> - ))} - </List> + <List sx={{ userSelect: "none" }}> + {routers.map((router) => ( + <ListItemLink key={router.label} to={router.link}> + {router.label} + </ListItemLink> + ))} + </List> - <div className="layout__traffic"> - <Traffic /> + <div className="layout__traffic"> + <Traffic /> + </div> </div> - </div> - <div className="layout__content"> - <Routes> - <Route path="/" element={<HomePage />} /> - <Route path="/proxy" element={<ProxyPage />} /> - <Route path="/rules" element={<RulesPage />} /> - <Route path="/log" element={<LogPage />} /> - <Route path="/connections" element={<ConnectionsPage />} /> - <Route path="/setting" element={<SettingPage />} /> - </Routes> - </div> - </Paper> - </ThemeProvider> + <div className="layout__content"> + <Routes> + <Route path="/" element={<HomePage />} /> + <Route path="/proxy" element={<ProxyPage />} /> + <Route path="/rules" element={<RulesPage />} /> + <Route path="/log" element={<LogPage />} /> + <Route path="/connections" element={<ConnectionsPage />} /> + <Route path="/setting" element={<SettingPage />} /> + </Routes> + </div> + </Paper> + </ThemeProvider> + </SWRConfig> ); }; diff --git a/src/pages/rules.tsx b/src/pages/rules.tsx index 72f54fbfb544dacafd898647fc9592e4a8f5f1a7..030a0b8772f06cf800117d086d8c0daa132dd38c 100644 --- a/src/pages/rules.tsx +++ b/src/pages/rules.tsx @@ -1,14 +1,18 @@ import { useState } from "react"; -import { Box, Button, TextField, Typography } from "@mui/material"; -import { importProfile } from "../services/command"; +import useSWR, { useSWRConfig } from "swr"; +import { Box, Button, Grid, TextField, Typography } from "@mui/material"; +import { getProfiles, importProfile, putProfiles } from "../services/command"; +import ProfileItemComp from "../components/profile-item"; import useNotice from "../utils/use-notice"; const RulesPage = () => { const [url, setUrl] = useState(""); const [disabled, setDisabled] = useState(false); - const [notice, noticeElement] = useNotice(); + const { mutate } = useSWRConfig(); + const { data: profiles = {} } = useSWR("getProfiles", getProfiles); + const onClick = () => { if (!url) return; setUrl(""); @@ -19,14 +23,26 @@ const RulesPage = () => { .finally(() => setDisabled(false)); }; + const onProfileChange = (index: number) => { + putProfiles(index) + .then(() => { + mutate("getProfiles", { ...profiles, current: index }, true); + }) + .catch((err) => { + console.error(err); + }); + }; + return ( <Box sx={{ width: 0.9, maxWidth: "850px", mx: "auto", mb: 2 }}> <Typography variant="h4" component="h1" sx={{ py: 2, mb: 1 }}> Rules </Typography> - <Box sx={{ display: "flex" }}> + <Box sx={{ display: "flex", mb: 3 }}> <TextField + id="profile_url" + name="profile_url" label="Profile URL" size="small" fullWidth @@ -34,11 +50,27 @@ const RulesPage = () => { onChange={(e) => setUrl(e.target.value)} sx={{ mr: 4 }} /> - <Button disabled={disabled} variant="contained" onClick={onClick}> + <Button + disabled={!url || disabled} + variant="contained" + onClick={onClick} + > Import </Button> </Box> + <Grid container spacing={3}> + {profiles?.items?.map((item, idx) => ( + <Grid item xs={12} sm={6} key={item.file}> + <ProfileItemComp + selected={profiles.current === idx} + itemData={item} + onClick={() => onProfileChange(idx)} + /> + </Grid> + ))} + </Grid> + {noticeElement} </Box> ); diff --git a/src/services/command.ts b/src/services/command.ts index ccc44e217d13a4c1a20e77d4492f6b7326861d41..57bd471d1f8b2bc822724012cb0b8fc5dcd65c46 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -38,9 +38,13 @@ export interface ProfilesConfig { } export async function getProfiles() { - return (await invoke<ProfilesConfig[] | null>("get_profiles")) ?? []; + return invoke<ProfilesConfig | null>("get_profiles"); } export async function setProfiles(current: number, profile: ProfileItem) { return invoke<void>("set_profiles", { current, profile }); } + +export async function putProfiles(current: number) { + return invoke<void>("put_profiles", { current }); +}