diff --git a/forum.nim b/forum.nim index f69298f..4bd503d 100644 --- a/forum.nim +++ b/forum.nim @@ -12,6 +12,7 @@ import parseutils, utils, random, rst, ranks, recaptcha, json import redesign/threadlist except User +import redesign/[category, postlist] when not defined(windows): import bcrypt # TODO @@ -1013,6 +1014,47 @@ template createTFD() = if request.cookies.len > 0: checkLoggedIn(c) +#[ DB functions. TODO: Move to another module? ]# + +proc selectUser(userRow: seq[string]): threadlist.User = + let isOnline = getTime().toUnix() - userRow[2].parseInt > (60*5) + return threadlist.User( + name: userRow[0], + avatarUrl: userRow[1].getGravatarUrl(), + isOnline: isOnline + ) + +proc selectThread(threadRow: seq[string]): Thread = + const postsQuery = + sql"""select count(*), strftime('%s', creation) from post + where thread = ? + order by creation asc limit 1;""" + const usersListQuery = + sql"""select distinct name, email, strftime('%s', lastOnline) + from person where id in + (select author from post where thread = ?) + limit 5;""" # TODO: Order by most posts. + + let posts = getRow(db, postsQuery, threadRow[0]) + + var thread = Thread( + id: threadRow[0].parseInt, + topic: threadRow[1], + category: Category(id: "", color: "#ff0000"), # TODO + users: @[], + replies: posts[0].parseInt, + views: threadRow[2].parseInt, + activity: threadRow[3].parseInt, + creation: posts[1].parseInt, + isLocked: false, + isSolved: false # TODO: ^ and this. Add a field to `post` to identify. + ) + + # Gather the users list. + for user in getAllRows(db, usersListQuery, thread.id): + thread.users.add(selectUser(user)) + + return thread initialise() @@ -1037,56 +1079,71 @@ routes: get "/karax/threads.json": var - start = 0 - count = 30 - parseInt(@"start", start, 0..1_000_000) - parseInt(@"count", start, 0..1_000_000) + start = getInt(@"start", 0) + count = getInt(@"count", 30) const threadsQuery = sql"""select id, name, views, strftime('%s', modified) from thread - order by modified desc limit ?, ?;""" - const postsQuery = - sql"""select count(*), strftime('%s', creation) from post - where thread = ? - order by creation asc limit 1;""" - const usersListQuery = - sql"""select distinct name, email, strftime('%s', lastOnline) - from person where id in - (select author from post where thread = ?) - limit 5;""" # TODO: Order by most posts. + order by modified desc limit ?, ?;""" # TODO: Moderation let thrCount = getValue(db, sql"select count(*) from thread;").parseInt() let moreCount = max(0, thrCount - (start + count)) var list = ThreadList(threads: @[], lastVisit: 0, moreCount: moreCount) for data in getAllRows(db, threadsQuery, start, count): - let posts = getRow(db, postsQuery, data[0]) - - var thread = Thread( - id: data[0].parseInt, - topic: data[1], - category: Category(id: "", color: "#ff0000"), # TODO - users: @[], - replies: posts[0].parseInt, - views: data[2].parseInt, - activity: data[3].parseInt, - creation: posts[1].parseInt, - isLocked: false, - isSolved: false # TODO: ^ and this. Add a field to `post` to identify. - ) - - # Gather the users list. - for user in getAllRows(db, usersListQuery, thread.id): - let isOnline = getTime().toUnix() - user[2].parseInt > (60*5) - thread.users.add(threadlist.User( - name: user[0], - avatarUrl: user[1].getGravatarUrl(), - isOnline: isOnline - )) + let thread = selectThread(data) list.threads.add(thread) resp $(%list), "application/json" + get "/karax/posts.json": + createTFD() + var + id = getInt(@"id", -1) + start = getInt(@"start", 0) + count = getInt(@"count", 5) + cond id != -1 + + const threadsQuery = + sql"""select id, name, views, strftime('%s', modified) from thread + where id = ?;""" + + let threadRow = getRow(db, threadsQuery, id) + let thread = selectThread(threadRow) + + let modClause = + if c.rank >= Moderator: + "(1 or u.id = ?)" + else: + "(u.status <> 'Moderated' or p.author = ?)" + let postsQuery = + sql( + """select p.id, p.content, strftime('%s', p.creation), p.author, + u.name, u.email, strftime('%s', u.lastOnline) + from post p, person u + where u.id = p.author and p.thread = ? and $# + and (u.status <> 'Spammer' or p.author = ?) + order by p.id limit ?, ?""" % modClause + ) + + var list = PostList(posts: @[], history: @[], thread: thread) + for post in getAllRows(db, postsQuery, id, c.userId, c.userId, + start, count): + list.posts.add(Post( + id: post[0].parseInt, + author: selectUser(@[post[4], post[5], post[6]]), + likes: @[], # TODO: + seen: false, # TODO: + history: @[], # TODO: + info: PostInfo( + creation: post[2].parseInt, + content: post[1] + ) + )) + + resp $(%list), "application/json" + + get "/threadActivity.xml": createTFD() c.isThreadsList = true diff --git a/redesign/category.nim b/redesign/category.nim new file mode 100644 index 0000000..70c1293 --- /dev/null +++ b/redesign/category.nim @@ -0,0 +1,23 @@ + +type + Category* = object + id*: string + color*: string + + +when defined(js): + include karax/prelude + import karax / [vstyles, kajax, kdom] + + import karaxutils + + proc render*(category: Category): VNode = + result = buildHtml(): + if category.id.len > 0: + tdiv(class="triangle", + style=style( + (StyleAttr.borderBottom, kstring"0.6rem solid " & category.color) + )): + text category.id + else: + span() \ No newline at end of file diff --git a/redesign/error.nim b/redesign/error.nim new file mode 100644 index 0000000..a670804 --- /dev/null +++ b/redesign/error.nim @@ -0,0 +1,17 @@ +include karax/prelude +import karax / [vstyles, kajax, kdom] + + +proc renderError*(message: string): VNode = + result = buildHtml(): + tdiv(class="empty error"): + tdiv(class="empty icon"): + italic(class="fas fa-bug fa-5x") + p(class="empty-title h5"): + text message + p(class="empty-subtitle"): + text "Please report this issue to us so we can fix it!" + tdiv(class="empty-action"): + a(href="https://github.com/nim-lang/nimforum/issues", target="_blank"): + button(class="btn btn-primary"): + text "Report issue" \ No newline at end of file diff --git a/redesign/forum.nim b/redesign/forum.nim index 93c0da2..f24cfc7 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -4,7 +4,7 @@ from dom import window, Location include karax/prelude -import threadlist, karaxutils +import threadlist, postlist, karaxutils type State = ref object @@ -44,7 +44,7 @@ proc render(): VNode = result = buildHtml(tdiv()): genHeader() if "/t/" in state.url.pathname: - text "" + renderPostList(3806, false) else: renderThreadList() diff --git a/redesign/postlist.nim b/redesign/postlist.nim new file mode 100644 index 0000000..33ed677 --- /dev/null +++ b/redesign/postlist.nim @@ -0,0 +1,117 @@ + +import options, json, times, httpcore, strformat + +import threadlist, category +type + PostInfo* = object + creation*: int64 + content*: string + + Post* = object + id*: int + author*: User + likes*: seq[User] ## Users that liked this post. + seen*: bool ## Determines whether the current user saw this post. + ## I considered using a simple timestamp for each thread, + ## but that wouldn't work when a user navigates to the last + ## post in a thread for example. + history*: seq[PostInfo] ## If the post was edited this will contain the + ## older versions of the post. + info*: PostInfo + + PostList* = ref object + thread*: Thread + history*: seq[Thread] ## If the thread was edited this will contain the + ## older versions of the thread (title/category + ## changes). + posts*: seq[Post] + +when defined(js): + include karax/prelude + import karax / [vstyles, kajax, kdom] + + import karaxutils, error + + type + State = ref object + list: Option[PostList] + loading: bool + status: HttpCode + + proc newState(): State = + State( + list: none[PostList](), + loading: false, + status: Http200 + ) + + var + state = newState() + + proc onPostList(httpStatus: int, response: kstring) = + state.loading = false + state.status = httpStatus.HttpCode + if state.status != Http200: return + + let parsed = parseJson($response) + let list = to(parsed, PostList) + + if state.list.isSome: + state.list.get().posts.add(list.posts) + # TODO: Incorporate other possible changes? + else: + state.list = some(list) + + proc renderPostUrl(post: Post, thread: Thread): string = + makeUri(fmt"/t/{thread.id}/p/{post.id}") + + proc genPost(post: Post, thread: Thread, isLoggedIn: bool): VNode = + result = buildHtml(): + tdiv(class="post"): + tdiv(class="post-icon"): + render(post.author, "post-avatar") + tdiv(class="post-main"): + tdiv(class="post-title"): + tdiv(class="post-username"): + text post.author.name + tdiv(class="post-time"): + let title = post.info.creation.fromUnix().local. + format("MMM d, yyyy HH:mm") + a(href=renderPostUrl(post, thread), title=title): + text renderActivity(post.info.creation) + tdiv(class="post-content"): + p(text post.info.content) # TODO: RSTGEN + tdiv(class="post-buttons"): + 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 isLoggedIn: + tdiv(class="flag-button"): + button(class="btn"): + italic(class="far fa-flag") + tdiv(class="reply-button"): + button(class="btn"): + italic(class="fas fa-reply") + text " Reply" + + proc renderPostList*(threadId: int, isLoggedIn: bool): VNode = + if state.status != Http200: + return renderError("Couldn't retrieve posts.") + + if state.list.isNone: + ajaxGet(makeUri("posts.json?id=" & $threadId), @[], onPostList) + + return buildHtml(tdiv(class="loading loading-lg")) + + let list = state.list.get() + result = buildHtml(): + section(class="container grid-xl"): + tdiv(class="title"): + p(): text list.thread.topic + render(list.thread.category) + tdiv(class="posts"): + for post in list.posts: + genPost(post, list.thread, isLoggedIn) \ No newline at end of file diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 3d8bb9f..3dfab50 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -1,4 +1,6 @@ -import strformat, times, options, json +import strformat, times, options, json, httpcore + +import category type User* = object @@ -6,10 +8,6 @@ type avatarUrl*: string isOnline*: bool - Category* = object - id*: string - color*: string - Thread* = object id*: int topic*: string @@ -31,17 +29,19 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils + import karaxutils, error type State = ref object list: Option[ThreadList] loading: bool + status: HttpCode proc newState(): State = State( list: none[ThreadList](), - loading: false + loading: false, + status: Http200 ) var @@ -63,16 +63,20 @@ when defined(js): button(class="btn btn-link"): text "Categories" section(class="navbar-section") + proc render*(user: User, class: string): VNode = + result = buildHtml(): + figure(class=class): + img(src=user.avatarUrl, title=user.name) + if user.isOnline: + italic(class="avatar-presense online") + proc genUserAvatars(users: seq[User]): VNode = result = buildHtml(td): for user in users: - figure(class="avatar avatar-sm"): - img(src=user.avatarUrl, title=user.name) - if user.isOnline: - italic(class="avatar-presense online") + render(user, "avatar avatar-sm") text " " - proc renderActivity(activity: int64): string = + proc renderActivity*(activity: int64): string = let currentTime = getTime() let activityTime = fromUnix(activity) let duration = currentTime - activityTime @@ -97,12 +101,7 @@ when defined(js): italic(class="fas fa-lock fa-xs") a(href=makeUri("/t/" & $thread.id), onClick=anchorCB): text thread.topic td(): - if thread.category.id.len > 0: - tdiv(class="triangle", - style=style( - (StyleAttr.borderBottom, kstring"0.6rem solid " & thread.category.color) - )): - text thread.category.id + render(thread.category) genUserAvatars(thread.users) td(): text $thread.replies td(class=class({ @@ -119,6 +118,9 @@ when defined(js): proc onThreadList(httpStatus: int, response: kstring) = state.loading = false + state.status = httpStatus.HttpCode + if state.status != Http200: return + let parsed = parseJson($response) let list = to(parsed, ThreadList) @@ -132,11 +134,14 @@ when defined(js): proc onLoadMore(ev: Event, n: VNode) = state.loading = true let start = state.list.get().threads.len - ajaxGet("threads.json?start=" & $start, @[], onThreadList) + ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList) proc genThreadList(): VNode = + if state.status != Http200: + return renderError("Couldn't retrieve threads.") + if state.list.isNone: - ajaxGet("threads.json", @[], onThreadList) + ajaxGet(makeUri("threads.json"), @[], onThreadList) return buildHtml(tdiv(class="loading loading-lg")) diff --git a/utils.nim b/utils.nim index f30ad60..ca0e992 100644 --- a/utils.nim +++ b/utils.nim @@ -15,6 +15,11 @@ proc parseInt*(s: string, value: var int, validRange: Slice[int]) {. discard if x in validRange: value = x +proc getInt*(s: string, default = 0): int = + ## Safely parses an int and returns it. + result = default + parseInt(s, result, 0..1_000_000_000) + type Config* = object smtpAddress: string