diff --git a/ui/package-lock.json b/ui/package-lock.json index f73e352f..2ba9c9c4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -26,6 +26,7 @@ "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-table": "^8.20.5", + "ahooks": "^3.8.4", "antd": "^5.22.2", "antd-zod": "^6.0.0", "class-variance-authority": "^0.7.0", @@ -38,7 +39,6 @@ "immer": "^10.1.1", "jszip": "^3.10.1", "lucide-react": "^0.417.0", - "moment": "^2.30.1", "nanoid": "^5.0.7", "pocketbase": "^0.21.4", "react": "^18.3.1", @@ -3607,6 +3607,28 @@ "object-assign": "4.x" } }, + "node_modules/ahooks": { + "version": "3.8.4", + "resolved": "https://registry.npmmirror.com/ahooks/-/ahooks-3.8.4.tgz", + "integrity": "sha512-39wDEw2ZHvypaT14EpMMk4AzosHWt0z9bulY0BeDsvc9PqJEV+Kjh/4TZfftSsotBMq52iYIOFPd3PR56e0ZJg==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "dayjs": "^1.9.1", + "intersection-observer": "^0.12.0", + "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "react-fast-compare": "^3.2.2", + "resize-observer-polyfill": "^1.5.1", + "screenfull": "^5.0.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", @@ -5345,6 +5367,11 @@ "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/intersection-observer": { + "version": "0.12.2", + "resolved": "https://registry.npmmirror.com/intersection-observer/-/intersection-observer-0.12.2.tgz", + "integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==" + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmmirror.com/invariant/-/invariant-2.2.4.tgz", @@ -5454,6 +5481,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5699,6 +5734,8 @@ "version": "2.30.1", "resolved": "https://registry.npmmirror.com/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "optional": true, + "peer": true, "engines": { "node": "*" } @@ -6842,6 +6879,11 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, "node_modules/react-hook-form": { "version": "7.52.1", "resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.52.1.tgz", @@ -7173,6 +7215,17 @@ "loose-envify": "^1.1.0" } }, + "node_modules/screenfull": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/screenfull/-/screenfull-5.2.0.tgz", + "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/scroll-into-view-if-needed": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", diff --git a/ui/package.json b/ui/package.json index 8758ecbf..986fdd6a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-table": "^8.20.5", + "ahooks": "^3.8.4", "antd": "^5.22.2", "antd-zod": "^6.0.0", "class-variance-authority": "^0.7.0", @@ -40,7 +41,6 @@ "immer": "^10.1.1", "jszip": "^3.10.1", "lucide-react": "^0.417.0", - "moment": "^2.30.1", "nanoid": "^5.0.7", "pocketbase": "^0.21.4", "react": "^18.3.1", diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 00000000..ab42081d --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,49 @@ +import { useLayoutEffect, useState } from "react"; +import { RouterProvider } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { App as AntdApp, ConfigProvider as AntdConfigProvider } from "antd"; +import { type Locale } from "antd/es/locale"; +import AntdLocaleEnUs from "antd/locale/en_US"; +import AntdLocaleZhCN from "antd/locale/zh_CN"; +import dayjs from "dayjs"; +import "dayjs/locale/zh-cn"; + +import { localeNames } from "./i18n"; +import { router } from "./router.tsx"; +import { ThemeProvider } from "./components/ThemeProvider.tsx"; + +const App = () => { + const { i18n } = useTranslation(); + + const antdLocalesMap: Record = { + [localeNames.ZH]: AntdLocaleZhCN, + [localeNames.EN]: AntdLocaleEnUs, + }; + const [antdLocale, setAntdLocale] = useState(antdLocalesMap[i18n.language]); + + const handleLanguageChanged = () => { + setAntdLocale(antdLocalesMap[i18n.language]); + dayjs.locale(i18n.language); + }; + i18n.on("languageChanged", handleLanguageChanged); + useLayoutEffect(handleLanguageChanged, [i18n]); + + return ( + + + + + + + + ); +}; + +export default App; diff --git a/ui/src/components/LocaleToggle.tsx b/ui/src/components/LocaleToggle.tsx deleted file mode 100644 index 2385716a..00000000 --- a/ui/src/components/LocaleToggle.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Languages } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; - -export default function LocaleToggle() { - const { i18n } = useTranslation(); - - return ( - - - - - - {Object.keys(i18n.store.data).map((key) => ( - i18n.changeLanguage(key)}>{i18n.store.data[key].name as string} - ))} - - - ); -} diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts index 6cd0044a..0d89e0b9 100644 --- a/ui/src/i18n/index.ts +++ b/ui/src/i18n/index.ts @@ -2,14 +2,14 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; -import resources from "./locales"; +import resources, { LOCALE_ZH_NAME, LOCALE_EN_NAME } from "./locales"; i18n .use(LanguageDetector) .use(initReactI18next) .init({ resources, - fallbackLng: "zh", + fallbackLng: LOCALE_ZH_NAME, debug: true, interpolation: { escapeValue: false, @@ -19,4 +19,9 @@ i18n }, }); +export const localeNames = { + ZH: LOCALE_ZH_NAME, + EN: LOCALE_EN_NAME, +}; + export default i18n; diff --git a/ui/src/i18n/locales/index.ts b/ui/src/i18n/locales/index.ts index bbf74e64..a352885f 100644 --- a/ui/src/i18n/locales/index.ts +++ b/ui/src/i18n/locales/index.ts @@ -3,12 +3,15 @@ import { Resource } from "i18next"; import zh from "./zh"; import en from "./en"; +export const LOCALE_ZH_NAME = "zh" as const; +export const LOCALE_EN_NAME = "en" as const; + const resources: Resource = { - zh: { + [LOCALE_ZH_NAME]: { name: "简体中文", translation: zh, }, - en: { + [LOCALE_EN_NAME]: { name: "English", translation: en, }, diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 97fcfd1e..699dbf2c 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,31 +1,17 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { RouterProvider } from "react-router-dom"; -import { App, ConfigProvider } from "antd"; -import AntdLocaleZhCN from "antd/locale/zh_CN"; +import dayjs from "dayjs"; +import dayjsUtc from "dayjs/plugin/utc"; import "dayjs/locale/zh-cn"; -import { router } from "./router.tsx"; -import { ThemeProvider } from "./components/ThemeProvider.tsx"; +import App from "./App"; import "./i18n"; import "./global.css"; -// TODO: antd i18n +dayjs.extend(dayjsUtc); + ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - - - + ); diff --git a/ui/src/pages/accesses/AccessList.tsx b/ui/src/pages/accesses/AccessList.tsx index a6a27fd1..ec7c560c 100644 --- a/ui/src/pages/accesses/AccessList.tsx +++ b/ui/src/pages/accesses/AccessList.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Avatar, Button, Empty, Modal, notification, Space, Table, Tooltip, Typography, type TableProps } from "antd"; import { PageHeader } from "@ant-design/pro-components"; import { Copy as CopyIcon, Pencil as PencilIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react"; -import moment from "moment"; +import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; import AccessEditDialog from "@/components/certimate/AccessEditDialog"; @@ -50,7 +50,7 @@ const AccessList = () => { title: t("common.text.created_at"), ellipsis: true, render: (_, record) => { - return moment(record.created!).format("YYYY-MM-DD HH:mm:ss"); + return dayjs(record.created!).format("YYYY-MM-DD HH:mm:ss"); }, }, { @@ -58,7 +58,7 @@ const AccessList = () => { title: t("common.text.updated_at"), ellipsis: true, render: (_, record) => { - return moment(record.updated!).format("YYYY-MM-DD HH:mm:ss"); + return dayjs(record.updated!).format("YYYY-MM-DD HH:mm:ss"); }, }, { diff --git a/ui/src/pages/certificates/CertificateList.tsx b/ui/src/pages/certificates/CertificateList.tsx index 65760fa9..fa50cc19 100644 --- a/ui/src/pages/certificates/CertificateList.tsx +++ b/ui/src/pages/certificates/CertificateList.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Button, Divider, Empty, Menu, notification, Radio, Space, Table, theme, Tooltip, Typography, type MenuProps, type TableProps } from "antd"; import { PageHeader } from "@ant-design/pro-components"; import { Eye as EyeIcon, Filter as FilterIcon } from "lucide-react"; -import moment from "moment"; +import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer"; @@ -87,8 +87,8 @@ const CertificateList = () => { }, filterIcon: () => , render: (_, record) => { - const total = moment(record.expireAt).diff(moment(record.created), "d") + 1; - const left = moment(record.expireAt).diff(moment(), "d"); + const total = dayjs(record.expireAt).diff(dayjs(record.created), "d") + 1; + const left = dayjs(record.expireAt).diff(dayjs(), "d"); return ( {left > 0 ? ( @@ -98,7 +98,7 @@ const CertificateList = () => { )} - {t("certificate.props.expiry.expiration", { date: moment(record.expireAt).format("YYYY-MM-DD") })} + {t("certificate.props.expiry.expiration", { date: dayjs(record.expireAt).format("YYYY-MM-DD") })} ); @@ -132,7 +132,7 @@ const CertificateList = () => { title: t("common.text.created_at"), ellipsis: true, render: (_, record) => { - return moment(record.created!).format("YYYY-MM-DD HH:mm:ss"); + return dayjs(record.created!).format("YYYY-MM-DD HH:mm:ss"); }, }, { @@ -140,7 +140,7 @@ const CertificateList = () => { title: t("common.text.updated_at"), ellipsis: true, render: (_, record) => { - return moment(record.updated!).format("YYYY-MM-DD HH:mm:ss"); + return dayjs(record.updated!).format("YYYY-MM-DD HH:mm:ss"); }, }, { diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx index 2ce5e3bc..5dd09eaa 100644 --- a/ui/src/pages/workflows/WorkflowList.tsx +++ b/ui/src/pages/workflows/WorkflowList.tsx @@ -20,7 +20,7 @@ import { } from "antd"; import { PageHeader } from "@ant-design/pro-components"; import { Filter as FilterIcon, Pencil as PencilIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react"; -import moment from "moment"; +import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; import { Workflow as WorkflowType } from "@/domain/workflow"; @@ -153,7 +153,7 @@ const WorkflowList = () => { title: t("common.text.created_at"), ellipsis: true, render: (_, record) => { - return moment(record.created!).format("YYYY-MM-DD HH:mm:ss"); + return dayjs(record.created!).format("YYYY-MM-DD HH:mm:ss"); }, }, { @@ -161,7 +161,7 @@ const WorkflowList = () => { title: t("common.text.updated_at"), ellipsis: true, render: (_, record) => { - return moment(record.updated!).format("YYYY-MM-DD HH:mm:ss"); + return dayjs(record.updated!).format("YYYY-MM-DD HH:mm:ss"); }, }, { diff --git a/ui/src/repository/access.ts b/ui/src/repository/access.ts index e522a1cd..08bd7833 100644 --- a/ui/src/repository/access.ts +++ b/ui/src/repository/access.ts @@ -1,4 +1,4 @@ -import moment from "moment"; +import dayjs from "dayjs"; import { type Access } from "@/domain/access"; import { getPocketBase } from "./pocketbase"; @@ -19,6 +19,6 @@ export const save = async (record: Access) => { }; export const remove = async (record: Access) => { - record.deleted = moment.utc().format("YYYY-MM-DD HH:mm:ss"); + record.deleted = dayjs.utc().format("YYYY-MM-DD HH:mm:ss"); return await getPocketBase().collection("access").update(record.id, record); }; diff --git a/ui/src/repository/certificate.ts b/ui/src/repository/certificate.ts index ee911377..52487192 100644 --- a/ui/src/repository/certificate.ts +++ b/ui/src/repository/certificate.ts @@ -1,5 +1,5 @@ +import dayjs from "dayjs"; import { type RecordListOptions } from "pocketbase"; -import moment from "moment"; import { type Certificate } from "@/domain/certificate"; import { getPocketBase } from "./pocketbase"; @@ -23,7 +23,7 @@ export const list = async (req: CertificateListReq) => { if (req.state === "expireSoon") { options.filter = pb.filter("expireAt<{:expiredAt}", { - expiredAt: moment().add(15, "d").toDate(), + expiredAt: dayjs().add(15, "d").toDate(), }); } else if (req.state === "expired") { options.filter = pb.filter("expireAt<={:expiredAt}", {