diff --git a/internal/api_handler.go b/internal/api_handler.go index 3830ecdb..52648bf7 100644 --- a/internal/api_handler.go +++ b/internal/api_handler.go @@ -20,6 +20,12 @@ func apiHandler(e *echo.Echo) { e.POST("/web/article/saveChanges", post.SaveArticleChanges, session.CheckSignIn) // comment apis - e.POST("/web/post/comment", post.Comment, session.CheckSignIn) - e.GET("/web/post/queryComments", post.QueryComments) + e.POST("/web/comment/create", post.Comment, session.CheckSignIn) + e.POST("/web/comment/reply", post.CommentReply, session.CheckSignIn) + e.POST("/web/comment/edit", post.EditComment, session.CheckSignIn) + e.POST("/web/comment/delete", post.DeleteComment, session.CheckSignIn) + e.POST("/web/comment/revert", post.RevertComment, session.CheckSignIn) + e.GET("/web/comment/query", post.QueryComments) + e.POST("/web/comment/like", post.CommentLike, session.CheckSignIn) + e.POST("/web/comment/dislike", post.CommentDislike, session.CheckSignIn) } diff --git a/internal/ecode/ecode.go b/internal/ecode/ecode.go index 2243638b..43cd40e2 100644 --- a/internal/ecode/ecode.go +++ b/internal/ecode/ecode.go @@ -23,3 +23,9 @@ const ( PostNotFound = 1101 PostNotFoundMsg = "Target post not found" ) + +// comment +const ( + CommentLiked = 1200 + CommentLikedMsg = "You have agreed this comment before" +) diff --git a/internal/post/comment.go b/internal/post/comment.go index 02fac510..313bfbd8 100644 --- a/internal/post/comment.go +++ b/internal/post/comment.go @@ -2,11 +2,14 @@ package post import ( "encoding/json" + "fmt" "net/http" "sort" "strconv" + "strings" "time" + "github.com/gocql/gocql" "github.com/labstack/echo" "github.com/thinkindev/im.dev/internal/ecode" "github.com/thinkindev/im.dev/internal/misc" @@ -18,14 +21,23 @@ import ( // CommentContent holds the comment content type CommentContent struct { ID string `json:"id"` + PID string `json:"pid"` + Depth int `json:"depth"` MD string `json:"md"` Render string `json:"render"` UID string `json:"uid"` UName string `json:"uname"` UNickname string `json:"unickname"` - UAvatar string `json:"uavatar"` PublishDate string `json:"date"` publishDate int64 + + EditDate string `json:"edit_date,omitempty"` + editDate int64 + + Likes int `json:"likes"` + Liked int `json:"liked"` // current login user's liked to the comment, 0 normal, 1 liked, 2 disliked + + Status int `json:"status"` } // Comments is the list of comment @@ -38,7 +50,7 @@ func (a Comments) Swap(i, j int) { // 重写 Swap() 方法 a[i], a[j] = a[j], a[i] } func (a Comments) Less(i, j int) bool { // 重写 Less() 方法, 从小到大排序 - return a[i].publishDate < a[j].publishDate + return a[i].publishDate >= a[j].publishDate } // Comment is the action that user make commention to one post @@ -89,11 +101,15 @@ func Comment(c echo.Context) error { cc.UID = sess.ID // generate id for article cc.ID = misc.GenID() + cc.UName = sess.Name + cc.UNickname = sess.NickName // modify render cc.Render = modify(cc.Render) + cc.publishDate = time.Now().Unix() + cc.PublishDate = utils.Time2ReadableString(time.Unix(cc.publishDate, 0)) err = misc.CQL.Query("INSERT INTO comment (id,uid,post_id,post_type,md,render,publish_date) VALUES (?,?,?,?,?,?,?)", - cc.ID, sess.ID, postID, postType, cc.MD, cc.Render, time.Now().Unix()).Exec() + cc.ID, sess.ID, postID, postType, cc.MD, cc.Render, cc.publishDate).Exec() if err != nil { misc.Log.Warn("access database error", zap.Error(err)) return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ @@ -116,6 +132,249 @@ func Comment(c echo.Context) error { }) } +// CommentReply is the action that user make commention to one post +func CommentReply(c echo.Context) error { + pid := c.FormValue("pid") + postID := c.FormValue("post_id") + postType, _ := strconv.Atoi(c.FormValue("post_type")) + content := c.FormValue("content") + + if pid == "" || postID == "" || postType != ArticleType || content == "" { + return c.JSON(http.StatusBadRequest, misc.HTTPResp{ + ErrCode: ecode.ParamInvalid, + Message: ecode.ParamInvalidMsg, + }) + } + + // check whether target post exist + var title string + var err error + switch postType { + case ArticleType: + err = misc.CQL.Query("SELECT title FROM article WHERE id=?", postID).Scan(&title) + } + if err != nil { + if err.Error() == misc.CQLNotFound { + return c.JSON(http.StatusNotFound, misc.HTTPResp{ + ErrCode: ecode.PostNotFound, + Message: ecode.PostNotFoundMsg, + }) + } + misc.Log.Warn("access database error", zap.Error(err)) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + cc := &CommentContent{} + err = json.Unmarshal([]byte(content), &cc) + if err != nil { + return c.JSON(http.StatusBadRequest, misc.HTTPResp{ + ErrCode: ecode.ParamInvalid, + Message: ecode.ParamInvalidMsg, + }) + } + + sess := session.Get(c) + + cc.UID = sess.ID + // generate id for article + cc.ID = misc.GenID() + cc.UName = sess.Name + cc.UNickname = sess.NickName + // modify render + cc.Render = modify(cc.Render) + cc.publishDate = time.Now().Unix() + cc.PublishDate = utils.Time2ReadableString(time.Unix(cc.publishDate, 0)) + cc.PID = pid + err = misc.CQL.Query("INSERT INTO comment (id,pid,uid,post_id,post_type,md,render,publish_date) VALUES (?,?,?,?,?,?,?,?)", + cc.ID, pid, sess.ID, postID, postType, cc.MD, cc.Render, cc.publishDate).Exec() + if err != nil { + misc.Log.Warn("access database error", zap.Error(err)) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + // update post comment count + switch postType { + case ArticleType: + err = misc.CQL.Query("UPDATE post_counter SET comments=comments + 1 WHERE id=?", postID).Exec() + } + if err != nil { + misc.Log.Warn("access database error", zap.Error(err)) + } + + return c.JSON(http.StatusOK, misc.HTTPResp{ + Data: cc, + }) +} + +// EditComment edit the comment +func EditComment(c echo.Context) error { + id := c.FormValue("id") + content := c.FormValue("content") + + if id == "" || content == "" { + return c.JSON(http.StatusBadRequest, misc.HTTPResp{ + ErrCode: ecode.ParamInvalid, + Message: ecode.ParamInvalidMsg, + }) + } + + sess := session.Get(c) + + // check permission + q := misc.CQL.Query(`SELECT uid FROM comment WHERE id=?`, id) + var uid string + err := q.Scan(&uid) + if err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + if uid != sess.ID { + return c.JSON(http.StatusUnauthorized, misc.HTTPResp{ + ErrCode: ecode.NoPermission, + Message: ecode.NoPermissionMsg, + }) + } + + cc := &CommentContent{} + err = json.Unmarshal([]byte(content), &cc) + if err != nil { + return c.JSON(http.StatusBadRequest, misc.HTTPResp{ + ErrCode: ecode.ParamInvalid, + Message: ecode.ParamInvalidMsg, + }) + } + + cc.Render = modify(cc.Render) + + q = misc.CQL.Query(`UPDATE comment SET md=?,render=?,edit_date=? WHERE id=?`, cc.MD, cc.Render, time.Now().Unix(), id) + err = q.Exec() + if err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + return c.JSON(http.StatusOK, misc.HTTPResp{ + Data: cc.Render, + }) +} + +// DeleteComment delete the comment +// Only comment author can do this +func DeleteComment(c echo.Context) error { + id := c.FormValue("id") + if id == "" { + return c.JSON(http.StatusBadRequest, misc.HTTPResp{ + ErrCode: ecode.ParamInvalid, + Message: ecode.ParamInvalidMsg, + }) + } + + sess := session.Get(c) + // check comment exists and this user has permission + var uid string + q := misc.CQL.Query(`SELECT uid FROM comment WHERE id=?`, id) + err := q.Scan(&uid) + if err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + if sess.ID != uid { + return c.JSON(http.StatusUnauthorized, misc.HTTPResp{ + ErrCode: ecode.NoPermission, + Message: ecode.NoPermissionMsg, + }) + } + + // set comment to delete status + q = misc.CQL.Query(`UPDATE comment SET status=? WHERE id=?`, StatusDeleted, id) + err = q.Exec() + if err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + return c.JSON(http.StatusOK, misc.HTTPResp{}) +} + +// RevertComment revert the comment from delete status +// Only comment owner and post author can do this +func RevertComment(c echo.Context) error { + id := c.FormValue("id") + if id == "" { + return c.JSON(http.StatusBadRequest, misc.HTTPResp{ + ErrCode: ecode.ParamInvalid, + Message: ecode.ParamInvalidMsg, + }) + } + + sess := session.Get(c) + // check comment exists and this user has permission + var uid, md, render string + + q := misc.CQL.Query(`SELECT uid,md,render FROM comment WHERE id=?`, id) + err := q.Scan(&uid, &md, &render) + if err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + if sess.ID != uid { + return c.JSON(http.StatusUnauthorized, misc.HTTPResp{ + ErrCode: ecode.NoPermission, + Message: ecode.NoPermissionMsg, + }) + } + + // set comment to delete status + q = misc.CQL.Query(`UPDATE comment SET status=? WHERE id=?`, StatusNormal, id) + err = q.Exec() + if err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + comment := &CommentContent{} + comment.MD = md + comment.Render = render + u := session.GetUserByID(uid) + if u == nil { + comment.UName = "[404]" + comment.UNickname = "[404]" + } else { + comment.UName = u.Name + comment.UNickname = u.NickName + } + + return c.JSON(http.StatusOK, misc.HTTPResp{ + Data: comment, + }) +} + // QueryComments return the comments by post id func QueryComments(c echo.Context) error { postID := c.FormValue("post_id") @@ -126,34 +385,61 @@ func QueryComments(c echo.Context) error { }) } - q := misc.CQL.Query(`SELECT id,uid,md,render,publish_date FROM comment WHERE post_id=?`, postID) + q := misc.CQL.Query(`SELECT id,pid,uid,md,render,publish_date,edit_date,status FROM comment WHERE post_id=?`, postID) iter := q.Iter() - comments := make(Comments, 0) - var cid, uid, md, render string - var pdate int64 - for iter.Scan(&cid, &uid, &md, &render, &pdate) { + // comment map + cMap := make(map[string]*CommentContent) + // parent -> child map + pMap := make(map[string][]string) + // no parent list + nopList := make([]string, 0) + + var cid, uid, md, render, pid string + var pdate, edate int64 + var status int + for iter.Scan(&cid, &pid, &uid, &md, &render, &pdate, &edate, &status) { comment := &CommentContent{ ID: cid, + PID: pid, UID: uid, MD: md, Render: render, publishDate: pdate, + editDate: edate, + Status: status, } u := session.GetUserByID(comment.UID) if u == nil { continue } - comment.UName = u.Name - comment.UNickname = u.NickName - comment.UAvatar = u.Avatar - comment.PublishDate = utils.Time2ReadableString(time.Unix(comment.publishDate, 0)) + if status == StatusDeleted { + comment.MD = "[deleted]" + comment.Render = "[deleted]" + comment.UName = "[deleted]" + comment.UNickname = "[deleted]" + } else { + comment.UName = u.Name + comment.UNickname = u.NickName + } - comments = append(comments, comment) + comment.PublishDate = utils.Time2ReadableString(time.Unix(comment.publishDate, 0)) + if comment.editDate != 0 { + comment.EditDate = utils.Time2ReadableString(time.Unix(comment.editDate, 0)) + } + cMap[comment.ID] = comment + if comment.PID == "" { + nopList = append(nopList, comment.ID) + } else { + childs, ok := pMap[comment.PID] + if !ok { + pMap[comment.PID] = []string{comment.ID} + } else { + pMap[comment.PID] = append(childs, comment.ID) + } + } } - sort.Sort(comments) - if err := iter.Close(); err != nil { misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ @@ -162,7 +448,241 @@ func QueryComments(c echo.Context) error { }) } + // defualt sort based on time ascending + sort.Strings(nopList) + for _, childs := range pMap { + sort.Strings(childs) + } + + comments := make([]*CommentContent, 0) + + for _, nop := range nopList { + // add first level comment to final list + comment := cMap[nop] + comment.Depth = 0 + comments = append(comments, comment) + + // recursively find his childs + findCommentChilds(&comments, nop, pMap, cMap, 1) + } + + var b strings.Builder + b.WriteString(`SELECT id,likes FROM comment_counter WHERE id in (`) + + var b1 strings.Builder + sess := session.Get(c) + if sess != nil { + b1.WriteString(`SELECT comment_id,type FROM comment_like WHERE uid=? and comment_id in (`) + } + for i, c := range comments { + if i == len(comments)-1 { + b.WriteString(fmt.Sprintf("'%s')", c.ID)) + if sess != nil { + b1.WriteString(fmt.Sprintf("'%s')", c.ID)) + } + } else { + b.WriteString(fmt.Sprintf("'%s',", c.ID)) + if sess != nil { + b1.WriteString(fmt.Sprintf("'%s',", c.ID)) + } + } + } + + if len(comments) != 0 { + q = misc.CQL.Query(b.String()) + iter = q.Iter() + + var id string + var likes int + likesMap := make(map[string]int) + for iter.Scan(&id, &likes) { + likesMap[id] = likes + } + + if err := iter.Close(); err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + likedMap := make(map[string]int) + if sess != nil { + q := misc.CQL.Query(b1.String(), sess.ID) + iter := q.Iter() + var liked int + var postID string + for iter.Scan(&postID, &liked) { + likedMap[postID] = liked + } + + if err := iter.Close(); err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + } + for _, c := range comments { + c.Likes = likesMap[c.ID] + if sess != nil { + c.Liked = likedMap[c.ID] + } + } + } + return c.JSON(http.StatusOK, misc.HTTPResp{ Data: comments, }) } + +func findCommentChilds(comments *([]*CommentContent), pid string, pMap map[string][]string, cMap map[string]*CommentContent, depth int) { + childs, ok := pMap[pid] + if !ok { + return + } + + for _, child := range childs { + comment := cMap[child] + comment.Depth = depth + *comments = append(*comments, comment) + // findCommentChilds + findCommentChilds(comments, child, pMap, cMap, depth+1) + } +} + +// CommentLike indicates that a user like/dislike a Comment +func CommentLike(c echo.Context) error { + postID := c.FormValue("id") + if postID == "" { + return c.JSON(http.StatusBadRequest, misc.HTTPResp{ + ErrCode: ecode.ParamInvalid, + Message: ecode.ParamInvalidMsg, + }) + } + + sess := session.Get(c) + + // check whether you already liked this comment + status, err := commentLikeStatus(postID, sess.ID) + if err != nil { + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + if status == OpCommentLike { + err = commentLike(postID, sess.ID, OpCommentLike, OpDelete) + } else { + err = commentLike(postID, sess.ID, OpCommentLike, OpUpdate) + } + if err != nil { + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + var q *gocql.Query + if status == OpCommentLike { // from like to normal, -1 + q = misc.CQL.Query(`UPDATE comment_counter SET likes=likes-1 WHERE id=?`, postID) + } else if status == OpCommentDislike { // from dislike to like , + 2 + q = misc.CQL.Query(`UPDATE comment_counter SET likes=likes+2 WHERE id=?`, postID) + } else { // from normal to like, + 1 + q = misc.CQL.Query(`UPDATE comment_counter SET likes=likes+1 WHERE id=?`, postID) + } + + err = q.Exec() + if err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + } + + return c.JSON(http.StatusOK, misc.HTTPResp{}) +} + +// CommentDislike indicates that a user dislike a Comment +func CommentDislike(c echo.Context) error { + postID := c.FormValue("id") + if postID == "" { + return c.JSON(http.StatusBadRequest, misc.HTTPResp{ + ErrCode: ecode.ParamInvalid, + Message: ecode.ParamInvalidMsg, + }) + } + + sess := session.Get(c) + + // check whether you already liked this comment + status, err := commentLikeStatus(postID, sess.ID) + if err != nil { + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + if status == OpCommentDislike { + err = commentLike(postID, sess.ID, OpCommentDislike, OpDelete) + } else { + err = commentLike(postID, sess.ID, OpCommentDislike, OpUpdate) + } + + if err != nil { + return c.JSON(http.StatusInternalServerError, misc.HTTPResp{ + ErrCode: ecode.DatabaseError, + Message: ecode.CommonErrorMsg, + }) + } + + var q *gocql.Query + if status == OpCommentLike { // from like to dislike, -2 + q = misc.CQL.Query(`UPDATE comment_counter SET likes=likes-2 WHERE id=?`, postID) + } else if status == OpCommentDislike { // from dislike to normal + 1 + q = misc.CQL.Query(`UPDATE comment_counter SET likes=likes+1 WHERE id=?`, postID) + } else { // from normal to dislike -1 + q = misc.CQL.Query(`UPDATE comment_counter SET likes=likes-1 WHERE id=?`, postID) + } + + err = q.Exec() + if err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + } + + return c.JSON(http.StatusOK, misc.HTTPResp{}) +} + +func commentLikeStatus(id string, uid string) (int, error) { + var tp int + q := misc.CQL.Query(`SELECT type FROM comment_like WHERE uid=? and comment_id=?`, uid, id) + err := q.Scan(&tp) + if err != nil { + if err.Error() == misc.CQLNotFound { + return 0, nil + } + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return 0, err + } + return tp, nil +} + +// postLike is the action that a user click like or dislike on a post/comment +func commentLike(id string, uid string, tp int, op int) error { + var q *gocql.Query + switch op { + case OpUpdate: + q = misc.CQL.Query(`UPDATE comment_like SET type=?,input_date=? WHERE uid=? and comment_id=?`, tp, time.Now().Unix(), uid, id) + case OpDelete: + q = misc.CQL.Query(`DELETE FROM comment_like WHERE uid=? and comment_id=?`, uid, id) + } + + err := q.Exec() + if err != nil { + misc.Log.Warn("access database error", zap.Error(err), zap.String("query", q.String())) + return err + } + + return nil +} diff --git a/internal/post/const.go b/internal/post/const.go index bfccf642..f9988b0e 100644 --- a/internal/post/const.go +++ b/internal/post/const.go @@ -13,3 +13,24 @@ const ( // ArticleType means the post is an ariticle ArticleType = 1 ) + +const ( + // OpCommentLike means a user like a post + OpCommentLike = 1 + // OpCommentDislike means a user dislike a post + OpCommentDislike = 2 +) + +const ( + // OpUpdate is the update operation + OpUpdate = 1 + // OpDelete is the delete operation + OpDelete = 2 +) + +const ( + // StatusNormal means post is in normal status + StatusNormal = 0 + // StatusDeleted means post is deleted + StatusDeleted = 1 +) diff --git a/internal/post/modify.go b/internal/post/modify.go new file mode 100644 index 00000000..aba4b32d --- /dev/null +++ b/internal/post/modify.go @@ -0,0 +1,75 @@ +package post + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo" + "github.com/microcosm-cc/bluemonday" + "github.com/thinkindev/im.dev/internal/misc" + "github.com/thinkindev/im.dev/internal/session" + "github.com/thinkindev/im.dev/internal/utils" +) + +// Preview return the new review html of article +func Preview(c echo.Context) error { + render := c.FormValue("render") + + newr := modify(render) + return c.JSON(http.StatusOK, misc.HTTPResp{ + Data: newr, + }) +} + +/* modify the post content*/ + +// every user input need to be modified +// @user -> @user +// remove js,iframe such html tags and attributes +func modify(s string) string { + p := bluemonday.UGCPolicy() + p.AllowAttrs("class").Globally() + p.AllowAttrs("id").Globally() + p.AllowElements("input") + p.AllowAttrs("checked").OnElements("input") + p.AllowAttrs("disabled").OnElements("input") + p.AllowAttrs("type").OnElements("input") + p.AllowAttrs("style").OnElements("span") + p.AllowAttrs("style").OnElements("td") + p.AllowAttrs("style").OnElements("th") + // The policy can then be used to sanitize lots of input and it is safe to use the policy in multiple goroutines + render := p.Sanitize(s) + + afterRender := make([]rune, 0, len(render)) + idParseFlag := false + tempName := make([]rune, 0) + for _, r := range render { + if r == '@' { + idParseFlag = true + afterRender = append(afterRender, r) + continue + } + if idParseFlag { + if utils.ValidNameRune(r) { + tempName = append(tempName, r) + } else { + // end flag for parse name + idParseFlag = false + + // check name exist + if session.CheckUserExist(string(tempName)) { + // converse @name -> @user + afterRender = append(afterRender, []rune(fmt.Sprintf("%s", string(tempName), string(tempName)))...) + } else { + afterRender = append(afterRender, tempName...) + } + + afterRender = append(afterRender, r) + } + continue + } + + afterRender = append(afterRender, r) + } + return string(afterRender) +} diff --git a/internal/post/post.go b/internal/post/post.go index 3c7f241a..235520f8 100644 --- a/internal/post/post.go +++ b/internal/post/post.go @@ -1,67 +1 @@ package post - -import ( - "fmt" - "net/http" - - "github.com/labstack/echo" - "github.com/microcosm-cc/bluemonday" - "github.com/thinkindev/im.dev/internal/misc" - "github.com/thinkindev/im.dev/internal/session" - "github.com/thinkindev/im.dev/internal/utils" -) - -// Preview return the new review html of article -func Preview(c echo.Context) error { - render := c.FormValue("render") - - newr := modify(render) - return c.JSON(http.StatusOK, misc.HTTPResp{ - Data: newr, - }) -} - -/* modify the post content*/ - -// every user input need to be modified -// @user -> @user -// remove js,iframe such html tags and attributes -func modify(s string) string { - p := bluemonday.UGCPolicy() - p.AllowAttrs("class").Globally() - // The policy can then be used to sanitize lots of input and it is safe to use the policy in multiple goroutines - render := p.Sanitize(s) - - afterRender := make([]rune, 0, len(render)) - idParseFlag := false - tempName := make([]rune, 0) - for _, r := range render { - if r == '@' { - idParseFlag = true - afterRender = append(afterRender, r) - continue - } - if idParseFlag { - if utils.ValidNameRune(r) { - tempName = append(tempName, r) - } else { - // end flag for parse name - idParseFlag = false - - // check name exist - if session.CheckUserExist(string(tempName)) { - // converse @name -> @user - afterRender = append(afterRender, []rune(fmt.Sprintf("%s", string(tempName), string(tempName)))...) - } else { - afterRender = append(afterRender, tempName...) - } - - afterRender = append(afterRender, r) - } - continue - } - - afterRender = append(afterRender, r) - } - return string(afterRender) -} diff --git a/internal/utils/time.go b/internal/utils/time.go index a460a67a..7e0c731d 100644 --- a/internal/utils/time.go +++ b/internal/utils/time.go @@ -18,15 +18,15 @@ func Time2ReadableString(t time.Time) string { now := time.Now().Local() intv := now.Unix() - t.Unix() if intv < 60 { - return strconv.FormatInt(intv, 10) + "seconds ago" + return strconv.FormatInt(intv, 10) + " seconds ago" } if intv < 3600 { - return strconv.FormatInt(intv/60, 10) + "minutes ago" + return strconv.FormatInt(intv/60, 10) + " minutes ago" } if intv < 86400 { - return strconv.FormatInt(intv/3600, 10) + "hours ago" + return strconv.FormatInt(intv/3600, 10) + " hours ago" } y1, m1, d1 := now.Date() diff --git a/quick-start/cql/start.cql b/quick-start/cql/start.cql index df3ba4d3..382fb9ea 100644 --- a/quick-start/cql/start.cql +++ b/quick-start/cql/start.cql @@ -51,7 +51,8 @@ CREATE TABLE IF NOT EXISTS tags ( CREATE TABLE IF NOT EXISTS comment ( id text, - uid text, -- author user id + pid text, -- parent comment id + uid text, -- author user id post_id text, -- related post id post_type tinyint, -- related post type @@ -60,11 +61,16 @@ CREATE TABLE IF NOT EXISTS comment ( render text, -- rendered html publish_date bigint, + edit_date bigint, + + status tinyint, -- 0: normal, 1: deleted PRIMARY KEY (id) ) WITH gc_grace_seconds = 10800; CREATE CUSTOM INDEX IF NOT EXISTS ON comment (post_id) USING 'org.apache.cassandra.index.sasi.SASIIndex' ; + + CREATE TABLE IF NOT EXISTS post_counter ( id text, -- post id @@ -73,4 +79,21 @@ CREATE TABLE IF NOT EXISTS post_counter ( recommands counter, PRIMARY KEY (id) -) WITH gc_grace_seconds = 10800; \ No newline at end of file +) WITH gc_grace_seconds = 10800; + +CREATE TABLE IF NOT EXISTS comment_counter ( + id text, -- comment id + likes counter, + PRIMARY KEY (id) +) WITH gc_grace_seconds = 10800; + +-- record whether a user likes a comment +CREATE TABLE IF NOT EXISTS comment_like ( + comment_id text, -- comment id + uid text, -- user id + type tinyint, -- 1: like 2: dislike + input_date bigint, + PRIMARY KEY (comment_id,uid) +) WITH gc_grace_seconds = 10800; +CREATE CUSTOM INDEX IF NOT EXISTS ON comment_like (uid) + USING 'org.apache.cassandra.index.sasi.SASIIndex' ; diff --git a/ui/src/assets/icon/iconfont.css b/ui/src/assets/icon/iconfont.css new file mode 100644 index 00000000..69f13563 --- /dev/null +++ b/ui/src/assets/icon/iconfont.css @@ -0,0 +1,37 @@ +@font-face {font-family: "iconfont"; + src: url('iconfont.eot?t=1568170482672'); /* IE9 */ + src: url('iconfont.eot?t=1568170482672#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAPEAAsAAAAACEgAAAN1AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCDOAqDQIMIATYCJAMYCw4ABCAFhG0HZBs6BxEVnHnIfh7GtojtKjpzws2LWP1NWda478HzlIe9yZ+kKYRT11ZW9lFAunYSIKBu37nvH9BA+KhzQ/ntkUc7TIGy16YH1Uf/w/1Ml+L57NtcolqjEgc4HtC0Isl2gHgK4tl08D8Bu6EAP5Jkp8pXrF4fG40+SAGqe5dObbATSnRFpMJGYRXM1JFqFia2Mdk4D8z0/l69IW82GJiCvlftjhXaU9qNcQuK4zk0F4exw+kBfh4IkB1oUN0LjR2RwJmd4Kf6a4oC29AbMJQzv1vQ8xhcXvZWGJXBAYBgbOMfzwRloYcHAFAdLhPcGAuBgJsXgQFuPgQK3PwINLgFszABsAZMt4EBwEGKzxCr49cpvwD9gsiYOrDHrbtt6diJJw/a3bzZ8NatRjduNLh+3afHaeDVx1vaLFi6cX1kg/mL1m2IGiZrblYMOn49xVh9owLz10c3Cjx1q5EfteQBpH39G8zfGK38OX469e12u/HhGxVCY+eU7bpjSq97zY7drBhy/HqZLtun/hrVemOjWmXN/6P0kT+nFWOFO/7bJCtLtss2h7+No0d00OC7vdQsOXxYbN5Mjg4GQwNvFpKnFVmSrdbhIaVruq3I3+HSZuXKtUvXpkIz8O8eUGTB+mHDkoTD+x1HObrXVqWdQt4f0bqgSED3ejb2HL8QaPik59muFb5mV5G+NP5Fc6oXzd7VHQDPkSvigHdIBkhpL3SA8cYTf/2qh9Hhcr7/rYKLf7P9NQB3Voz5jB5ej3bEgU/Dasz6hnLtVKByNY6usrTCstAOnyZ0MDWyRQ5iNwA/QwNfzwthHg4lipyI6ZhtIiMw8CERBJsMtMZnBxN/8oOFTSnwI5tK8/0J1xdBaV8g03gCihA7wSCIQyCEuIDW+AdgEuUdWIQkwCt8F75i6TFdlIu4MO6w/oPeKAhTw4Uf+Y4yuyXGOR7hG2PSFhjaPu15w4BxiDHpR47MAkQkD69kP3SO4IhkcONWMR/nrhNFX2o38qfCkwgtGNqB1T+gbUggnNKsyPz+HZIyZxErCqrU31CU6MrBoNU3QL8JQ6OCS7kn+SGNGBOAEBEPvBJ/5DikCY/ibgbasJbqYD+cdUYx0VTYTi/2d7kM8KOfY2UoUVqZylI+XB92uu1HOWWqdvFgj4FTsziuu+olMOUpqSXIMuH408vpBAAAAA==') format('woff2'), + url('iconfont.woff?t=1568170482672') format('woff'), + url('iconfont.ttf?t=1568170482672') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */ + url('iconfont.svg?t=1568170482672#iconfont') format('svg'); /* iOS 4.1- */ +} + +.iconfont { + font-family: "iconfont" !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-undo:before { + content: "\e633"; +} + +.icon-sousuo:before { + content: "\e632"; +} + +.icon-comments-alt:before { + content: "\e618"; +} + +.icon-jiantou_shang:before { + content: "\e634"; +} + +.icon-jiantou_xia:before { + content: "\e636"; +} + diff --git a/ui/src/assets/icon/iconfont.eot b/ui/src/assets/icon/iconfont.eot new file mode 100644 index 00000000..35cb862d Binary files /dev/null and b/ui/src/assets/icon/iconfont.eot differ diff --git a/ui/src/assets/icon/iconfont.svg b/ui/src/assets/icon/iconfont.svg new file mode 100644 index 00000000..4e529708 --- /dev/null +++ b/ui/src/assets/icon/iconfont.svg @@ -0,0 +1,41 @@ + + + + + +Created by iconfont + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/assets/icon/iconfont.ttf b/ui/src/assets/icon/iconfont.ttf new file mode 100644 index 00000000..1df09f3e Binary files /dev/null and b/ui/src/assets/icon/iconfont.ttf differ diff --git a/ui/src/assets/icon/iconfont.woff b/ui/src/assets/icon/iconfont.woff new file mode 100644 index 00000000..0748929c Binary files /dev/null and b/ui/src/assets/icon/iconfont.woff differ diff --git a/ui/src/assets/icons/downvote.svg b/ui/src/assets/icons/downvote.svg new file mode 100644 index 00000000..fdc2c69c --- /dev/null +++ b/ui/src/assets/icons/downvote.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/icons/upvote.svg b/ui/src/assets/icons/upvote.svg new file mode 100644 index 00000000..c37c03e9 --- /dev/null +++ b/ui/src/assets/icons/upvote.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/main.js b/ui/src/main.js index ae053204..52c8c36f 100644 --- a/ui/src/main.js +++ b/ui/src/main.js @@ -12,6 +12,8 @@ import "./theme/editor.css"; Vue.use(mavonEditor) +import './assets/icon/iconfont.css'; + import App from './App' Vue.use(ElementUI); diff --git a/ui/src/router/index.js b/ui/src/router/index.js index a5f02597..a58b4caa 100644 --- a/ui/src/router/index.js +++ b/ui/src/router/index.js @@ -14,7 +14,7 @@ const router = new Router({ component: Nav, children: [ { path: '/', meta: {'title':'im.dev'}, component: () => import('@/views/home')}, - { path: '/x/article/new', meta: {'title':'Post - im.dev'},component: () => import('@/views/article/edit')}, + { path: '/dev/article/new', meta: {'title':'Post - im.dev'},component: () => import('@/views/article/edit')}, { path: '/:uname/:arID', meta: {'title':'Article - im.dev',},component: () => import('@/views/article/detail')}, { path: '/:uname/:arID/edit', meta: {'title':'Post - im.dev'},component: () => import('@/views/article/edit')}, ] diff --git a/ui/src/theme/light/style.less b/ui/src/theme/light/style.less index 2ebbd7a4..64b2e272 100644 --- a/ui/src/theme/light/style.less +++ b/ui/src/theme/light/style.less @@ -5,4 +5,16 @@ .post-huge-title { font-size: 36px; -} \ No newline at end of file +} + +.meta-word { + font-size:12px; + color:rgb(124,124,124); +} + +.bold-meta-word { + color:rgb(135, 138, 140); + font-weight:700; + font-size:12px; +} + diff --git a/ui/src/theme/md_render.css b/ui/src/theme/md_render.css index 0a7d46c0..e5c9448e 100644 --- a/ui/src/theme/md_render.css +++ b/ui/src/theme/md_render.css @@ -1,5 +1,5 @@ .render .content p { - margin: 0 0 25px + margin: 0 0 8px } @@ -79,10 +79,10 @@ .render .content h4, .render .content h5, .render .content h6 { - margin: 0 0 15px; + margin: 0 0 12px; font-weight: 700; color: #2f2f2f; - line-height: 1.7; + line-height: 1.6; text-rendering: optimizelegibility } @@ -129,45 +129,7 @@ -.render .content ol { - list-style-type: square; -} -.render .content ul { - list-style-type: none; -} - -.render .content ol, -.render .content ul { - padding: 0; - margin-left: 16px; - margin-bottom: 20px; - display: block; -} - -.render .content ol { - margin-left: 36px; -} - -.render .content ol li, -.render .content ul li { - margin-bottom: 10px; - /* line-height: 30px */ -} - -.render .content ol li ol, -.render .content ol li ul, -.render .content ul li ol, -.render .content ul li ul { - margin-top: 15px -} - -.render .content ul li:before { - font-size: 19.8px; - padding-top: 4px; - padding-right: 12px; - content: '\2022'; -} .render .content ol .image-package, .render .content ul .image-package { @@ -418,3 +380,6 @@ } + + + diff --git a/ui/src/theme/style.less b/ui/src/theme/style.less index cfb29833..806877fa 100644 --- a/ui/src/theme/style.less +++ b/ui/src/theme/style.less @@ -60,4 +60,8 @@ input::-webkit-input-placeholder { .background-light-grey { background-color: #fafafa!important +} + +.el-message.el-message--error.network-error { + top: 20px !important; } \ No newline at end of file diff --git a/ui/src/utils/request.js b/ui/src/utils/request.js index 1e5f0e6b..36416a0c 100644 --- a/ui/src/utils/request.js +++ b/ui/src/utils/request.js @@ -31,6 +31,14 @@ service.interceptors.response.use( }, error => { var response = error.response + if (response == undefined) { + Message.error({ + message: error.message, + duration: 3000, + customClass: 'network-error' + }) + return Promise.reject(error) + } if (response.data.err_code == 1001) { store.dispatch("setNeedSignin", 1) store.dispatch("ClearUserInfo") @@ -48,10 +56,7 @@ service.interceptors.response.use( return Promise.reject(response.data.message+' : '+ response.data.err_code) } - Message.error({ - content: error.message, - duration: 3 - }) + Message.error(error.message) return Promise.reject(error) }) diff --git a/ui/src/views/article/detail.vue b/ui/src/views/article/detail.vue index 0b8d954b..6d406926 100644 --- a/ui/src/views/article/detail.vue +++ b/ui/src/views/article/detail.vue @@ -50,8 +50,8 @@
{{arDetail.title}}
@@ -62,10 +62,10 @@ - + @@ -111,7 +111,6 @@ export default { article_id: this.arID } }).then(res0 => { - console.log(222) request({ url: "/web/user/get", method: "GET", @@ -137,7 +136,6 @@ export default { .square { margin-top: 3px; line-height: 36px; - border: 1px solid white; text-align: center; z-index: 100; font-size: 25px; diff --git a/ui/src/views/article/edit.vue b/ui/src/views/article/edit.vue index 4a71450c..45d69d23 100644 --- a/ui/src/views/article/edit.vue +++ b/ui/src/views/article/edit.vue @@ -33,7 +33,7 @@
- + diff --git a/ui/src/views/components/discuss.vue b/ui/src/views/components/discuss.vue index 78140d1b..fd8fba3b 100644 --- a/ui/src/views/components/discuss.vue +++ b/ui/src/views/components/discuss.vue @@ -1,27 +1,73 @@ @@ -30,7 +76,7 @@ import editor from "./editor" import render from "./render" import request from "@/utils/request"; export default { - props: ['postID','postType'], + props: ['postAuthorID','postID','postType'], components: {editor,render}, data() { return { @@ -41,16 +87,229 @@ export default { tempCommentRender: '', commentPreviewd : false, - comments: [] + comments: [], + currentCommentID: '', + + tempReply: { + md:'', + render: '' + }, + replyPreviewd : false, + tempReplyRender: '', + + currentEditCommentID: '', + tempEditReply: { + md: '', + render: '' + } }; }, watch: { + "$store.state.user.id"() { + if (this.$store.state.user.id != '') { + this.initComments() + } + }, }, methods: { + revertComment(id) { + request({ + url: "/web/comment/revert", + method: "POST", + params: { + id: id + } + }).then(res => { + for (var i=0;i { + request({ + url: "/web/comment/delete", + method: "POST", + params: { + id: id + } + }).then(res => { + for (var i=0;i { + }); + }, + upvoteComment(c) { + request({ + url: "/web/comment/like", + method: "POST", + params: { + id: c.id + } + }).then(res => { + if (c.liked == 0) { + c.liked = 1 + c.likes++ + } else if (c.liked == 1) { + c.liked = 0 + c.likes -- + } else { + c.liked = 1 + c.likes = c.likes + 2 + } + }); + }, + downvoteComment(c) { + request({ + url: "/web/comment/dislike", + method: "POST", + params: { + id: c.id + } + }).then(res => { + if (c.liked == 0) { + c.liked = 2 + c.likes-- + } else if (c.liked == 1) { + c.liked = 2 + c.likes = c.likes - 2 + } else { + c.liked = 0 + c.likes ++ + } + }); + }, + previewReply() { + this.replyPreviewd = true + request({ + url: "/web/post/preview", + method: "POST", + params: { + render: this.tempReplyRender + } + }).then(res => { + this.tempReply.render = res.data.data + }); + }, + editReplySetMD(md,render) { + this.tempEditReply.md = md + this.tempEditReply.render = render + }, + replySetMD(md,render) { + this.tempReply.md = md + this.tempReplyRender = render + }, + cancelEditReply() { + for (var i=0;i { + this.currentEditCommentID = '' + this.tempEditReply.md = '' + }).catch(() => { + }); + } else { + this.currentEditCommentID = '' + this.tempEditReply.md = '' + } + break + } + } + }, + editReply(c) { + this.currentEditCommentID = c.id + this.currentCommentID = '' + this.tempEditReply = { + md: c.md, + render: '' + } + }, + reply(id) { + if (id == this.currentCommentID) { + this.currentCommentID = '' + return + } + this.currentCommentID = id + this.currentEditCommentID = '' + }, + publishEditReply(id) { + request({ + url: "/web/comment/edit", + method: "POST", + params: { + id: id, + content: JSON.stringify(this.tempEditReply) + } + }).then(res => { + this.currentEditCommentID = '' + for (var i=0;i { + this.tempReply = { + md : '', + render: '' + } + this.tempReplyRender = '' + this.replyPreviewd = false + this.currentCommentID = '' + var newComments = [] + for (var i=0;i { + this.comments = res.data.data + }); } }, mounted() { - request({ - url: "/web/post/queryComments", - method: "GET", - params: { - post_id: this.postID, - } - }).then(res => { - this.comments = res.data.data - console.log(this.comments) - }); + this.initComments() } }; + + \ No newline at end of file diff --git a/ui/src/views/components/editor.vue b/ui/src/views/components/editor.vue index 612e63ca..ea38f075 100644 --- a/ui/src/views/components/editor.vue +++ b/ui/src/views/components/editor.vue @@ -1,26 +1,26 @@