diff --git a/config.yaml b/config.yaml index 739d058b..8bf8ac59 100644 --- a/config.yaml +++ b/config.yaml @@ -4,10 +4,11 @@ common: log_level: "info" is_prod: false - #################################### Server ############################## +#################################### Server ############################## server: addr: ":6001" base_url: "/api" + #################################### User/Session ############################## user: # github username @@ -22,3 +23,7 @@ paths: # sqlite3 db files data: "" logs: "" + +#################################### Posts ############################## +posts: + brief_max_len: 100 \ No newline at end of file diff --git a/go.mod b/go.mod index 836a169a..1d3501f5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/imdotdev/im.dev go 1.14 require ( + github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef github.com/gin-gonic/gin v1.6.3 github.com/go-stack/stack v1.8.0 github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac diff --git a/go.sum b/go.sum index cb279220..242b1117 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg= +github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= diff --git a/layouts/nav-links.ts b/layouts/nav/nav-links.ts similarity index 100% rename from layouts/nav-links.ts rename to layouts/nav/nav-links.ts diff --git a/layouts/nav.tsx b/layouts/nav/nav.tsx similarity index 65% rename from layouts/nav.tsx rename to layouts/nav/nav.tsx index ac9b6363..aaff2e3d 100644 --- a/layouts/nav.tsx +++ b/layouts/nav/nav.tsx @@ -29,9 +29,9 @@ import siteConfig from "configs/site-config" import { useViewportScroll } from "framer-motion" import NextLink from "next/link" import React from "react" -import { FaMoon, FaSun, FaUserAlt, FaRegSun, FaSignOutAlt, FaRegBookmark, FaChartBar, FaHome, FaArrowRight, FaGithub } from "react-icons/fa" +import { FaMoon, FaSun, FaUserAlt, FaRegSun, FaSignOutAlt, FaRegBookmark, FaChartBar, FaHome, FaArrowRight, FaGithub, FaFileAlt, FaBookmark, FaEdit } from "react-icons/fa" import Logo, { LogoIcon } from "src/components/logo" -import { MobileNavButton, MobileNavContent } from "./mobile-nav" +import { MobileNavButton, MobileNavContent } from "../mobile-nav" import AlgoliaSearch from "src/components/search/algolia-search" import useSession from "hooks/use-session" import { requestApi } from "utils/axios/request" @@ -39,6 +39,9 @@ import { removeToken, saveToken } from "utils/axios/getToken" import { Session } from "src/types/session" import navLinks from "./nav-links" import { useRouter } from "next/router" +import events from "utils/events" +import storage from "utils/localStorage" +import { logout } from "utils/session" const DiscordIcon = (props) => ( @@ -62,12 +65,12 @@ const GithubIcon = (props) => ( function HeaderContent() { - const { pathname } = useRouter() + const router = useRouter() + const {pathname} = router const mobileNav = useDisclosure() - const [session, storeSession]: [Session, any] = useSession() - const { isOpen: isLoginOpen, onOpen: onLoginOpen, onClose: onLoginClose } = useDisclosure() - + const session:Session = useSession() + const { toggleColorMode: toggleMode } = useColorMode() const text = useColorModeValue("dark", "light") const SwitchIcon = useColorModeValue(FaMoon, FaSun) @@ -77,20 +80,11 @@ function HeaderContent() { mobileNavBtnRef.current?.focus() }, [mobileNav.isOpen]) - const login = async () => { - const res = await requestApi.post("/login") - saveToken(res.data.token) - storeSession(res.data) - onLoginClose() - } - - const logout = async () => { - await requestApi.post("/logout") - removeToken() - storeSession(null) + const login = () => { + storage.set("current-page", pathname) + router.push('/login') } - return ( <> @@ -167,11 +161,11 @@ function HeaderContent() { Sunface - }>Dashboard - }>Bookmarks + {} href="/editor">编辑中心} + }>书签收藏 - }>Account Settings - logout()} icon={}>Log out + }>偏好设置 + logout()} icon={}>账号登出 : login()} // leftIcon={} > SIGN IN @@ -193,36 +187,6 @@ function HeaderContent() { - - - - - - - - - 欢迎加入im.dev,一起打造全世界最好的开发者社区 - - - - 从世界各地精选最优秀的内容 - - - - 丰富的功能特性等待你的探索 - - - - 充分展示自我并获得猎头关注 - - - - login()} layerStyle="colorButton" mt="6" fontSize=".9rem" leftIcon={ }>使用github登录 - 如果继续,则表示你同意im.dev的服务条款和隐私政策 - {/* */} - - - > ) } diff --git a/layouts/page-container.tsx b/layouts/page-container.tsx index a480fd81..71697204 100644 --- a/layouts/page-container.tsx +++ b/layouts/page-container.tsx @@ -2,7 +2,7 @@ import { Badge, Box, chakra } from "@chakra-ui/react" import { SkipNavContent, SkipNavLink } from "@chakra-ui/skip-nav" import Container from "components/container" import Footer from "./footer" -import Nav from "./nav" +import Nav from "./nav/nav" import SEO from "components/seo" import { useRouter } from "next/router" import * as React from "react" diff --git a/layouts/sidebar/sidebar-link.tsx b/layouts/sidebar/sidebar-link.tsx new file mode 100644 index 00000000..f89452fe --- /dev/null +++ b/layouts/sidebar/sidebar-link.tsx @@ -0,0 +1,61 @@ +import { chakra, PropsOf, useColorModeValue } from "@chakra-ui/react" +import NextLink from "next/link" +import { useRouter } from "next/router" +import React from "react" + +const StyledLink = React.forwardRef(function StyledLink( + props: PropsOf & { isActive?: boolean }, + ref: React.Ref, +) { + const { isActive, icon,children, ...rest } = props + return ( + {icon} {children} + ) +}) + +type SidebarLinkProps = PropsOf & { + href?: string + icon?: React.ReactElement +} + +const SidebarLink = (props: SidebarLinkProps) => { + const { href, icon, children, ...rest } = props + + const { pathname } = useRouter() + const isActive = pathname === href + + return ( + + + {children} + + + ) +} + +export default SidebarLink diff --git a/layouts/sidebar/sidebar.tsx b/layouts/sidebar/sidebar.tsx new file mode 100644 index 00000000..910e00a3 --- /dev/null +++ b/layouts/sidebar/sidebar.tsx @@ -0,0 +1,56 @@ +import { Box, Stack } from "@chakra-ui/react" +import Card from "components/card" +import { useRouter } from "next/router" +import * as React from "react" +import { Route } from "src/types/route" +import SidebarLink from "./sidebar-link" + + +export function SidebarContent(props) { + const { routes, pathname, contentRef } = props + return ( + <> + + {routes.map((route:Route) => { + if (route.disabled) {return null} + return + {route.title} + + })} + + > + ) +} + +const Sidebar = ({ routes, ...props }) => { + const { pathname } = useRouter() + const ref = React.useRef(null) + + return ( + + + + + + ) +} + +export default Sidebar + diff --git a/next-redirect.js b/next-redirect.js index 34da1af5..2179fb42 100644 --- a/next-redirect.js +++ b/next-redirect.js @@ -5,10 +5,10 @@ async function redirect() { destination: "https://discord.gg/dQHfcWF", permanent: true, }, - // GENERAL + // GENERAL { - source: "/getting-started", - destination: "/docs/getting-started", + source: "/editor", + destination: "/editor/articles", permanent: true, } ] diff --git a/package.json b/package.json index baddeda6..a71dbfb3 100644 --- a/package.json +++ b/package.json @@ -21,20 +21,26 @@ "@emotion/react": "^11.1.4", "@emotion/styled": "^11.0.0", "@octokit/rest": "^18.0.12", + "@types/validator": "^13.1.3", "axios": "^0.19.2", "date-fns": "^2.16.1", "docsearch.js": "^2.6.3", + "eventemitter3": "^4.0.4", + "formik": "^2.2.6", "framer-motion": "^3.1.1", "json-bigint": "^1.0.0", + "lodash": "^4.17.15", "moment": "^2.27.0", "next": "^10.0.4", "next-seo": "^4.17.0", "react": "^17.0.1", "react-dom": "^17.0.1", - "react-icons": "^4.1.0" + "react-icons": "^4.1.0", + "validator": "^13.5.2" }, "devDependencies": { "@next/bundle-analyzer": "^10.0.4", + "@types/lodash": "^4.14.123", "@types/moment": "^2.13.0", "@types/node": "^14.14.19", "@types/react": "^17.0.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index dbe902c0..2192aaea 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -7,6 +7,8 @@ import { ChakraProvider } from "@chakra-ui/react" import theme from "theme" import FontFace from "src/components/font-face" import { getSeo } from "utils/seo" +import GAScript from "analytics/ga-script" +import {initUIConfig} from 'src/utils/config' Router.events.on("routeChangeComplete", (url) => { trackPageview(url) @@ -15,6 +17,7 @@ Router.events.on("routeChangeComplete", (url) => { const App = ({ Component, pageProps }) => { const seo = getSeo({ omitOpenGraphImage: false }) + initUIConfig() return ( <> @@ -29,6 +32,7 @@ const App = ({ Component, pageProps }) => { + > ) diff --git a/pages/courses.tsx b/pages/courses.tsx index 97ade6e7..2b21f684 100644 --- a/pages/courses.tsx +++ b/pages/courses.tsx @@ -2,7 +2,7 @@ import { chakra } from "@chakra-ui/react" import Container from "components/container" import SEO from "components/seo" import siteConfig from "configs/site-config" -import Nav from "layouts/nav" +import Nav from "layouts/nav/nav" import PageContainer from "layouts/page-container" import React from "react" diff --git a/pages/editor/articles.tsx b/pages/editor/articles.tsx new file mode 100644 index 00000000..39fbe323 --- /dev/null +++ b/pages/editor/articles.tsx @@ -0,0 +1,190 @@ +import { createStandaloneToast, Text, Box, Heading, Image, HStack, Center, Button, Flex, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, FormControl, FormLabel, FormHelperText, Input, FormErrorMessage, VStack, Textarea, Divider } from "@chakra-ui/react" +import Card from "components/card" +import Nav from "layouts/nav/nav" +import PageContainer from "layouts/page-container" +import Sidebar from "layouts/sidebar/sidebar" +import React, { useEffect, useState } from "react" +import editorLinks from "src/data/editor-links" +import { requestApi } from "utils/axios/request" +import { useDisclosure } from "@chakra-ui/react" +import { Field, Form, Formik } from "formik" +import { config } from "utils/config" +import TextArticleCard from "components/articles/text-article-card" +import { Article } from "src/types/posts" +var validator = require('validator'); +const toast = createStandaloneToast() + +const newPost:Article = { title: '', url: '', cover: ''} +const ArticlesPage = () => { + const [posts, setPosts] = useState([]) + const [currentPost, setCurrentPost] = useState(newPost) + useEffect(() => { + getPosts() + }, []) + + const getPosts = () => { + requestApi.get(`/editor/articles`).then((res) => setPosts(res.data)).catch(_ => setPosts([])) + } + + const { isOpen, onOpen, onClose } = useDisclosure() + + function validateTitle(value) { + console.log(value) + let error + if (!value?.trim()) { + error = "标题不能为空" + } + return error + } + + function validateUrl(value) { + let error + if (!validator.isURL(value)) { + error = "URL格式不合法" + } + return error + } + + function validateBrief(value) { + let error + if (value && value.length > config.posts.briefMaxLen) { + error = `文本长度不能超过${config.posts.briefMaxLen}` + } + return error + } + + const submitArticle = async (values, _) => { + await requestApi.post(`/editor/article`, values) + onClose() + toast({ + description: "提交成功", + status: "success", + duration: 2000, + isClosable: true, + }) + setCurrentPost(newPost) + } + + const editArticle = (ar: Article) => { + setCurrentPost(ar) + onOpen() + } + + const onDeleteArticle = () => { + getPosts() + toast({ + description: "删除成功", + status: "success", + duration: 2000, + isClosable: true, + }) + } + + return ( + <> + + + + + + + 文章列表({posts.length}) + 添加文章 + + { + posts.length === 0 ? + <> + + + + + 你还没创建任何文章 + + > + : + + {posts.map(post => + + editArticle(post)} onDelete={() => onDeleteArticle()} /> + + + )} + + } + + 没有更多文章了 + + + + + + + + 编辑文章 + + + {(props) => ( + + + + {({ field, form }) => ( + + 标题 + + {form.errors.title} + + )} + + + {({ field, form }) => ( + + URL + + {form.errors.url} + + )} + + + {({ field, form }) => ( + + 封面图片 + + {form.errors.cover} + + )} + + + {({ field, form }) => ( + + 文章简介 + + {form.errors.brief} + + )} + + + + + 提交文章 + + 取消 + + + )} + + + + + > + ) +} +export default ArticlesPage + diff --git a/pages/index.tsx b/pages/index.tsx index 0a96a5fd..cf7eda25 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,8 +1,8 @@ import { chakra } from "@chakra-ui/react" -import Container from "components/container" +import Card from "components/card" import SEO from "components/seo" import siteConfig from "configs/site-config" -import Nav from "layouts/nav" +import Nav from "layouts/nav/nav" import PageContainer from "layouts/page-container" import React from "react" @@ -14,8 +14,12 @@ const HomePage = () => ( /> + NOT FOUND You just hit a route that doesn't exist... the sadness. + + + > ) diff --git a/pages/login.tsx b/pages/login.tsx new file mode 100644 index 00000000..5e46ff3a --- /dev/null +++ b/pages/login.tsx @@ -0,0 +1,66 @@ +import React from "react" +import { + Text, + Box, + VStack, + Button, + Image, + useColorModeValue, + Link, + Center +} from "@chakra-ui/react" +import Logo from "components/logo" +import { FaGithub } from "react-icons/fa" +import { requestApi } from "utils/axios/request" +import { saveToken } from "utils/axios/getToken" +import storage from "utils/localStorage" +import { useRouter } from "next/router" + + +const LoginPage = () => { + const router = useRouter() + const login = async () => { + const res = await requestApi.post("/login") + saveToken(res.data.token) + storage.set('session', res.data) + const oldPage = storage.get('current-page') + if (oldPage) { + storage.remove('current-page') + router.push(oldPage) + } else { + router.push('/') + } + } + + return ( + + + + + 欢迎加入im.dev,一起打造全世界最好的开发者社区 + + + + 从世界各地精选最优秀的内容 + + + + 丰富的功能特性等待你的探索 + + + + 充分展示自我并获得猎头关注 + + + + login()} layerStyle="colorButton" mt="6" fontSize=".9rem" leftIcon={}>使用github登录 + 如果继续,则表示你同意im.dev的服务条款和隐私政策 + {/* */} + + + + ) +} + +export default LoginPage + diff --git a/pages/posts/[id].tsx b/pages/posts/[id].tsx index b2262bfd..94d9a29f 100644 --- a/pages/posts/[id].tsx +++ b/pages/posts/[id].tsx @@ -2,7 +2,7 @@ import { chakra } from "@chakra-ui/react" import Container from "components/container" import SEO from "components/seo" import siteConfig from "configs/site-config" -import Nav from "layouts/nav" +import Nav from "layouts/nav/nav" import PageContainer from "layouts/page-container" import React from "react" diff --git a/pages/tags.tsx b/pages/tags.tsx index 71e461ea..d6a18135 100644 --- a/pages/tags.tsx +++ b/pages/tags.tsx @@ -2,7 +2,7 @@ import { chakra } from "@chakra-ui/react" import Container from "components/container" import SEO from "components/seo" import siteConfig from "configs/site-config" -import Nav from "layouts/nav" +import Nav from "layouts/nav/nav" import PageContainer from "layouts/page-container" import React from "react" diff --git a/public/empty-posts.png b/public/empty-posts.png new file mode 100644 index 00000000..f042df8b Binary files /dev/null and b/public/empty-posts.png differ diff --git a/server/internal/api/api.go b/server/internal/api/api.go new file mode 100644 index 00000000..193cac41 --- /dev/null +++ b/server/internal/api/api.go @@ -0,0 +1,5 @@ +package api + +import "github.com/imdotdev/im.dev/server/pkg/log" + +var logger = log.RootLogger.New("logger", "api") diff --git a/server/internal/api/editor.go b/server/internal/api/editor.go new file mode 100644 index 00000000..e6622800 --- /dev/null +++ b/server/internal/api/editor.go @@ -0,0 +1,44 @@ +package api + +import ( + "database/sql" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/imdotdev/im.dev/server/internal/posts" + "github.com/imdotdev/im.dev/server/internal/session" + "github.com/imdotdev/im.dev/server/pkg/common" +) + +func GetEditorArticles(c *gin.Context) { + user := session.CurrentUser(c) + ars, err := posts.UserArticles(int64(user.ID)) + if err != nil && err != sql.ErrNoRows { + logger.Warn("get user articles error", "error", err) + c.JSON(http.StatusInternalServerError, common.RespInternalError()) + return + } + + c.JSON(http.StatusOK, common.RespSuccess(ars)) +} + +func PostEditorArticle(c *gin.Context) { + err := posts.PostArticle(c) + if err != nil { + logger.Warn("post article error", "error", err) + c.JSON(400, common.RespError(err.Error())) + return + } + + c.JSON(http.StatusOK, common.RespSuccess(nil)) +} + +func DeleteEditorArticle(c *gin.Context) { + err := posts.DeleteArticle(c) + if err != nil { + logger.Warn("delete article error", "error", err) + c.JSON(400, common.RespError(err.Error())) + return + } + c.JSON(http.StatusOK, common.RespSuccess(nil)) +} diff --git a/server/internal/posts/article.go b/server/internal/posts/article.go new file mode 100644 index 00000000..1474e2aa --- /dev/null +++ b/server/internal/posts/article.go @@ -0,0 +1,82 @@ +package posts + +import ( + "errors" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/asaskevich/govalidator" + "github.com/gin-gonic/gin" + "github.com/imdotdev/im.dev/server/internal/session" + "github.com/imdotdev/im.dev/server/pkg/config" + "github.com/imdotdev/im.dev/server/pkg/db" + "github.com/imdotdev/im.dev/server/pkg/errcode" + "github.com/imdotdev/im.dev/server/pkg/models" +) + +func UserArticles(uid int64) (models.Articles, error) { + ars := make(models.Articles, 0) + rows, err := db.Conn.Query("select id,title,url,cover,brief,created,updated from articles where creator=?", uid) + if err != nil { + return ars, err + } + + creator := &models.UserSimple{ID: uid} + creator.Query() + for rows.Next() { + ar := &models.Article{} + err := rows.Scan(&ar.ID, &ar.Title, &ar.URL, &ar.Cover, &ar.Brief, &ar.Created, &ar.Updated) + if err != nil { + logger.Warn("scan articles error", "error", err) + continue + } + + ar.Creator = creator + ars = append(ars, ar) + } + + sort.Sort(ars) + return ars, nil +} + +func PostArticle(c *gin.Context) error { + user := session.CurrentUser(c) + if !user.Role.IsEditor() { + return errors.New(errcode.NoEditorPermission) + } + + ar := &models.Article{} + err := c.Bind(&ar) + if err != nil { + return err + } + + if strings.TrimSpace(ar.Title) == "" || utf8.RuneCountInString(ar.Brief) > config.Data.Posts.BriefMaxLen || !govalidator.IsURL(ar.URL) || !govalidator.IsURL(ar.Cover) { + return errors.New(errcode.ParamInvalid) + } + + now := time.Now() + if ar.ID == 0 { + //create + _, err = db.Conn.Exec("INSERT INTO articles (creator, title, url, cover, brief, created, updated) VALUES(?,?,?,?,?,?,?)", + user.ID, ar.Title, ar.URL, ar.Cover, ar.Brief, now, now) + return err + } + + _, err = db.Conn.Exec("UPDATE articles SET title=?, url=?, cover=?, brief=?, updated=? WHERE id=?", + ar.Title, ar.URL, ar.Cover, ar.Brief, now, ar.ID) + return err +} + +func DeleteArticle(c *gin.Context) error { + user := session.CurrentUser(c) + if !user.Role.IsEditor() { + return errors.New(errcode.NoEditorPermission) + } + + id := c.Param("id") + _, err := db.Conn.Exec("DELETE FROM articles WHERE id=?", id) + return err +} diff --git a/server/internal/posts/posts.go b/server/internal/posts/posts.go new file mode 100644 index 00000000..8e680650 --- /dev/null +++ b/server/internal/posts/posts.go @@ -0,0 +1,5 @@ +package posts + +import "github.com/imdotdev/im.dev/server/pkg/log" + +var logger = log.RootLogger.New("logger", "posts") diff --git a/server/internal/posts/series.go b/server/internal/posts/series.go new file mode 100644 index 00000000..c427a4f5 --- /dev/null +++ b/server/internal/posts/series.go @@ -0,0 +1 @@ +package posts diff --git a/server/internal/server.go b/server/internal/server.go index 929323b8..3ef6cc0e 100644 --- a/server/internal/server.go +++ b/server/internal/server.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/imdotdev/im.dev/server/internal/api" "github.com/imdotdev/im.dev/server/internal/session" "github.com/imdotdev/im.dev/server/internal/storage" "github.com/imdotdev/im.dev/server/pkg/common" @@ -43,8 +44,19 @@ func (s *Server) Start() error { { r.POST("/login", session.Login) r.POST("/logout", session.Logout) + r.GET("/uiconfig", GetUIConfig) } + // login apis + lr := r.Group("", IsLogin()) + { + editorR := lr.Group("/editor") + { + editorR.GET("/articles", api.GetEditorArticles) + editorR.POST("/article", api.PostEditorArticle) + editorR.DELETE("/article/:id", api.DeleteEditorArticle) + } + } err := router.Run(config.Data.Server.Addr) if err != nil { logger.Crit("start backend server error", "error", err) @@ -83,9 +95,9 @@ func Cors() gin.HandlerFunc { // Auth is a gin middleware for user auth func IsLogin() gin.HandlerFunc { return func(c *gin.Context) { - user := session.Current(c) + user := session.CurrentUser(c) if user == nil { - c.JSON(http.StatusUnauthorized, common.RespError(nil, errcode.NeedLogin)) + c.JSON(http.StatusUnauthorized, common.RespError(errcode.NeedLogin)) c.Abort() return } diff --git a/server/internal/session/session.go b/server/internal/session/session.go index 8ba37878..5356d8bc 100644 --- a/server/internal/session/session.go +++ b/server/internal/session/session.go @@ -91,7 +91,7 @@ func storeSession(s *Session) error { return nil } -func Current(c *gin.Context) *models.User { +func CurrentUser(c *gin.Context) *models.User { token := getToken(c) createTime, _ := strconv.ParseInt(token, 10, 64) if createTime != 0 { diff --git a/server/internal/storage/sql_tables.go b/server/internal/storage/sql_tables.go index 12e4294a..75d969a5 100644 --- a/server/internal/storage/sql_tables.go +++ b/server/internal/storage/sql_tables.go @@ -27,4 +27,22 @@ var sqlTables = map[string]string{ user_id INTEGER ); `, + + "articles": `CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + creator INTEGER NOT NULL, + + title VARCHAR(255) NOT NULL, + url VARCHAR(255) NOT NULL, + cover VARCHAR(255), + brief TEXT, + + created DATETIME NOT NULL, + updated DATETIME + ); + CREATE INDEX IF NOT EXISTS articles_creator + ON articles (creator); + CREATE INDEX IF NOT EXISTS articles_created + ON articles (created); +`, } diff --git a/server/internal/ui_config.go b/server/internal/ui_config.go new file mode 100644 index 00000000..8f0bdfd6 --- /dev/null +++ b/server/internal/ui_config.go @@ -0,0 +1,27 @@ +package internal + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/imdotdev/im.dev/server/pkg/common" + "github.com/imdotdev/im.dev/server/pkg/config" +) + +type UIConfig struct { + Posts *UIPosts `json:"posts"` +} + +type UIPosts struct { + BriefMaxLen int `json:"briefMaxLen"` +} + +func GetUIConfig(c *gin.Context) { + conf := &UIConfig{ + Posts: &UIPosts{ + BriefMaxLen: config.Data.Posts.BriefMaxLen, + }, + } + + c.JSON(http.StatusOK, common.RespSuccess(conf)) +} diff --git a/server/pkg/common/resp.go b/server/pkg/common/resp.go index 580cb77c..5b215477 100644 --- a/server/pkg/common/resp.go +++ b/server/pkg/common/resp.go @@ -16,10 +16,9 @@ func RespSuccess(data interface{}) *Resp { return r } -func RespError(data interface{}, msg string) *Resp { +func RespError(msg string) *Resp { r := &Resp{} r.Status = Error - r.Data = data r.Message = msg return r diff --git a/server/pkg/config/config.go b/server/pkg/config/config.go index bb3e45ab..925c8130 100644 --- a/server/pkg/config/config.go +++ b/server/pkg/config/config.go @@ -31,6 +31,10 @@ type Config struct { Data string Logs string } + + Posts struct { + BriefMaxLen int `yaml:"brief_max_len"` + } } // Data ... diff --git a/server/pkg/errcode/err_code.go b/server/pkg/errcode/err_code.go index 43048a68..ba0b253c 100644 --- a/server/pkg/errcode/err_code.go +++ b/server/pkg/errcode/err_code.go @@ -2,4 +2,6 @@ package errcode const DB = "database error" const Internal = "server internal error" -const NeedLogin = "login status is needed" +const NeedLogin = "你需要登录才能访问该页面" +const NoEditorPermission = "只有编辑角色才能执行此操作" +const ParamInvalid = "请求参数不正确" diff --git a/server/pkg/models/post.go b/server/pkg/models/post.go new file mode 100644 index 00000000..06f7badf --- /dev/null +++ b/server/pkg/models/post.go @@ -0,0 +1,22 @@ +package models + +import "time" + +type Article struct { + ID int64 `json:"id"` + Creator *UserSimple `json:"creator"` + Title string `json:"title"` + URL string `json:"url"` + Cover string `json:"cover"` + Brief string `json:"brief"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +type Articles []*Article + +func (ar Articles) Len() int { return len(ar) } +func (ar Articles) Swap(i, j int) { ar[i], ar[j] = ar[j], ar[i] } +func (ar Articles) Less(i, j int) bool { + return ar[i].Created.Unix() > ar[j].Created.Unix() +} diff --git a/server/pkg/models/user.go b/server/pkg/models/user.go index 76726151..963495fa 100644 --- a/server/pkg/models/user.go +++ b/server/pkg/models/user.go @@ -7,7 +7,7 @@ import ( ) type User struct { - ID int `json:"id"` + ID int64 `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` @@ -29,3 +29,22 @@ func (user *User) Query(id int64, username string, email string) error { return err } + +type UserSimple struct { + ID int64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} + +func (user *UserSimple) Query() error { + err := db.Conn.QueryRow(`SELECT id,username,nickname,avatar FROM user WHERE id=? or username=? or email=?`, user.ID).Scan( + &user.ID, &user.Username, &user.Nickname, &user.Avatar, + ) + + if user.Avatar == "" { + user.Avatar = DefaultAvatar + } + + return err +} diff --git a/src/components/articles/text-article-card.tsx b/src/components/articles/text-article-card.tsx new file mode 100644 index 00000000..d5064ec2 --- /dev/null +++ b/src/components/articles/text-article-card.tsx @@ -0,0 +1,37 @@ +import React from "react" +import {chakra, Heading, VStack, Text, HStack,Button, Flex,PropsOf } from "@chakra-ui/react" +import { Article } from "src/types/posts" +import moment from 'moment' +import { requestApi } from "utils/axios/request" + +type Props = PropsOf & { + article: Article + showActions: boolean + onEdit?: any + onDelete?: any +} + + +export const TextArticleCard= (props:Props) =>{ + const {article,showActions,onEdit,onDelete, ...rest} = props + + const gap = moment(article.created).fromNow() + const onDeleteArticle = async () => { + await requestApi.delete(`/editor/article/${article.id}`) + onDelete() + } + return ( + + + {props.article.title} + {gap} + + {props.showActions && + Edit + Delete + } + + ) +} + +export default TextArticleCard diff --git a/src/components/card.tsx b/src/components/card.tsx new file mode 100644 index 00000000..db772de5 --- /dev/null +++ b/src/components/card.tsx @@ -0,0 +1,14 @@ +import React from "react" +import { Box, BoxProps } from "@chakra-ui/react" + +export const Card = (props: BoxProps) => ( + +) + +export default Card diff --git a/src/data/editor-links.tsx b/src/data/editor-links.tsx new file mode 100644 index 00000000..468f6018 --- /dev/null +++ b/src/data/editor-links.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { FaFileAlt, FaScroll, FaBookOpen } from 'react-icons/fa' +import { Route } from 'src/types/route' +const editorLinks: Route[] = [{ + title: '文章', + path: '/editor/article', + icon: , + disabled: false +}, +{ + title: '系列', + path: '/editor/series', + icon: , + disabled: false +}, +{ + title: '课程', + path: '/editor/course', + icon: , + disabled: false +}, +] + +export default editorLinks \ No newline at end of file diff --git a/src/data/messages.ts b/src/data/messages.ts new file mode 100644 index 00000000..171f1d01 --- /dev/null +++ b/src/data/messages.ts @@ -0,0 +1 @@ +export const needLogin = "你需要登录才能访问该页面" \ No newline at end of file diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index aae04cd4..04a78c0f 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -1,14 +1,21 @@ import { useEffect, useState } from "react" import { Session } from "src/types/session" +import events from "utils/events" import storage from "utils/localStorage" -function useSession(): [Session, any] { +function useSession(): Session{ const [session, setSession] = useState(null) useEffect(() => { const sess = storage.get('session') if (sess) { setSession(sess) } + + events.on('set-session',storeSession) + + return() => { + events.off('set-session',storeSession) + } }, []) @@ -17,7 +24,7 @@ function useSession(): [Session, any] { setSession(sess) } - return [session, storeSession] + return session } export default useSession diff --git a/src/types/posts.ts b/src/types/posts.ts new file mode 100644 index 00000000..45a78d6b --- /dev/null +++ b/src/types/posts.ts @@ -0,0 +1,11 @@ +import {User} from './session' + +export interface Article { + id?: number + creator?: User + title: string + url: string + cover: string + brief?: string + created?: string +} \ No newline at end of file diff --git a/src/types/route.ts b/src/types/route.ts new file mode 100644 index 00000000..734c7b91 --- /dev/null +++ b/src/types/route.ts @@ -0,0 +1,6 @@ +export interface Route { + path: string + icon: any + title: string + disabled: boolean +} \ No newline at end of file diff --git a/src/types/session.ts b/src/types/session.ts index fd899121..5a267f28 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -8,9 +8,9 @@ export interface User { id :number username: string nickname: string - role: string avatar: string - email: string + role?: string + email?: string lastSeenAt?: string - created: string + created?: string } \ No newline at end of file diff --git a/src/utils/axios/request.ts b/src/utils/axios/request.ts index bdf96a66..3458e40d 100644 --- a/src/utils/axios/request.ts +++ b/src/utils/axios/request.ts @@ -17,6 +17,8 @@ const JSONbigString = require('json-bigint')({ storeAsString: true }) import type { OutgoingHttpHeaders } from 'http' import { createStandaloneToast } from "@chakra-ui/react" +import { logout } from 'utils/session' +import { getToken } from './getToken' const toast = createStandaloneToast() axios.defaults.transformResponse = [ @@ -54,21 +56,30 @@ requestApi.interceptors.response.use(printResData) // 对返回信息进行处理 requestApi.interceptors.response.use( response => { - return response + return response.data }, error => { let message = "error msg missing" + let status = 200 if (error.response && error.response.data) { message = error.response.data.message + status = error.response.status } else { - message = error.message + message = error.text ?? error.message + } + + if (status === 401) { + if (getToken()) { + // 当前登录状态已经过期,进行登出操作 + logout() + } } toast({ title: `请求错误`, description: message, status: "error", - duration: 5000, + duration: 2000, isClosable: true, }) diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 00000000..9761b4da --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,13 @@ +import { requestApi } from "./axios/request" + +export let config = { + posts: { + briefMaxLen: 10 + } +} + +export function initUIConfig() { + requestApi.get("/uiconfig").then((res) => { + console.log("初始化UI config:", res.data) + config = res.data + })} \ No newline at end of file diff --git a/src/utils/emitter.ts b/src/utils/emitter.ts new file mode 100644 index 00000000..5370fced --- /dev/null +++ b/src/utils/emitter.ts @@ -0,0 +1,98 @@ +import EventEmitter3, { EventEmitter } from 'eventemitter3'; + +export type AppEvent = { + readonly name: string; + payload?: T; + } + +export class Emitter { + emitter: EventEmitter3; + + constructor() { + this.emitter = new EventEmitter(); + } + + /** + * DEPRECATED. + */ + emit(name: string, data?: any): void; + + /** + * Emits an `event` with `payload`. + */ + emit(event: AppEvent): void; + //@ts-ignore + emit extends T ? Partial : never>(event: AppEvent): void; + emit(event: AppEvent, payload: T): void; + emit(event: AppEvent | string, payload?: T | any): void { + if (typeof event === 'string') { + // console.log(`Using strings as events is deprecated and will be removed in a future version. (${event})`); + this.emitter.emit(event, payload); + } else { + this.emitter.emit(event.name, payload); + } + } + + /** + * DEPRECATED. + */ + on(name: string, handler: (payload?: any) => void, scope?: any): void; + + /** + * Handles `event` with `handler()` when emitted. + */ + on(event: AppEvent, handler: () => void, scope?: any): void; + //@ts-ignore + on extends T ? Partial : never>(event: AppEvent, handler: () => void, scope?: any): void; + on(event: AppEvent, handler: (payload: T) => void, scope?: any): void; + on(event: AppEvent | string, handler: (payload?: T | any) => void, scope?: any) { + if (typeof event === 'string') { + // console.log(`Using strings as events is deprecated and will be removed in a future version. (${event})`); + this.emitter.on(event, handler); + + if (scope) { + const unbind = scope.$on('$destroy', () => { + this.emitter.off(event, handler); + unbind(); + }); + } + return; + } + + this.emitter.on(event.name, handler); + + if (scope) { + const unbind = scope.$on('$destroy', () => { + this.emitter.off(event.name, handler); + unbind(); + }); + } + } + + /** + * DEPRECATED. + */ + off(name: string, handler: (payload?: any) => void): void; + + off(event: AppEvent, handler: () => void): void; + //@ts-ignore + off extends T ? Partial : never>(event: AppEvent, handler: () => void, scope?: any): void; + off(event: AppEvent, handler: (payload: T) => void): void; + off(event: AppEvent | string, handler: (payload?: T | any) => void) { + if (typeof event === 'string') { + // console.log(`Using strings as events is deprecated and will be removed in a future version. (${event})`); + this.emitter.off(event, handler); + return; + } + + this.emitter.off(event.name, handler); + } + + removeAllListeners(evt?: string) { + this.emitter.removeAllListeners(evt); + } + + getEventCount(): number { + return (this.emitter as any)._eventsCount; + } +} diff --git a/src/utils/events.ts b/src/utils/events.ts new file mode 100644 index 00000000..9c8bf3e7 --- /dev/null +++ b/src/utils/events.ts @@ -0,0 +1,5 @@ +import { Emitter } from './emitter'; + +export const events = new Emitter(); + +export default events; diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts index 1ba62498..ef74b052 100644 --- a/src/utils/localStorage.ts +++ b/src/utils/localStorage.ts @@ -8,7 +8,7 @@ const storage = { }, remove(key:string){ localStorage.removeItem(adminKey+key) - } + } } - + export default storage \ No newline at end of file diff --git a/src/utils/session.ts b/src/utils/session.ts new file mode 100644 index 00000000..9e8132e3 --- /dev/null +++ b/src/utils/session.ts @@ -0,0 +1,9 @@ +import { removeToken } from "./axios/getToken" +import { requestApi } from "./axios/request" +import events from "./events" + +export const logout = async () => { + await requestApi.post("/logout") + removeToken() + events.emit('set-session', null) + } diff --git a/tsconfig.json b/tsconfig.json index 1e043ad2..33cc7fd9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/yarn.lock b/yarn.lock index 74b63075..97408c90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1099,7 +1099,7 @@ dependencies: "@types/lodash" "*" -"@types/lodash@*": +"@types/lodash@*", "@types/lodash@^4.14.123": version "4.14.168" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== @@ -1151,6 +1151,11 @@ resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.2.tgz#721ca5c5d1a2988b4a886e35c2ffc5735b6afbdf" integrity sha512-PeHg/AtdW6aaIO2a+98Xj7rWY4KC1E6yOy7AFknJQ7VXUGNrMlyxDFxJo7HqLtjQms/ZhhQX52mLVW/EX3JGOw== +"@types/validator@^13.1.3": + version "13.1.3" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.1.3.tgz#366b394aa3fbeed2392bf0a20ded606fa4a3d35e" + integrity sha512-DaOWN1zf7j+8nHhqXhIgNmS+ltAC53NXqGxYuBhWqWgqolRhddKzfZU814lkHQSTG0IUfQxU7Cg0gb8fFWo2mA== + "@types/warning@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52" @@ -1945,6 +1950,11 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== +deepmerge@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" + integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -2183,6 +2193,11 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + events@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" @@ -2291,6 +2306,19 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formik@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.6.tgz#378a4bafe4b95caf6acf6db01f81f3fe5147559d" + integrity sha512-Kxk2zQRafy56zhLmrzcbryUpMBvT0tal5IvcifK5+4YNGelKsnrODFJ0sZQRMQboblWNym4lAW3bt+tf2vApSA== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.14" + lodash-es "^4.17.14" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^1.10.0" + framer-motion@^3.1.1: version "3.2.1" resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-3.2.1.tgz#66eeb883a0b5c425dd7767ecacdeac451c184cdb" @@ -2483,7 +2511,7 @@ hogan.js@^3.0.2: mkdirp "0.3.0" nopt "1.0.10" -hoist-non-react-statics@^3.3.1: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -2774,6 +2802,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash-es@^4.17.14: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.20.tgz#29f6332eefc60e849f869c264bc71126ad61e8f7" + integrity sha512-JD1COMZsq8maT6mnuz1UMV0jvYD0E0aUsSOdrr1/nAG3dhqQXwRRgeW0cSqH1U43INKcqxaiVIQNOUDld7gRDA== + lodash.mergewith@4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" @@ -2784,7 +2817,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.17.13, lodash@^4.17.19, lodash@^4.17.20: +lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20: version "4.17.20" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== @@ -3447,6 +3480,11 @@ react-fast-compare@3.2.0: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== +react-fast-compare@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + react-focus-lock@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.4.1.tgz#e842cc93da736b5c5d331799012544295cbcee4f" @@ -3984,6 +4022,11 @@ tiny-invariant@^1.0.6: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== +tiny-warning@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + tinycolor2@1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" @@ -4152,6 +4195,11 @@ uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +validator@^13.5.2: + version "13.5.2" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.5.2.tgz#c97ae63ed4224999fb6f42c91eaca9567fe69a46" + integrity sha512-mD45p0rvHVBlY2Zuy3F3ESIe1h5X58GPfAtslBjY7EtTqGquZTj+VX/J4RnHWN8FKq0C9WRVt1oWAcytWRuYLQ== + verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
You just hit a route that doesn't exist... the sadness.