diff --git a/forum.nim b/forum.nim index 633c212..0f11d6c 100644 --- a/forum.nim +++ b/forum.nim @@ -1021,6 +1021,9 @@ proc executeReply(c: TForumData, threadId: int, content: string, if rateLimitCheck(c): raise newForumError("You're posting too fast!") + if not validateRst(c, content): + raise newForumError("Message needs to be valid RST", @["msg"]) + # TODO: Replying to. # Verify that content can be parsed as RST. let retID = insertID( @@ -1039,6 +1042,42 @@ proc executeReply(c: TForumData, threadId: int, content: string, return retID +proc updatePost(c: TForumData, postId: int, content: string, + subject: Option[string]) = + ## Updates an existing post. + assert c.loggedIn() + + let postQuery = sql""" + select author, strftime('%s', creation), thread + from post where id = ? + """ + + let postRow = getRow(db, postQuery, postId) + + # Verify that the current user has permissions to edit the specified post. + let creation = fromUnix(postRow[1].parseInt) + let isArchived = (getTime() - creation).weeks > 8 + let canEdit = c.rank == Admin or c.username == postRow[0] + if isArchived: + raise newForumError("This post is archived and can no longer be edited") + if not canEdit: + raise newForumError("You cannot edit this post") + + if not validateRst(c, content): + raise newForumError("Message needs to be valid RST", @["msg"]) + + # Update post. + exec(db, crud(crUpdate, "post", "content"), content, $postId) + exec(db, crud(crUpdate, "post_fts", "content"), content, $postId) + # Check if post is the first post of the thread. + if subject.isSome(): + let threadId = postRow[2] + let row = db.getRow(sql(""" + select id from post where thread = ? order by id asc + """), threadId) + if row[0] == $postId: + exec(db, crud(crUpdate, "thread", "name"), subject.get(), threadId) + proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = const query = sql""" @@ -1472,6 +1511,34 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" + post "/karax/updatePost": + createTFD() + if not c.loggedIn(): + let err = PostError( + errorFields: @[], + message: "Not logged in." + ) + resp Http401, $(%err), "application/json" + + let formData = request.formData + cond "msg" in formData + cond "postId" in formData + + let msg = formData["msg"].body + let postId = getInt(formData["postId"].body, -1) + cond postId != -1 + let subject = + if "subject" in formData: + some(formData["subject"].body) + else: + none[string]() + + try: + updatePost(c, postId, msg, subject) + resp Http200, msg.rstToHtml(), "text/html" + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + post "/karax/newthread": createTFD() if not c.loggedIn(): diff --git a/redesign/editbox.nim b/redesign/editbox.nim index 2cdf9ce..861c629 100644 --- a/redesign/editbox.nim +++ b/redesign/editbox.nim @@ -1,21 +1,30 @@ when defined(js): - import httpcore, options, sugar + import httpcore, options, sugar, json include karax/prelude import karax/kajax - import replybox, post, karaxutils, threadlist + import replybox, post, karaxutils, threadlist, error type + OnEditPosted* = proc (id: int, content: string, subject: Option[string]) + EditBox* = ref object box: ReplyBox - post: Option[Post] + post: Post rawContent: Option[kstring] ## The raw rst for a post (needs to be loaded) + loading: bool status: HttpCode + error: Option[PostError] + onEditPosted: OnEditPosted + onEditCancel: proc () - proc newEditBox*(): EditBox = + proc newEditBox*(onEditPosted: OnEditPosted, onEditCancel: proc ()): EditBox = EditBox( - box: newReplyBox(nil) + box: newReplyBox(nil), + onEditPosted: onEditPosted, + onEditCancel: onEditCancel, + status: Http200 ) proc onRawContent(httpStatus: int, response: kstring, state: EditBox) = @@ -23,21 +32,60 @@ when defined(js): if state.status != Http200: return state.rawContent = some(response) + state.box.setText(state.rawContent.get()) + + proc onEditPost(httpStatus: int, response: kstring, state: EditBox) = + postFinished: + state.onEditPosted( + state.post.id, + $response, + none[string]() + ) + + proc save(state: EditBox) = + if state.loading: + # TODO: Weird behaviour: onClick handler gets called 80+ times. + return + state.loading = true + state.error = none[PostError]() + + let formData = newFormData() + formData.append("msg", state.box.getText()) + formData.append("postId", $state.post.id) + # TODO: Subject + let uri = makeUri("/updatePost") + ajaxPost(uri, @[], cast[cstring](formData), + (s: int, r: kstring) => onEditPost(s, r, state)) proc render*(state: EditBox, post: Post): VNode = - if state.post.isNone() or state.post.get().id != post.id: - state.post = some(post) + if state.rawContent.isNone() or state.post.id != post.id: + state.post = post + state.rawContent = none[kstring]() var params = @[("id", $post.id)] let uri = makeUri("post.rst", params) ajaxGet(uri, @[], (s: int, r: kstring) => onRawContent(s, r, state)) return buildHtml(tdiv(class="loading")) - state.box.setText(state.rawContent.get()) result = buildHtml(): tdiv(class="edit-box"): renderContent( state.box, none[Thread](), none[Post]() - ) \ No newline at end of file + ) + + if state.error.isSome(): + span(class="text-error"): + text state.error.get().message + + tdiv(class="edit-buttons"): + tdiv(class="reply-button"): + button(class="btn btn-link", + onClick=(e: Event, n: VNode) => (state.onEditCancel())): + text " Cancel" + tdiv(class="save-button"): + button(class=class({"loading": state.loading}, "btn btn-primary"), + onClick=(e: Event, n: VNode) => state.save()): + italic(class="fas fa-check") + text " Save" \ No newline at end of file diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index a3413c6..e6f5376 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -502,21 +502,26 @@ hr { } .edit-box { - margin-bottom: $control-padding-y; + .edit-buttons { + margin-top: $control-padding-y*2; + + float: right; + + > div { + display: inline-block; + } + } + + .text-error { + margin-top: $control-padding-y*3; + display: inline-block; + } .form-input.post-text-area { margin-bottom: $control-padding-y*2; } } -.edit-buttons { - float: right; - - > div { - display: inline-block; - } -} - @import "syntax.scss"; // - Profile view diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 461f587..795cb94 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -30,6 +30,8 @@ when defined(js): editBox: EditBox proc onReplyPosted(id: int) + proc onEditPosted(id: int, content: string, subject: Option[string]) + proc onEditCancelled() proc newState(): State = State( list: none[PostList](), @@ -37,7 +39,7 @@ when defined(js): status: Http200, replyingTo: none[Post](), replyBox: newReplyBox(onReplyPosted), - editBox: newEditBox() + editBox: newEditBox(onEditPosted, onEditCancelled) ) var @@ -109,17 +111,17 @@ when defined(js): ## Executed when a reply has been successfully posted. loadMore(state.list.get().posts.len, @[id]) - proc onEditPosted(id: int, content: string) = + proc onEditCancelled() = state.editing = none[Post]() + + proc onEditPosted(id: int, content: string, subject: Option[string]) = ## Executed when an edit has been successfully posted. + state.editing = none[Post]() let list = state.list.get() for i in 0 ..< list.posts.len: if list.posts[i].id == id: list.posts[i].info.content = content break - proc onEditConfirm(e: Event, n: VNode, p: Post) = - discard - proc onReplyClick(e: Event, n: VNode, p: Option[Post]) = state.replyingTo = p state.replyBox.show() @@ -151,48 +153,37 @@ when defined(js): loggedIn and currentUser.get().name == post.author.name if state.editing.isSome() and state.editing.get() == post: - result = buildHtml(): - tdiv(class="edit-buttons"): - tdiv(class="reply-button"): - button(class="btn btn-link", - onClick=(e: Event, n: VNode) => - (state.editing = none[Post]())): - text " Cancel" - tdiv(class="save-button"): - button(class="btn btn-primary", onClick=(e: Event, n: VNode) => - onEditConfirm(e, n, post)): - italic(class="fas fa-check") - text " Save" - else: - result = buildHtml(): - tdiv(class="post-buttons"): - if authoredByUser: - tdiv(class="edit-button", onClick=(e: Event, n: VNode) => - onEditClick(e, n, post)): - button(class="btn"): - italic(class="far fa-edit") - tdiv(class="delete-button"): - button(class="btn"): - italic(class="far fa-trash-alt") - else: - tdiv(class="like-button"): - button(class="btn"): - span(class="like-count"): - if post.likes.len > 0: - text $post.likes.len - italic(class="far fa-heart") + return buildHtml(tdiv()) - if loggedIn: - tdiv(class="flag-button"): - button(class="btn"): - italic(class="far fa-flag") + result = buildHtml(): + tdiv(class="post-buttons"): + if authoredByUser: + tdiv(class="edit-button", onClick=(e: Event, n: VNode) => + onEditClick(e, n, post)): + button(class="btn"): + italic(class="far fa-edit") + tdiv(class="delete-button"): + button(class="btn"): + italic(class="far fa-trash-alt") + else: + tdiv(class="like-button"): + button(class="btn"): + span(class="like-count"): + if post.likes.len > 0: + text $post.likes.len + italic(class="far fa-heart") if loggedIn: - tdiv(class="reply-button"): - button(class="btn", onClick=(e: Event, n: VNode) => - onReplyClick(e, n, some(post))): - italic(class="fas fa-reply") - text " Reply" + tdiv(class="flag-button"): + button(class="btn"): + italic(class="far fa-flag") + + if loggedIn: + tdiv(class="reply-button"): + button(class="btn", onClick=(e: Event, n: VNode) => + onReplyClick(e, n, some(post))): + italic(class="fas fa-reply") + text " Reply" proc genPost(post: Post, thread: Thread, currentUser: Option[User]): VNode = let postCopy = post # TODO: Another workaround here, closure capture :( diff --git a/redesign/user.nim b/redesign/user.nim index 545a4a6..ba8b494 100644 --- a/redesign/user.nim +++ b/redesign/user.nim @@ -8,7 +8,7 @@ type Moderated ## new member: posts manually reviewed before everybody ## can see them User ## Ordinary user - Moderator ## Moderator: can ban/moderate users + Moderator ## Moderator: can change a user's rank Admin ## Admin: can do everything User* = object