pull/50/head
sunface 4 years ago
parent 42db49c060
commit 3a1c406da4

@ -27,3 +27,5 @@ paths:
#################################### Posts ##############################
posts:
brief_max_len: 100
# whether allow writing posts
writing_enabled: true

@ -7,6 +7,7 @@ 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/golang/snappy v0.0.2
github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac
github.com/karrick/godirwalk v1.16.1 // indirect
github.com/keegancsmith/rpc v1.3.0 // indirect

@ -73,6 +73,8 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=

@ -0,0 +1,123 @@
import {
chakra,
Flex,
Button,
IconButton,
useColorMode,
useColorModeValue,
Box,
useRadioGroup,
HStack,
Input
} from "@chakra-ui/react"
import { useViewportScroll } from "framer-motion"
import NextLink from "next/link"
import React from "react"
import { FaMoon, FaSun } from "react-icons/fa"
import Logo, { LogoIcon } from "src/components/logo"
import RadioCard from "components/radio-card"
import { EditMode } from "src/types/editor"
function HeaderContent(props:any) {
const { toggleColorMode: toggleMode } = useColorMode()
const text = useColorModeValue("dark", "light")
const SwitchIcon = useColorModeValue(FaMoon, FaSun)
const editOptions = [EditMode.Edit,EditMode.Preview]
const { getRootProps, getRadioProps } = useRadioGroup({
name: "framework",
defaultValue: EditMode.Edit,
onChange: (v) => {
props.changeEditMode(v)
},
})
const group = getRootProps()
return (
<>
<Flex w="100%" h="100%" align="center" justify="space-between" px={{ base: "4", md: "6" }}>
<Flex align="center">
<NextLink href="/" passHref>
<chakra.a display={{ base: "none", md: "block" }} style={{ marginTop: '-5px' }} aria-label="Chakra UI, Back to homepage">
<Logo width="130" />
</chakra.a>
</NextLink>
<NextLink href="/" passHref>
<chakra.a display={{ base: "block", md: "none" }} aria-label="Chakra UI, Back to homepage">
<LogoIcon />
</chakra.a>
</NextLink>
</Flex>
<Box>
<Input value={props.ar.title} placeholder="Title..." onChange={props.changeTitle} focusBorderColor={useColorModeValue('teal.400','teal.100')} variant="flushed"/>
</Box>
<HStack {...group}>
{editOptions.map((value) => {
const radio = getRadioProps({ value })
return (
<RadioCard key={value} {...radio} bg="teal" color="white">
{value}
</RadioCard>
)
})}
</HStack>
<Box
color={useColorModeValue("gray.500", "gray.400")}
>
<IconButton
size="md"
fontSize="lg"
aria-label={`Switch to ${text} mode`}
variant="ghost"
color="current"
ml={{ base: "0", md: "1" }}
onClick={toggleMode}
_focus={null}
icon={<SwitchIcon />}
/>
<Button layerStyle="colorButton" ml="2" onClick={props.publish}></Button>
</Box>
</Flex>
</>
)
}
function EditorNav(props) {
const bg = useColorModeValue("white", "gray.800")
const ref = React.useRef<HTMLHeadingElement>()
const [y, setY] = React.useState(0)
const { height = 0 } = ref.current?.getBoundingClientRect() ?? {}
const { scrollY } = useViewportScroll()
React.useEffect(() => {
return scrollY.onChange(() => setY(scrollY.get()))
}, [scrollY])
return (
<chakra.header
ref={ref}
shadow={y > height ? "sm" : undefined}
transition="box-shadow 0.2s"
pos="fixed"
top="0"
zIndex="3"
bg={bg}
left="0"
right="0"
borderTop="4px solid"
borderTopColor="teal.400"
width="full"
>
<chakra.div height="4.5rem" mx="auto" maxW="1200px">
<HeaderContent {...props} />
</chakra.div>
</chakra.header>
)
}
export default EditorNav

@ -3,7 +3,6 @@ import {
Flex,
Button,
HStack,
Icon,
IconButton,
Link,
useColorMode,
@ -16,14 +15,6 @@ import {
MenuItem,
MenuDivider,
Image,
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
Text,
Box,
VStack,
} from "@chakra-ui/react"
import siteConfig from "configs/site-config"
import { useViewportScroll } from "framer-motion"
@ -66,7 +57,7 @@ const GithubIcon = (props) => (
function HeaderContent() {
const router = useRouter()
const { pathname } = router
const { asPath } = router
const mobileNav = useDisclosure()
const session: Session = useSession()
@ -81,7 +72,8 @@ function HeaderContent() {
}, [mobileNav.isOpen])
const login = () => {
storage.set("current-page", pathname)
console.log(router)
storage.set("current-page", asPath)
router.push('/login')
}
@ -101,7 +93,7 @@ function HeaderContent() {
</NextLink>
<HStack display={{ base: "none", md: "flex" }} ml={{ base: 1, md: 4, lg: 12 }} fontSize="1rem" minWidth="250px">
{navLinks.map(link => <Link px="4" py="0.7rem" rounded="md" href={link.url} key={link.url} color={useColorModeValue("gray.700", "whiteAlpha.900")} aria-current={pathname === link.url ? "page" : undefined} _activeLink={{ bg: useColorModeValue("transparent", "rgba(48, 140, 122, 0.3)"), color: useColorModeValue("teal.500", "teal.200"), fontWeight: "bold", }} _hover={null} _focus={null}>{link.title}</Link>)}
{navLinks.map(link => <Link px="4" py="0.7rem" rounded="md" href={link.url} key={link.url} color={useColorModeValue("gray.700", "whiteAlpha.900")} aria-current={asPath === link.url ? "page" : undefined} _activeLink={{ bg: useColorModeValue("transparent", "rgba(48, 140, 122, 0.3)"), color: useColorModeValue("teal.500", "teal.200"), fontWeight: "bold", }} _hover={null} _focus={null}>{link.title}</Link>)}
</HStack>
</Flex>
@ -109,7 +101,7 @@ function HeaderContent() {
w="100%"
maxW="600px"
align="center"
color="gray.400"
color={useColorModeValue("gray.500","gray.400")}
>
<AlgoliaSearch />
<HStack spacing="5" display={{ base: "none", md: "flex" }}>
@ -151,7 +143,7 @@ function HeaderContent() {
boxSize="2.8em"
borderRadius="full"
src="https://placekitten.com/100/100"
alt="Fluffybuns the destroyer"
alt="user"
/> :
<FaUserAlt />
}
@ -163,7 +155,7 @@ function HeaderContent() {
<span>Sunface</span>
</MenuItem>
<MenuDivider />
{<MenuItem as="a" icon={<FaEdit fontSize="16" />} href="/editor"></MenuItem>}
{<MenuItem as="a" icon={<FaEdit fontSize="16" />} href="/editor"></MenuItem>}
<MenuItem icon={<FaBookmark fontSize="16" />}></MenuItem>
<MenuDivider />
<MenuItem icon={<FaRegSun fontSize="16" />}></MenuItem>

@ -26,10 +26,11 @@ function useHeadingFocusOnRouteChange() {
interface PageContainerProps {
children: React.ReactNode
nav?: any
}
function PageContainer(props: PageContainerProps) {
const { children } = props
const { children ,nav} = props
useHeadingFocusOnRouteChange()
return (
@ -39,7 +40,7 @@ function PageContainer(props: PageContainerProps) {
description={siteConfig.seo.description}
/>
<SkipNavLink zIndex={20}>Skip to Content</SkipNavLink>
<Nav />
{nav ? nav : <Nav />}
<Container as="main" className="main-content">
<Box display={{ base: "block", md: "flex" }}>
<div style={{ flex: 1 }}>

@ -8,7 +8,7 @@ async function redirect() {
// GENERAL
{
source: "/editor",
destination: "/editor/articles",
destination: "/editor/posts",
permanent: true,
}
]

@ -28,18 +28,22 @@
"eventemitter3": "^4.0.4",
"formik": "^2.2.6",
"framer-motion": "^3.1.1",
"highlight.js": "^9.16.2",
"json-bigint": "^1.0.0",
"lodash": "^4.17.15",
"markdown-to-jsx": "^7.1.1",
"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-markdown-editor-lite": "^1.2.4",
"validator": "^13.5.2"
},
"devDependencies": {
"@next/bundle-analyzer": "^10.0.4",
"@types/highlight.js": "^9.12.3",
"@types/lodash": "^4.14.123",
"@types/moment": "^2.13.0",
"@types/node": "^14.14.19",

@ -0,0 +1,26 @@
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/nav"
import PageContainer from "layouts/page-container"
import { useRouter } from "next/router"
import React from "react"
const UserPage = () => {
const router = useRouter()
return (
<>
<SEO
title={siteConfig.seo.title}
description={siteConfig.seo.description}
/>
<Nav />
<PageContainer>
<chakra.h1>{router.query.username}{router.query.post_slug}</chakra.h1>
</PageContainer>
</>
)}
export default UserPage

@ -0,0 +1,26 @@
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/nav"
import PageContainer from "layouts/page-container"
import { useRouter } from "next/router"
import React from "react"
const UserPage = () => {
const router = useRouter()
return (
<>
<SEO
title={siteConfig.seo.title}
description={siteConfig.seo.description}
/>
<Nav />
<PageContainer>
<chakra.h1>{router.query.username}'s home</chakra.h1>
</PageContainer>
</>
)}
export default UserPage

@ -0,0 +1,83 @@
import { Box, Button,createStandaloneToast} from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { MarkdownEditor } from 'components/markdown-editor/editor';
import PageContainer from 'layouts/page-container';
import EditorNav from 'layouts/editor-nav'
import { EditMode } from 'src/types/editor';
import { MarkdownRender } from 'components/markdown-editor/render';
import { Post } from 'src/types/posts';
import { requestApi } from 'utils/axios/request';
import { useRouter } from 'next/router';
const toast = createStandaloneToast()
const content = `
# test
`
function PostEditPage() {
const router = useRouter()
const {id} = router.query
const [editMode, setEditMode] = useState(EditMode.Edit)
const [ar,setAr] = useState({
md: content,
title: ''
})
useEffect(() => {
if (id && id !== 'new') {
requestApi.get(`/editor/post/${id}`).then(res => setAr(res.data))
}
},[id])
const onChange = newMd => {
setAr({
...ar,
md: newMd
})
}
const publish = async () => {
await requestApi.post(`/editor/post`, ar)
toast({
description: "发布成功",
status: "success",
duration: 2000,
isClosable: true,
})
router.push('/editor/posts')
}
console.log(ar)
return (
<PageContainer
nav={<EditorNav
ar={ar}
changeEditMode={(v) => setEditMode(v)}
changeTitle={(e) => {setAr({...ar, title: e.target.value})}}
publish={() => publish()}
/>}
>
<Box style={{ height: 'calc(100vh - 145px)' }}>
{editMode === EditMode.Edit ?
<MarkdownEditor
options={{
overrides: {
Button: {
component: Button,
},
},
}}
onChange={(md) => onChange(md)}
md={ar.md}
/> :
<Box height="100%" p="6">
<MarkdownRender md={ar.md} />
</Box>
}
</Box>
</PageContainer>
);
}
export default PostEditPage

@ -1,4 +1,4 @@
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 { Menu,MenuButton,MenuList,MenuItem,createStandaloneToast, Text, Box, Heading, Image, HStack, Center, Button, Flex, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, FormControl, FormLabel, FormHelperText, Input, FormErrorMessage, VStack, Textarea, Divider, useColorModeValue } from "@chakra-ui/react"
import Card from "components/card"
import Nav from "layouts/nav/nav"
import PageContainer from "layouts/page-container"
@ -9,24 +9,27 @@ 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"
import TextPostCard from "components/posts/text-post-card"
import { Post } from "src/types/posts"
import { FaExternalLinkAlt, FaRegEdit } from "react-icons/fa"
import { useRouter } from "next/router"
var validator = require('validator');
const toast = createStandaloneToast()
const newPost: Article = { title: '', url: '', cover: '' }
const ArticlesPage = () => {
const [posts, setPosts] = useState([])
const newPost: Post = { title: '', url: '', cover: '' }
const PostsPage = () => {
const [currentPost, setCurrentPost] = useState(newPost)
const [posts, setPosts] = useState([])
const { isOpen, onOpen, onClose } = useDisclosure()
const router = useRouter()
const getPosts = () => {
requestApi.get(`/editor/posts`).then((res) => setPosts(res.data)).catch(_ => setPosts([]))
}
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)
@ -53,8 +56,8 @@ const ArticlesPage = () => {
return error
}
const submitArticle = async (values, _) => {
await requestApi.post(`/editor/article`, values)
const submitPost = async (values, _) => {
await requestApi.post(`/editor/post`, values)
onClose()
toast({
description: "提交成功",
@ -66,12 +69,17 @@ const ArticlesPage = () => {
getPosts()
}
const editArticle = (ar: Article) => {
setCurrentPost(ar)
const editPost = (post: Post) => {
if (post.url.trim() === "") {
router.push(`/editor/post/${post.id}`)
} else {
setCurrentPost(post)
onOpen()
}
}
const onDeleteArticle = () => {
const onDeletePost= async (id) => {
await requestApi.delete(`/editor/post/${id}`)
getPosts()
toast({
description: "删除成功",
@ -90,7 +98,18 @@ const ArticlesPage = () => {
<Card ml="4" p="6" width="100%">
<Flex alignItems="center" justify="space-between">
<Heading size="md">({posts.length})</Heading>
<Button colorScheme="teal" size="sm" onClick={onOpen} _focus={null}></Button>
{config.posts.writingEnabled ?
<Menu>
<MenuButton as={Button} colorScheme="teal" size="sm" _focus={null}>
</MenuButton>
<MenuList color={useColorModeValue("gray.500","gray.400")}>
<MenuItem icon={<FaExternalLinkAlt fontSize="14" />} onClick={onOpen}></MenuItem>
<MenuItem icon={<FaRegEdit fontSize="16" />} as="a" href="/editor/post/new"></MenuItem>
</MenuList>
</Menu>
:
<Button colorScheme="teal" size="sm" onClick={onOpen} _focus={null}></Button>}
</Flex>
{
posts.length === 0 ?
@ -107,12 +126,12 @@ const ArticlesPage = () => {
<VStack mt="4">
{posts.map(post =>
<Box width="100%" key={post.id}>
<TextArticleCard article={post} showActions={true} mt="4" onEdit={() => editArticle(post)} onDelete={() => onDeleteArticle()} />
<TextPostCard post={post} showActions={true} mt="4" onEdit={() => editPost(post)} onDelete={() => onDeletePost(post.id)} />
<Divider mt="5" />
</Box>
)}
</VStack>
<Center><Text layerStyle="textSecondary" fontSize="sm" mt="6"></Text></Center>
<Center><Text layerStyle="textSecondary" fontSize="sm" mt="5"></Text></Center>
</>
}
</Card>
@ -122,11 +141,11 @@ const ArticlesPage = () => {
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
<ModalHeader>{currentPost.id ? "编辑文章" : "新建文章"}</ModalHeader>
<ModalBody mb="2">
<Formik
initialValues={currentPost}
onSubmit={submitArticle}
onSubmit={submitPost}
>
{(props) => (
<Form>
@ -175,7 +194,7 @@ const ArticlesPage = () => {
type="submit"
_focus={null}
>
</Button>
<Button variant="ghost" ml="4" _focus={null} onClick={onClose}></Button>
</Box>
@ -188,5 +207,5 @@ const ArticlesPage = () => {
</>
)
}
export default ArticlesPage
export default PostsPage

@ -1,44 +1,92 @@
package api
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"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"
"github.com/imdotdev/im.dev/server/pkg/e"
)
func GetEditorArticles(c *gin.Context) {
func GetEditorPosts(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())
ars, err := posts.UserPosts(int64(user.ID))
if err != nil {
c.JSON(err.Status, common.RespError(err.Message))
return
}
c.JSON(http.StatusOK, common.RespSuccess(ars))
}
func PostEditorArticle(c *gin.Context) {
err := posts.PostArticle(c)
func SubmitPost(c *gin.Context) {
err := posts.SubmitPost(c)
if err != nil {
logger.Warn("post article error", "error", err)
c.JSON(400, common.RespError(err.Error()))
logger.Warn("submit post error", "error", err)
c.JSON(err.Status, common.RespError(err.Message))
return
}
c.JSON(http.StatusOK, common.RespSuccess(nil))
}
func DeleteEditorArticle(c *gin.Context) {
err := posts.DeleteArticle(c)
func DeletePost(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if id == 0 {
c.JSON(http.StatusBadRequest, common.RespError(e.ParamInvalid))
return
}
user := session.CurrentUser(c)
creator, err := posts.GetPostCreator(id)
if err != nil {
logger.Warn("delete article error", "error", err)
c.JSON(400, common.RespError(err.Error()))
c.JSON(err.Status, common.RespError(err.Message))
return
}
if user.ID != creator {
c.JSON(http.StatusForbidden, common.RespError(e.NoPermission))
return
}
err = posts.DeletePost(id)
if err != nil {
c.JSON(err.Status, common.RespError(err.Message))
return
}
c.JSON(http.StatusOK, common.RespSuccess(nil))
}
func GetEditorPost(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
fmt.Println(c.Param("id"))
if id == 0 {
c.JSON(http.StatusBadRequest, common.RespError(e.ParamInvalid))
return
}
user := session.CurrentUser(c)
creator, err := posts.GetPostCreator(id)
if err != nil {
c.JSON(err.Status, common.RespError(err.Message))
return
}
if user.ID != creator {
c.JSON(http.StatusForbidden, common.RespError(e.NoPermission))
return
}
ar, err := posts.GetPost(id)
if err != nil {
c.JSON(err.Status, common.RespError(err.Message))
return
}
c.JSON(http.StatusOK, common.RespSuccess(ar))
}

@ -1,7 +1,8 @@
package posts
import (
"errors"
"database/sql"
"net/http"
"sort"
"strings"
"time"
@ -12,24 +13,29 @@ import (
"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/e"
"github.com/imdotdev/im.dev/server/pkg/models"
"github.com/imdotdev/im.dev/server/pkg/utils"
)
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)
func UserPosts(uid int64) (models.Posts, *e.Error) {
ars := make(models.Posts, 0)
rows, err := db.Conn.Query("select id,title,url,cover,brief,created,updated from posts where creator=?", uid)
if err != nil {
return ars, err
if err == sql.ErrNoRows {
return ars, e.New(http.StatusNotFound, e.NotFound)
}
logger.Warn("get user posts error", "error", err)
return ars, e.New(http.StatusInternalServerError, e.Internal)
}
creator := &models.UserSimple{ID: uid}
creator.Query()
for rows.Next() {
ar := &models.Article{}
ar := &models.Post{}
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)
logger.Warn("scan post error", "error", err)
continue
}
@ -41,42 +47,95 @@ func UserArticles(uid int64) (models.Articles, error) {
return ars, nil
}
func PostArticle(c *gin.Context) error {
func SubmitPost(c *gin.Context) *e.Error {
user := session.CurrentUser(c)
if !user.Role.IsEditor() {
return errors.New(errcode.NoEditorPermission)
return e.New(http.StatusForbidden, e.NoEditorPermission)
}
ar := &models.Article{}
ar := &models.Post{}
err := c.Bind(&ar)
if err != nil {
return err
return e.New(http.StatusBadRequest, e.ParamInvalid)
}
if strings.TrimSpace(ar.Title) == "" || utf8.RuneCountInString(ar.Brief) > config.Data.Posts.BriefMaxLen {
return e.New(http.StatusBadRequest, e.ParamInvalid)
}
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)
if strings.TrimSpace(ar.URL) != "" && !govalidator.IsURL(ar.URL) {
return e.New(http.StatusBadRequest, e.ParamInvalid)
}
if strings.TrimSpace(ar.Cover) != "" && !govalidator.IsURL(ar.Cover) {
return e.New(http.StatusBadRequest, e.ParamInvalid)
}
now := time.Now()
md := utils.Compress(ar.Md)
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("INSERT INTO posts (creator, title, md, url, cover, brief, created, updated) VALUES(?,?,?,?,?,?,?,?)",
user.ID, ar.Title, md, ar.URL, ar.Cover, ar.Brief, now, now)
if err != nil {
logger.Warn("submit post error", "error", err)
return e.New(http.StatusInternalServerError, e.Internal)
}
} else {
_, err = db.Conn.Exec("UPDATE posts SET title=?, md=?, url=?, cover=?, brief=?, updated=? WHERE id=?",
ar.Title, md, ar.URL, ar.Cover, ar.Brief, now, ar.ID)
if err != nil {
logger.Warn("upate post error", "error", err)
return e.New(http.StatusInternalServerError, e.Internal)
}
}
_, 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
return nil
}
func DeleteArticle(c *gin.Context) error {
user := session.CurrentUser(c)
if !user.Role.IsEditor() {
return errors.New(errcode.NoEditorPermission)
func DeletePost(id int64) *e.Error {
_, err := db.Conn.Exec("DELETE FROM posts WHERE id=?", id)
if err != nil {
logger.Warn("delete post error", "error", err)
return e.New(http.StatusInternalServerError, e.Internal)
}
return nil
}
func GetPost(id int64) (*models.Post, *e.Error) {
ar := &models.Post{}
var rawmd []byte
err := db.Conn.QueryRow("select id,title,md,url,cover,brief,creator,created,updated from posts where id=?", id).Scan(
&ar.ID, &ar.Title, &rawmd, &ar.URL, &ar.Cover, &ar.Brief, &ar.CreatorID, &ar.Created, &ar.Updated,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, e.New(http.StatusNotFound, e.NotFound)
}
logger.Warn("get post error", "error", err)
return nil, e.New(http.StatusInternalServerError, e.Internal)
}
md, _ := utils.Uncompress(rawmd)
ar.Md = string(md)
ar.Creator = &models.UserSimple{ID: ar.CreatorID}
err = ar.Creator.Query()
return ar, nil
}
func GetPostCreator(id int64) (int64, *e.Error) {
var uid int64
err := db.Conn.QueryRow("SELECT creator FROM posts WHERE id=?", id).Scan(&uid)
if err != nil {
if err == sql.ErrNoRows {
return 0, e.New(http.StatusNotFound, e.NotFound)
}
logger.Warn("get post creator error", "error", err)
return 0, e.New(http.StatusInternalServerError, e.Internal)
}
id := c.Param("id")
_, err := db.Conn.Exec("DELETE FROM articles WHERE id=?", id)
return err
return uid, nil
}

@ -9,7 +9,7 @@ import (
"github.com/imdotdev/im.dev/server/internal/storage"
"github.com/imdotdev/im.dev/server/pkg/common"
"github.com/imdotdev/im.dev/server/pkg/config"
"github.com/imdotdev/im.dev/server/pkg/errcode"
"github.com/imdotdev/im.dev/server/pkg/e"
"github.com/imdotdev/im.dev/server/pkg/log"
)
@ -52,10 +52,12 @@ func (s *Server) Start() error {
{
editorR := lr.Group("/editor")
{
editorR.GET("/articles", api.GetEditorArticles)
editorR.POST("/article", api.PostEditorArticle)
editorR.DELETE("/article/:id", api.DeleteEditorArticle)
editorR.GET("/posts", api.GetEditorPosts)
editorR.POST("/post", api.SubmitPost)
editorR.DELETE("/post/:id", api.DeletePost)
editorR.GET("/post/:id", api.GetEditorPost)
}
}
err := router.Run(config.Data.Server.Addr)
if err != nil {
@ -97,7 +99,7 @@ func IsLogin() gin.HandlerFunc {
return func(c *gin.Context) {
user := session.CurrentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, common.RespError(errcode.NeedLogin))
c.JSON(http.StatusUnauthorized, common.RespError(e.NeedLogin))
c.Abort()
return
}

@ -28,21 +28,22 @@ var sqlTables = map[string]string{
);
`,
"articles": `CREATE TABLE IF NOT EXISTS articles (
"posts": `CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
url VARCHAR(255) NOT NULL,
md TEXT,
url VARCHAR(255),
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);
CREATE INDEX IF NOT EXISTS posts_creator
ON posts (creator);
CREATE INDEX IF NOT EXISTS posts_created
ON posts (created);
`,
}

@ -14,12 +14,14 @@ type UIConfig struct {
type UIPosts struct {
BriefMaxLen int `json:"briefMaxLen"`
WritingEnabled bool `json:"writingEnabled"`
}
func GetUIConfig(c *gin.Context) {
conf := &UIConfig{
Posts: &UIPosts{
BriefMaxLen: config.Data.Posts.BriefMaxLen,
WritingEnabled: config.Data.Posts.WritingEnabled,
},
}

@ -1,6 +1,6 @@
package common
import "github.com/imdotdev/im.dev/server/pkg/errcode"
import "github.com/imdotdev/im.dev/server/pkg/e"
type Resp struct {
Status string `json:"status"`
@ -27,7 +27,7 @@ func RespError(msg string) *Resp {
func RespInternalError() *Resp {
r := &Resp{}
r.Status = Error
r.Message = errcode.Internal
r.Message = e.Internal
return r
}

@ -34,6 +34,7 @@ type Config struct {
Posts struct {
BriefMaxLen int `yaml:"brief_max_len"`
WritingEnabled bool `yaml:"writing_enabled"`
}
}

@ -0,0 +1,23 @@
package e
type Error struct {
Status int
Message string
}
func New(status int, msg string) *Error {
return &Error{
Status: status,
Message: msg,
}
}
const (
DB = "数据库异常"
Internal = "服务器内部错误"
NeedLogin = "你需要登录才能访问该页面"
NoEditorPermission = "只有编辑角色才能执行此操作"
ParamInvalid = "请求参数不正确"
NotFound = "目标不存在"
NoPermission = "你没有权限执行此操作"
)

@ -1,7 +0,0 @@
package errcode
const DB = "database error"
const Internal = "server internal error"
const NeedLogin = "你需要登录才能访问该页面"
const NoEditorPermission = "只有编辑角色才能执行此操作"
const ParamInvalid = "请求参数不正确"

@ -2,10 +2,12 @@ package models
import "time"
type Article struct {
type Post struct {
ID int64 `json:"id"`
Creator *UserSimple `json:"creator"`
CreatorID int64 `json:"creatorId"`
Title string `json:"title"`
Md string `json:"md"`
URL string `json:"url"`
Cover string `json:"cover"`
Brief string `json:"brief"`
@ -13,10 +15,10 @@ type Article struct {
Updated time.Time `json:"updated"`
}
type Articles []*Article
type Posts []*Post
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 {
func (ar Posts) Len() int { return len(ar) }
func (ar Posts) Swap(i, j int) { ar[i], ar[j] = ar[j], ar[i] }
func (ar Posts) Less(i, j int) bool {
return ar[i].Created.Unix() > ar[j].Created.Unix()
}

@ -38,7 +38,7 @@ type UserSimple struct {
}
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(
err := db.Conn.QueryRow(`SELECT id,username,nickname,avatar FROM user WHERE id=?`, user.ID).Scan(
&user.ID, &user.Username, &user.Nickname, &user.Avatar,
)

@ -0,0 +1,12 @@
package utils
import "github.com/golang/snappy"
func Compress(s string) []byte {
encoded := snappy.Encode(nil, []byte(s))
return encoded
}
func Uncompress(b []byte) ([]byte, error) {
return snappy.Decode(nil, b)
}

@ -1,37 +0,0 @@
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<typeof chakra.div> & {
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 (
<Flex justifyContent="space-between" {...rest}>
<VStack alignItems="left">
<Heading size="sm">{props.article.title}</Heading>
<Text fontSize=".9rem">{gap}</Text>
</VStack>
{props.showActions && <HStack>
<Button size="sm" colorScheme="teal" variant="outline" onClick={onEdit}>Edit</Button>
<Button size="sm" onClick={onDeleteArticle}>Delete</Button>
</HStack>}
</Flex>
)
}
export default TextArticleCard

@ -0,0 +1,45 @@
import React from 'react';
import 'highlight.js/styles/atom-one-dark.css';
import { chakra,PropsOf } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import 'react-markdown-editor-lite/lib/index.css';
const MdEditor = dynamic(() => import('react-markdown-editor-lite'), {
ssr: false
});
type Props = PropsOf<typeof chakra.div> & {
md: string
onChange: any
}
export function MarkdownEditor(props) {
function handleEditorChange({html, text}) {
props.onChange(text)
}
return (
<MdEditor
width="100%"
value={props.md}
style={{ height: "102%" }}
renderHTML={_ => null}
onChange={handleEditorChange}
config={{
canView: false,
view:{
menu: true,
md: true,
html: false,
fullScreen: true,
}
}}
/>
);
}

@ -0,0 +1,26 @@
import React, { useRef, useEffect } from 'react';
import Markdown from 'markdown-to-jsx';
import hljs from 'highlight.js';
import 'highlight.js/styles/atom-one-dark.css';
import { chakra,PropsOf } from '@chakra-ui/react';
type Props = PropsOf<typeof chakra.div> & {
md: string
}
export function MarkdownRender({ md,...rest }:Props) {
const rootRef = useRef<HTMLDivElement>();
useEffect(() => {
rootRef.current.querySelectorAll('pre code').forEach((block) => {
hljs.highlightBlock(block);
});
}, [md]);
return (
<div ref={rootRef} style={{height:'100%'}}>
<Markdown children={md} {...rest} style={{height:'100%',fontSize: '14px'}}></Markdown>
</div>
);
}

@ -0,0 +1,35 @@
import React from "react"
import {chakra, Heading, VStack, Text, HStack,Button, Flex,PropsOf, Tag } from "@chakra-ui/react"
import { Post } from "src/types/posts"
import moment from 'moment'
type Props = PropsOf<typeof chakra.div> & {
post: Post
showActions: boolean
onEdit?: any
onDelete?: any
}
export const TextPostCard= (props:Props) =>{
const {post,showActions,onEdit,onDelete, ...rest} = props
const gap = moment(post.created).fromNow()
return (
<Flex justifyContent="space-between" {...rest}>
<VStack alignItems="left" as="a" href={post.url ? post.url : `/${post.creator.username}/${post.slug}`}>
<Heading size="sm" display="flex" alignItems="center">
{post.url ? <Tag size="sm" mr="2"></Tag> : <Tag size="sm" mr="2"></Tag>}
{post.title}
</Heading>
<Text fontSize=".9rem">{gap}</Text>
</VStack>
{props.showActions && <HStack>
<Button size="sm" colorScheme="teal" variant="outline" onClick={onEdit}>Edit</Button>
<Button size="sm" onClick={props.onDelete}>Delete</Button>
</HStack>}
</Flex>
)
}
export default TextPostCard

@ -0,0 +1,35 @@
import { Box, useRadio } from "@chakra-ui/react"
function RadioCard(props) {
const { getInputProps, getCheckboxProps} = useRadio(props)
const input = getInputProps()
const checkbox = getCheckboxProps()
return (
<Box as="label">
<input {...input} />
<Box
{...checkbox}
cursor="pointer"
borderWidth="1px"
borderRadius="md"
boxShadow="md"
_checked={{
bg: props.bg,
color: props.color,
borderColor: props.bg,
}}
_focus={{
boxShadow: "outline",
}}
px={2}
py={1}
>
{props.children}
</Box>
</Box>
)
}
export default RadioCard

@ -3,7 +3,7 @@ import { FaFileAlt, FaScroll, FaBookOpen } from 'react-icons/fa'
import { Route } from 'src/types/route'
const editorLinks: Route[] = [{
title: '文章',
path: '/editor/article',
path: '/editor/posts',
icon: <FaFileAlt />,
disabled: false
},

@ -0,0 +1,4 @@
export enum EditMode {
Edit = '编辑',
Preview = '预览'
}

@ -1,11 +1,14 @@
import {User} from './session'
export interface Article {
export interface Post {
id?: number
slug?: string
creator?: User
title: string
url: string
cover: string
creatorId?: number
title?: string
md?: string
url?: string
cover?: string
brief?: string
created?: string
}

@ -2,7 +2,8 @@ import { requestApi } from "./axios/request"
export let config = {
posts: {
briefMaxLen: 10
briefMaxLen: 10,
writingEnabled: false
}
}

@ -1,5 +1,7 @@
import { extendTheme } from "@chakra-ui/react"
import { mode } from "@chakra-ui/theme-tools"
import reactMarkdownStyls from 'theme/react-markdown-editor'
import layerStyles from 'theme/layer-styles'
const customTheme = extendTheme({
config: {
@ -8,20 +10,7 @@ const customTheme = extendTheme({
},
//predefined mixin
layerStyles: {
textSecondary: {
opacity: "0.8"
},
colorButton: {
bg: "linear-gradient(270deg,#0076f5,#0098a3)",
color: "white",
_hover: {
cursor: 'pointer'
},
_focus: null
}
},
layerStyles: layerStyles(),
fonts: {
heading: "Inter, sans-serif",
body: "Inter, sans-serif",
@ -43,6 +32,7 @@ const customTheme = extendTheme({
fontStyle: "normal !important",
},
},
...reactMarkdownStyls(props)
}),
},
textStyles: {

@ -0,0 +1,15 @@
export default function layerStyles(theme) {
return {
textSecondary: {
opacity: "0.8"
},
colorButton: {
bg: "linear-gradient(270deg,#0076f5,#0098a3)",
color: "white",
_hover: {
cursor: 'pointer'
},
_focus: null
}
}
}

@ -0,0 +1,34 @@
import { mode } from "@chakra-ui/theme-tools"
import userCustomTheme from "./user-custom"
export default function reactMarkdownStyles(props) {
return {
'.rc-md-editor': {
borderWidth: '0px',
background: 'transparent',
textarea: {
background: 'transparent!important',
color: mode("#2D3748!important", "rgba(255, 255, 255, 0.92)!important")(props),
},
'.rc-md-navigation' :{
background: 'transparent',
borderBottomColor: mode(userCustomTheme.borderColor.light, userCustomTheme.borderColor.dark + '!important')(props),
'.navigation-nav' :{
'.button': {
color: mode("#2D3748!important", "rgba(255, 255, 255, 0.92)!important")(props),
}
}
},
'.drop-wrap' : {
background: mode("white", "#1A202C")(props),
borderWidth: '1px',
borderColor: mode(userCustomTheme.borderColor.light, userCustomTheme.borderColor.dark + '!important')(props),
},
'.header-list .list-item': {
_hover: {
background: 'transparent'
}
}
}
}
}

@ -0,0 +1,10 @@
import { extendTheme } from "@chakra-ui/react"
const theme = extendTheme()
const userCustomTheme = {
borderColor: {
light: theme.colors.gray['200'],
dark: theme.colors.whiteAlpha['300']
}
}
export default userCustomTheme

@ -1092,6 +1092,11 @@
dependencies:
tslib "^2.0.0"
"@types/highlight.js@^9.12.3":
version "9.12.4"
resolved "https://registry.yarnpkg.com/@types/highlight.js/-/highlight.js-9.12.4.tgz#8c3496bd1b50cc04aeefd691140aa571d4dbfa34"
integrity sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==
"@types/lodash.mergewith@4.6.6":
version "4.6.6"
resolved "https://registry.yarnpkg.com/@types/lodash.mergewith/-/lodash.mergewith-4.6.6.tgz#c4698f5b214a433ff35cb2c75ee6ec7f99d79f10"
@ -1652,7 +1657,7 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
inherits "^2.0.1"
safe-buffer "^5.0.1"
classnames@2.2.6:
classnames@2.2.6, classnames@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
@ -2494,6 +2499,11 @@ hey-listen@^1.0.8:
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==
highlight.js@^9.16.2:
version "9.18.5"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.5.tgz#d18a359867f378c138d6819edfc2a8acd5f29825"
integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA==
hmac-drbg@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -2843,6 +2853,11 @@ make-dir@^3.0.2:
dependencies:
semver "^6.0.0"
markdown-to-jsx@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.1.1.tgz#269145a585650258691a9a1ec9fb4b273ed2d32a"
integrity sha512-PJgNmvdKM7f7dFDgr4N2ZQv5OuJ2dwwBZvKel61BO7JLh2QQLDs880uQO+OjsEKNmhCZ0ZOIKkCXFEuY9G0yEA==
md5.js@^1.3.4:
version "1.3.5"
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@ -3507,6 +3522,13 @@ react-is@16.13.1, react-is@^16.7.0, react-is@^16.8.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-markdown-editor-lite@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/react-markdown-editor-lite/-/react-markdown-editor-lite-1.2.4.tgz#d47ea00f62e7c82ae8803a990d011d4f44015c20"
integrity sha512-zUMDsrRxmjNeZEaVkL2oXlOpSJOmVtI5wwRv71MevyvwA/gZQOE7oJP6dSRe84HMccuoU2v1+FxNWKYJtMdPdw==
dependencies:
classnames "^2.2.6"
react-refresh@0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"

Loading…
Cancel
Save