5 Commits 0bbe70258e ... d4fc65a359

Auteur SHA1 Bericht Datum
  RegMs If d4fc65a359 feat: connect memo page with apis 3 jaren geleden
  RegMs If 3dca063097 feat: add dict detail page 3 jaren geleden
  RegMs If 3c1fdc95e3 feat: add dict list page 3 jaren geleden
  RegMs If 51e10478a5 feat: add user register and login 3 jaren geleden
  RegMs If a5f6ae3927 feat: add apis implementation 3 jaren geleden

+ 4 - 0
package.json

@@ -9,14 +9,18 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "ahooks": "^3.7.1",
     "antd": "^4.23.4",
+    "axios": "^1.1.2",
     "lodash": "^4.17.21",
+    "qs": "^6.11.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-router-dom": "^6.4.1"
   },
   "devDependencies": {
     "@types/lodash": "^4.14.186",
+    "@types/qs": "^6.9.7",
     "@types/react": "^18.0.17",
     "@types/react-dom": "^18.0.6",
     "@typescript-eslint/eslint-plugin": "^5.39.0",

+ 11 - 1
src/App.tsx

@@ -1,7 +1,17 @@
 import React from 'react';
 import { RouterProvider } from 'react-router-dom';
+import 'antd/dist/antd.css';
+import zhCN from 'antd/es/locale/zh_CN';
+import { ConfigProvider } from 'antd';
+import { AuthProvider } from './apis/AuthProvider';
 import router from './router';
 
-const App: React.FC = () => <RouterProvider router={router} />;
+const App: React.FC = () => (
+  <ConfigProvider locale={zhCN}>
+    <AuthProvider>
+      <RouterProvider router={router} />
+    </AuthProvider>
+  </ConfigProvider>
+);
 
 export default App;

+ 39 - 0
src/apis/AuthProvider.tsx

@@ -0,0 +1,39 @@
+import React, { useState, useEffect, useContext } from 'react';
+import { Result } from 'antd';
+import { Loading3QuartersOutlined } from '@ant-design/icons';
+import type { UserResult } from './users';
+import { useGetCurrentUser } from './users';
+
+const AuthContext = React.createContext<{
+  user?: UserResult;
+  refresh: () => void;
+}>({ refresh: () => console.log('Missing AuthContext.Provider') });
+
+export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
+  children,
+}) => {
+  const [initialized, setInitialized] = useState(false);
+
+  const { data: user, loading, refresh } = useGetCurrentUser();
+
+  useEffect(() => {
+    if (!loading) {
+      setInitialized(true);
+    }
+  }, [loading]);
+
+  return (
+    <AuthContext.Provider value={{ user, refresh }}>
+      {initialized ? (
+        children
+      ) : (
+        <Result
+          icon={<Loading3QuartersOutlined spin />}
+          subTitle="Loading..."
+        />
+      )}
+    </AuthContext.Provider>
+  );
+};
+
+export const useAuth = () => useContext(AuthContext);

+ 71 - 0
src/apis/dicts.ts

@@ -0,0 +1,71 @@
+import qs from 'qs';
+import { useRequest } from 'ahooks';
+import instance from './instance';
+import type { Response } from './response';
+import { unwrap } from './response';
+import type { WordResult } from './words';
+
+export interface DictResult {
+  id: number;
+  name: string;
+  value: string;
+  meaning: string;
+  extra: string;
+  wordCount: number;
+}
+
+interface ListDictsResponse {
+  total: number;
+  list: DictResult[];
+}
+
+export const useListDicts = () =>
+  useRequest(async () =>
+    unwrap(
+      (await instance.get<Response<ListDictsResponse>>('/dict/list')).data,
+    ),
+  );
+
+interface GetDictRequest {
+  id: number;
+}
+
+interface ListWordsResponse {
+  total: number;
+  list: WordResult[];
+}
+
+interface GetDictResponse {
+  dict: DictResult;
+  words: ListWordsResponse;
+}
+
+export const useGetDict = (request: GetDictRequest) =>
+  useRequest(
+    async () =>
+      unwrap(
+        (
+          await instance.get<Response<GetDictResponse>>(
+            `/dict/get?${qs.stringify(request)}`,
+          )
+        ).data,
+      ),
+    { ready: !!request.id },
+  );
+
+interface CreateDictRequest {
+  name: string;
+  value: string;
+  meaning: string;
+  extra: string;
+}
+
+export const useCreateDict = () => async (request: CreateDictRequest) =>
+  unwrap(
+    (
+      await instance.post<Response<DictResult>>(
+        '/dict/create',
+        qs.stringify(request),
+      )
+    ).data,
+  );

+ 6 - 0
src/apis/instance.ts

@@ -0,0 +1,6 @@
+import axios from 'axios';
+
+export default axios.create({
+  baseURL: 'https://api.word.regmsif.cf',
+  withCredentials: true,
+});

+ 12 - 0
src/apis/response.ts

@@ -0,0 +1,12 @@
+export interface Response<T> {
+  code: number;
+  message?: string;
+  data?: T;
+}
+
+export const unwrap = <T>(response: Response<T>) => {
+  if (response.code !== 200) {
+    throw new Error(response.message);
+  }
+  return response.data;
+};

+ 45 - 0
src/apis/users.ts

@@ -0,0 +1,45 @@
+import qs from 'qs';
+import { useRequest } from 'ahooks';
+import instance from './instance';
+import type { Response } from './response';
+import { unwrap } from './response';
+
+export interface UserResult {
+  id: number;
+  name: string;
+}
+
+interface RegisterRequest {
+  name: string;
+  password: string;
+}
+
+export const useRegister = () => async (request: RegisterRequest) =>
+  unwrap(
+    (
+      await instance.post<Response<UserResult>>(
+        '/user/register',
+        qs.stringify(request),
+      )
+    ).data,
+  );
+
+interface LoginRequest {
+  name: string;
+  password: string;
+}
+
+export const useLogin = () => async (request: LoginRequest) =>
+  unwrap(
+    (
+      await instance.post<Response<UserResult>>(
+        '/user/login',
+        qs.stringify(request),
+      )
+    ).data,
+  );
+
+export const useGetCurrentUser = () =>
+  useRequest(async () =>
+    unwrap((await instance.get<Response<UserResult>>('/user/get')).data),
+  );

+ 60 - 0
src/apis/words.ts

@@ -0,0 +1,60 @@
+import qs from 'qs';
+import instance from './instance';
+import type { Response } from './response';
+import { unwrap } from './response';
+
+export interface WordResult {
+  id: number;
+  value: string;
+  meaning: string;
+  extra: string;
+  star: boolean;
+}
+
+interface CreateWordRequest {
+  value: string;
+  meaning: string;
+  extra: string;
+  dictID: number;
+}
+
+export const useCreateWord = () => async (request: CreateWordRequest) =>
+  unwrap(
+    (
+      await instance.post<Response<WordResult>>(
+        '/word/create',
+        qs.stringify(request),
+      )
+    ).data,
+  );
+
+interface UpdateWordRequest {
+  id: number;
+  value: string;
+  meaning: string;
+  extra: string;
+  star: boolean;
+}
+
+export const useUpdateWord = () => async (request: UpdateWordRequest) =>
+  unwrap(
+    (
+      await instance.put<Response<WordResult>>(
+        '/word/update',
+        qs.stringify(request),
+      )
+    ).data,
+  );
+
+interface DeleteWordRequest {
+  id: number;
+}
+
+export const useDeleteWord = () => async (request: DeleteWordRequest) =>
+  unwrap(
+    (
+      await instance.delete<Response<null>>(
+        `/word/delete?${qs.stringify(request)}`,
+      )
+    ).data,
+  );

+ 77 - 0
src/components/DictDrawer.tsx

@@ -0,0 +1,77 @@
+import React from 'react';
+import { Drawer, Form, Input, Button, message } from 'antd';
+import type { DrawerProps } from 'antd';
+import { useCreateDict } from '../apis/dicts';
+
+interface DictDrawerProps extends DrawerProps {
+  refresh: () => void;
+  onClose: () => void;
+}
+
+const DictDrawer: React.FC<DictDrawerProps> = ({ refresh, ...props }) => {
+  const createDict = useCreateDict();
+
+  return (
+    <Drawer title="创建新词库" destroyOnClose {...props}>
+      <Form
+        name="dict"
+        labelCol={{ span: 6 }}
+        wrapperCol={{ span: 18 }}
+        onFinish={(values: {
+          name: string;
+          value: string;
+          meaning: string;
+          extra: string;
+        }) =>
+          void (async () => {
+            try {
+              await createDict(values);
+              void message.success('创建成功');
+              refresh();
+              props.onClose();
+            } catch (err) {
+              if (err instanceof Error) {
+                void message.error(err.message);
+              }
+            }
+          })()
+        }
+      >
+        <Form.Item
+          label="词库名称"
+          name="name"
+          rules={[{ required: true, message: '请输入词库名称' }]}
+          validateTrigger="onBlur"
+        >
+          <Input placeholder="请输入词库名称" />
+        </Form.Item>
+        <Form.Item
+          label="单词标题"
+          name="value"
+          rules={[{ required: true, message: '请输入单词标题' }]}
+          validateTrigger="onBlur"
+        >
+          <Input placeholder="请输入单词标题(如:英文/日文)" />
+        </Form.Item>
+        <Form.Item
+          label="词义标题"
+          name="meaning"
+          rules={[{ required: true, message: '请输入词义标题' }]}
+          validateTrigger="onBlur"
+        >
+          <Input placeholder="请输入词义标题(如:中文)" />
+        </Form.Item>
+        <Form.Item label="附加标题" name="extra">
+          <Input placeholder="请输入附加标题(如:音标/假名)" />
+        </Form.Item>
+        <Form.Item label=" " colon={false}>
+          <Button htmlType="submit" type="primary">
+            创建
+          </Button>
+        </Form.Item>
+      </Form>
+    </Drawer>
+  );
+};
+
+export default DictDrawer;

+ 101 - 0
src/components/UserDrawer.tsx

@@ -0,0 +1,101 @@
+import React, { useState } from 'react';
+import { Drawer, Form, Input, Button, message } from 'antd';
+import type { DrawerProps } from 'antd';
+import { useAuth } from '../apis/AuthProvider';
+import { useRegister, useLogin } from '../apis/users';
+
+enum Mode {
+  Register,
+  Login,
+}
+
+interface UserDrawerProps extends DrawerProps {
+  onClose: () => void;
+}
+
+const UserDrawer: React.FC<UserDrawerProps> = props => {
+  const [mode, setMode] = useState<Mode>(Mode.Login);
+
+  const { refresh } = useAuth();
+  const register = useRegister();
+  const login = useLogin();
+
+  return (
+    <Drawer
+      title={mode === Mode.Register ? '注册' : '登录'}
+      destroyOnClose
+      {...props}
+    >
+      <Form
+        name="user"
+        labelCol={{ span: 6 }}
+        wrapperCol={{ span: 18 }}
+        onFinish={(values: { name: string; password: string }) =>
+          void (async () => {
+            try {
+              if (mode === Mode.Register) {
+                await register(values);
+                void message.success('注册成功');
+              }
+              await login(values);
+              void message.success('登录成功');
+              refresh();
+              props.onClose();
+            } catch (err) {
+              if (err instanceof Error) {
+                void message.error(err.message);
+              }
+            }
+          })()
+        }
+      >
+        <Form.Item
+          label="用户名"
+          name="name"
+          rules={[
+            { required: true, message: '请输入用户名' },
+            { min: 2, max: 16, message: '用户名长度应在 4 到 16 之间' },
+            { pattern: /^\w+$/, message: '用户名应仅包含字母、数字和下划线' },
+          ]}
+          validateTrigger="onBlur"
+        >
+          <Input placeholder="请输入用户名" />
+        </Form.Item>
+        <Form.Item
+          label="密码"
+          name="password"
+          rules={[
+            { required: true, message: '请输入密码' },
+            { min: 2, max: 16, message: '密码长度应在 4 到 16 之间' },
+          ]}
+          validateTrigger="onBlur"
+        >
+          <Input.Password placeholder="请输入密码" />
+        </Form.Item>
+        <Form.Item label=" " colon={false}>
+          {mode === Mode.Register ? (
+            <>
+              <Button htmlType="submit" type="primary">
+                注册
+              </Button>
+              <Button type="link" onClick={() => setMode(Mode.Login)}>
+                已有账号?返回登录
+              </Button>
+            </>
+          ) : (
+            <>
+              <Button htmlType="submit" type="primary">
+                登录
+              </Button>
+              <Button type="link" onClick={() => setMode(Mode.Register)}>
+                没有账号?前往注册
+              </Button>
+            </>
+          )}
+        </Form.Item>
+      </Form>
+    </Drawer>
+  );
+};
+
+export default UserDrawer;

+ 83 - 0
src/components/WordDrawer.tsx

@@ -0,0 +1,83 @@
+import React from 'react';
+import { Drawer, Form, Input, Button, message } from 'antd';
+import type { DrawerProps } from 'antd';
+import type { WordResult } from '../apis/words';
+import { useCreateWord, useUpdateWord } from '../apis/words';
+
+interface WordDrawerProps extends DrawerProps {
+  dictID: number;
+  initialWord?: WordResult;
+  refresh: () => void;
+  onClose: () => void;
+}
+
+const WordDrawer: React.FC<WordDrawerProps> = ({
+  dictID,
+  initialWord,
+  refresh,
+  ...props
+}) => {
+  const createWord = useCreateWord();
+  const updateWord = useUpdateWord();
+
+  return (
+    <Drawer
+      title={initialWord ? '更新单词' : '创建新单词'}
+      destroyOnClose
+      {...props}
+    >
+      <Form
+        name="word"
+        labelCol={{ span: 6 }}
+        wrapperCol={{ span: 18 }}
+        initialValues={initialWord}
+        onFinish={(values: { value: string; meaning: string; extra: string }) =>
+          void (async () => {
+            try {
+              if (initialWord) {
+                await updateWord({ ...initialWord, ...values });
+                void message.success('更新成功');
+              } else {
+                await createWord({ ...values, dictID });
+                void message.success('创建成功');
+              }
+              refresh();
+              props.onClose();
+            } catch (err) {
+              if (err instanceof Error) {
+                void message.error(err.message);
+              }
+            }
+          })()
+        }
+      >
+        <Form.Item
+          label="单词"
+          name="value"
+          rules={[{ required: true, message: '请输入单词' }]}
+          validateTrigger="onBlur"
+        >
+          <Input placeholder="请输入单词(如:good)" />
+        </Form.Item>
+        <Form.Item
+          label="词义"
+          name="meaning"
+          rules={[{ required: true, message: '请输入词义' }]}
+          validateTrigger="onBlur"
+        >
+          <Input placeholder="请输入词义(如:好的)" />
+        </Form.Item>
+        <Form.Item label="附加" name="extra">
+          <Input placeholder="请输入附加(如:[ɡʊd])" />
+        </Form.Item>
+        <Form.Item label=" " colon={false}>
+          <Button htmlType="submit" type="primary">
+            {initialWord ? '更新' : '创建'}
+          </Button>
+        </Form.Item>
+      </Form>
+    </Drawer>
+  );
+};
+
+export default WordDrawer;

+ 0 - 20
src/index.css

@@ -1,20 +0,0 @@
-:root {
-  font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
-  font-size: 16px;
-  line-height: 24px;
-  font-weight: 400;
-
-  font-synthesis: none;
-  text-rendering: optimizeLegibility;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-  -webkit-text-size-adjust: 100%;
-}
-
-body {
-  margin: 0;
-  display: flex;
-  place-items: center;
-  min-width: 320px;
-  min-height: 100vh;
-}

+ 0 - 5
src/layout/PageLayout.module.css

@@ -1,8 +1,3 @@
-.layout {
-  width: 100vw;
-  height: 100vh;
-}
-
 .header {
   background-color: white;
   box-shadow: 0px 4px 4px -2px lightgray;

+ 56 - 15
src/layout/PageLayout.tsx

@@ -1,22 +1,63 @@
-import React from 'react';
-import { Outlet } from 'react-router-dom';
-import { Layout, Typography } from 'antd';
+import React, { useState, useEffect } from 'react';
+import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
+import { Layout, Row, Col, Typography, Button, Result } from 'antd';
+import { ArrowLeftOutlined } from '@ant-design/icons';
+import { useAuth } from '../apis/AuthProvider';
+import UserDrawer from '../components/UserDrawer';
 import styles from './PageLayout.module.css';
 
 const { Header, Content } = Layout;
 const { Text } = Typography;
 
-const PageLayout: React.FC = () => (
-  <Layout className={styles.layout}>
-    <Header className={styles.header}>
-      <Text className={styles.title} strong>
-        Woord
-      </Text>
-    </Header>
-    <Content className={styles.content}>
-      <Outlet />
-    </Content>
-  </Layout>
-);
+const PageLayout: React.FC = () => {
+  const [open, setOpen] = useState(false);
+
+  const { pathname } = useLocation();
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    if (pathname === '/') {
+      navigate('/dicts');
+    }
+  }, [pathname, navigate]);
+
+  const { user } = useAuth();
+
+  return (
+    <Layout>
+      <Header className={styles.header}>
+        <Row gutter={8}>
+          <Col>
+            <Link to={pathname.slice(0, pathname.lastIndexOf('/'))}>
+              <Button
+                type="link"
+                icon={<ArrowLeftOutlined />}
+                disabled={pathname === '/dicts'}
+              />
+            </Link>
+          </Col>
+          <Col flex={1}>
+            <Text className={styles.title} strong>
+              Woord
+            </Text>
+          </Col>
+          <Col>
+            {user ? (
+              user.name
+            ) : (
+              <Button shape="round" onClick={() => setOpen(true)}>
+                登录
+              </Button>
+            )}
+          </Col>
+        </Row>
+      </Header>
+      <Content className={styles.content}>
+        {user ? <Outlet /> : <Result status="403" subTitle="请先登录" />}
+      </Content>
+      <UserDrawer open={open} onClose={() => setOpen(false)} />
+    </Layout>
+  );
+};
 
 export default PageLayout;

+ 0 - 2
src/main.tsx

@@ -1,7 +1,5 @@
 import React from 'react';
 import ReactDOM from 'react-dom/client';
-import './index.css';
-import 'antd/dist/antd.css';
 import App from './App';
 
 ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(

+ 3 - 0
src/pages/DictPage.module.css

@@ -0,0 +1,3 @@
+.wrapper {
+  padding: 32px;
+}

+ 184 - 0
src/pages/DictPage.tsx

@@ -0,0 +1,184 @@
+import React, { useState } from 'react';
+import { Link, useParams } from 'react-router-dom';
+import {
+  List,
+  Row,
+  Col,
+  Input,
+  Button,
+  Popconfirm,
+  Space,
+  Typography,
+  message,
+} from 'antd';
+import {
+  PlusOutlined,
+  AimOutlined,
+  EditOutlined,
+  StarFilled,
+  StarOutlined,
+  DeleteOutlined,
+} from '@ant-design/icons';
+import { useGetDict } from '../apis/dicts';
+import type { WordResult } from '../apis/words';
+import { useUpdateWord, useDeleteWord } from '../apis/words';
+import WordDrawer from '../components/WordDrawer';
+import styles from './DictPage.module.css';
+
+const { Text } = Typography;
+
+const DictPage: React.FC = () => {
+  const [search, setSearch] = useState('');
+  const [initialWord, setInitialWord] = useState<WordResult>();
+  const [open, setOpen] = useState(false);
+
+  const { dictID = '' } = useParams();
+
+  const {
+    data: { dict, words } = {},
+    refresh,
+    mutate,
+  } = useGetDict({
+    id: parseInt(dictID),
+  });
+  const updateWord = useUpdateWord();
+  const deleteWord = useDeleteWord();
+
+  const mutateWordsList = (word: WordResult, index: number) =>
+    dict &&
+    words &&
+    mutate({
+      dict,
+      words: {
+        total: words.total,
+        list: [
+          ...words.list.slice(0, index),
+          word,
+          ...words.list.slice(index + 1),
+        ],
+      },
+    });
+
+  if (!dict || !words) {
+    return <></>;
+  }
+
+  return (
+    <div className={styles.wrapper}>
+      <List
+        bordered
+        header={
+          <Row justify="space-between">
+            <Col>
+              <Input
+                allowClear
+                placeholder="搜索"
+                onChange={e => setSearch(e.target.value)}
+              />
+            </Col>
+            <Col>
+              <Button
+                type="link"
+                icon={<PlusOutlined />}
+                onClick={() => {
+                  setInitialWord(undefined);
+                  setOpen(true);
+                }}
+              >
+                创建新单词
+              </Button>
+              <Link to={`/dicts/${dictID}/memo`}>
+                <Button type="link" icon={<AimOutlined />}>
+                  开始复习
+                </Button>
+              </Link>
+            </Col>
+          </Row>
+        }
+        dataSource={
+          search
+            ? words.list.filter(
+                word =>
+                  word.value.includes(search) ||
+                  word.meaning.includes(search) ||
+                  word.extra.includes(search),
+              )
+            : words.list
+        }
+        renderItem={(word, index) => (
+          <List.Item
+            actions={[
+              <Button
+                key="update"
+                type="link"
+                icon={<EditOutlined />}
+                onClick={() => {
+                  setInitialWord(word);
+                  setOpen(true);
+                }}
+              />,
+              <Button
+                key="star"
+                type="link"
+                danger
+                icon={word.star ? <StarFilled /> : <StarOutlined />}
+                onClick={() =>
+                  void (async () => {
+                    try {
+                      const newWord = { ...word, star: !word.star };
+                      mutateWordsList(newWord, index);
+                      await updateWord(newWord);
+                      void message.success('更新成功');
+                    } catch (err) {
+                      if (err instanceof Error) {
+                        void message.error(err.message);
+                      }
+                    }
+                  })()
+                }
+              />,
+              <Popconfirm
+                key="delete"
+                title="确定删除该单词?"
+                onConfirm={() =>
+                  void (async () => {
+                    try {
+                      await deleteWord({ id: word.id });
+                      void message.success('删除成功');
+                      refresh();
+                    } catch (err) {
+                      if (err instanceof Error) {
+                        void message.error(err.message);
+                      }
+                    }
+                  })()
+                }
+              >
+                <Button type="link" danger icon={<DeleteOutlined />} />
+              </Popconfirm>,
+            ]}
+          >
+            <List.Item.Meta
+              title={
+                <Space size="middle">
+                  {word.value}
+                  <Text type="secondary">{word.extra}</Text>
+                </Space>
+              }
+              description={word.meaning}
+            />
+          </List.Item>
+        )}
+      ></List>
+      <WordDrawer
+        dictID={parseInt(dictID)}
+        initialWord={initialWord}
+        refresh={refresh}
+        open={open}
+        onClose={() => setOpen(false)}
+      />
+    </div>
+  );
+};
+
+export default DictPage;

+ 3 - 0
src/pages/DictsPage.module.css

@@ -0,0 +1,3 @@
+.wrapper {
+  padding: 32px;
+}

+ 53 - 0
src/pages/DictsPage.tsx

@@ -0,0 +1,53 @@
+import React, { useState } from 'react';
+import { Link } from 'react-router-dom';
+import { Row, Col, Card, Space, Typography } from 'antd';
+import { PlusOutlined } from '@ant-design/icons';
+import { useListDicts } from '../apis/dicts';
+import DictDrawer from '../components/DictDrawer';
+import styles from './DictsPage.module.css';
+
+const { Text } = Typography;
+
+const DictsPage: React.FC = () => {
+  const [open, setOpen] = useState(false);
+
+  const { data: dicts, refresh } = useListDicts();
+
+  return (
+    <div className={styles.wrapper}>
+      <Row gutter={[16, 16]}>
+        <Col xs={24} sm={12} md={8} lg={6} xl={4} xxl={3}>
+          <Card hoverable onClick={() => setOpen(true)}>
+            <PlusOutlined /> 创建新词库
+          </Card>
+        </Col>
+        {dicts?.list.map(dict => (
+          <Col key={dict.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={3}>
+            <Link to={`/dicts/${dict.id}`}>
+              <Card hoverable>
+                <Card.Meta
+                  title={
+                    <Space size="middle">
+                      {dict.name}
+                      <Text type="secondary">
+                        {dict.value} ↔ {dict.meaning}
+                      </Text>
+                    </Space>
+                  }
+                  description={`词数:${dict.wordCount}`}
+                />
+              </Card>
+            </Link>
+          </Col>
+        ))}
+      </Row>
+      <DictDrawer
+        refresh={refresh}
+        open={open}
+        onClose={() => setOpen(false)}
+      />
+    </div>
+  );
+};
+
+export default DictsPage;

+ 1 - 1
src/pages/MemoPage.module.css

@@ -1,5 +1,5 @@
 .wrapper {
-  height: 100%;
+  height: calc(100vh - 64px);
   display: flex;
 }
 

+ 103 - 72
src/pages/MemoPage.tsx

@@ -1,30 +1,22 @@
 import React, { useState, useEffect } from 'react';
+import { useParams } from 'react-router-dom';
 import _ from 'lodash';
-import { Space, Row, Col, Button, Typography, Empty } from 'antd';
+import { Space, Row, Col, Button, Typography, Empty, message } from 'antd';
 import {
   EyeOutlined,
   StarFilled,
   StarOutlined,
   ArrowLeftOutlined,
-  PlusOutlined,
   ReloadOutlined,
   ArrowRightOutlined,
 } from '@ant-design/icons';
-import type { Dict } from '../types/dicts';
+import { useGetDict } from '../apis/dicts';
+import type { WordResult } from '../apis/words';
+import { useUpdateWord } from '../apis/words';
 import styles from './MemoPage.module.css';
 
 const { Text } = Typography;
 
-const dict: Dict = {
-  valueTitle: '日文',
-  meaningTitle: '中文',
-  extraTitle: '假名',
-  words: [
-    { value: 'test', meaning: '测试', extra: '附加?', star: true },
-    { value: 'good', meaning: '好', extra: 'no!', star: false },
-  ],
-};
-
 enum Mode {
   ValueToMeaning,
   MeaningToValue,
@@ -32,21 +24,42 @@ enum Mode {
 
 const MemoPage: React.FC = () => {
   const [mode, setMode] = useState<Mode>(Mode.ValueToMeaning);
-  const [words, setWords] = useState<Dict['words']>([]);
+  const [shuffledWords, setShuffledWords] = useState<WordResult[]>();
   const [index, setIndex] = useState(0);
   const [showValueOrMeaning, setShowValueOrMeaning] = useState(false);
   const [showExtra, setShowExtra] = useState(false);
 
+  const { dictID = '' } = useParams();
+
+  const { data: { dict, words } = {} } = useGetDict({
+    id: parseInt(dictID),
+  });
+  const updateWord = useUpdateWord();
+
   useEffect(() => {
-    setWords(_.shuffle(dict.words));
-  }, []);
+    if (!shuffledWords && words?.list) {
+      setShuffledWords(_.shuffle(words.list));
+    }
+  }, [shuffledWords, words?.list]);
 
-  const updateIndex = (index: number) => {
+  const mutateWordsList = (word: WordResult, index: number) =>
+    shuffledWords &&
+    setShuffledWords([
+      ...shuffledWords.slice(0, index),
+      word,
+      ...shuffledWords.slice(index + 1),
+    ]);
+
+  const mutateIndex = (index: number) => {
     setIndex(index);
     setShowValueOrMeaning(false);
     setShowExtra(false);
   };
 
+  if (!dict || !shuffledWords) {
+    return <></>;
+  }
+
   return (
     <div className={styles.wrapper}>
       <Space className={styles.space} size="large" direction="vertical">
@@ -56,7 +69,7 @@ const MemoPage: React.FC = () => {
               type={mode === Mode.ValueToMeaning ? 'primary' : 'default'}
               onClick={() => setMode(Mode.ValueToMeaning)}
             >
-              {dict.valueTitle} → {dict.meaningTitle}
+              {dict.value} → {dict.meaning}
             </Button>
           </Col>
           <Col>
@@ -64,18 +77,18 @@ const MemoPage: React.FC = () => {
               type={mode === Mode.MeaningToValue ? 'primary' : 'default'}
               onClick={() => setMode(Mode.MeaningToValue)}
             >
-              {dict.meaningTitle} → {dict.valueTitle}
+              {dict.meaning} → {dict.value}
             </Button>
           </Col>
         </Row>
-        {words.length ? (
+        {shuffledWords.length ? (
           <>
             <Row justify="center">
               <Col className={styles.primaryText}>
                 <Text strong>
                   {mode === Mode.ValueToMeaning
-                    ? words[index].value
-                    : words[index].meaning}
+                    ? shuffledWords[index].value
+                    : shuffledWords[index].meaning}
                 </Text>
               </Col>
             </Row>
@@ -84,8 +97,8 @@ const MemoPage: React.FC = () => {
                 {showValueOrMeaning ? (
                   <Text>
                     {mode === Mode.MeaningToValue
-                      ? words[index].value
-                      : words[index].meaning}
+                      ? shuffledWords[index].value
+                      : shuffledWords[index].meaning}
                   </Text>
                 ) : (
                   <Button
@@ -94,15 +107,13 @@ const MemoPage: React.FC = () => {
                     icon={<EyeOutlined />}
                     onClick={() => setShowValueOrMeaning(true)}
                   >
-                    {mode === Mode.MeaningToValue
-                      ? dict.valueTitle
-                      : dict.meaningTitle}
+                    {mode === Mode.MeaningToValue ? dict.value : dict.meaning}
                   </Button>
                 )}
               </Col>
               <Col className={styles.secondaryText} flex="1">
                 {showExtra ? (
-                  <Text>{words[index].extra}</Text>
+                  <Text>{shuffledWords[index].extra}</Text>
                 ) : (
                   <Button
                     type="text"
@@ -110,61 +121,81 @@ const MemoPage: React.FC = () => {
                     icon={<EyeOutlined />}
                     onClick={() => setShowExtra(true)}
                   >
-                    {dict.extraTitle}
+                    {dict.extra}
                   </Button>
                 )}
               </Col>
             </Row>
-            <Row justify="center">
-              {words[index].star ? (
-                <Button type="primary" danger icon={<StarFilled />}>
-                  取消易错词
+            <Row>
+              <Col flex="1">
+                <Button
+                  type="link"
+                  block
+                  disabled={index <= 0}
+                  onClick={() => mutateIndex(index - 1)}
+                >
+                  <ArrowLeftOutlined /> 上一个
                 </Button>
-              ) : (
-                <Button danger icon={<StarOutlined />}>
-                  标记易错词
+              </Col>
+              <Col>
+                <Button
+                  type={shuffledWords[index].star ? 'primary' : 'default'}
+                  danger
+                  icon={
+                    shuffledWords[index].star ? (
+                      <StarFilled />
+                    ) : (
+                      <StarOutlined />
+                    )
+                  }
+                  onClick={() =>
+                    void (async () => {
+                      try {
+                        const newWord = {
+                          ...shuffledWords[index],
+                          star: !shuffledWords[index].star,
+                        };
+                        mutateWordsList(newWord, index);
+                        await updateWord(newWord);
+                        void message.success('更新成功');
+                      } catch (err) {
+                        if (err instanceof Error) {
+                          void message.error(err.message);
+                        }
+                      }
+                    })()
+                  }
+                >
+                  {shuffledWords[index].star ? '取消易错词' : '标记易错词'}
                 </Button>
-              )}
+              </Col>
+              <Col flex="1">
+                {index >= shuffledWords.length - 1 ? (
+                  <Button
+                    type="link"
+                    block
+                    onClick={() => {
+                      setShuffledWords(_.shuffle(shuffledWords));
+                      mutateIndex(0);
+                    }}
+                  >
+                    再来一次 <ReloadOutlined />
+                  </Button>
+                ) : (
+                  <Button
+                    type="link"
+                    block
+                    onClick={() => mutateIndex(index + 1)}
+                  >
+                    下一个 <ArrowRightOutlined />
+                  </Button>
+                )}
+              </Col>
             </Row>
           </>
         ) : (
           <Empty />
         )}
-        <Row>
-          <Col flex="1">
-            <Button
-              type="link"
-              block
-              disabled={index <= 0}
-              onClick={() => updateIndex(index - 1)}
-            >
-              <ArrowLeftOutlined /> 上一个
-            </Button>
-          </Col>
-          <Col>
-            <Button type="dashed" icon={<PlusOutlined />}>
-              输入新单词
-            </Button>
-          </Col>
-          <Col flex="1">
-            {index >= words.length - 1 ? (
-              <Button
-                type="link"
-                block
-                onClick={() => {
-                  setWords(_.shuffle(dict.words));
-                  updateIndex(0);
-                }}
-              >
-                再来一次 <ReloadOutlined />
-              </Button>
-            ) : (
-              <Button type="link" block onClick={() => updateIndex(index + 1)}>
-                下一个 <ArrowRightOutlined />
-              </Button>
-            )}
-          </Col>
-        </Row>
       </Space>
     </div>
   );

+ 18 - 4
src/router/index.tsx

@@ -1,21 +1,35 @@
 import React from 'react';
-import { createBrowserRouter } from 'react-router-dom';
+import { Link, createBrowserRouter } from 'react-router-dom';
+import { Result, Button } from 'antd';
 import PageLayout from '../layout/PageLayout';
+import DictsPage from '../pages/DictsPage';
+import DictPage from '../pages/DictPage';
 import MemoPage from '../pages/MemoPage';
 
 export default createBrowserRouter([
   {
     path: '/',
     element: <PageLayout />,
+    errorElement: (
+      <Result
+        status="404"
+        subTitle="页面不存在"
+        extra={
+          <Link to="/dicts">
+            <Button type="primary">返回首页</Button>
+          </Link>
+        }
+      />
+    ),
     children: [
       {
         path: 'dicts',
         children: [
-          { index: true, element: '233' },
+          { index: true, element: <DictsPage /> },
           {
-            path: ':dictId',
+            path: ':dictID',
             children: [
-              { index: true, element: '666' },
+              { index: true, element: <DictPage /> },
               { path: 'memo', element: <MemoPage /> },
             ],
           },

+ 0 - 9
src/types/dicts.ts

@@ -1,9 +0,0 @@
-export interface Word {
-  value: string;
-  meaning: string;
-  extra: string;
-}
-
-export type Dict = {
-  [field in keyof Word as `${field}Title`]: string;
-} & { words: (Word & { star: boolean })[] };

+ 109 - 1
yarn.lock

@@ -401,6 +401,11 @@
   resolved "https://registry.npmmirror.com/@remix-run/router/-/router-1.0.1.tgz#88d7ac31811ab0cef14aaaeae2a0474923b278bc"
   integrity sha512-eBV5rvW4dRFOU1eajN7FmYxjAIVz/mRHgUE9En9mBn6m3mulK3WTR5C3iQhL9MZ14rWAq+xOlEaCkDiW0/heOg==
 
+"@types/js-cookie@^2.x.x":
+  version "2.2.7"
+  resolved "https://registry.npmmirror.com/@types/js-cookie/-/js-cookie-2.2.7.tgz#226a9e31680835a6188e887f3988e60c04d3f6a3"
+  integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==
+
 "@types/json-schema@^7.0.9":
   version "7.0.11"
   resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
@@ -416,6 +421,11 @@
   resolved "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
   integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
 
+"@types/qs@^6.9.7":
+  version "6.9.7"
+  resolved "https://registry.npmmirror.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
+  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
+
 "@types/react-dom@^18.0.6":
   version "18.0.6"
   resolved "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1"
@@ -540,6 +550,25 @@ acorn@^8.8.0:
   resolved "https://registry.npmmirror.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8"
   integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
 
+ahooks-v3-count@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.npmmirror.com/ahooks-v3-count/-/ahooks-v3-count-1.0.0.tgz#ddeb392e009ad6e748905b3cbf63a9fd8262ca80"
+  integrity sha512-V7uUvAwnimu6eh/PED4mCDjE7tokeZQLKlxg9lCTMPhN+NjsSbtdacByVlR1oluXQzD3MOw55wylDmQo4+S9ZQ==
+
+ahooks@^3.7.1:
+  version "3.7.1"
+  resolved "https://registry.npmmirror.com/ahooks/-/ahooks-3.7.1.tgz#f0c51ed85c82144fb1356e89b995584ece1edaf2"
+  integrity sha512-9fooKjhScNyJaIPnlWd13LkY1gQYqv3BqwSA9ynHg1ZUtDqAICuCRoedV97ylrEL6QqI4zeq3bO3lQxkfWVNcg==
+  dependencies:
+    "@types/js-cookie" "^2.x.x"
+    ahooks-v3-count "^1.0.0"
+    dayjs "^1.9.1"
+    intersection-observer "^0.12.0"
+    js-cookie "^2.x.x"
+    lodash "^4.17.21"
+    resize-observer-polyfill "^1.5.1"
+    screenfull "^5.0.0"
+
 ajv@^6.10.0, ajv@^6.12.4:
   version "6.12.6"
   resolved "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
@@ -660,6 +689,20 @@ async-validator@^4.1.0:
   resolved "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz#c96ea3332a521699d0afaaceed510a54656c6339"
   integrity sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==
 
+asynckit@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+  integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
+
+axios@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.npmmirror.com/axios/-/axios-1.1.2.tgz#8b6f6c540abf44ab98d9904e8daf55351ca4a331"
+  integrity sha512-bznQyETwElsXl2RK7HLLwb5GPpOLlycxHCtrpDR/4RqqBzjARaOTo3jz4IgtntWUYee7Ne4S8UHd92VCuzPaWA==
+  dependencies:
+    follow-redirects "^1.15.0"
+    form-data "^4.0.0"
+    proxy-from-env "^1.1.0"
+
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
@@ -754,6 +797,13 @@ color-name@~1.1.4:
   resolved "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
+combined-stream@^1.0.8:
+  version "1.0.8"
+  resolved "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
+  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
+  dependencies:
+    delayed-stream "~1.0.0"
+
 compute-scroll-into-view@^1.0.17:
   version "1.0.17"
   resolved "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab"
@@ -797,7 +847,7 @@ date-fns@2.x:
   resolved "https://registry.npmmirror.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8"
   integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==
 
-dayjs@1.x:
+dayjs@1.x, dayjs@^1.9.1:
   version "1.11.5"
   resolved "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.5.tgz#00e8cc627f231f9499c19b38af49f56dc0ac5e93"
   integrity sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==
@@ -822,6 +872,11 @@ define-properties@^1.1.3, define-properties@^1.1.4:
     has-property-descriptors "^1.0.0"
     object-keys "^1.1.1"
 
+delayed-stream@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+  integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
+
 dir-glob@^3.0.1:
   version "3.0.1"
   resolved "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@@ -1251,6 +1306,20 @@ flatted@^3.1.0:
   resolved "https://registry.npmmirror.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787"
   integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
 
+follow-redirects@^1.15.0:
+  version "1.15.2"
+  resolved "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
+  integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
+
+form-data@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.npmmirror.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
+  integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.8"
+    mime-types "^2.1.12"
+
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -1439,6 +1508,11 @@ internal-slot@^1.0.3:
     has "^1.0.3"
     side-channel "^1.0.4"
 
+intersection-observer@^0.12.0:
+  version "0.12.2"
+  resolved "https://registry.npmmirror.com/intersection-observer/-/intersection-observer-0.12.2.tgz#4a45349cc0cd91916682b1f44c28d7ec737dc375"
+  integrity sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==
+
 is-bigint@^1.0.1:
   version "1.0.4"
   resolved "https://registry.npmmirror.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3"
@@ -1543,6 +1617,11 @@ isexe@^2.0.0:
   resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
   integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
+js-cookie@^2.x.x:
+  version "2.2.1"
+  resolved "https://registry.npmmirror.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"
+  integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==
+
 js-sdsl@^4.1.4:
   version "4.1.5"
   resolved "https://registry.npmmirror.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a"
@@ -1659,6 +1738,18 @@ micromatch@^4.0.4:
     braces "^3.0.2"
     picomatch "^2.3.1"
 
+mime-db@1.52.0:
+  version "1.52.0"
+  resolved "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.12:
+  version "2.1.35"
+  resolved "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
 minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
   version "3.1.2"
   resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@@ -1849,11 +1940,23 @@ prop-types@^15.8.1:
     object-assign "^4.1.1"
     react-is "^16.13.1"
 
+proxy-from-env@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+  integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
 punycode@^2.1.0:
   version "2.1.1"
   resolved "https://registry.npmmirror.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
+qs@^6.11.0:
+  version "6.11.0"
+  resolved "https://registry.npmmirror.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
+  integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
+  dependencies:
+    side-channel "^1.0.4"
+
 queue-microtask@^1.2.2:
   version "1.2.3"
   resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
@@ -2362,6 +2465,11 @@ scheduler@^0.23.0:
   dependencies:
     loose-envify "^1.1.0"
 
+screenfull@^5.0.0:
+  version "5.2.0"
+  resolved "https://registry.npmmirror.com/screenfull/-/screenfull-5.2.0.tgz#6533d524d30621fc1283b9692146f3f13a93d1ba"
+  integrity sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==
+
 scroll-into-view-if-needed@^2.2.25:
   version "2.2.29"
   resolved "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.29.tgz#551791a84b7e2287706511f8c68161e4990ab885"