add codes

This commit is contained in:
Gabe Yuan
2023-07-20 13:45:41 +08:00
parent 10183e3013
commit 0041d6d528
44 changed files with 13020 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
export default function LoadingIcon() {
return (
<svg
viewBox="0 0 100 100"
style={{
maxWidth: "1.2em",
maxHeight: "1.2em",
}}
>
<circle fill="#209CEE" stroke="none" cx="6" cy="50" r="6">
<animateTransform
attributeName="transform"
dur="1s"
type="translate"
values="0 15 ; 0 -15; 0 15"
repeatCount="indefinite"
begin="0.1"
/>
</circle>
<circle fill="#209CEE" stroke="none" cx="30" cy="50" r="6">
<animateTransform
attributeName="transform"
dur="1s"
type="translate"
values="0 10 ; 0 -10; 0 10"
repeatCount="indefinite"
begin="0.2"
/>
</circle>
<circle fill="#209CEE" stroke="none" cx="54" cy="50" r="6">
<animateTransform
attributeName="transform"
dur="1s"
type="translate"
values="0 5 ; 0 -5; 0 5"
repeatCount="indefinite"
begin="0.3"
/>
</circle>
</svg>
);
}

View File

@@ -0,0 +1,59 @@
import { useMemo, useState } from "react";
import LoadingIcon from "./LoadingIcon";
import { OPT_STYLE_FUZZY, OPT_STYLE_LINE } from "../../config";
import { useTranslate } from "../../hooks/Translate";
export default function Content({ q, rule }) {
const [hover, setHover] = useState(false);
const { text, sameLang, loading, textStyle } = useTranslate(q, rule);
const handleMouseEnter = () => {
setHover(true);
};
const handleMouseLeave = () => {
setHover(false);
};
const style = useMemo(() => {
switch (textStyle) {
case OPT_STYLE_LINE:
return {
opacity: hover ? 1 : 0.6,
textDecoration: "dashed underline 2px",
textUnderlineOffset: "0.3em",
};
case OPT_STYLE_FUZZY:
return {
filter: hover ? "none" : "blur(5px)",
transition: "filter 0.3s ease-in-out",
};
default:
return {};
}
}, [textStyle, hover]);
if (loading) {
return (
<>
{q.length > 40 ? <br /> : " "}
<LoadingIcon />
</>
);
}
if (text && !sameLang) {
return (
<>
{q.length > 40 ? <br /> : " "}
<span
style={style}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{text}
</span>
</>
);
}
}

View File

@@ -0,0 +1,18 @@
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import ReactMarkdown from "react-markdown";
import { useI18n, useI18nMd } from "../../hooks/I18n";
export default function About() {
const i18n = useI18n();
const [md, loading, error] = useI18nMd("about_md");
return (
<Box>
{loading ? (
<CircularProgress />
) : (
<ReactMarkdown children={error ? i18n("about_md_local") : md} />
)}
</Box>
);
}

View File

@@ -0,0 +1,51 @@
import PropTypes from "prop-types";
import AppBar from "@mui/material/AppBar";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
import Toolbar from "@mui/material/Toolbar";
import Box from "@mui/material/Box";
import { useDarkModeSwitch } from "../../hooks/ColorMode";
import { useDarkMode } from "../../hooks/ColorMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import { useI18n } from "../../hooks/I18n";
function Header(props) {
const i18n = useI18n();
const { onDrawerToggle } = props;
const switchColorMode = useDarkModeSwitch();
const darkMode = useDarkMode();
return (
<AppBar
color="primary"
position="sticky"
sx={{
zIndex: 1300,
}}
>
<Toolbar variant="dense">
<Box sx={{ display: { sm: "none", xs: "block" } }}>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={onDrawerToggle}
edge="start"
>
<MenuIcon />
</IconButton>
</Box>
<Box sx={{ flexGrow: 1 }}>{i18n("app_name")}</Box>
<IconButton onClick={switchColorMode} color="inherit">
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
</Toolbar>
</AppBar>
);
}
Header.propTypes = {
onDrawerToggle: PropTypes.func.isRequired,
};
export default Header;

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import useMediaQuery from "@mui/material/useMediaQuery";
import CssBaseline from "@mui/material/CssBaseline";
import Box from "@mui/material/Box";
import Navigator from "./Navigator";
import Header from "./Header";
import { useTheme } from "@mui/material/styles";
export default function Layout() {
const navWidth = 256;
const location = useLocation();
const theme = useTheme();
const [open, setOpen] = useState(false);
const isSm = useMediaQuery(theme.breakpoints.up("sm"));
const handleDrawerToggle = () => {
setOpen(!open);
};
useEffect(() => {
setOpen(false);
}, [location]);
return (
<Box>
<CssBaseline />
<Header onDrawerToggle={handleDrawerToggle} />
<Box sx={{ display: "flex" }}>
<Box
component="nav"
sx={{ width: { sm: navWidth }, flexShrink: { sm: 0 } }}
>
<Navigator
PaperProps={{ style: { width: navWidth } }}
variant={isSm ? "permanent" : "temporary"}
open={isSm ? true : open}
onClose={handleDrawerToggle}
/>
</Box>
<Box component="main" sx={{ flex: 1, p: 2 }}>
<Outlet />
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,50 @@
import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Toolbar from "@mui/material/Toolbar";
import { NavLink, useMatch } from "react-router-dom";
import SettingsIcon from "@mui/icons-material/Settings";
import InfoIcon from "@mui/icons-material/Info";
import DesignServicesIcon from "@mui/icons-material/DesignServices";
import { useI18n } from "../../hooks/I18n";
function LinkItem({ label, url, icon }) {
const match = useMatch(url);
return (
<ListItemButton component={NavLink} to={url} selected={!!match}>
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText>{label}</ListItemText>
</ListItemButton>
);
}
export default function Navigator(props) {
const i18n = useI18n();
const memus = [
{
id: "basic_setting",
label: i18n("basic_setting"),
url: "/",
icon: <SettingsIcon />,
},
{
id: "rules_setting",
label: i18n("rules_setting"),
url: "/rules",
icon: <DesignServicesIcon />,
},
{ id: "about", label: i18n("about"), url: "/about", icon: <InfoIcon /> },
];
return (
<Drawer {...props}>
<Toolbar variant="dense" />
<List component="nav">
{memus.map(({ id, label, url, icon }) => (
<LinkItem key={id} label={label} url={url} icon={icon} />
))}
</List>
</Drawer>
);
}

412
src/views/Options/Rules.js Normal file
View File

@@ -0,0 +1,412 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import {
DEFAULT_RULE,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_TRANS_ALL,
OPT_STYLE_ALL,
} from "../../config";
import { useState, useRef } from "react";
import Alert from "@mui/material/Alert";
import { useI18n } from "../../hooks/I18n";
import Typography from "@mui/material/Typography";
import Accordion from "@mui/material/Accordion";
import AccordionSummary from "@mui/material/AccordionSummary";
import AccordionDetails from "@mui/material/AccordionDetails";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { useRules } from "../../hooks/Rules";
import MenuItem from "@mui/material/MenuItem";
import Grid from "@mui/material/Grid";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import FileUploadIcon from "@mui/icons-material/FileUpload";
function RuleFields({
rule,
rules,
index,
addRule,
delRule,
putRule,
setShow,
}) {
const initFormValues = rule || {
...DEFAULT_RULE,
pattern: "",
transOpen: true,
};
const editMode = !!rule;
const i18n = useI18n();
const [disabled, setDisabled] = useState(editMode);
const [errors, setErrors] = useState({});
const [formValues, setFormValues] = useState(initFormValues);
const {
pattern,
selector,
translator,
fromLang,
toLang,
textStyle,
transOpen,
} = formValues;
const hasSamePattern = (str) => {
for (const item of rules) {
if (item.pattern === str && rule?.pattern !== str) {
return true;
}
}
return false;
};
const handleFocus = (e) => {
e.preventDefault();
const { name } = e.target;
setErrors((pre) => ({ ...pre, [name]: "" }));
};
const handleChange = (e) => {
e.preventDefault();
const { name, value } = e.target;
setFormValues((pre) => ({ ...pre, [name]: value }));
};
const handleCancel = (e) => {
e.preventDefault();
if (editMode) {
setDisabled(true);
} else {
setShow(false);
}
setErrors({});
setFormValues(initFormValues);
};
const handleSubmit = (e) => {
e.preventDefault();
const errors = {};
if (!pattern.trim()) {
errors.pattern = i18n("error_cant_be_blank");
}
if (!selector.trim()) {
errors.selector = i18n("error_cant_be_blank");
}
if (hasSamePattern(pattern)) {
errors.pattern = i18n("error_duplicate_values");
}
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
if (editMode) {
// 编辑
setDisabled(true);
putRule(index, formValues);
} else {
// 添加
addRule(formValues);
setShow(false);
setFormValues(initFormValues);
}
};
return (
<form onSubmit={handleSubmit}>
<Stack spacing={2}>
<TextField
size="small"
label={i18n("pattern")}
error={!!errors.pattern}
helperText={errors.pattern ?? i18n("pattern_helper")}
name="pattern"
value={pattern}
disabled={rule?.pattern === "*" || disabled}
onChange={handleChange}
onFocus={handleFocus}
/>
<TextField
size="small"
label={i18n("selector")}
error={!!errors.selector}
helperText={errors.selector ?? i18n("selector_helper")}
name="selector"
value={selector}
disabled={disabled}
onChange={handleChange}
onFocus={handleFocus}
multiline
minRows={2}
maxRows={10}
/>
<Box>
<Grid container spacing={2} columns={20}>
<Grid item xs={10} md={4}>
<TextField
select
size="small"
fullWidth
name="transOpen"
value={transOpen}
label={i18n("translate_switch")}
disabled={disabled}
onChange={handleChange}
>
<MenuItem value={true}>{i18n("default_enabled")}</MenuItem>
<MenuItem value={false}>{i18n("default_disabled")}</MenuItem>
</TextField>
</Grid>
<Grid item xs={10} md={4}>
<TextField
select
size="small"
fullWidth
name="translator"
value={translator}
label={i18n("translate_service")}
disabled={disabled}
onChange={handleChange}
>
{OPT_TRANS_ALL.map((item) => (
<MenuItem value={item}>{item}</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={10} md={4}>
<TextField
select
size="small"
fullWidth
name="fromLang"
value={fromLang}
label={i18n("from_lang")}
disabled={disabled}
onChange={handleChange}
>
{OPT_LANGS_FROM.map(([lang, name]) => (
<MenuItem value={lang}>{name}</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={10} md={4}>
<TextField
select
size="small"
fullWidth
name="toLang"
value={toLang}
label={i18n("to_lang")}
disabled={disabled}
onChange={handleChange}
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem value={lang}>{name}</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={10} md={4}>
<TextField
select
size="small"
fullWidth
name="textStyle"
value={textStyle}
label={i18n("text_style")}
disabled={disabled}
onChange={handleChange}
>
{OPT_STYLE_ALL.map((item) => (
<MenuItem value={item}>{i18n(item)}</MenuItem>
))}
</TextField>
</Grid>
</Grid>
</Box>
{editMode ? (
// 编辑
<Stack direction="row" spacing={2}>
{disabled ? (
<>
<Button
size="small"
variant="contained"
onClick={(e) => {
e.preventDefault();
setDisabled(false);
}}
>
{i18n("edit")}
</Button>
{rule?.pattern !== "*" && (
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.preventDefault();
delRule(rule.pattern);
}}
>
{i18n("delete")}
</Button>
)}
</>
) : (
<>
<Button size="small" variant="contained" type="submit">
{i18n("save")}
</Button>
<Button size="small" variant="outlined" onClick={handleCancel}>
{i18n("cancel")}
</Button>
</>
)}
</Stack>
) : (
// 添加
<Stack direction="row" spacing={2}>
<Button size="small" variant="contained" type="submit">
{i18n("save")}
</Button>
<Button size="small" variant="outlined" onClick={handleCancel}>
{i18n("cancel")}
</Button>
</Stack>
)}
</Stack>
</form>
);
}
function DownloadButton({ data, text, fileName }) {
const handleClick = (e) => {
e.preventDefault();
if (data) {
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", fileName || `${Date.now()}.json`);
document.body.appendChild(link);
link.click();
link.remove();
}
};
return (
<Button
size="small"
variant="outlined"
onClick={handleClick}
startIcon={<FileDownloadIcon />}
>
{text}
</Button>
);
}
function UploadButton({ onChange, text }) {
const inputRef = useRef(null);
const handleClick = () => {
inputRef.current && inputRef.current.click();
};
return (
<Button
size="small"
variant="outlined"
onClick={handleClick}
startIcon={<FileUploadIcon />}
>
{text}
<input
type="file"
accept=".json"
ref={inputRef}
onChange={onChange}
hidden
/>
</Button>
);
}
export default function Rules() {
const i18n = useI18n();
const [rules, addRule, delRule, putRule, mergeRules] = useRules();
const [showAdd, setShowAdd] = useState(false);
const handleImport = (e) => {
const file = e.target.files[0];
if (!file) {
return;
}
if (!file.type.includes("json")) {
alert(i18n("error_wrong_file_type"));
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
try {
await mergeRules(JSON.parse(e.target.result));
} catch (err) {
console.log("[import rules]", err);
}
};
reader.readAsText(file);
};
return (
<Box>
<Stack spacing={3}>
<Alert severity="warning">{i18n("advanced_warn")}</Alert>
<Stack direction="row" spacing={2}>
<Button
size="small"
variant="contained"
disabled={showAdd}
onClick={(e) => {
e.preventDefault();
setShowAdd(true);
}}
>
{i18n("add")}
</Button>
<UploadButton text={i18n("import")} onChange={handleImport} />
<DownloadButton
data={JSON.stringify([...rules].reverse(), null, "\t")}
text={i18n("export")}
/>
</Stack>
{showAdd && (
<RuleFields addRule={addRule} rules={rules} setShow={setShowAdd} />
)}
<Box>
{rules.map((rule, index) => (
<Accordion key={rule.pattern}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>{rule.pattern}</Typography>
</AccordionSummary>
<AccordionDetails>
<RuleFields
rule={rule}
index={index}
putRule={putRule}
delRule={delRule}
rules={rules}
/>
</AccordionDetails>
</Accordion>
))}
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,141 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import InputLabel from "@mui/material/InputLabel";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
import { useSetting, useSettingUpdate } from "../../hooks/Setting";
import { limitNumber } from "../../libs/utils";
import { useI18n } from "../../hooks/I18n";
import { UI_LANGS } from "../../config";
export default function Settings() {
const i18n = useI18n();
const setting = useSetting();
const updateSetting = useSettingUpdate();
if (!setting) {
return;
}
const {
uiLang,
googleUrl,
fetchLimit,
openaiUrl,
openaiKey,
openaiModel,
openaiPrompt,
clearCache,
} = setting;
return (
<Box>
<Stack spacing={3}>
<FormControl size="small">
<InputLabel>{i18n("ui_lang")}</InputLabel>
<Select
value={uiLang}
label={i18n("ui_lang")}
onChange={(e) => {
updateSetting({
uiLang: e.target.value,
});
}}
>
{UI_LANGS.map(([lang, name]) => (
<MenuItem value={lang}>{name}</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
label={i18n("fetch_limit")}
type="number"
defaultValue={fetchLimit}
onChange={(e) => {
updateSetting({
fetchLimit: limitNumber(e.target.value, 1, 10),
});
}}
/>
<FormControl size="small">
<InputLabel>{i18n("clear_cache")}</InputLabel>
<Select
value={clearCache}
label={i18n("clear_cache")}
onChange={(e) => {
updateSetting({
clearCache: e.target.value,
});
}}
>
<MenuItem value={false}>{i18n("clear_cache_never")}</MenuItem>
<MenuItem value={true}>{i18n("clear_cache_restart")}</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label={i18n("google_api")}
defaultValue={googleUrl}
onChange={(e) => {
updateSetting({
googleUrl: e.target.value,
});
}}
/>
<TextField
size="small"
label={i18n("openai_api")}
defaultValue={openaiUrl}
onChange={(e) => {
updateSetting({
openaiUrl: e.target.value,
});
}}
/>
<TextField
size="small"
label={i18n("openai_key")}
defaultValue={openaiKey}
onChange={(e) => {
updateSetting({
openaiKey: e.target.value,
});
}}
/>
<TextField
size="small"
label={i18n("openai_model")}
defaultValue={openaiModel}
onChange={(e) => {
updateSetting({
openaiModel: e.target.value,
});
}}
/>
<TextField
size="small"
label={i18n("openai_prompt")}
defaultValue={openaiPrompt}
onChange={(e) => {
updateSetting({
openaiPrompt: e.target.value,
});
}}
multiline
minRows={2}
maxRows={10}
/>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,17 @@
import { Routes, Route } from "react-router-dom";
import About from "./About";
import Rules from "./Rules";
import Setting from "./Setting";
import Layout from "./Layout";
export default function Options() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Setting />} />
<Route path="rules" element={<Rules />} />
<Route path="about" element={<About />} />
</Route>
</Routes>
);
}

142
src/views/Popup/index.js Normal file
View File

@@ -0,0 +1,142 @@
import { useState, useEffect } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import Button from "@mui/material/Button";
import { sendTabMsg } from "../../libs/msg";
import browser from "../../libs/browser";
import { useI18n } from "../../hooks/I18n";
import TextField from "@mui/material/TextField";
import {
MSG_TRANS_TOGGLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
OPT_TRANS_ALL,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_STYLE_ALL,
} from "../../config";
export default function Popup() {
const i18n = useI18n();
const [rule, setRule] = useState(null);
const handleOpenSetting = () => {
browser?.runtime.openOptionsPage();
};
const handleTransToggle = async (e) => {
try {
setRule({ ...rule, transOpen: e.target.checked });
await sendTabMsg(MSG_TRANS_TOGGLE);
} catch (err) {
console.log("[toggle trans]", err);
}
};
const handleChange = async (e) => {
try {
const { name, value } = e.target;
setRule((pre) => ({ ...pre, [name]: value }));
await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value });
} catch (err) {
console.log("[update rule]", err);
}
};
useEffect(() => {
(async () => {
try {
const res = await sendTabMsg(MSG_TRANS_GETRULE);
if (!res.error) {
setRule(res.data);
}
} catch (err) {
console.log("[query rule]", err);
}
})();
}, []);
if (!rule) {
return (
<Box minWidth={300} sx={{ p: 2 }}>
<Stack spacing={3}>
<Button variant="text" onClick={handleOpenSetting}>
{i18n("setting")}
</Button>
</Stack>
</Box>
);
}
const { transOpen, translator, fromLang, toLang, textStyle } = rule;
return (
<Box minWidth={300} sx={{ p: 2 }}>
<Stack spacing={3}>
<FormControlLabel
control={<Switch checked={transOpen} onChange={handleTransToggle} />}
label={i18n("translate")}
/>
<TextField
select
size="small"
value={translator}
name="translator"
label={i18n("translate_service")}
onChange={handleChange}
>
{OPT_TRANS_ALL.map((item) => (
<MenuItem value={item}>{item}</MenuItem>
))}
</TextField>
<TextField
select
size="small"
value={fromLang}
name="fromLang"
label={i18n("from_lang")}
onChange={handleChange}
>
{OPT_LANGS_FROM.map(([lang, name]) => (
<MenuItem value={lang}>{name}</MenuItem>
))}
</TextField>
<TextField
select
size="small"
value={toLang}
name="toLang"
label={i18n("to_lang")}
onChange={handleChange}
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem value={lang}>{name}</MenuItem>
))}
</TextField>
<TextField
select
size="small"
value={textStyle}
name="textStyle"
label={i18n("text_style")}
onChange={handleChange}
>
{OPT_STYLE_ALL.map((item) => (
<MenuItem value={item}>{i18n(item)}</MenuItem>
))}
</TextField>
<Button variant="text" onClick={handleOpenSetting}>
{i18n("setting")}
</Button>
</Stack>
</Box>
);
}