feat: optimize profile page
This commit is contained in:
parent
f1a68ece01
commit
33ce235713
@ -21,12 +21,16 @@ const EnhancedMode = (props: Props) => {
|
|||||||
const { items, chain } = props;
|
const { items, chain } = props;
|
||||||
|
|
||||||
const { mutate: mutateProfiles } = useSWR("getProfiles", getProfiles);
|
const { mutate: mutateProfiles } = useSWR("getProfiles", getProfiles);
|
||||||
const { data: chainLogs = {} } = useSWR("getRuntimeLogs", getRuntimeLogs);
|
const { data: chainLogs = {}, mutate: mutateLogs } = useSWR(
|
||||||
|
"getRuntimeLogs",
|
||||||
|
getRuntimeLogs
|
||||||
|
);
|
||||||
|
|
||||||
// handler
|
// handler
|
||||||
const onEnhance = useLockFn(async () => {
|
const onEnhance = useLockFn(async () => {
|
||||||
try {
|
try {
|
||||||
await enhanceProfiles();
|
await enhanceProfiles();
|
||||||
|
mutateLogs();
|
||||||
Notice.success("Refresh clash config", 1000);
|
Notice.success("Refresh clash config", 1000);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
Notice.error(err.message || err.toString());
|
||||||
@ -39,6 +43,7 @@ const EnhancedMode = (props: Props) => {
|
|||||||
const newChain = [...chain, uid];
|
const newChain = [...chain, uid];
|
||||||
await changeProfileChain(newChain);
|
await changeProfileChain(newChain);
|
||||||
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
|
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
|
||||||
|
mutateLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
const onEnhanceDisable = useLockFn(async (uid: string) => {
|
const onEnhanceDisable = useLockFn(async (uid: string) => {
|
||||||
@ -47,6 +52,7 @@ const EnhancedMode = (props: Props) => {
|
|||||||
const newChain = chain.filter((i) => i !== uid);
|
const newChain = chain.filter((i) => i !== uid);
|
||||||
await changeProfileChain(newChain);
|
await changeProfileChain(newChain);
|
||||||
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
|
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
|
||||||
|
mutateLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
const onEnhanceDelete = useLockFn(async (uid: string) => {
|
const onEnhanceDelete = useLockFn(async (uid: string) => {
|
||||||
@ -54,6 +60,7 @@ const EnhancedMode = (props: Props) => {
|
|||||||
await onEnhanceDisable(uid);
|
await onEnhanceDisable(uid);
|
||||||
await deleteProfile(uid);
|
await deleteProfile(uid);
|
||||||
mutateProfiles();
|
mutateProfiles();
|
||||||
|
mutateLogs();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err?.message || err.toString());
|
Notice.error(err?.message || err.toString());
|
||||||
}
|
}
|
||||||
@ -65,6 +72,7 @@ const EnhancedMode = (props: Props) => {
|
|||||||
const newChain = [uid].concat(chain.filter((i) => i !== uid));
|
const newChain = [uid].concat(chain.filter((i) => i !== uid));
|
||||||
await changeProfileChain(newChain);
|
await changeProfileChain(newChain);
|
||||||
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
|
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
|
||||||
|
mutateLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
const onMoveEnd = useLockFn(async (uid: string) => {
|
const onMoveEnd = useLockFn(async (uid: string) => {
|
||||||
@ -73,10 +81,11 @@ const EnhancedMode = (props: Props) => {
|
|||||||
const newChain = chain.filter((i) => i !== uid).concat([uid]);
|
const newChain = chain.filter((i) => i !== uid).concat([uid]);
|
||||||
await changeProfileChain(newChain);
|
await changeProfileChain(newChain);
|
||||||
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
|
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
|
||||||
|
mutateLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 4 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Stack
|
<Stack
|
||||||
spacing={1}
|
spacing={1}
|
||||||
direction="row"
|
direction="row"
|
||||||
|
@ -82,7 +82,9 @@ const FileEditor = (props: Props) => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose}>{t("Cancel")}</Button>
|
<Button onClick={onClose} variant="outlined">
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
<Button onClick={onSave} variant="contained">
|
<Button onClick={onSave} variant="contained">
|
||||||
{t("Save")}
|
{t("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Box, Button, Typography } from "@mui/material";
|
import { Box, Button, Typography } from "@mui/material";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -9,6 +10,7 @@ interface Props {
|
|||||||
const FileInput = (props: Props) => {
|
const FileInput = (props: Props) => {
|
||||||
const { onChange } = props;
|
const { onChange } = props;
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
// file input
|
// file input
|
||||||
const inputRef = useRef<any>();
|
const inputRef = useRef<any>();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -40,7 +42,7 @@ const FileInput = (props: Props) => {
|
|||||||
sx={{ flex: "none" }}
|
sx={{ flex: "none" }}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
>
|
>
|
||||||
Choose File
|
{t("Choose File")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
@ -23,7 +23,7 @@ interface Props {
|
|||||||
|
|
||||||
// edit the profile item
|
// edit the profile item
|
||||||
// remote / local file / merge / script
|
// remote / local file / merge / script
|
||||||
const ProfileEdit = (props: Props) => {
|
const InfoEditor = (props: Props) => {
|
||||||
const { open, itemData, onClose } = props;
|
const { open, itemData, onClose } = props;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -56,7 +56,6 @@ const ProfileEdit = (props: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await patchProfile(uid, { uid, name, desc, url, option: option_ });
|
await patchProfile(uid, { uid, name, desc, url, option: option_ });
|
||||||
setShowOpt(false);
|
|
||||||
mutate("getProfiles");
|
mutate("getProfiles");
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -133,7 +132,7 @@ const ProfileEdit = (props: Props) => {
|
|||||||
value={option.update_interval}
|
value={option.update_interval}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const str = e.target.value?.replace(/\D/, "");
|
const str = e.target.value?.replace(/\D/, "");
|
||||||
setOption({ update_interval: str != null ? +str : str });
|
setOption({ update_interval: !!str ? +str : undefined });
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
|
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
|
||||||
/>
|
/>
|
||||||
@ -144,6 +143,7 @@ const ProfileEdit = (props: Props) => {
|
|||||||
{form.type === "remote" && (
|
{form.type === "remote" && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
color="inherit"
|
||||||
sx={{ position: "absolute", left: 18 }}
|
sx={{ position: "absolute", left: 18 }}
|
||||||
onClick={() => setShowOpt((o) => !o)}
|
onClick={() => setShowOpt((o) => !o)}
|
||||||
>
|
>
|
||||||
@ -151,13 +151,15 @@ const ProfileEdit = (props: Props) => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose} variant="outlined">
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
<Button onClick={onUpdate} variant="contained">
|
<Button onClick={onUpdate} variant="contained">
|
||||||
Update
|
{t("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfileEdit;
|
export default InfoEditor;
|
71
src/components/profile/log-viewer.tsx
Normal file
71
src/components/profile/log-viewer.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import BaseEmpty from "../base/base-empty";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
logInfo: [string, string][];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogViewer = (props: Props) => {
|
||||||
|
const { open, logInfo, onClose } = props;
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose}>
|
||||||
|
<DialogTitle>{t("Script Console")}</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent
|
||||||
|
sx={{
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
overflowX: "hidden",
|
||||||
|
userSelect: "text",
|
||||||
|
pb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logInfo.map(([level, log], index) => (
|
||||||
|
<Fragment key={index.toString()}>
|
||||||
|
<Typography color="text.secondary" component="div">
|
||||||
|
<Chip
|
||||||
|
label={level}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color={
|
||||||
|
level === "error" || level === "exception"
|
||||||
|
? "error"
|
||||||
|
: "default"
|
||||||
|
}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
{log}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 0.5 }} />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{logInfo.length === 0 && <BaseEmpty />}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} variant="outlined">
|
||||||
|
{t("Back")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogViewer;
|
43
src/components/profile/profile-box.tsx
Normal file
43
src/components/profile/profile-box.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { alpha, Box, styled } from "@mui/material";
|
||||||
|
|
||||||
|
const ProfileBox = styled(Box)(({ theme, "aria-selected": selected }) => {
|
||||||
|
const { mode, primary, text, grey, background } = theme.palette;
|
||||||
|
const key = `${mode}-${!!selected}`;
|
||||||
|
|
||||||
|
const backgroundColor = {
|
||||||
|
"light-true": alpha(primary.main, 0.2),
|
||||||
|
"light-false": alpha(background.paper, 0.75),
|
||||||
|
"dark-true": alpha(primary.main, 0.45),
|
||||||
|
"dark-false": alpha(grey[700], 0.45),
|
||||||
|
}[key]!;
|
||||||
|
|
||||||
|
const color = {
|
||||||
|
"light-true": text.secondary,
|
||||||
|
"light-false": text.secondary,
|
||||||
|
"dark-true": alpha(text.secondary, 0.85),
|
||||||
|
"dark-false": alpha(text.secondary, 0.65),
|
||||||
|
}[key]!;
|
||||||
|
|
||||||
|
const h2color = {
|
||||||
|
"light-true": primary.main,
|
||||||
|
"light-false": text.primary,
|
||||||
|
"dark-true": primary.light,
|
||||||
|
"dark-false": text.primary,
|
||||||
|
}[key]!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: "100%",
|
||||||
|
display: "block",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
boxShadow: theme.shadows[2],
|
||||||
|
padding: "8px 16px",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
backgroundColor,
|
||||||
|
color,
|
||||||
|
"& h2": { color: h2color },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ProfileBox;
|
@ -1,13 +1,11 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { mutate } from "swr";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
import { useSWRConfig } from "swr";
|
|
||||||
import { useRecoilState } from "recoil";
|
import { useRecoilState } from "recoil";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
alpha,
|
|
||||||
Box,
|
Box,
|
||||||
styled,
|
|
||||||
Typography,
|
Typography,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
IconButton,
|
IconButton,
|
||||||
@ -19,21 +17,11 @@ import { RefreshRounded } from "@mui/icons-material";
|
|||||||
import { atomLoadingCache } from "@/services/states";
|
import { atomLoadingCache } from "@/services/states";
|
||||||
import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds";
|
import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds";
|
||||||
import parseTraffic from "@/utils/parse-traffic";
|
import parseTraffic from "@/utils/parse-traffic";
|
||||||
import ProfileEdit from "./profile-edit";
|
import ProfileBox from "./profile-box";
|
||||||
|
import InfoEditor from "./info-editor";
|
||||||
import FileEditor from "./file-editor";
|
import FileEditor from "./file-editor";
|
||||||
import Notice from "../base/base-notice";
|
import Notice from "../base/base-notice";
|
||||||
|
|
||||||
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",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const round = keyframes`
|
const round = keyframes`
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
@ -49,7 +37,6 @@ const ProfileItem = (props: Props) => {
|
|||||||
const { selected, itemData, onSelect } = props;
|
const { selected, itemData, onSelect } = props;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutate } = useSWRConfig();
|
|
||||||
const [anchorEl, setAnchorEl] = useState<any>(null);
|
const [anchorEl, setAnchorEl] = useState<any>(null);
|
||||||
const [position, setPosition] = useState({ left: 0, top: 0 });
|
const [position, setPosition] = useState({ left: 0, top: 0 });
|
||||||
const [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache);
|
const [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache);
|
||||||
@ -58,7 +45,6 @@ const ProfileItem = (props: Props) => {
|
|||||||
|
|
||||||
// local file mode
|
// local file mode
|
||||||
// remote file mode
|
// remote file mode
|
||||||
// subscription url mode
|
|
||||||
const hasUrl = !!itemData.url;
|
const hasUrl = !!itemData.url;
|
||||||
const hasExtra = !!extra; // only subscription url has extra info
|
const hasExtra = !!extra; // only subscription url has extra info
|
||||||
|
|
||||||
@ -79,7 +65,6 @@ const ProfileItem = (props: Props) => {
|
|||||||
const handler = () => {
|
const handler = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastUpdate = updated * 1000;
|
const lastUpdate = updated * 1000;
|
||||||
|
|
||||||
// 大于一天的不管
|
// 大于一天的不管
|
||||||
if (now - lastUpdate >= 24 * 36e5) return;
|
if (now - lastUpdate >= 24 * 36e5) return;
|
||||||
|
|
||||||
@ -152,13 +137,6 @@ const ProfileItem = (props: Props) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const boxStyle = {
|
|
||||||
height: 26,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
};
|
|
||||||
|
|
||||||
const urlModeMenu = [
|
const urlModeMenu = [
|
||||||
{ label: "Select", handler: onForceSelect },
|
{ label: "Select", handler: onForceSelect },
|
||||||
{ label: "Edit Info", handler: onEditInfo },
|
{ label: "Edit Info", handler: onEditInfo },
|
||||||
@ -176,36 +154,17 @@ const ProfileItem = (props: Props) => {
|
|||||||
{ label: "Delete", handler: onDelete },
|
{ label: "Delete", handler: onDelete },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const boxStyle = {
|
||||||
|
height: 26,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Wrapper
|
<ProfileBox
|
||||||
sx={({ palette }) => {
|
aria-selected={selected}
|
||||||
const { mode, primary, text, grey } = palette;
|
|
||||||
const key = `${mode}-${selected}`;
|
|
||||||
|
|
||||||
const bgcolor = {
|
|
||||||
"light-true": alpha(primary.main, 0.15),
|
|
||||||
"light-false": palette.background.paper,
|
|
||||||
"dark-true": alpha(primary.main, 0.35),
|
|
||||||
"dark-false": alpha(grey[700], 0.35),
|
|
||||||
}[key]!;
|
|
||||||
|
|
||||||
const color = {
|
|
||||||
"light-true": text.secondary,
|
|
||||||
"light-false": text.secondary,
|
|
||||||
"dark-true": alpha(text.secondary, 0.75),
|
|
||||||
"dark-false": alpha(text.secondary, 0.75),
|
|
||||||
}[key]!;
|
|
||||||
|
|
||||||
const h2color = {
|
|
||||||
"light-true": primary.main,
|
|
||||||
"light-false": text.primary,
|
|
||||||
"dark-true": primary.light,
|
|
||||||
"dark-false": text.primary,
|
|
||||||
}[key]!;
|
|
||||||
|
|
||||||
return { bgcolor, color, "& h2": { color: h2color } };
|
|
||||||
}}
|
|
||||||
onClick={() => onSelect(false)}
|
onClick={() => onSelect(false)}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
const { clientX, clientY } = event;
|
const { clientX, clientY } = event;
|
||||||
@ -214,9 +173,9 @@ const ProfileItem = (props: Props) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box display="flex" justifyContent="space-between">
|
<Box position="relative">
|
||||||
<Typography
|
<Typography
|
||||||
width="calc(100% - 40px)"
|
width="calc(100% - 36px)"
|
||||||
variant="h6"
|
variant="h6"
|
||||||
component="h2"
|
component="h2"
|
||||||
noWrap
|
noWrap
|
||||||
@ -229,10 +188,13 @@ const ProfileItem = (props: Props) => {
|
|||||||
{hasUrl && (
|
{hasUrl && (
|
||||||
<IconButton
|
<IconButton
|
||||||
sx={{
|
sx={{
|
||||||
width: 26,
|
position: "absolute",
|
||||||
height: 26,
|
p: "3px",
|
||||||
|
top: -1,
|
||||||
|
right: -5,
|
||||||
animation: loading ? `1s linear infinite ${round}` : "none",
|
animation: loading ? `1s linear infinite ${round}` : "none",
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -240,47 +202,47 @@ const ProfileItem = (props: Props) => {
|
|||||||
onUpdate(false);
|
onUpdate(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshRounded />
|
<RefreshRounded color="inherit" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* the second line show url's info or description */}
|
{/* the second line show url's info or description */}
|
||||||
{hasUrl ? (
|
<Box sx={boxStyle}>
|
||||||
<Box sx={boxStyle}>
|
{hasUrl ? (
|
||||||
<Typography noWrap title={`From: ${from}`}>
|
<>
|
||||||
{from}
|
<Typography noWrap title={`From: ${from}`}>
|
||||||
</Typography>
|
{from}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
noWrap
|
noWrap
|
||||||
flex="1 0 auto"
|
flex="1 0 auto"
|
||||||
fontSize={14}
|
fontSize={14}
|
||||||
textAlign="right"
|
textAlign="right"
|
||||||
title="updated time"
|
title={`Updated Time: ${parseExpire(updated)}`}
|
||||||
>
|
>
|
||||||
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
|
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={boxStyle}>
|
|
||||||
<Typography noWrap title={itemData.desc}>
|
<Typography noWrap title={itemData.desc}>
|
||||||
{itemData.desc}
|
{itemData.desc}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
)}
|
||||||
)}
|
</Box>
|
||||||
|
|
||||||
{/* the third line show extra info or last updated time */}
|
{/* the third line show extra info or last updated time */}
|
||||||
{hasExtra ? (
|
{hasExtra ? (
|
||||||
<Box sx={{ ...boxStyle, fontSize: 14 }}>
|
<Box sx={{ ...boxStyle, fontSize: 14 }}>
|
||||||
<span title="used / total">
|
<span title="Used / Total">
|
||||||
{parseTraffic(upload + download)} / {parseTraffic(total)}
|
{parseTraffic(upload + download)} / {parseTraffic(total)}
|
||||||
</span>
|
</span>
|
||||||
<span title="expire time">{expire}</span>
|
<span title="Expire Time">{expire}</span>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ ...boxStyle, fontSize: 14, justifyContent: "flex-end" }}>
|
<Box sx={{ ...boxStyle, fontSize: 14, justifyContent: "flex-end" }}>
|
||||||
<span title="updated time">{parseExpire(updated)}</span>
|
<span title="Updated Time">{parseExpire(updated)}</span>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -289,7 +251,7 @@ const ProfileItem = (props: Props) => {
|
|||||||
value={progress}
|
value={progress}
|
||||||
color="inherit"
|
color="inherit"
|
||||||
/>
|
/>
|
||||||
</Wrapper>
|
</ProfileBox>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
open={!!anchorEl}
|
open={!!anchorEl}
|
||||||
@ -314,22 +276,18 @@ const ProfileItem = (props: Props) => {
|
|||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{editOpen && (
|
<InfoEditor
|
||||||
<ProfileEdit
|
open={editOpen}
|
||||||
open={editOpen}
|
itemData={itemData}
|
||||||
itemData={itemData}
|
onClose={() => setEditOpen(false)}
|
||||||
onClose={() => setEditOpen(false)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fileOpen && (
|
<FileEditor
|
||||||
<FileEditor
|
uid={uid}
|
||||||
uid={uid}
|
open={fileOpen}
|
||||||
open={fileOpen}
|
mode="yaml"
|
||||||
mode="yaml"
|
onClose={() => setFileOpen(false)}
|
||||||
onClose={() => setFileOpen(false)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,32 +1,24 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
import {
|
import {
|
||||||
alpha,
|
|
||||||
Box,
|
Box,
|
||||||
|
Badge,
|
||||||
Chip,
|
Chip,
|
||||||
styled,
|
|
||||||
Typography,
|
Typography,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Menu,
|
Menu,
|
||||||
|
IconButton,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { FeaturedPlayListRounded } from "@mui/icons-material";
|
||||||
import { viewProfile } from "@/services/cmds";
|
import { viewProfile } from "@/services/cmds";
|
||||||
import ProfileEdit from "./profile-edit";
|
import InfoEditor from "./info-editor";
|
||||||
import FileEditor from "./file-editor";
|
import FileEditor from "./file-editor";
|
||||||
|
import ProfileBox from "./profile-box";
|
||||||
|
import LogViewer from "./log-viewer";
|
||||||
import Notice from "../base/base-notice";
|
import Notice from "../base/base-notice";
|
||||||
|
|
||||||
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 {
|
interface Props {
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
itemData: CmdType.ProfileItem;
|
itemData: CmdType.ProfileItem;
|
||||||
@ -55,18 +47,11 @@ const ProfileMore = (props: Props) => {
|
|||||||
|
|
||||||
const { uid, type } = itemData;
|
const { uid, type } = itemData;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState<any>(null);
|
const [anchorEl, setAnchorEl] = useState<any>(null);
|
||||||
const [position, setPosition] = useState({ left: 0, top: 0 });
|
const [position, setPosition] = useState({ left: 0, top: 0 });
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [fileOpen, setFileOpen] = useState(false);
|
const [fileOpen, setFileOpen] = useState(false);
|
||||||
// const [status, setStatus] = useState(enhance.status(uid));
|
const [logOpen, setLogOpen] = useState(false);
|
||||||
|
|
||||||
// unlisten when unmount
|
|
||||||
// useEffect(() => enhance.listen(uid, setStatus), [uid]);
|
|
||||||
|
|
||||||
// error during enhanced mode
|
|
||||||
const hasError = !!logInfo.find((e) => e[0] === "exception"); // selected && status?.status === "error";
|
|
||||||
|
|
||||||
const onEditInfo = () => {
|
const onEditInfo = () => {
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
@ -92,6 +77,7 @@ const ProfileMore = (props: Props) => {
|
|||||||
return fn();
|
return fn();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasError = !!logInfo.find((e) => e[0] === "exception");
|
||||||
const showMove = enableNum > 1 && !hasError;
|
const showMove = enableNum > 1 && !hasError;
|
||||||
|
|
||||||
const enableMenu = [
|
const enableMenu = [
|
||||||
@ -122,39 +108,8 @@ const ProfileMore = (props: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Wrapper
|
<ProfileBox
|
||||||
sx={({ palette }) => {
|
aria-selected={selected}
|
||||||
// todo
|
|
||||||
// 区分 selected 和 error 和 mode 下各种颜色的排列组合
|
|
||||||
const { mode, primary, text, grey, error } = palette;
|
|
||||||
const key = `${mode}-${selected}`;
|
|
||||||
const bgkey = hasError ? `${mode}-err` : key;
|
|
||||||
|
|
||||||
const bgcolor = {
|
|
||||||
"light-true": alpha(primary.main, 0.15),
|
|
||||||
"light-false": palette.background.paper,
|
|
||||||
"dark-true": alpha(primary.main, 0.35),
|
|
||||||
"dark-false": alpha(grey[700], 0.35),
|
|
||||||
"light-err": alpha(error.main, 0.12),
|
|
||||||
"dark-err": alpha(error.main, 0.3),
|
|
||||||
}[bgkey]!;
|
|
||||||
|
|
||||||
const color = {
|
|
||||||
"light-true": text.secondary,
|
|
||||||
"light-false": text.secondary,
|
|
||||||
"dark-true": alpha(text.secondary, 0.6),
|
|
||||||
"dark-false": alpha(text.secondary, 0.6),
|
|
||||||
}[key]!;
|
|
||||||
|
|
||||||
const h2color = {
|
|
||||||
"light-true": primary.main,
|
|
||||||
"light-false": text.primary,
|
|
||||||
"dark-true": primary.light,
|
|
||||||
"dark-false": text.primary,
|
|
||||||
}[key]!;
|
|
||||||
|
|
||||||
return { bgcolor, color, "& h2": { color: h2color } };
|
|
||||||
}}
|
|
||||||
// onClick={() => onSelect(false)}
|
// onClick={() => onSelect(false)}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
const { clientX, clientY } = event;
|
const { clientX, clientY } = event;
|
||||||
@ -163,7 +118,12 @@ const ProfileMore = (props: Props) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
<Box
|
||||||
|
display="flex"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
mb={0.5}
|
||||||
|
>
|
||||||
<Typography
|
<Typography
|
||||||
width="calc(100% - 52px)"
|
width="calc(100% - 52px)"
|
||||||
variant="h6"
|
variant="h6"
|
||||||
@ -179,22 +139,33 @@ const ProfileMore = (props: Props) => {
|
|||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{ textTransform: "capitalize" }}
|
sx={{ height: 20, textTransform: "capitalize" }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={boxStyle}>
|
<Box sx={boxStyle}>
|
||||||
{hasError ? (
|
{selected ? (
|
||||||
<Typography
|
hasError ? (
|
||||||
noWrap
|
<Badge color="primary" variant="dot" overlap="circular">
|
||||||
color="error"
|
<IconButton
|
||||||
sx={{ width: "calc(100% - 75px)" }}
|
size="small"
|
||||||
// title={status.message}
|
edge="start"
|
||||||
title="error"
|
color="error"
|
||||||
>
|
onClick={() => setLogOpen(true)}
|
||||||
{/* {status.message} */}
|
>
|
||||||
error
|
<FeaturedPlayListRounded fontSize="inherit" />
|
||||||
</Typography>
|
</IconButton>
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setLogOpen(true)}
|
||||||
|
>
|
||||||
|
<FeaturedPlayListRounded fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Typography
|
<Typography
|
||||||
noWrap
|
noWrap
|
||||||
@ -207,13 +178,15 @@ const ProfileMore = (props: Props) => {
|
|||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
component="span"
|
component="span"
|
||||||
title="updated time"
|
title={`Updated Time: ${parseExpire(itemData.updated)}`}
|
||||||
style={{ fontSize: 14 }}
|
style={{ fontSize: 14 }}
|
||||||
>
|
>
|
||||||
{parseExpire(itemData.updated)}
|
{!!itemData.updated
|
||||||
|
? dayjs(itemData.updated! * 1000).fromNow()
|
||||||
|
: ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Wrapper>
|
</ProfileBox>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
open={!!anchorEl}
|
open={!!anchorEl}
|
||||||
@ -240,20 +213,24 @@ const ProfileMore = (props: Props) => {
|
|||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{editOpen && (
|
<InfoEditor
|
||||||
<ProfileEdit
|
open={editOpen}
|
||||||
open={editOpen}
|
itemData={itemData}
|
||||||
itemData={itemData}
|
onClose={() => setEditOpen(false)}
|
||||||
onClose={() => setEditOpen(false)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fileOpen && (
|
<FileEditor
|
||||||
<FileEditor
|
uid={uid}
|
||||||
uid={uid}
|
open={fileOpen}
|
||||||
open={fileOpen}
|
mode={type === "merge" ? "yaml" : "javascript"}
|
||||||
mode={type === "merge" ? "yaml" : "javascript"}
|
onClose={() => setFileOpen(false)}
|
||||||
onClose={() => setFileOpen(false)}
|
/>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<LogViewer
|
||||||
|
open={logOpen}
|
||||||
|
logInfo={logInfo}
|
||||||
|
onClose={() => setLogOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useSWRConfig } from "swr";
|
import { mutate } from "swr";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLockFn, useSetState } from "ahooks";
|
import { useLockFn, useSetState } from "ahooks";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -29,7 +30,7 @@ interface Props {
|
|||||||
const ProfileNew = (props: Props) => {
|
const ProfileNew = (props: Props) => {
|
||||||
const { open, onClose } = props;
|
const { open, onClose } = props;
|
||||||
|
|
||||||
const { mutate } = useSWRConfig();
|
const { t } = useTranslation();
|
||||||
const [form, setForm] = useSetState({
|
const [form, setForm] = useSetState({
|
||||||
type: "remote",
|
type: "remote",
|
||||||
name: "",
|
name: "",
|
||||||
@ -83,7 +84,7 @@ const ProfileNew = (props: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose}>
|
<Dialog open={open} onClose={onClose}>
|
||||||
<DialogTitle sx={{ pb: 0.5 }}>Create Profile</DialogTitle>
|
<DialogTitle sx={{ pb: 0.5 }}>{t("Create Profile")}</DialogTitle>
|
||||||
|
|
||||||
<DialogContent sx={{ width: 336, pb: 1 }}>
|
<DialogContent sx={{ width: 336, pb: 1 }}>
|
||||||
<FormControl size="small" fullWidth sx={{ mt: 2, mb: 1 }}>
|
<FormControl size="small" fullWidth sx={{ mt: 2, mb: 1 }}>
|
||||||
@ -120,7 +121,7 @@ const ProfileNew = (props: Props) => {
|
|||||||
{form.type === "remote" && (
|
{form.type === "remote" && (
|
||||||
<TextField
|
<TextField
|
||||||
{...textFieldProps}
|
{...textFieldProps}
|
||||||
label="Subscription Url"
|
label="Subscription URL"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={form.url}
|
value={form.url}
|
||||||
onChange={(e) => setForm({ url: e.target.value })}
|
onChange={(e) => setForm({ url: e.target.value })}
|
||||||
@ -146,6 +147,7 @@ const ProfileNew = (props: Props) => {
|
|||||||
{form.type === "remote" && (
|
{form.type === "remote" && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
color="inherit"
|
||||||
sx={{ position: "absolute", left: 18 }}
|
sx={{ position: "absolute", left: 18 }}
|
||||||
onClick={() => setShowOpt((o) => !o)}
|
onClick={() => setShowOpt((o) => !o)}
|
||||||
>
|
>
|
||||||
@ -153,9 +155,11 @@ const ProfileNew = (props: Props) => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose} variant="outlined">
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
<Button onClick={onCreate} variant="contained">
|
<Button onClick={onCreate} variant="contained">
|
||||||
Create
|
{t("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -18,6 +18,8 @@
|
|||||||
"Profile URL": "Profile URL",
|
"Profile URL": "Profile URL",
|
||||||
"Import": "Import",
|
"Import": "Import",
|
||||||
"New": "New",
|
"New": "New",
|
||||||
|
"Create Profile": "Create Profile",
|
||||||
|
"Choose File": "Choose File",
|
||||||
"Close All": "Close All",
|
"Close All": "Close All",
|
||||||
"Select": "Select",
|
"Select": "Select",
|
||||||
"Edit Info": "Edit Info",
|
"Edit Info": "Edit Info",
|
||||||
|
@ -18,6 +18,8 @@
|
|||||||
"Profile URL": "配置文件链接",
|
"Profile URL": "配置文件链接",
|
||||||
"Import": "导入",
|
"Import": "导入",
|
||||||
"New": "新建",
|
"New": "新建",
|
||||||
|
"Create Profile": "新建配置",
|
||||||
|
"Choose File": "选择文件",
|
||||||
"Close All": "关闭全部",
|
"Close All": "关闭全部",
|
||||||
"Select": "使用",
|
"Select": "使用",
|
||||||
"Edit Info": "编辑信息",
|
"Edit Info": "编辑信息",
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import useSWR, { useSWRConfig } from "swr";
|
import useSWR, { mutate } from "swr";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSetRecoilState } from "recoil";
|
import { useSetRecoilState } from "recoil";
|
||||||
import { Box, Button, Grid, TextField } from "@mui/material";
|
import { Button, Grid, Stack, TextField } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
getProfiles,
|
getProfiles,
|
||||||
@ -20,7 +20,6 @@ import EnhancedMode from "@/components/profile/enhanced";
|
|||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutate } = useSWRConfig();
|
|
||||||
|
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [disabled, setDisabled] = useState(false);
|
const [disabled, setDisabled] = useState(false);
|
||||||
@ -110,10 +109,13 @@ const ProfilePage = () => {
|
|||||||
getProfiles().then((newProfiles) => {
|
getProfiles().then((newProfiles) => {
|
||||||
mutate("getProfiles", newProfiles);
|
mutate("getProfiles", newProfiles);
|
||||||
|
|
||||||
if (!newProfiles.current && newProfiles.items?.length) {
|
const remoteItem = newProfiles.items?.find((e) => e.type === "remote");
|
||||||
const current = newProfiles.items[0].uid;
|
|
||||||
|
if (!newProfiles.current && remoteItem) {
|
||||||
|
const current = remoteItem.uid;
|
||||||
selectProfile(current);
|
selectProfile(current);
|
||||||
mutate("getProfiles", { ...newProfiles, current }, true);
|
mutate("getProfiles", { ...newProfiles, current }, true);
|
||||||
|
mutate("getRuntimeLogs");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@ -130,6 +132,7 @@ const ProfilePage = () => {
|
|||||||
await selectProfile(uid);
|
await selectProfile(uid);
|
||||||
setCurrentProfile(uid);
|
setCurrentProfile(uid);
|
||||||
mutate("getProfiles", { ...profiles, current: uid }, true);
|
mutate("getProfiles", { ...profiles, current: uid }, true);
|
||||||
|
mutate("getRuntimeLogs");
|
||||||
if (force) Notice.success("Refresh clash config", 1000);
|
if (force) Notice.success("Refresh clash config", 1000);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err?.message || err.toString());
|
Notice.error(err?.message || err.toString());
|
||||||
@ -138,29 +141,34 @@ const ProfilePage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BasePage title={t("Profiles")}>
|
<BasePage title={t("Profiles")}>
|
||||||
<Box sx={{ display: "flex", mb: 2.5 }}>
|
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
id="clas_verge_profile_url"
|
hiddenLabel
|
||||||
name="profile_url"
|
|
||||||
label={t("Profile URL")}
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
|
size="small"
|
||||||
value={url}
|
value={url}
|
||||||
|
variant="outlined"
|
||||||
|
autoComplete="off"
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
sx={{ mr: 1 }}
|
sx={{ input: { py: 0.65, px: 1.25 } }}
|
||||||
|
placeholder={t("Profile URL")}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
disabled={!url || disabled}
|
disabled={!url || disabled}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
size="small"
|
||||||
onClick={onImport}
|
onClick={onImport}
|
||||||
sx={{ mr: 1 }}
|
|
||||||
>
|
>
|
||||||
{t("Import")}
|
{t("Import")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={() => setDialogOpen(true)}>
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
>
|
||||||
{t("New")}
|
{t("New")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Stack>
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{regularItems.map((item) => (
|
{regularItems.map((item) => (
|
||||||
|
Loading…
Reference in New Issue
Block a user