From e65168db55a91874abecefc5abb76aaa4a68e365 Mon Sep 17 00:00:00 2001 From: Andreas Rumpf Date: Thu, 5 Jan 2017 11:49:13 +0100 Subject: [PATCH 001/396] db model: introduce indexes for better performance --- createdb.nim | 4 ++++ editdb.nim | 16 +++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/createdb.nim b/createdb.nim index 4162c36..83e4b86 100644 --- a/createdb.nim +++ b/createdb.nim @@ -90,6 +90,10 @@ create table if not exists antibot( );""", []): echo "antibot table already exists" + +db.exec sql"create index PersonStatusIdx on person(status);" +db.exec sql"create index PostByAuthorIdx on post(thread, author);" + # -------------------- Search -------------------------------------------------- if not db.tryExec(sql""" diff --git a/editdb.nim b/editdb.nim index e12f679..7588822 100644 --- a/editdb.nim +++ b/editdb.nim @@ -4,10 +4,16 @@ import strutils, db_sqlite, ranks var db = open(connection="nimforum.db", user="postgres", password="", database="nimforum") -db.exec(sql("update person set status = ?"), $User) -db.exec(sql("update person set status = ? where ban <> ''"), $Troll) -db.exec(sql("update person set status = ? where ban like '%spam%'"), $Spammer) -db.exec(sql("update person set status = ? where ban = 'DEACTIVATED' or ban = 'EMAILCONFIRMATION'"), $EmailUnconfirmed) -db.exec(sql("update person set status = ? where admin = 'true'"), $Admin) +when false: + db.exec(sql("update person set status = ?"), $User) + db.exec(sql("update person set status = ? where ban <> ''"), $Troll) + db.exec(sql("update person set status = ? where ban like '%spam%'"), $Spammer) + db.exec(sql("update person set status = ? where ban = 'DEACTIVATED' or ban = 'EMAILCONFIRMATION'"), $EmailUnconfirmed) + db.exec(sql("update person set status = ? where admin = 'true'"), $Admin) +else: + db.exec sql"create index PersonStatusIdx on person(status);" + db.exec sql"create index PostByAuthorIdx on post(thread, author);" + db.exec sql"update person set name = 'cheatfate' where name = 'ka';" + close(db) From 5184c7c281c2df06e044192fdd77bc2ebf10d0e9 Mon Sep 17 00:00:00 2001 From: Simon Krauter Date: Sat, 25 Feb 2017 20:03:07 +0100 Subject: [PATCH 002/396] CSS: Add missing font color definition Fixes: On my pc, text and background have the same color. --- public/css/style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index fa38840..5a731e7 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -106,7 +106,7 @@ pre .EscapeSequence background:url("/images/glow-line-vert.png") no-repeat; } -#body { z-index:1; position:relative; background:rgba(220,231,248,.6); } +#body { z-index:1; position:relative; background:rgba(220,231,248,.6); color:black; } #body.docs { margin:0 40px 20px 320px; } #body.forum { margin:0 40px 20px 40px; min-height: 700px; } @@ -710,4 +710,4 @@ blockquote { blockquote p { color: rgb(109, 109, 109) !important; -} \ No newline at end of file +} From 720f38b3d45064cd8b0fd5e1e6cf6295ce600709 Mon Sep 17 00:00:00 2001 From: Euan T Date: Tue, 28 Feb 2017 13:14:51 +0000 Subject: [PATCH 003/396] Update Gravatar URL to use HTTPS Gravatars are currently loaded over HTTP, causing mixed content forums. Changing to HTTPS is a trivial change that makes sense in my opinion. --- forum.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forum.nim b/forum.nim index 1b29cdf..e006d7a 100644 --- a/forum.nim +++ b/forum.nim @@ -202,7 +202,7 @@ proc formatTimestamp(t: int): string = proc getGravatarUrl(email: string, size = 80): string = let emailMD5 = email.toLowerAscii.toMD5 - return ("http://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size & + return ("https://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size & "&d=identicon") proc genGravatar(email: string, size: int = 80): string = From 9200165c42c6fc4fab7d5f8fee9c87b7e4378327 Mon Sep 17 00:00:00 2001 From: Euan Torano Date: Wed, 1 Mar 2017 18:55:58 +0000 Subject: [PATCH 004/396] =?UTF-8?q?Show=20the=20user=E2=80=99s=20last=20IP?= =?UTF-8?q?=20on=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Euan Torano --- forum.nim | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/forum.nim b/forum.nim index e006d7a..7414144 100644 --- a/forum.nim +++ b/forum.nim @@ -69,6 +69,7 @@ type email: string ban: string rank: Rank + lastIp: string ForumError = object of Exception @@ -900,6 +901,10 @@ proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool = ui.ban = row[2] ui.rank = parseEnum[Rank](row[3]) + const lastIpQuery = sql"select `ip` from `session` where `userid` = ? order by `id` desc limit 1;" + let ipRow = db.getRow(lastIpQuery, $uid) + ui.lastIp = ipRow[0] + include "forms.tmpl" include "main.tmpl" @@ -944,6 +949,12 @@ proc genProfile(c: var TForumData, ui: TUserInfo): string = th("Status"), td($ui.rank) ), + tr( + th(if c.rank >= Moderator: "Last IP" else: ""), + td(if c.rank >= Moderator: + htmlgen.a(href="http://whatismyipaddress.com/ip/" & encodeUrl(ui.lastIp), ui.lastIp) + else: "") + ), tr( th(""), td(if c.rank >= Moderator and c.rank > ui.rank: From c1bd44b997e55ad98d8ea9ccddb13ec53cc3c95d Mon Sep 17 00:00:00 2001 From: Euan Torano Date: Tue, 7 Mar 2017 18:57:05 +0000 Subject: [PATCH 005/396] Adding reCAPTCHA rather than the custom captcha. Signed-off-by: Euan Torano --- captchas.nim | 37 ------------------------------------- forms.tmpl | 6 ++++-- forum.json.example | 4 ++++ forum.nim | 25 ++++++++++++++++++++----- forum.nim.cfg | 2 ++ nimforum.nimble | 2 +- public/captchas/.gitignore | 2 -- utils.nim | 4 ++++ 8 files changed, 35 insertions(+), 47 deletions(-) delete mode 100644 captchas.nim create mode 100644 forum.json.example delete mode 100644 public/captchas/.gitignore diff --git a/captchas.nim b/captchas.nim deleted file mode 100644 index f569f04..0000000 --- a/captchas.nim +++ /dev/null @@ -1,37 +0,0 @@ -# -# -# The Nim Forum -# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta -# Look at license.txt for more info. -# All rights reserved. -# - -import cairo, os, strutils, jester - -proc getCaptchaFilename*(i: int): string {.inline.} = - result = "public/captchas/capture_" & $i & ".png" - -proc getCaptchaUrl*(req: Request, i: int): string = - result = req.makeUri("/captchas/capture_" & $i & ".png", absolute = false) - -proc createCaptcha*(file, text: string) = - var surface = imageSurfaceCreate(FORMAT_ARGB32, int32(10*text.len), int32(10)) - var cr = create(surface) - - selectFontFace(cr, "serif", FONT_SLANT_NORMAL, FONT_WEIGHT_BOLD) - setFontSize(cr, 12.0) - - setSourceRgb(cr, 1.0, 0.5, 0.0) - moveTo(cr, 0.0, 10.0) - showText(cr, repeat('O', text.len)) - - setSourceRgb(cr, 0.0, 0.0, 1.0) - moveTo(cr, 0.0, 10.0) - showText(cr, text) - - destroy(cr) - discard writeToPng(surface, file) - destroy(surface) - -when isMainModule: - createCaptcha("test.png", "1+33") diff --git a/forms.tmpl b/forms.tmpl index 9d62d95..9f6d2a7 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -249,10 +249,12 @@ ${fieldValid(c, "email", "E-Mail:")} ${textWidget(c, "email", reuseText, maxlength=300)} + #if useCaptcha: - ${fieldValid(c, "antibot", "What is " & antibot(c) & "?")} - ${textWidget(c, "antibot", "", maxlength=4)} + ${fieldValid(c, "g-recaptcha-response", "Captcha:")} + ${captcha.render(includeNoScript=true)} + #end if #if c.errorMsg != "":
diff --git a/forum.json.example b/forum.json.example new file mode 100644 index 0000000..8cf9e86 --- /dev/null +++ b/forum.json.example @@ -0,0 +1,4 @@ +{ + "recaptchaSecretKey": "", + "recaptchaSiteKey": "" +} \ No newline at end of file diff --git a/forum.nim b/forum.nim index 7414144..3222112 100644 --- a/forum.nim +++ b/forum.nim @@ -8,8 +8,8 @@ import os, strutils, times, md5, strtabs, cgi, math, db_sqlite, - captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils, - parseutils, utils, random, rst, ranks + scgi, jester, asyncdispatch, asyncnet, cache, sequtils, + parseutils, utils, random, rst, ranks, recaptcha when not defined(windows): import bcrypt # TODO @@ -77,6 +77,8 @@ var db: DbConn isFTSAvailable: bool config: Config + useCaptcha: bool + captcha: ReCaptcha proc init(c: var TForumData) = c.userPass = "" @@ -314,8 +316,16 @@ proc register(c: var TForumData, name, pass, antibot, return setError(c, "new_password", "Invalid password!") # captcha validation: - if not isCaptchaCorrect(c, antibot): - return setError(c, "antibot", "Answer to captcha incorrect!") + if useCaptcha: + var captchaValid: bool = false + try: + captchaValid = waitFor captcha.verify(antibot) + except: + echo("[ERROR] Error checking captcha: " & getCurrentExceptionMsg()) + captchaValid = false + + if not captchaValid: + return setError(c, "antibot", "Answer to captcha incorrect!") # email validation if not ('@' in email and '.' in email): @@ -1123,7 +1133,7 @@ routes: post "/doregister": createTFD() - if c.register(@"name", @"new_password", @"antibot", @"email"): + if c.register(@"name", @"new_password", @"g-recaptcha-response", @"email"): resp genMain(c, "You are now registered. You must now confirm your" & " email address by clicking the link sent to " & @"email", "Registration successful - Nim Forum") @@ -1353,6 +1363,11 @@ when isMainModule: isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & "type='table' AND name='post_fts'")).len == 1 config = loadConfig() + if len(config.recaptchaSecretKey) > 0 and len(config.recaptchaSiteKey) > 0: + useCaptcha = true + captcha = initReCaptcha(config.recaptchaSecretKey, config.recaptchaSiteKey) + else: + useCaptcha = false var http = true if paramCount() > 0: if paramStr(1) == "scgi": diff --git a/forum.nim.cfg b/forum.nim.cfg index 429dc5b..3af1ac0 100644 --- a/forum.nim.cfg +++ b/forum.nim.cfg @@ -2,3 +2,5 @@ # we need the documentation generator of the compiler: path="$lib/packages/docutils" path="$nim" + +-d:ssl \ No newline at end of file diff --git a/nimforum.nimble b/nimforum.nimble index 3591767..b0cdb09 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -8,4 +8,4 @@ license = "MIT" bin = "forum" [Deps] -Requires: "nim >= 0.14.0, cairo#head, jester#head, bcrypt#head" +Requires: "nim >= 0.14.0, cairo#head, jester#head, bcrypt#head, recaptcha >= 1.0.0" diff --git a/public/captchas/.gitignore b/public/captchas/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/public/captchas/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/utils.nim b/utils.nim index 002ee94..ce48804 100644 --- a/utils.nim +++ b/utils.nim @@ -22,6 +22,8 @@ type smtpUser: string smtpPassword: string mlistAddress: string + recaptchaSecretKey*: string + recaptchaSiteKey*: string var docConfig: StringTableRef @@ -38,6 +40,8 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config = result.smtpUser = root{"smtpUser"}.getStr("") result.smtpPassword = root{"smtpPassword"}.getStr("") result.mlistAddress = root{"mlistAddress"}.getStr("") + result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("") + result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("") except: echo("[WARNING] Couldn't read config file: ", filename) From b780b970f4738ea631dc53231c04a6f54918155c Mon Sep 17 00:00:00 2001 From: Euan Torano Date: Tue, 7 Mar 2017 19:09:46 +0000 Subject: [PATCH 006/396] Use captcha for reset password. Signed-off-by: Euan Torano --- forms.tmpl | 6 ++++-- forum.nim | 46 ++++++++++++++++++---------------------------- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/forms.tmpl b/forms.tmpl index 9f6d2a7..010759a 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -423,10 +423,12 @@ ${fieldValid(c, "nick", "Your nickname:")} + #if useCaptcha: - ${fieldValid(c, "antibot", "What is " & antibot(c) & "?")} - ${textWidget(c, "antibot", "", maxlength=4)} + ${fieldValid(c, "g-recaptcha-response", "Captcha:")} + ${captcha.render(includeNoScript=true)} + #end if #if c.errorMsg != "":
diff --git a/forum.nim b/forum.nim index 3222112..cfa6a67 100644 --- a/forum.nim +++ b/forum.nim @@ -276,19 +276,6 @@ proc validThreadId(c: TForumData): bool = result = getValue(db, sql"select id from thread where id = ?", $c.threadId).len > 0 -proc antibot(c: var TForumData): string = - let a = random(10)+1 - let b = random(1000)+1 - let answer = $(a+b) - - exec(db, sql"delete from antibot where ip = ?", c.req.ip) - let captchaId = tryInsertID(db, - sql"insert into antibot(ip, answer) values (?, ?)", c.req.ip, - answer).int mod 10_000 - let captchaFile = getCaptchaFilename(captchaId) - createCaptcha(captchaFile, $a & "+" & $b) - result = """""" % c.req.getCaptchaUrl(captchaId) - const SecureChars = {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'} @@ -297,13 +284,7 @@ proc setError(c: var TForumData, field, msg: string): bool {.inline.} = c.errorMsg = "Error: " & msg return false -proc isCaptchaCorrect(c: var TForumData, antibot: string): bool = - ## Determines whether the user typed in the captcha correctly. - let correctRes = getValue(db, - sql"select answer from antibot where ip = ?", c.req.ip) - return antibot == correctRes - -proc register(c: var TForumData, name, pass, antibot, +proc register(c: var TForumData, name, pass, antibot, userIp, email: string): bool = # Username validation: if name.len == 0 or not allCharsInSet(name, SecureChars): @@ -319,13 +300,13 @@ proc register(c: var TForumData, name, pass, antibot, if useCaptcha: var captchaValid: bool = false try: - captchaValid = waitFor captcha.verify(antibot) + captchaValid = waitFor captcha.verify(antibot, userIp) except: echo("[ERROR] Error checking captcha: " & getCurrentExceptionMsg()) captchaValid = false if not captchaValid: - return setError(c, "antibot", "Answer to captcha incorrect!") + return setError(c, "g-recaptcha-response", "Answer to captcha incorrect!") # email validation if not ('@' in email and '.' in email): @@ -360,10 +341,19 @@ proc register(c: var TForumData, name, pass, antibot, return true -proc resetPassword(c: var TForumData, nick, antibot: string): bool = - # Validate captcha - if not isCaptchaCorrect(c, antibot): - return setError(c, "antibot", "Answer to captcha incorrect!") +proc resetPassword(c: var TForumData, nick, antibot, userIp: string): bool = + # captcha validation: + if useCaptcha: + var captchaValid: bool = false + try: + captchaValid = waitFor captcha.verify(antibot, userIp) + except: + echo("[ERROR] Error checking captcha: " & getCurrentExceptionMsg()) + captchaValid = false + + if not captchaValid: + return setError(c, "g-recaptcha-response", "Answer to captcha incorrect!") + # Gather some extra information to determine ident hash. let epoch = $int(epochTime()) let row = db.getRow( @@ -1133,7 +1123,7 @@ routes: post "/doregister": createTFD() - if c.register(@"name", @"new_password", @"g-recaptcha-response", @"email"): + if c.register(@"name", @"new_password", @"g-recaptcha-response", request.host, @"email"): resp genMain(c, "You are now registered. You must now confirm your" & " email address by clicking the link sent to " & @"email", "Registration successful - Nim Forum") @@ -1302,7 +1292,7 @@ routes: echo(request.params) cond(@"nick" != "") - if resetPassword(c, @"nick", @"antibot"): + if resetPassword(c, @"nick", @"g-recaptcha-response", request.host): resp genMain(c, "Email sent!", "Reset Password - Nim Forum") else: resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum") From e6c4c85bb1d1a461a63b6e9c6cb3f6a5b92b7585 Mon Sep 17 00:00:00 2001 From: Euan Torano Date: Sun, 19 Mar 2017 18:43:28 +0000 Subject: [PATCH 007/396] Making TForumData a ref object Signed-off-by: Euan Torano --- forms.tmpl | 20 +++++++------- forum.nim | 81 +++++++++++++++++++++++++++--------------------------- main.tmpl | 8 +++--- 3 files changed, 55 insertions(+), 54 deletions(-) diff --git a/forms.tmpl b/forms.tmpl index 010759a..a7b7006 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -5,7 +5,7 @@ #end template # # -#proc genThreadsList(c: var TForumData, count: var int): string = +#proc genThreadsList(c: TForumData, count: var int): string = # const queryModAdmin = sql"""select id, name, views, modified from thread # where id in (select thread from post where author in # (select id from person where status not in ('Spammer') or id = ?)) @@ -92,7 +92,7 @@ #end proc # # -#proc genPostPreview(c: var TForumData, +#proc genPostPreview(c: TForumData, # title, content, author, date: string): string = # result = "" @@ -119,7 +119,7 @@ #end proc # # -#proc genPostsList(c: var TForumData, threadId: string, count: var int): string = +#proc genPostsList(c: TForumData, threadId: string, count: var int): string = # let query = sql("""select p.id, u.name, p.header, p.content, p.creation, p.author, u.email from post p, # person u # where u.id = p.author and p.thread = ? and $# @@ -180,7 +180,7 @@ # #proc genMarkHelp(): string #end proc -#proc genFormPost(c: var TForumData, action: string, +#proc genFormPost(c: TForumData, action: string, # topText, title, content: string, isEdit: bool): string = # result = ""
@@ -225,7 +225,7 @@ #end proc # # -#proc genFormRegister(c: var TForumData): string = +#proc genFormRegister(c: TForumData): string = # result = ""
@@ -265,7 +265,7 @@ #end proc # -#proc genFormSetRank(c: var TForumData; ui: TUserInfo): string = +#proc genFormSetRank(c: TForumData; ui: TUserInfo): string = # result = ""
@@ -288,7 +288,7 @@ #end proc # -#proc genFormLogin(c: var TForumData): string = +#proc genFormLogin(c: TForumData): string = # result = "" # if not c.loggedIn: @@ -307,7 +307,7 @@ #end proc # # -#proc genListOnline(c: var TForumData, stats: TForumStats): string = +#proc genListOnline(c: TForumData, stats: TForumStats): string = # result = "" # var active: seq[string] = @[] # for i in stats.activeUsers: @@ -324,7 +324,7 @@ # # # -#proc genSearchResults(c: var TForumData, +#proc genSearchResults(c: TForumData, # results: iterator: db_sqlite.Row {.closure, tags: [ReadDbEffect].}, # count: var int): string = # const threadId = 0 @@ -407,7 +407,7 @@ #end proc # # -#proc genFormResetPassword(c: var TForumData): string = +#proc genFormResetPassword(c: TForumData): string = # result = ""
diff --git a/forum.nim b/forum.nim index cfa6a67..7165819 100644 --- a/forum.nim +++ b/forum.nim @@ -37,7 +37,7 @@ type TPost = tuple[subject, content: string] - TForumData = object of TSession + TForumData = ref object of TSession req: Request userid: string actionContent: string @@ -80,7 +80,7 @@ var useCaptcha: bool captcha: ReCaptcha -proc init(c: var TForumData) = +proc init(c: TForumData) = c.userPass = "" c.userName = "" c.threadId = unselectedThread @@ -143,16 +143,16 @@ proc genThreadUrl(c: TForumData, postId = "", action = "", threadid = "", pageNu result.add("#" & postId) result = c.req.makeUri(result, absolute = false) -proc formSession(c: var TForumData, nextAction: string): string = +proc formSession(c: TForumData, nextAction: string): string = return """ """ % [ $c.threadId, $c.postid] -proc urlButton(c: var TForumData, text, url: string): string = +proc urlButton(c: TForumData, text, url: string): string = return ("""$2""") % [ url, text] -proc genButtons(c: var TForumData, btns: seq[TStyledButton]): string = +proc genButtons(c: TForumData, btns: seq[TStyledButton]): string = if btns.len == 1: var anchor = "" @@ -279,13 +279,13 @@ proc validThreadId(c: TForumData): bool = const SecureChars = {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'} -proc setError(c: var TForumData, field, msg: string): bool {.inline.} = +proc setError(c: TForumData, field, msg: string): bool {.inline.} = c.invalidField = field c.errorMsg = "Error: " & msg return false -proc register(c: var TForumData, name, pass, antibot, userIp, - email: string): bool = +proc register(c: TForumData, name, pass, antibot, userIp, + email: string): Future[bool] {.async.} = # Username validation: if name.len == 0 or not allCharsInSet(name, SecureChars): return setError(c, "name", "Invalid username!") @@ -300,7 +300,7 @@ proc register(c: var TForumData, name, pass, antibot, userIp, if useCaptcha: var captchaValid: bool = false try: - captchaValid = waitFor captcha.verify(antibot, userIp) + captchaValid = await captcha.verify(antibot, userIp) except: echo("[ERROR] Error checking captcha: " & getCurrentExceptionMsg()) captchaValid = false @@ -341,12 +341,12 @@ proc register(c: var TForumData, name, pass, antibot, userIp, return true -proc resetPassword(c: var TForumData, nick, antibot, userIp: string): bool = +proc resetPassword(c: TForumData, nick, antibot, userIp: string): Future[bool] {.async.} = # captcha validation: if useCaptcha: var captchaValid: bool = false try: - captchaValid = waitFor captcha.verify(antibot, userIp) + captchaValid = await captcha.verify(antibot, userIp) except: echo("[ERROR] Error checking captcha: " & getCurrentExceptionMsg()) captchaValid = false @@ -378,7 +378,7 @@ proc resetPassword(c: var TForumData, nick, antibot, userIp: string): bool = return true -proc logout(c: var TForumData) = +proc logout(c: TForumData) = const query = sql"delete from session where ip = ? and password = ?" c.username = "" c.userpass = "" @@ -395,7 +395,7 @@ proc getBanErrorMsg(banValue: string; rank: Rank): string = of Moderated, User, Moderator, Admin: return "" -proc checkLoggedIn(c: var TForumData) = +proc checkLoggedIn(c: TForumData) = if not c.req.cookies.hasKey("sid"): return let pass = c.req.cookies["sid"] if execAffectedRows(db, @@ -425,7 +425,7 @@ proc checkLoggedIn(c: var TForumData) = else: echo("SID not found in sessions. Assuming logged out.") -proc incrementViews(c: var TForumData) = +proc incrementViews(c: TForumData) = const query = sql"update thread set views = views + 1 where id = ?" exec(db, query, $c.threadId) @@ -435,7 +435,7 @@ proc isPreview(c: TForumData): bool = proc isDelete(c: TForumData): bool = result = c.req.params.hasKey("delete") -proc validateRst(c: var TForumData, content: string): bool = +proc validateRst(c: TForumData, content: string): bool = result = true try: discard rstToHtml(content) @@ -513,7 +513,7 @@ template writeToDb(c, cr, setPostId: untyped) = if setPostId: c.postId = retID.int -proc updateThreads(c: var TForumData): int = +proc updateThreads(c: TForumData): int = ## Removes threads if they have no posts, or changes their modified field ## if they still contain posts. const query = @@ -531,7 +531,7 @@ proc updateThreads(c: var TForumData): int = result = -1 discard setError(c, "", "database error") -proc edit(c: var TForumData, postId: int): bool = +proc edit(c: TForumData, postId: int): bool = checkLogin(c) if c.isPreview: retrPost(c) @@ -564,8 +564,8 @@ proc edit(c: var TForumData, postId: int): bool = exec(db, crud(crUpdate, "thread", "name"), subject, $c.threadId) result = true -proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool -proc spamCheck(c: var TForumData, subject, content: string): bool = +proc gatherUserInfo(c: TForumData, nick: string, ui: var TUserInfo): bool +proc spamCheck(c: TForumData, subject, content: string): bool = # Check current user's info var ui: TUserInfo if gatherUserInfo(c, c.userName, ui): @@ -595,7 +595,7 @@ proc spamCheck(c: var TForumData, subject, content: string): bool = word in contentAlphabet.toLowerAscii(): return true -proc rateLimitCheck(c: var TForumData): bool = +proc rateLimitCheck(c: TForumData): bool = const query40 = sql("SELECT count(*) FROM post where author = ? and " & "(strftime('%s', 'now') - strftime('%s', creation)) < 40") @@ -614,7 +614,7 @@ proc rateLimitCheck(c: var TForumData): bool = if last300s > 6: return true return false -proc makeThreadURL(c: var TForumData): string = +proc makeThreadURL(c: TForumData): string = c.req.makeUri("/t/" & $c.threadId) template postChecks() {.dirty.} = @@ -624,7 +624,7 @@ template postChecks() {.dirty.} = if rateLimitCheck(c): return setError(c, "subject", "You're posting too fast.") -proc reply(c: var TForumData): bool = +proc reply(c: TForumData): bool = # reply to an existing thread checkLogin(c) retrPost(c) @@ -642,7 +642,7 @@ proc reply(c: var TForumData): bool = threadUrl=c.makeThreadURL()) result = true -proc newThread(c: var TForumData): bool = +proc newThread(c: TForumData): bool = # create new conversation thread (permanent or transient) const query = sql"insert into thread(name, views, modified) values (?, 0, DATETIME('now'))" checkLogin(c) @@ -665,7 +665,7 @@ proc newThread(c: var TForumData): bool = threadUrl=c.makeThreadURL()) result = true -proc login(c: var TForumData, name, pass: string): bool = +proc login(c: TForumData, name, pass: string): bool = # get form data: const query = sql"select id, name, password, email, salt, status, ban from person where name = ?" @@ -693,7 +693,7 @@ proc login(c: var TForumData, name, pass: string): bool = else: return c.setError("password", "Login failed!") -proc verifyIdentHash(c: var TForumData, name, epoch, ident: string): bool = +proc verifyIdentHash(c: TForumData, name, epoch, ident: string): bool = const query = sql"select password, salt, strftime('%s', lastOnline) from person where name = ?" var row = getRow(db, query, name) @@ -704,13 +704,13 @@ proc verifyIdentHash(c: var TForumData, name, epoch, ident: string): bool = if row[2].parseInt > (epoch.parseInt + 60): return false result = newIdent == ident -proc deleteAll(c: var TForumData, nick: string): bool = +proc deleteAll(c: TForumData, nick: string): bool = const query = sql("delete from post where author = (select id from person where name = ?)") result = tryExec(db, query, nick) result = result and updateThreads(c) >= 0 -proc setStatus(c: var TForumData, nick: string, status: Rank; +proc setStatus(c: TForumData, nick: string, status: Rank; reason: string): bool = const query = sql("update person set status = ?, ban = ? where name = ?") @@ -722,13 +722,13 @@ proc setStatus(c: var TForumData, nick: string, status: Rank; if status == Spammer and result: result = deleteAll(c, nick) -proc setPassword(c: var TForumData, nick, pass: string): bool = +proc setPassword(c: TForumData, nick, pass: string): bool = const query = sql("update person set password = ?, salt = ? where name = ?") var salt = makeSalt() result = tryExec(db, query, makePassword(pass, salt), salt, nick) -proc hasReplyBtn(c: var TForumData): bool = +proc hasReplyBtn(c: TForumData): bool = result = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply" result = result and c.req.params.getOrDefault("action") notin ["reply", "edit"] # If the user is not logged in and there are no page numbers then we shouldn't @@ -737,7 +737,7 @@ proc hasReplyBtn(c: var TForumData): bool = result = result and (pages > 1 or c.loggedIn) return c.threadId >= 0 and result -proc getStats(c: var TForumData, simple: bool): TForumStats = +proc getStats(c: TForumData, simple: bool): TForumStats = const totalUsersQuery = sql"select count(*) from person" result.totalUsers = getValue(db, totalUsersQuery).parseInt @@ -762,7 +762,7 @@ proc getStats(c: var TForumData, simple: bool): TForumStats = result.newestMember = (row[1], row[0].parseInt) newestMemberCreation = row[3].parseInt -proc genPagenumNav(c: var TForumData, stats: TForumStats): string = +proc genPagenumNav(c: TForumData, stats: TForumStats): string = result = "" var firstUrl = "" @@ -835,22 +835,22 @@ proc genPagenumNav(c: var TForumData, stats: TForumStats): string = result.add(nextTag) result.add(lastTag) -proc gatherTotalPostsByID(c: var TForumData, thrid: int): int = +proc gatherTotalPostsByID(c: TForumData, thrid: int): int = ## Gets the total post count of a thread. result = getValue(db, sql"select count(*) from post where thread = ?", $thrid).parseInt -proc gatherTotalPosts(c: var TForumData) = +proc gatherTotalPosts(c: TForumData) = if c.totalPosts > 0: return # Gather some data. const totalPostsQuery = sql"select count(*) from post p, person u where u.id = p.author and p.thread = ?" c.totalPosts = getValue(db, totalPostsQuery, $c.threadId).parseInt -proc getPagesInThread(c: var TForumData): int = +proc getPagesInThread(c: TForumData): int = c.gatherTotalPosts() # Get total post count result = ceil(c.totalPosts / PostsPerPage).int-1 -proc getPagesInThreadByID(c: var TForumData, thrid: int): int = +proc getPagesInThreadByID(c: TForumData, thrid: int): int = result = ceil(c.gatherTotalPostsByID(thrid) / PostsPerPage).int proc getThreadTitle(thrid: int, pageNum: int): string = @@ -858,7 +858,7 @@ proc getThreadTitle(thrid: int, pageNum: int): string = if pageNum notin {0,1}: result.add(" - Page " & $pageNum) -proc genPagenumLocalNav(c: var TForumData, thrid: int): string = +proc genPagenumLocalNav(c: TForumData, thrid: int): string = result = "" const maxPostPages = 6 # Maximum links to pages shown. const hmpp = maxPostPages div 2 @@ -878,7 +878,7 @@ proc genPagenumLocalNav(c: var TForumData, thrid: int): string = result = htmlgen.span(class = "pages", result) -proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool = +proc gatherUserInfo(c: TForumData, nick: string, ui: var TUserInfo): bool = ui.nick = nick const getUIDQuery = sql"select id from person where name = ?" var uid = getValue(db, getUIDQuery, nick) @@ -908,7 +908,7 @@ proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool = include "forms.tmpl" include "main.tmpl" -proc genProfile(c: var TForumData, ui: TUserInfo): string = +proc genProfile(c: TForumData, ui: TUserInfo): string = result = "" result.add(htmlgen.`div`(id = "talk-head", @@ -982,6 +982,7 @@ proc prependRe(s: string): string = template createTFD() = var c {.inject.}: TForumData + new(c) init(c) c.req = request c.startTime = epochTime() @@ -1123,7 +1124,7 @@ routes: post "/doregister": createTFD() - if c.register(@"name", @"new_password", @"g-recaptcha-response", request.host, @"email"): + if await c.register(@"name", @"new_password", @"g-recaptcha-response", request.host, @"email"): resp genMain(c, "You are now registered. You must now confirm your" & " email address by clicking the link sent to " & @"email", "Registration successful - Nim Forum") @@ -1292,7 +1293,7 @@ routes: echo(request.params) cond(@"nick" != "") - if resetPassword(c, @"nick", @"g-recaptcha-response", request.host): + if await resetPassword(c, @"nick", @"g-recaptcha-response", request.host): resp genMain(c, "Email sent!", "Reset Password - Nim Forum") else: resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum") diff --git a/main.tmpl b/main.tmpl index 3590b8d..f52d869 100644 --- a/main.tmpl +++ b/main.tmpl @@ -1,5 +1,5 @@ #? stdtmpl | standard -#proc genMain(c: var TForumData, content: string, title = "Nim Forum", +#proc genMain(c: TForumData, content: string, title = "Nim Forum", # additional_headers = "", showRssLinks = false): string = # result = "" # var stats: TForumStats @@ -170,7 +170,7 @@ #end proc # -#proc genRSSHeaders(c: var TForumData): string = +#proc genRSSHeaders(c: TForumData): string = # result = "" @@ -178,7 +178,7 @@ type="application/atom+xml" rel="alternate"> #end proc # -#proc genThreadsRSS(c: var TForumData): string = +#proc genThreadsRSS(c: TForumData): string = # result = "" # const query = sql"""SELECT A.id, A.name, # strftime('%Y-%m-%dT%H:%M:%SZ', (A.modified)), @@ -226,7 +226,7 @@ ${xmlEncode(rstToHtml(%postContent))} #end proc # -#proc genPostsRSS(c: var TForumData): string = +#proc genPostsRSS(c: TForumData): string = # result = "" # const query = sql"""SELECT A.id, B.name, A.content, A.thread, # A.header, strftime('%Y-%m-%dT%H:%M:%SZ', A.creation), From 57f5a81e48e467939450319d1c88db3227eb6861 Mon Sep 17 00:00:00 2001 From: Silvio Date: Wed, 19 Apr 2017 15:03:33 +0200 Subject: [PATCH 008/396] Fix compile: Move addr, port to connect in smtp Following https://github.com/nim-lang/Nim/commit/fecad72e02256c947e1c16cd003ceca62a3633e5 , moved address and port to `connect` from `newAsyncSmtp` , forum compiles again. --- utils.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils.nim b/utils.nim index 002ee94..c052bd5 100644 --- a/utils.nim +++ b/utils.nim @@ -110,8 +110,8 @@ proc sendMail(config: Config, subject, message, recipient: string, from_addr = " echo("[WARNING] Cannot send mail: no smtp server configured (smtpAddress).") return - var client = newAsyncSmtp(config.smtpAddress, Port(config.smtpPort)) - await client.connect() + var client = newAsyncSmtp() + await client.connect(config.smtpAddress, Port(config.smtpPort)) if config.smtpUser.len > 0: await client.auth(config.smtpUser, config.smtpPassword) From 957b3738f83e54c2ad1b167c425d47610e983f4f Mon Sep 17 00:00:00 2001 From: stisa Date: Sat, 7 Oct 2017 16:54:13 +0200 Subject: [PATCH 009/396] remove cairo dependency and update the readme --- README.md | 25 +++++-------------------- nimforum.nimble | 2 +- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1df440e..1ec4b8c 100644 --- a/README.md +++ b/README.md @@ -15,23 +15,8 @@ _See also: Running the forum for how to create the database_ ## Dependencies The code depends on the RST parser of the Nim -compiler and on Jester. The code generating captchas for registration uses the -[cairo module](https://github.com/nim-lang/cairo), which requires you to have -the [cairo library](http://cairographics.org) installed when you run the forum, -or you will be greeted by a cryptic error message similar to: - - $ ./forum could not load: libcairo.so(1.2) - -### Mac OS X - -#### cairo -If you are using macosx and have installed the ``cairo`` library through -[MacPorts](https://www.macports.org) you still need to add the library path to -your ``LD_LIBRARY_PATH`` environment variable. Example: - - $ LD_LIBRARY_PATH=/opt/local/lib/ ./forum - -Replace ``/opt/local/lib`` with the correct path on your system. +compiler and on Jester. The captchas for registration uses the +[reCaptcha module](https://github.com/euantorano/recaptcha.nim). #### bcrypt @@ -47,7 +32,7 @@ changing the dependencies slightly. ``` [Deps] -Requires: "nimrod >= 0.10.3, cairo#head, jester#head, bcrypt >= 0.2.1" +Requires: "nim >= 0.14.0, jester#head, bcrypt#head, recaptcha >= 1.0.0" ``` # Running the forum @@ -69,12 +54,12 @@ After that you can just run `forum` and if everything is ok you will get the inf _There is an update helper `editdb` which you can safely ignore for now._ -_The files `captchas.nim`, `cache.nim` are included by `forum.nim` and do +_The file `cache.nim` is included by `forum.nim` and do not need to be compiled by you._ # Copyright -Copyright (c) 2012-2015 Andreas Rumpf, Dominik Picheta. +Copyright (c) 2012-2017 Andreas Rumpf, Dominik Picheta. All rights reserved. diff --git a/nimforum.nimble b/nimforum.nimble index b0cdb09..b6943e0 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -8,4 +8,4 @@ license = "MIT" bin = "forum" [Deps] -Requires: "nim >= 0.14.0, cairo#head, jester#head, bcrypt#head, recaptcha >= 1.0.0" +Requires: "nim >= 0.14.0, jester#head, bcrypt#head, recaptcha >= 1.0.0" From 13f2de94a3bfd8f640ad460620b17237d1c4401e Mon Sep 17 00:00:00 2001 From: stisa Date: Sat, 7 Oct 2017 17:11:50 +0200 Subject: [PATCH 010/396] macos bcrypt may need a fixed version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ec4b8c..1e3205c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ changing the dependencies slightly. ``` [Deps] -Requires: "nim >= 0.14.0, jester#head, bcrypt#head, recaptcha >= 1.0.0" +Requires: "nim >= 0.14.0, jester#head, bcrypt >= 0.2.1, recaptcha >= 1.0.0" ``` # Running the forum From 8bbe9356febfef1a5816f06d60bc9056eb0bf0a9 Mon Sep 17 00:00:00 2001 From: stisa Date: Sat, 7 Oct 2017 17:17:08 +0200 Subject: [PATCH 011/396] close a paren, fix a link --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1e3205c..b0ee351 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,18 @@ This is Nim's forum. Available at http://forum.nim-lang.org. ## Building -You can use ``nimble`` (available [here](https://github.com/nim-lang/nimble) +You can use ``nimble`` (available [here](https://github.com/nim-lang/nimble)) to get all the necessary [dependencies](https://github.com/nim-lang/nimforum/blob/master/nimforum.nimble#L11). Clone this repo and execute ``nimble build`` in this repositories directory. -_See also: Running the forum for how to create the database_ +See also: Running the forum for how to create the database. ## Dependencies -The code depends on the RST parser of the Nim -compiler and on Jester. The captchas for registration uses the -[reCaptcha module](https://github.com/euantorano/recaptcha.nim). +The code depends on the RST parser of the Nim compiler and on Jester. +The captchas for registration uses the [reCaptcha module](https://github.com/euantorano/recaptcha.nim). #### bcrypt From d441f9445d48781c598bba990073330ae94f8c23 Mon Sep 17 00:00:00 2001 From: stisa Date: Sat, 7 Oct 2017 17:32:34 +0200 Subject: [PATCH 012/396] repurpose rst.txt as rst help --- rst.txt => static/rst.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) rename rst.txt => static/rst.rst (92%) diff --git a/rst.txt b/static/rst.rst similarity index 92% rename from rst.txt rename to static/rst.rst index 70bbd63..f8dc48a 100644 --- a/rst.txt +++ b/static/rst.rst @@ -3,7 +3,7 @@ =========================================================================== This is a cheat sheet for the *reStructuredText* dialect as implemented by -Nimrod's documentation generator which has been reused for this forum. :-) +Nim's documentation generator which has been reused for this forum. See also the `official RST cheat sheet `_ @@ -32,14 +32,14 @@ Plain text Result Links ===== -Links are either direct URLs like ``http://nimrod-lang.org`` or written like +Links are either direct URLs like ``http://nim-lang.org`` or written like this:: - `Nimrod `_ + `Nim `_ Or like this:: - ``_ + ``_ Code blocks @@ -47,7 +47,7 @@ Code blocks are done this way:: - .. code-block:: nimrod + .. code-block:: nim if x == "abc": echo "xyz" @@ -55,25 +55,25 @@ are done this way:: Is rendered as: -.. code-block:: nimrod +.. code-block:: nim if x == "abc": echo "xyz" -Except Nimrod, the programming languages C, C++, Java and C# have highlighting +Except Nim, the programming languages C, C++, Java and C# have highlighting support. An alternative github-like syntax is also supported. This has the advantage that no excessive indentation is needed:: - ```nimrod + ```nim if x == "abc": echo "xyz"``` Is rendered as: -.. code-block:: nimrod +.. code-block:: nim if x == "abc": echo "xyz" From 6c72d077e378a8d6c71976c61f9b4d5ff659ec4e Mon Sep 17 00:00:00 2001 From: stisa Date: Sat, 7 Oct 2017 17:33:21 +0200 Subject: [PATCH 013/396] link to rst help - fixes #102 --- forms.tmpl | 2 +- forum.nim | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/forms.tmpl b/forms.tmpl index a7b7006..b28f637 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -443,7 +443,7 @@

nimforum uses a slightly-customized version of reStructuredText for formatting. See below for some basics, or check - this link for a more detailed help reference.

+ this link for a more detailed help reference.

diff --git a/forum.nim b/forum.nim index 7165819..11c098d 100644 --- a/forum.nim +++ b/forum.nim @@ -1346,6 +1346,8 @@ routes: resp genMain(c, page) get "/search-help": textPage "static/search-help" + get "/rst": + textPage "static/rst" when isMainModule: randomize() From 25d4303c6990761f4186eb965d525a56de83eb04 Mon Sep 17 00:00:00 2001 From: stisa Date: Sat, 7 Oct 2017 22:15:23 +0200 Subject: [PATCH 014/396] style pre blocks --- public/css/style.css | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/public/css/style.css b/public/css/style.css index 5a731e7..7c35f66 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -9,7 +9,19 @@ body { font: 13pt Helvetica,Arial,sans-serif; background:#152534 url("/images/bg.png") no-repeat fixed center top; } -pre { color: #F5F5F5;} +pre { + color: #F5F5F5; + overflow:auto; + margin:0; + padding:15px 10px; + font-size:10pt; + font-style:normal; + line-height:14pt; + background:rgba(0,0,0,.75); + border-left:8px solid rgba(0,0,0,.3); + margin-bottom: 10pt; + font-family: "DejaVu Sans Mono", monospace; +} pre, pre * { cursor:text; } pre .Comment { color:#6D6D6D; font-style:italic; } pre .Keyword { color:#43A8CF; font-weight:bold; } From bd730df6c4c032007e9df9a8d7745219294c412d Mon Sep 17 00:00:00 2001 From: stisa Date: Sat, 7 Oct 2017 22:18:38 +0200 Subject: [PATCH 015/396] use relative path for rst help --- forms.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forms.tmpl b/forms.tmpl index b28f637..2872e90 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -443,7 +443,7 @@

nimforum uses a slightly-customized version of reStructuredText for formatting. See below for some basics, or check - this link for a more detailed help reference.

+ this link for a more detailed help reference.

From afe9b41249d9c62dceac82295d0753e90a7be0c1 Mon Sep 17 00:00:00 2001 From: stisa Date: Sat, 7 Oct 2017 22:30:50 +0200 Subject: [PATCH 016/396] proper h1/h2, add images --- static/rst.rst | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/static/rst.rst b/static/rst.rst index f8dc48a..6b584fc 100644 --- a/static/rst.rst +++ b/static/rst.rst @@ -1,5 +1,4 @@ -=========================================================================== - reStructuredText cheat sheet +reStructuredText cheat sheet =========================================================================== This is a cheat sheet for the *reStructuredText* dialect as implemented by @@ -14,7 +13,7 @@ Elements of **markdown** are also supported. Inline elements -=============== +--------------- Ordinary text may contain *inline elements*: @@ -30,20 +29,20 @@ Plain text Result =============================== ============================================ Links -===== +----- -Links are either direct URLs like ``http://nim-lang.org`` or written like +Links are either direct URLs like ``https://nim-lang.org`` or written like this:: - `Nim `_ + `Nim `_ Or like this:: - ``_ + ``_ Code blocks -=========== +----------- are done this way:: @@ -81,7 +80,7 @@ Is rendered as: Literal blocks -============== +-------------- Are introduced by '::' and a newline. The block is indicated by indentation: @@ -98,7 +97,7 @@ Is rendered as:: Bullet lists -============ +------------ look like this:: @@ -123,7 +122,7 @@ Is rendered as: Enumerated lists -================ +---------------- are written like this:: @@ -143,7 +142,7 @@ Is rendered as: Quoting someone -=============== +--------------- quotes are just:: @@ -160,7 +159,7 @@ Is rendered as: Definition lists -================ +---------------- are written like this:: @@ -190,7 +189,7 @@ how Tables -====== +------ Only *simple tables* are supported. They are of the form:: @@ -218,3 +217,10 @@ Cell 4 Cell 5; any Cell 6 multiple lines Cell 7 Cell 8 Cell 9 ================== =============== =================== + +Images +------ + +``` +.. image:: path/to/img.png +``` \ No newline at end of file From 2cc8eb926907ea6ba4c71cbdd5e445e628a5a258 Mon Sep 17 00:00:00 2001 From: Daniil Yarancev <21169548+Yardanico@users.noreply.github.com> Date: Tue, 17 Oct 2017 15:10:15 +0300 Subject: [PATCH 017/396] Fix link for the Araq's blog Also make a new github link for nim :) --- main.tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.tmpl b/main.tmpl index f52d869..70c5527 100644 --- a/main.tmpl +++ b/main.tmpl @@ -139,7 +139,7 @@

Community

@@ -151,7 +151,7 @@
From 55d4790060a0de9434cc31de577f4e5ef61812ee Mon Sep 17 00:00:00 2001 From: Daniil Yarancev <21169548+Yardanico@users.noreply.github.com> Date: Tue, 17 Oct 2017 18:44:11 +0300 Subject: [PATCH 018/396] Fix link --- main.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tmpl b/main.tmpl index 70c5527..f295433 100644 --- a/main.tmpl +++ b/main.tmpl @@ -151,7 +151,7 @@ From 50587abe9cfce0603b0e43e32258b30096f42590 Mon Sep 17 00:00:00 2001 From: stisa Date: Sun, 22 Oct 2017 17:08:50 +0200 Subject: [PATCH 019/396] tiny js script that runs listings in the playground --- forms.tmpl | 38 ++++++++++++++++++++++++++++++++++++++ public/css/style.css | 27 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/forms.tmpl b/forms.tmpl index a7b7006..bd4b239 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -175,6 +175,44 @@ # end for + #end proc # diff --git a/public/css/style.css b/public/css/style.css index 5a731e7..07e9567 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -711,3 +711,30 @@ blockquote p { color: rgb(109, 109, 109) !important; } + +.runDiv > hr { + border: 1px solid slategray; +} + +.runDiv > button{ + cursor: pointer; + background-color: lightslategray; + text-decoration: none; + float: right; + color: #FFF; + display: block; +} + +.runDiv > .resDiv { + width: 80%; + margin-left: 1em; + padding: 0.2em 1em 0.2em 1em; + display: inline-block; +} + +.successComp { + color: lightgreen; +} +.failedComp { + color: lightcoral; +} From 95280674e57b2944a873492395c5d2c98ffc06f6 Mon Sep 17 00:00:00 2001 From: stisa Date: Sun, 22 Oct 2017 17:24:06 +0200 Subject: [PATCH 020/396] avoid innerHtml --- forms.tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forms.tmpl b/forms.tmpl index bd4b239..06e7268 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -195,9 +195,9 @@ var resDiv = document.createElement("DIV") resDiv.setAttribute("class", "resDiv successComp") if (res.log != "Compilation Failed\u000A"){ - resDiv.innerHTML = res.log + resDiv.textContent = res.log } else { - resDiv.innerHTML = res.compileLog + resDiv.textContent = res.compileLog resDiv.setAttribute("class","resDiv failedComp") } runDiv.appendChild(resDiv) From 1b0d39d706ebec16b430969d98cba2b49bab9fe7 Mon Sep 17 00:00:00 2001 From: stisa Date: Sun, 22 Oct 2017 22:01:30 +0200 Subject: [PATCH 021/396] overwrite result instead of appending multiple times --- forms.tmpl | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/forms.tmpl b/forms.tmpl index 06e7268..39bd5e2 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -192,15 +192,19 @@ if (httpRequest.readyState!=httpRequest.DONE){return} if (httpRequest.status == 200){ var res = JSON.parse(httpRequest.responseText) - var resDiv = document.createElement("DIV") - resDiv.setAttribute("class", "resDiv successComp") + // this works because only 1 `element` is inside `element` + var resDiv = element.getElementsByClassName("resDiv")[0] + if (resDiv == null) { + resDiv = document.createElement("DIV") + runDiv.appendChild(resDiv) + } if (res.log != "Compilation Failed\u000A"){ resDiv.textContent = res.log + resDiv.setAttribute("class", "resDiv successComp") } else { resDiv.textContent = res.compileLog resDiv.setAttribute("class","resDiv failedComp") } - runDiv.appendChild(resDiv) } else { console.log("There was a problem with the request.") } From 31f30a2fde4c02362dc5a6432d0b85c4561dfcd8 Mon Sep 17 00:00:00 2001 From: stisa Date: Sun, 22 Oct 2017 22:05:00 +0200 Subject: [PATCH 022/396] use button 4; remove margin from result --- public/css/style.css | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 07e9567..c79e7ee 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -721,13 +721,20 @@ blockquote p { background-color: lightslategray; text-decoration: none; float: right; - color: #FFF; + color: #e5e8ef; display: block; + padding: 0.3em 0.5em 0.3em 0.5em; + border: none; + border-bottom: 0.2em solid #b9b9b9 +} + +.runDiv > button:active{ + border: none; + border-top: 0.2em solid #b9b9b9 } .runDiv > .resDiv { width: 80%; - margin-left: 1em; padding: 0.2em 1em 0.2em 1em; display: inline-block; } From a956261ae8762acc80e48ea7afd2a1c1d38e64fc Mon Sep 17 00:00:00 2001 From: stisa Date: Mon, 23 Oct 2017 20:11:04 +0200 Subject: [PATCH 023/396] change btn style to match sidebar --- public/css/style.css | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index c79e7ee..141746b 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -441,7 +441,7 @@ div#sidebar .content } -div#sidebar .content .button +div#sidebar .content .button, .runDiv>button { background-color: rgba(0,0,0,0.2); text-decoration: none; @@ -713,24 +713,18 @@ blockquote p { } .runDiv > hr { - border: 1px solid slategray; + border: 1px solid #80828d; } -.runDiv > button{ +.runDiv > button { cursor: pointer; - background-color: lightslategray; - text-decoration: none; - float: right; - color: #e5e8ef; - display: block; - padding: 0.3em 0.5em 0.3em 0.5em; - border: none; - border-bottom: 0.2em solid #b9b9b9 + border: none; /* remove border from runDiv>button */ + border-bottom: 2px solid rgba(0,0,0,0.24); + background-color: #80828d; } -.runDiv > button:active{ - border: none; - border-top: 0.2em solid #b9b9b9 +.runDiv>button:hover { + border-bottom: 2px solid rgba(255, 255, 255, 0.5); } .runDiv > .resDiv { From 0121f8bd9d02ac3605553bad23595a32795176bc Mon Sep 17 00:00:00 2001 From: stisa Date: Tue, 24 Oct 2017 23:12:19 +0200 Subject: [PATCH 024/396] loading; open in playground; improve compat (ie9+) --- forms.tmpl | 83 ++++++++++++++++++++++++++++++-------------- public/css/style.css | 13 +++++-- 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/forms.tmpl b/forms.tmpl index 3b33156..fcb2623 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -115,6 +115,7 @@ + #end proc # @@ -177,45 +178,73 @@ # end for #end proc diff --git a/public/css/style.css b/public/css/style.css index 3359cfc..028cf56 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -453,7 +453,7 @@ div#sidebar .content } -div#sidebar .content .button, .runDiv>button +div#sidebar .content .button, .runDiv>button, .runDiv>a { background-color: rgba(0,0,0,0.2); text-decoration: none; @@ -728,14 +728,21 @@ blockquote p { border: 1px solid #80828d; } -.runDiv > button { +.runDiv > a { + color: #FFF !important; + padding: 3.5pt; + margin-right: 3pt; +} + +.runDiv > button, .runDiv > a { cursor: pointer; border: none; /* remove border from runDiv>button */ border-bottom: 2px solid rgba(0,0,0,0.24); background-color: #80828d; } -.runDiv>button:hover { +.runDiv>button:hover, .runDiv > a:hover { + text-decoration: none !important; border-bottom: 2px solid rgba(255, 255, 255, 0.5); } From f47449ea5c135121e0b48364a426cf9c083a56e5 Mon Sep 17 00:00:00 2001 From: stisa Date: Mon, 30 Oct 2017 19:59:01 +0100 Subject: [PATCH 025/396] add post link to date; ref #113 --- forms.tmpl | 2 +- public/css/style.css | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/forms.tmpl b/forms.tmpl index 3b33156..3e8ccb4 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -170,7 +170,7 @@ #except EParseError: # c.errorMsg = getCurrentExceptionMsg() #end - ${xmlEncode(%postCreation)} + ${xmlEncode(%postCreation)} diff --git a/public/css/style.css b/public/css/style.css index 3359cfc..c43b92e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -751,3 +751,9 @@ blockquote p { .failedComp { color: lightcoral; } +.date > a, .date > a:hover, .date > a:visited { + color: #3D3D3D !important; +} +.date > a:hover { + text-decoration: none !important; +} \ No newline at end of file From 212f49623e936d860ec387aca73f6c44daa42096 Mon Sep 17 00:00:00 2001 From: stisa Date: Mon, 30 Oct 2017 20:05:40 +0100 Subject: [PATCH 026/396] merge css rules --- public/css/style.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index c43b92e..d4aa78d 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -753,7 +753,5 @@ blockquote p { } .date > a, .date > a:hover, .date > a:visited { color: #3D3D3D !important; -} -.date > a:hover { text-decoration: none !important; } \ No newline at end of file From a44e17d03a0c2de022113a24a92fa39e40b5c56b Mon Sep 17 00:00:00 2001 From: stisa Date: Tue, 7 Nov 2017 23:35:59 +0100 Subject: [PATCH 027/396] restrict run to langNim --- forms.tmpl | 2 +- utils.nim | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/forms.tmpl b/forms.tmpl index fcb2623..3fa8481 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -241,7 +241,7 @@ element.appendChild(runDiv); } function appendRunBtn(){ - var els = Array.prototype.slice.call(document.getElementsByClassName("listing")); + var els = Array.prototype.slice.call(document.getElementsByClassName("langNim")); els.forEach(appendEachRunBtn, this); } appendRunBtn() diff --git a/utils.nim b/utils.nim index 10c57ab..f30ad60 100644 --- a/utils.nim +++ b/utils.nim @@ -28,6 +28,7 @@ type var docConfig: StringTableRef docConfig = rstgen.defaultConfig() +docConfig["doc.listing_start"] = "
"
 docConfig["doc.smiley_format"] = "/images/smilieys/$1.png"
 
 proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =

From 6200a30ccf8ea22d93133e711842a4497659ef6b Mon Sep 17 00:00:00 2001
From: Dominik Picheta 
Date: Mon, 7 May 2018 18:45:03 +0100
Subject: [PATCH 028/396] Add Spectre skeleton with builder.

---
 .gitmodules            | 3 +++
 redesign/builder.nim   | 4 ++++
 redesign/nimforum.scss | 8 ++++++++
 redesign/spectre       | 1 +
 4 files changed, 16 insertions(+)
 create mode 100644 .gitmodules
 create mode 100644 redesign/builder.nim
 create mode 100644 redesign/nimforum.scss
 create mode 160000 redesign/spectre

diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..ef41742
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "redesign/spectre"]
+	path = redesign/spectre
+	url = https://github.com/picturepan2/spectre
diff --git a/redesign/builder.nim b/redesign/builder.nim
new file mode 100644
index 0000000..5b2e528
--- /dev/null
+++ b/redesign/builder.nim
@@ -0,0 +1,4 @@
+import sass
+
+when isMainModule:
+  compileFile("nimforum.scss", "nimforum.css")
\ No newline at end of file
diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss
new file mode 100644
index 0000000..30a60d6
--- /dev/null
+++ b/redesign/nimforum.scss
@@ -0,0 +1,8 @@
+// Based on
+// https://picturepan2.github.io/spectre/getting-started.html#installation
+// Define variables to override default ones
+$primary-color: #2e5bec;
+$dark-color: #3e396b;
+
+// Import full Spectre source code
+@import "spectre/src/spectre";
\ No newline at end of file
diff --git a/redesign/spectre b/redesign/spectre
new file mode 160000
index 0000000..7a6af53
--- /dev/null
+++ b/redesign/spectre
@@ -0,0 +1 @@
+Subproject commit 7a6af53bca72b549b2cbf1948763348bd5b30dcd

From 86a7280585cc66f5dfd2af3f2c920794da7cb8dc Mon Sep 17 00:00:00 2001
From: Dominik Picheta 
Date: Mon, 7 May 2018 18:53:31 +0100
Subject: [PATCH 029/396] Adds mockup skeleton HTML file.

---
 redesign/index.html | 19 +++++++++++++++++++
 1 file changed, 19 insertions(+)
 create mode 100644 redesign/index.html

diff --git a/redesign/index.html b/redesign/index.html
new file mode 100644
index 0000000..57820a8
--- /dev/null
+++ b/redesign/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+  
+  
+  
+
+  The Nim programming language forum
+
+  
+  
+
+
+
+
+
+
+
\ No newline at end of file

From c10d5f4e44fb66d37a6e9a1c577b80f1de5df0aa Mon Sep 17 00:00:00 2001
From: Dominik Picheta 
Date: Mon, 7 May 2018 19:58:42 +0100
Subject: [PATCH 030/396] Simple navbar. Will need to fix colours later.

---
 redesign/index.html    | 15 +++++++++++++++
 redesign/nimforum.scss | 22 +++++++++++++++++++++-
 2 files changed, 36 insertions(+), 1 deletion(-)

diff --git a/redesign/index.html b/redesign/index.html
index 57820a8..606a07c 100644
--- a/redesign/index.html
+++ b/redesign/index.html
@@ -13,6 +13,21 @@
 
 
 
+    
 
 
 
diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss
index 30a60d6..2f7065c 100644
--- a/redesign/nimforum.scss
+++ b/redesign/nimforum.scss
@@ -5,4 +5,24 @@ $primary-color: #2e5bec;
 $dark-color: #3e396b;
 
 // Import full Spectre source code
-@import "spectre/src/spectre";
\ No newline at end of file
+@import "spectre/src/spectre";
+
+// Custom styles.
+// - Navigation bar.
+$navbar-height: 60px;
+$logo-height: $navbar-height - 20px;
+
+#main-navbar {
+  background-color: #17181f;
+  height: $navbar-height;
+}
+
+#img-logo {
+  vertical-align: middle;
+  height: $logo-height;
+}
+
+.navbar-control {
+  margin-left: $control-padding-x;
+
+}
\ No newline at end of file

From 736ec8c09a0de5adabea40a7b470204df17515bf Mon Sep 17 00:00:00 2001
From: Dominik Picheta 
Date: Mon, 7 May 2018 20:26:17 +0100
Subject: [PATCH 031/396] Implement secondary "navbar" containing top buttons.

---
 redesign/index.html    | 21 +++++++++++++++++++++
 redesign/nimforum.scss | 11 +++++++++++
 2 files changed, 32 insertions(+)

diff --git a/redesign/index.html b/redesign/index.html
index 606a07c..a11e367 100644
--- a/redesign/index.html
+++ b/redesign/index.html
@@ -9,6 +9,7 @@
   The Nim programming language forum
 
   
+  
   
 
 
@@ -29,6 +30,26 @@
       
     
 
+    
+
 
 
 
\ No newline at end of file
diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss
index 2f7065c..e4766d5 100644
--- a/redesign/nimforum.scss
+++ b/redesign/nimforum.scss
@@ -7,6 +7,12 @@ $dark-color: #3e396b;
 // Import full Spectre source code
 @import "spectre/src/spectre";
 
+// Global styles.
+// - TODO: Make these non-global.
+.btn {
+  margin-left: $control-padding-x;
+}
+
 // Custom styles.
 // - Navigation bar.
 $navbar-height: 60px;
@@ -24,5 +30,10 @@ $logo-height: $navbar-height - 20px;
 
 .navbar-control {
   margin-left: $control-padding-x;
+}
 
+// - Main buttons
+#main-buttons {
+  margin-top: $control-padding-y;
+  margin-bottom: $control-padding-y;
 }
\ No newline at end of file

From 25b1b42f1317de73824ef584b0212a10f916d348 Mon Sep 17 00:00:00 2001
From: Dominik Picheta 
Date: Mon, 7 May 2018 20:37:47 +0100
Subject: [PATCH 032/396] Create threads table.

---
 redesign/index.html    | 64 ++++++++++++++++++++++++++++++++++++++++++
 redesign/nimforum.scss |  2 +-
 2 files changed, 65 insertions(+), 1 deletion(-)

diff --git a/redesign/index.html b/redesign/index.html
index a11e367..d14cc27 100644
--- a/redesign/index.html
+++ b/redesign/index.html
@@ -50,6 +50,70 @@
 
     
 
+    
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TopicCategoryUsersRepliesViewsActivity
Few mixed up questionshelp + + + + + + 554745m
Lexers and parsers in Nimcommunity + + 01444m
I need helphelp + + + + 424.5k1d
Nim v1.0 is here!announcement + + + 486.1k4d
+ + \ No newline at end of file diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index e4766d5..47f997d 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -2,7 +2,7 @@ // https://picturepan2.github.io/spectre/getting-started.html#installation // Define variables to override default ones $primary-color: #2e5bec; -$dark-color: #3e396b; +// $dark-color: #3e396b; // Import full Spectre source code @import "spectre/src/spectre"; From 591f665cef3d10af3f111f5fcc65470e65712aff Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 7 May 2018 20:56:27 +0100 Subject: [PATCH 033/396] Better avatars. --- redesign/index.html | 39 ++++++++++++++++++++++++++++----------- redesign/nimforum.scss | 9 ++++++++- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/redesign/index.html b/redesign/index.html index d14cc27..82c750b 100644 --- a/redesign/index.html +++ b/redesign/index.html @@ -67,11 +67,18 @@ Few mixed up questions help - - - - - +
+ +
+
+ +
+
+
+
+
+
+
5 547 @@ -81,7 +88,8 @@ Lexers and parsers in Nim community - +
+
0 14 @@ -91,9 +99,14 @@ I need help help - - - +
+ +
+
+ +
+
+
4 24.5k @@ -103,8 +116,12 @@ Nim v1.0 is here! announcement - - +
+ +
+
+ +
4 86.1k diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 47f997d..601c6b4 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -13,6 +13,12 @@ $primary-color: #2e5bec; margin-left: $control-padding-x; } +// Spectre fixes. +// - Weird avatar outline. +.avatar { + background: transparent; +} + // Custom styles. // - Navigation bar. $navbar-height: 60px; @@ -36,4 +42,5 @@ $logo-height: $navbar-height - 20px; #main-buttons { margin-top: $control-padding-y; margin-bottom: $control-padding-y; -} \ No newline at end of file +} + From 91207650ccda2c304eba35fb3e3f00670daf9a4b Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 7 May 2018 21:06:21 +0100 Subject: [PATCH 034/396] Smaller table headings. --- redesign/nimforum.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 601c6b4..adc3ce2 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -13,6 +13,9 @@ $primary-color: #2e5bec; margin-left: $control-padding-x; } +table th { + font-size: 0.65rem; +} // Spectre fixes. // - Weird avatar outline. .avatar { From 24b60f36a51a2015f4019ae38d46117cd65efd24 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 7 May 2018 21:21:34 +0100 Subject: [PATCH 035/396] Some messing about with view/reply/activity colours. --- redesign/index.html | 16 ++++++++-------- redesign/nimforum.scss | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/redesign/index.html b/redesign/index.html index 82c750b..929b0e8 100644 --- a/redesign/index.html +++ b/redesign/index.html @@ -81,8 +81,8 @@ 5 - 547 - 45m + 547 + 45m Lexers and parsers in Nim @@ -92,8 +92,8 @@ 0 - 14 - 44m + 14 + 44m I need help @@ -109,7 +109,7 @@ 4 - 24.5k + 1.4k 1d @@ -123,9 +123,9 @@ - 4 - 86.1k - 4d + 4 + 24.2k + 4d diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index adc3ce2..839d172 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -47,3 +47,19 @@ $logo-height: $navbar-height - 20px; margin-bottom: $control-padding-y; } +// - Thread table +$super-popular-color: #f86713; +$popular-color: darken($super-popular-color, 25%); +$views-color: #545d70; + +.super-popular-text { + color: $super-popular-color; +} + +.popular-text { + color: $popular-color; +} + +.views-text { + color: $views-color; +} \ No newline at end of file From 6e032045e631cde1605a3d12df00fc99b8882a62 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 7 May 2018 21:37:35 +0100 Subject: [PATCH 036/396] Create unread count label. --- redesign/index.html | 2 +- redesign/nimforum.scss | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/redesign/index.html b/redesign/index.html index 929b0e8..9dfd870 100644 --- a/redesign/index.html +++ b/redesign/index.html @@ -96,7 +96,7 @@ 44m - I need help + I need help 2 help
diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 839d172..e3b6b2b 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -3,6 +3,7 @@ // Define variables to override default ones $primary-color: #2e5bec; // $dark-color: #3e396b; +$label-color: #7cd2ff; // Import full Spectre source code @import "spectre/src/spectre"; @@ -16,6 +17,7 @@ $primary-color: #2e5bec; table th { font-size: 0.65rem; } + // Spectre fixes. // - Weird avatar outline. .avatar { @@ -62,4 +64,14 @@ $views-color: #545d70; .views-text { color: $views-color; +} + +.label-custom { + color: white; + background-color: $label-color; + + font-size: 0.6rem; + padding-left: 0.3rem; + padding-right: 0.3rem; + border-radius: 5rem; } \ No newline at end of file From 88b0bf8ae5d003bcaa45e6557d0820e574cd6559 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 7 May 2018 22:09:09 +0100 Subject: [PATCH 037/396] Create last visit separator. --- redesign/index.html | 9 ++++++++- redesign/nimforum.scss | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/redesign/index.html b/redesign/index.html index 9dfd870..ad74ed3 100644 --- a/redesign/index.html +++ b/redesign/index.html @@ -95,7 +95,7 @@ 14 44m - + I need help 2 help @@ -112,6 +112,13 @@ 1.4k 1d + + + + last visit + + + Nim v1.0 is here! announcement diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index e3b6b2b..8d72d0c 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -74,4 +74,26 @@ $views-color: #545d70; padding-left: 0.3rem; padding-right: 0.3rem; border-radius: 5rem; +} + +.last-visit-separator { + td { + border-bottom: 1px solid $super-popular-color; + line-height: 0.1rem; + padding: 0; + text-align: center; + } + + span { + color: $super-popular-color; + padding: 0 8px; + font-size: 0.7rem; + background-color: $body-bg; + } +} + +.last-visit { + td { + border: none; + } } \ No newline at end of file From c62046d2a34650d22ecdbbddfe1d83f92065e7d0 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 7 May 2018 22:16:37 +0100 Subject: [PATCH 038/396] Add locked thread and solved thread icons. --- redesign/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redesign/index.html b/redesign/index.html index ad74ed3..ba41127 100644 --- a/redesign/index.html +++ b/redesign/index.html @@ -64,7 +64,7 @@ - Few mixed up questions + Few mixed up questions help
@@ -85,7 +85,7 @@ 45m - Lexers and parsers in Nim + Lexers and parsers in Nim community
From 7a13c32ccff1d34482ec7e87a5f9216d0915197a Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 7 May 2018 22:31:00 +0100 Subject: [PATCH 039/396] Triangles for category colours? Being different for the sake of it probably isn't a good idea, but I'll see how it goes. --- redesign/index.html | 8 ++++---- redesign/nimforum.scss | 9 +++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/redesign/index.html b/redesign/index.html index ba41127..78e8fd6 100644 --- a/redesign/index.html +++ b/redesign/index.html @@ -65,7 +65,7 @@ Few mixed up questions - help +
help
@@ -86,7 +86,7 @@ Lexers and parsers in Nim - community +
community
@@ -97,7 +97,7 @@ I need help 2 - help +
help
@@ -121,7 +121,7 @@ Nim v1.0 is here! - announcement +
announcement
diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 8d72d0c..3826213 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -96,4 +96,13 @@ $views-color: #545d70; td { border: none; } +} + +.triangle { + width: 0; + height: 0; + border-left: 0.3rem solid transparent; + border-right: 0.3rem solid transparent; + border-bottom: 0.6rem solid #98c766; + display: inline-block; } \ No newline at end of file From 088afbb9ac47d6e6b676af964dc355091baabdf5 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 7 May 2018 23:18:09 +0100 Subject: [PATCH 040/396] Fixes navbar colours. --- redesign/index.html | 2 +- redesign/nimforum.scss | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/redesign/index.html b/redesign/index.html index 78e8fd6..732a6bc 100644 --- a/redesign/index.html +++ b/redesign/index.html @@ -17,7 +17,7 @@ @@ -35,7 +35,7 @@
diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index c2a3689..6579c70 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -4,6 +4,7 @@ $primary-color: #f99c19; // $dark-color: #3e396b; $label-color: #7cd2ff; +$filter-color: #f1f1f1; // Define nav bar colours. $navbar-color: #17181f; @@ -15,8 +16,8 @@ $navbar-primary-color: #fee860; // Global styles. // - TODO: Make these non-global. -.btn { - margin-left: $control-padding-x; +.btn, .form-input { + margin-right: $control-padding-x; } table th { @@ -62,14 +63,18 @@ $logo-height: $navbar-height - 20px; height: $logo-height; } -.navbar-control { - margin-left: $control-padding-x; -} - // - Main buttons #main-buttons { margin-top: $control-padding-y; margin-bottom: $control-padding-y; + + .dropdown > .btn { + background: $filter-color; + border-color: darken($filter-color, 5%); + color: invert($filter-color); + + margin-right: $control-padding-x*2; + } } // - Thread table From 343a842fe0ceae2809be3b7567f6f2e7240239f4 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 8 May 2018 13:38:15 +0100 Subject: [PATCH 044/396] Add icons to sign up/log in buttons. --- redesign/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redesign/index.html b/redesign/index.html index 64b5c18..5d0e13f 100644 --- a/redesign/index.html +++ b/redesign/index.html @@ -26,8 +26,8 @@
- - + +
From 72a1863c2933c7a31d46b717a17f682fd122babd Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 8 May 2018 13:49:25 +0100 Subject: [PATCH 045/396] Larger margin on top buttons. --- redesign/nimforum.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 6579c70..d69ca98 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -65,8 +65,8 @@ $logo-height: $navbar-height - 20px; // - Main buttons #main-buttons { - margin-top: $control-padding-y; - margin-bottom: $control-padding-y; + margin-top: $control-padding-y*2; + margin-bottom: $control-padding-y*2; .dropdown > .btn { background: $filter-color; From e51b74a017600e0bcac957a9d3d7ffc156dfa494 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 8 May 2018 16:02:43 +0100 Subject: [PATCH 046/396] Initial thread design. --- redesign/nimforum.scss | 102 ++++++++++++++++++++++++++++++-- redesign/thread.html | 129 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 redesign/thread.html diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index d69ca98..72f9ba0 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -2,9 +2,9 @@ // https://picturepan2.github.io/spectre/getting-started.html#installation // Define variables to override default ones $primary-color: #f99c19; -// $dark-color: #3e396b; +// $dark-color: #17181f; $label-color: #7cd2ff; -$filter-color: #f1f1f1; +$secondary-btn-color: #f1f1f1; // Define nav bar colours. $navbar-color: #17181f; @@ -69,9 +69,9 @@ $logo-height: $navbar-height - 20px; margin-bottom: $control-padding-y*2; .dropdown > .btn { - background: $filter-color; - border-color: darken($filter-color, 5%); - color: invert($filter-color); + background: $secondary-btn-color; + border-color: darken($secondary-btn-color, 5%); + color: invert($secondary-btn-color); margin-right: $control-padding-x*2; } @@ -133,4 +133,96 @@ $views-color: #545d70; border-right: 0.3rem solid transparent; border-bottom: 0.6rem solid #98c766; display: inline-block; +} + +// - Thread view +.title { + margin-top: $control-padding-y*2; + margin-bottom: $control-padding-y*2; + + p { + font-size: 1.4rem; + font-weight: bold; + + color: darken($dark-color, 20%); + + margin: 0; + } +} + +.posts { + @extend .grid-sm; + @extend .container; + margin: 0; +} + +.post { + @extend .tile; +} + +.post-icon { + @extend .tile-icon; +} + +.post-avatar { + @extend .avatar; + @extend .avatar-xl; +} + +.post-main { + @extend .tile-content; + + margin-bottom: $control-padding-y-lg*2; +} + +.post-title { + margin-bottom: $control-padding-y*2; + + .post-username { + font-weight: bold; + display: inline-block; + } + + .post-time { + float: right; + } +} + +.post-content { + +} + +.post-buttons { + float: right; + + > div { + display: inline-block; + } + + .btn { + background: transparent; + border-color: transparent; + color: darken($secondary-btn-color, 40%); + + margin: 0; + margin-left: $control-padding-y-sm; + } + + .btn:hover { + background: $secondary-btn-color; + border-color: darken($secondary-btn-color, 5%); + color: invert($secondary-btn-color); + } + + .btn:focus { + @include control-shadow(darken($secondary-btn-color, 50%)); + } + + .btn:active { + box-shadow: inset 0 0 .4rem .01rem darken($secondary-btn-color, 80%); + } + + .like-button i { + color: #f783ac; + } } \ No newline at end of file diff --git a/redesign/thread.html b/redesign/thread.html new file mode 100644 index 0000000..513f1df --- /dev/null +++ b/redesign/thread.html @@ -0,0 +1,129 @@ + + + + + + + + + The Nim programming language forum + + + + + + + + + +
+
+

Lexers and parsers in nim

+
community +
+
+
+
+
+ Avatar +
+
+
+
+
+ ErikCampobadal +
+
44m
+
+
+

Hey! I'm willing to create a programming language using nim.

+ +

It's an educational project. Been reading about compilers for weeks now and I started using tools like flex and bison for lexer and parser. I know nim have a parsing library but nowhere near that level.

+ +

There is an old post (2014) with a similar question so I'm bringing that back a few years later. Is there anything anyone know that could speed up the process of developing a programing language using nim? (I can have c code if needed ofc)

+
+
+ +
+ +
+
+ +
+
+
+
+ +
+
+
+ Avatar +
+
+
+
+
+ twetzel59 +
+
44m
+
+
+

Wow, I was just reading about the compilation pipeline today!

+ +

I suppose you could use at least the lexing part from a generator like flex, not so sure about using AST generators easily (it's possible).

+ +

Is your language complicated enough to warrant a parser generator or could you just use a custom parser?

+
+
+ +
+ +
+
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file From 514bcf28edd0c44a9412d7328599902c82e2759c Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 8 May 2018 16:11:12 +0100 Subject: [PATCH 047/396] Small adjustments --- redesign/nimforum.scss | 3 +++ redesign/thread.html | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 72f9ba0..a0f5d55 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -154,10 +154,13 @@ $views-color: #545d70; @extend .grid-sm; @extend .container; margin: 0; + padding: 0; } .post { @extend .tile; + border-top: 1px solid $border-color; + padding-top: $control-padding-y-lg; } .post-icon { diff --git a/redesign/thread.html b/redesign/thread.html index 513f1df..8e301ff 100644 --- a/redesign/thread.html +++ b/redesign/thread.html @@ -91,12 +91,12 @@
twetzel59
-
44m
+
32m

Wow, I was just reading about the compilation pipeline today!

-

I suppose you could use at least the lexing part from a generator like flex, not so sure about using AST generators easily (it's possible).

+

I suppose you could use at least the lexing part from a generator like flex, not so sure about using AST generators easily (it's possible).

Is your language complicated enough to warrant a parser generator or could you just use a custom parser?

From f4a1a97ccfb521ad81cd03f739d41b13133ad085 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 8 May 2018 16:54:35 +0100 Subject: [PATCH 048/396] Adjustments to code and blockquote styles. --- redesign/nimforum.scss | 24 +++++++++++++++-- redesign/thread.html | 61 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index a0f5d55..0cc5a6a 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -2,7 +2,8 @@ // https://picturepan2.github.io/spectre/getting-started.html#installation // Define variables to override default ones $primary-color: #f99c19; -// $dark-color: #17181f; +$body-font-color: #292929; +$dark-color: #525252; $label-color: #7cd2ff; $secondary-btn-color: #f1f1f1; @@ -180,7 +181,7 @@ $views-color: #545d70; .post-title { margin-bottom: $control-padding-y*2; - + color: lighten($body-font-color, 20%); .post-username { font-weight: bold; display: inline-block; @@ -228,4 +229,23 @@ $views-color: #545d70; .like-button i { color: #f783ac; } +} + +blockquote { + border-left: 0.2rem solid darken($bg-color, 10%); + background-color: $bg-color; + + .detail { + margin-bottom: $control-padding-y; + color: lighten($body-font-color, 20%); + } +} + +.quote-avatar { + @extend .avatar; + @extend .avatar-sm; +} + +.quote-link { + float: right; } \ No newline at end of file diff --git a/redesign/thread.html b/redesign/thread.html index 8e301ff..4fa5144 100644 --- a/redesign/thread.html +++ b/redesign/thread.html @@ -121,6 +121,67 @@
+ +
+
+
+ Avatar +
+
+
+
+
+ dom96 +
+
32m
+
+
+

Let us test this new design a bit, shall we?

+
proc hello(x: int) =
+  echo("Hello ", x)
+
+42.hello()
+ +

The greatest function ever written is hello.

+
+

Designing websites is often a pain.

+
Multi-level baby!
+

True that.

+

I also want to be able to support more detailed quoting:

+
+
+
+ Avatar +
+ Araq: + +
+ Unix is a cancer. +
+

We also want to be able to highlight user mentions:

+

Please let @Araq know that this forum is awesome.

+
+
+ +
+ +
+
+ +
+
+
+
+ From 3a44489fcc17041a92936b682b8e37c0bca3c658 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 8 May 2018 17:12:42 +0100 Subject: [PATCH 049/396] Use mentions in threads. --- redesign/nimforum.scss | 16 ++++++++++++++++ redesign/thread.html | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 0cc5a6a..a5ce088 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -248,4 +248,20 @@ blockquote { .quote-link { float: right; +} + +.user-mention { + @extend .chip; + vertical-align: initial; + font-weight: bold; + display: inline-block; + font-size: 85%; + height: inherit; + padding: 0.08rem 0.4rem; + background-color: darken($bg-color-dark, 5%); + + img { + @extend .avatar; + @extend .avatar-sm; + } } \ No newline at end of file diff --git a/redesign/thread.html b/redesign/thread.html index 4fa5144..56b4679 100644 --- a/redesign/thread.html +++ b/redesign/thread.html @@ -159,7 +159,11 @@ Unix is a cancer.

We also want to be able to highlight user mentions:

-

Please let @Araq know that this forum is awesome.

+

Please let + + @Araq + + know that this forum is awesome.

Hey! I'm willing to create a programming language using nim.

@@ -91,7 +91,7 @@
twetzel59
-
32m
+
Jan 2015

Wow, I was just reading about the compilation pipeline today!

@@ -121,6 +121,16 @@
+
+
+ +
+
+
+ 3 YEARS LATER +
+
+
From 00ac2332a5c7ab28028ea75a7f5af2ebfb385584 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 12:58:34 +0100 Subject: [PATCH 054/396] Add simple "reply" info box. --- redesign/thread.html | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/redesign/thread.html b/redesign/thread.html index 4706d8c..7b6c470 100644 --- a/redesign/thread.html +++ b/redesign/thread.html @@ -196,6 +196,20 @@
+
+
+ +
+
+
+ Replying to "Lexers and parsers in nim" +
+
+ +
+
+
+ From 5795235a475ac09077a08f7a1641ca59bf1025b0 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 13:23:14 +0100 Subject: [PATCH 055/396] Load More Threads button in thread list. --- redesign/index.html | 11 +++++++++-- redesign/nimforum.scss | 21 ++++++++++++++++++++- redesign/thread.html | 2 +- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/redesign/index.html b/redesign/index.html index 5d0e13f..b836674 100644 --- a/redesign/index.html +++ b/redesign/index.html @@ -96,7 +96,7 @@ 14 44m - + I need help 2
help @@ -120,7 +120,7 @@ - + Nim v1.0 is here!
announcement @@ -135,6 +135,13 @@ 24.2k 4d + + + + load more threads + + + diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 8c5c8d1..eca1060 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -136,13 +136,14 @@ $views-color: #545d70; } } -.last-visit { +.no-border { td { border: none; } } .triangle { + // TODO: Abstract this into a "category" class. width: 0; height: 0; border-left: 0.3rem solid transparent; @@ -151,6 +152,24 @@ $views-color: #545d70; display: inline-block; } +.load-more-separator { + text-align: center; + color: darken($label-color, 35%); + // $border-color: desaturate(darken($label-color, 10%), 80%); + // border-top: 1px solid $border-color; + // border-bottom: 1px solid $border-color; + background-color: lighten($label-color, 15%); + text-transform: uppercase; + font-weight: bold; + font-size: 80%; + cursor: pointer; + + td { + border: none; + padding: $control-padding-x $control-padding-y/2; + } +} + // - Thread view .title { margin-top: $control-padding-y*2; diff --git a/redesign/thread.html b/redesign/thread.html index 7b6c470..e1015b2 100644 --- a/redesign/thread.html +++ b/redesign/thread.html @@ -205,7 +205,7 @@ Replying to "Lexers and parsers in nim"
- +
From 07e8af644e0518098ac93d697e56558968cefb64 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 13:38:50 +0100 Subject: [PATCH 056/396] Load More Posts in thread.html. --- redesign/nimforum.scss | 21 ++++++++++++++++++--- redesign/thread.html | 20 +++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index eca1060..7ed37ed 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -155,9 +155,6 @@ $views-color: #545d70; .load-more-separator { text-align: center; color: darken($label-color, 35%); - // $border-color: desaturate(darken($label-color, 10%), 80%); - // border-top: 1px solid $border-color; - // border-bottom: 1px solid $border-color; background-color: lighten($label-color, 15%); text-transform: uppercase; font-weight: bold; @@ -327,6 +324,10 @@ blockquote { .information-title { font-weight: bold; } + + &.no-border { + border: none; + } } .information-icon { @@ -338,4 +339,18 @@ blockquote { font-size: 1rem; } +} + +.time-passed { + text-transform: uppercase; +} + +.load-more-posts { + text-align: center; + color: darken($label-color, 35%); + background-color: lighten($label-color, 15%); + border: none; + text-transform: uppercase; + font-weight: bold; + cursor: pointer; } \ No newline at end of file diff --git a/redesign/thread.html b/redesign/thread.html index e1015b2..298bb80 100644 --- a/redesign/thread.html +++ b/redesign/thread.html @@ -121,13 +121,13 @@ -
+
- 3 YEARS LATER + 3 years later
@@ -196,7 +196,21 @@
-
+ + +
From 9e61224b2cc4494c22eb1f80fe50230ce6eb9872 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 14:54:32 +0100 Subject: [PATCH 057/396] Beginnings with karax. --- redesign/forum.nim | 24 ++++++++++++++++++++++++ redesign/karax.html | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 redesign/forum.nim create mode 100644 redesign/karax.html diff --git a/redesign/forum.nim b/redesign/forum.nim new file mode 100644 index 0000000..7287a50 --- /dev/null +++ b/redesign/forum.nim @@ -0,0 +1,24 @@ +include karax/prelude + + +proc genHeader(): VNode = + result = buildHtml(header(id="main-navbar")): + tdiv(class="navbar container grid-xl"): + section(class="navbar-section"): + a(href="/"): + img(src="images/crown.png", id="img-logo") # TODO: Customisable. + section(class="navbar-section"): + tdiv(class="input-group input-inline"): + input(class="form-input input-sm", `type`="text", placeholder="search") + button(class="btn btn-primary btn-sm"): + italic(class="fas fa-user-plus") + text " Sign up" + button(class="btn btn-primary btn-sm"): + italic(class="fas fa-sign-in-alt") + text " Log in" + +proc createDom(): VNode = + result = buildHtml(tdiv()): + genHeader() + +setRenderer createDom \ No newline at end of file diff --git a/redesign/karax.html b/redesign/karax.html new file mode 100644 index 0000000..054116d --- /dev/null +++ b/redesign/karax.html @@ -0,0 +1,22 @@ + + + + + + + + + The Nim programming language forum + + + + + + + + +
+ + + + From a88f879d36c88e482ef21a0476c81aa6029f3f96 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 15:01:22 +0100 Subject: [PATCH 058/396] Top buttons in Karax. --- redesign/forum.nim | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index 7287a50..ca9a847 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -6,7 +6,7 @@ proc genHeader(): VNode = tdiv(class="navbar container grid-xl"): section(class="navbar-section"): a(href="/"): - img(src="images/crown.png", id="img-logo") # TODO: Customisable. + img(src="images/crown.png", id="img-logo") # TODO: Customisation. section(class="navbar-section"): tdiv(class="input-group input-inline"): input(class="form-input input-sm", `type`="text", placeholder="search") @@ -17,8 +17,26 @@ proc genHeader(): VNode = italic(class="fas fa-sign-in-alt") text " Log in" +proc genTopButtons(): VNode = + result = buildHtml(): + section(class="navbar container grid-xl", id="main-buttons"): + section(class="navbar-section"): + tdiv(class="dropdown"): + a(href="#", class="btn dropdown-toggle"): + text "Filter " + italic(class="fas fa-caret-down") + ul(class="menu"): + li: text "community" + li: text "dev" + button(class="btn btn-primary"): text "Latest" + button(class="btn btn-link"): text "Most Active" + button(class="btn btn-link"): text "Categories" + section(class="navbar-section") + + proc createDom(): VNode = result = buildHtml(tdiv()): genHeader() + genTopButtons() setRenderer createDom \ No newline at end of file From 2efd6946607a8fad1fd151b3529330a3e9a7ee80 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 17:15:03 +0100 Subject: [PATCH 059/396] Fixes forum for 0.18.1. --- forum.nim | 92 +++++++++++++++++++++++++++---------------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/forum.nim b/forum.nim index 11c098d..73f1ede 100644 --- a/forum.nim +++ b/forum.nim @@ -182,26 +182,26 @@ proc toInterval(diff: int64): TimeInterval = remaining -= hours * 3600 let minutes = remaining div 60 remaining -= minutes * 60 - result = initInterval(0, remaining.int, minutes.int, hours.int, days.int, + result = initInterval(remaining.int, minutes.int, hours.int, days.int, months.int, years.int) proc formatTimestamp(t: int): string = - let t2 = Time(t) + let t2 = fromUnix(t) let now = getTime() - let diff = (now - t2).toInterval() - if diff.years > 0: - return getGMTime(t2).format("MMMM d',' yyyy") - elif diff.months > 0: - return $diff.months & (if diff.months > 1: " months ago" else: " month ago") - elif diff.days > 0: - return $diff.days & (if diff.days > 1: " days ago" else: " day ago") - elif diff.hours > 0: - return $diff.hours & (if diff.hours > 1: " hours ago" else: " hour ago") - elif diff.minutes > 0: - return $diff.minutes & - (if diff.minutes > 1: " minutes ago" else: " minute ago") - else: - return "just now" + # let diff = (now - t2).toInterval() + # if diff.years > 0: + # return getGMTime(t2).format("MMMM d',' yyyy") + # elif diff.months > 0: + # return $diff.months & (if diff.months > 1: " months ago" else: " month ago") + # elif diff.days > 0: + # return $diff.days & (if diff.days > 1: " days ago" else: " day ago") + # elif diff.hours > 0: + # return $diff.hours & (if diff.hours > 1: " hours ago" else: " hour ago") + # elif diff.minutes > 0: + # return $diff.minutes & + # (if diff.minutes > 1: " minutes ago" else: " minute ago") + # else: + return "just now" proc getGravatarUrl(email: string, size = 80): string = let emailMD5 = email.toLowerAscii.toMD5 @@ -755,9 +755,10 @@ proc getStats(c: TForumData, simple: bool): TForumStats = sql"select id, name, strftime('%s', lastOnline), strftime('%s', creation) from person" for row in fastRows(db, getUsersQuery): let secs = if row[3] == "": 0 else: row[3].parseint - let lastOnlineSeconds = getTime() - Time(secs) - if lastOnlineSeconds < (60 * 5): # 5 minutes - result.activeUsers.add((row[1], row[0].parseInt)) + when false: + let lastOnlineSeconds = getTime() - Time(secs) + if lastOnlineSeconds < (60 * 5): # 5 minutes + result.activeUsers.add((row[1], row[0].parseInt)) if row[3].parseInt > newestMemberCreation: result.newestMember = (row[1], row[0].parseInt) newestMemberCreation = row[3].parseInt @@ -854,6 +855,7 @@ proc getPagesInThreadByID(c: TForumData, thrid: int): int = result = ceil(c.gatherTotalPostsByID(thrid) / PostsPerPage).int proc getThreadTitle(thrid: int, pageNum: int): string = + echo thrid result = getValue(db, sql"select name from thread where id = ?", $thrid) if pageNum notin {0,1}: result.add(" - Page " & $pageNum) @@ -923,7 +925,7 @@ proc genProfile(c: TForumData, ui: TUserInfo): string = ) ) result.add(htmlgen.`div`(id = "avatar", genGravatar(ui.email, 250))) - let t2 = if ui.lastOnline != -1: getGMTime(Time(ui.lastOnline)) + let t2 = if ui.lastOnline != -1: getGMTime(fromUnix(ui.lastOnline)) else: getGMTime(getTime()) result.add(htmlgen.`div`(id = "info", @@ -980,6 +982,23 @@ proc prependRe(s: string): string = elif s.startswith("Re:"): s else: "Re: " & s +proc initialise() = + randomize() + db = open(connection="nimforum.db", user="postgres", password="", + database="nimforum") + isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & + "type='table' AND name='post_fts'")).len == 1 + config = loadConfig() + if len(config.recaptchaSecretKey) > 0 and len(config.recaptchaSiteKey) > 0: + useCaptcha = true + captcha = initReCaptcha(config.recaptchaSecretKey, config.recaptchaSiteKey) + else: + useCaptcha = false + var http = true + if paramCount() > 0: + if paramStr(1) == "scgi": + http = false + template createTFD() = var c {.inject.}: TForumData new(c) @@ -992,6 +1011,9 @@ template createTFD() = if request.cookies.len > 0: checkLoggedIn(c) + +initialise() + routes: get "/": createTFD() @@ -1104,9 +1126,9 @@ routes: template handleError(action: string, topText: string, isEdit: bool) = if c.isPreview: - body.add genPostPreview(c, @"subject", @"content", + body().add genPostPreview(c, @"subject", @"content", c.userName, $getGMTime(getTime())) - body.add genFormPost(c, action, topText, reuseText, reuseText, isEdit) + body().add genFormPost(c, action, topText, reuseText, reuseText, isEdit) resp genMain(c, body(), "Nim Forum - " & (if c.isPreview: "Preview" else: "Error")) @@ -1185,8 +1207,8 @@ routes: cond(@"nick" != "") if c.rank < Moderator: resp genMain(c, "You cannot delete this user's data.", "Error - Nim Forum") - let result = deleteAll(c, @"nick") - if result: + let res = deleteAll(c, @"nick") + if res: redirect(c.req.makeUri("/profile/" & @"nick")) else: resp genMain(c, "Failed to delete all user's posts and threads.", @@ -1348,25 +1370,3 @@ routes: textPage "static/search-help" get "/rst": textPage "static/rst" - -when isMainModule: - randomize() - db = open(connection="nimforum.db", user="postgres", password="", - database="nimforum") - isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & - "type='table' AND name='post_fts'")).len == 1 - config = loadConfig() - if len(config.recaptchaSecretKey) > 0 and len(config.recaptchaSiteKey) > 0: - useCaptcha = true - captcha = initReCaptcha(config.recaptchaSecretKey, config.recaptchaSiteKey) - else: - useCaptcha = false - var http = true - if paramCount() > 0: - if paramStr(1) == "scgi": - http = false - - #run("", port = TPort(9000), http = http) - - runForever() - db.close() From 8910e55ad1b30f22a27fee85ab4749494ec8f93d Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 19:02:18 +0100 Subject: [PATCH 060/396] Implements thread list in Karax and backend. --- forms.tmpl | 66 +++++++++++------------ forum.nim | 64 +++++++++++++++++++++- main.tmpl | 40 +++++++------- redesign/forum.nim | 42 +++++++++++++-- redesign/forum.nim.cfg | 1 + redesign/karaxutils.nim | 5 ++ redesign/threadlist.nim | 115 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 276 insertions(+), 57 deletions(-) create mode 100644 redesign/forum.nim.cfg create mode 100644 redesign/karaxutils.nim create mode 100644 redesign/threadlist.nim diff --git a/forms.tmpl b/forms.tmpl index c9025d2..c88ac9a 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -1,6 +1,6 @@ #? stdtmpl | standard # -#template `%`(idx: untyped): untyped = +#template `!`(idx: untyped): untyped = # row[idx] #end template # @@ -47,15 +47,15 @@
- ${xmlEncode(%name)} - ${genPagenumLocalNav(c, (%threadid).parseInt)} + ${xmlEncode(!name)} + ${genPagenumLocalNav(c, (!threadid).parseInt)}
#let users = getAllRows(db, # sql("select distinct name, email from person where id in " & - # "(select author from post where thread = ?)"), %threadId) + # "(select author from post where thread = ?)"), !threadId)
#for i in 0 .. min(6, users.len-1): @@ -66,19 +66,19 @@ #let latestReplyAuthor = getValue(db, sql("select name from person where id = " & # "(select author from post where id = " & - # "(select max(id) from post where thread = ?))"), %threadId) + # "(select max(id) from post where thread = ?))"), !threadId) #let replyProfileUrl = c.req.makeUri("profile/", false) & # xmlEncode(latestReplyAuthor) -# let posts = getValue(db, sql"select count(*) from post where thread = ?", %threadId) +# let posts = getValue(db, sql"select count(*) from post where thread = ?", !threadId)
-
${xmlEncode(%views)}
+
${xmlEncode(!views)}
$posts
#let latestReplyDate = getValue(db, sql("SELECT strftime('%s', " & - # "(select creation from post where id = (select max(id) from post where thread = ?)))"), %threadId) + # "(select creation from post where id = (select max(id) from post where thread = ?)))"), !threadId) #let timeStr = formatTimestamp(latestReplyDate.parseInt())
@@ -150,28 +150,28 @@
# for row in posts: # inc(count) - -
+ +
- #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName) -
${genGravatar(%userEmail)}
- ${xmlEncode(%userName)} - #if c.userId == %postAuthor and c.currentPost.subject.len == 0: -
Edit post + #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(!userName) +
${genGravatar(!userEmail)}
+ ${xmlEncode(!userName)} + #if c.userId == !postAuthor and c.currentPost.subject.len == 0: +
Edit post #elif c.rank >= Moderator and c.currentPost.subject.len == 0: -
Edit post +
Edit post #end if
#try: - ${(%postContent).rstToHtml} + ${(!postContent).rstToHtml} #except EParseError: # c.errorMsg = getCurrentExceptionMsg() #end - ${xmlEncode(%postCreation)} + ${xmlEncode(!postCreation)}
@@ -421,43 +421,43 @@
# for row in results(): # inc(count) -# let isThread = %what == "0" +# let isThread = !what == "0" # inc(whCount[isThread]) -# let postUrl = c.genThreadUrl(%postId,"",%threadId,"") -# let threadUrl = c.genThreadUrl("","",%threadId) +# let postUrl = c.genThreadUrl(!postId,"",!threadId,"") +# let threadUrl = c.genThreadUrl("","",!threadId) # var headersDiffer = false
- #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName) - - - #if c.userId == %postAuthor and c.currentPost.subject.len == 0: -
Edit post + #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(!userName) + + + #if c.userId == !postAuthor and c.currentPost.subject.len == 0: +
Edit post #elif c.rank >= Moderator and c.currentPost.subject.len == 0: -
Edit post +
Edit post #end if
- #if %postHeader != "": + #if !postHeader != "": #end if #if not isThread: #try: - ${(%postContent).rstToHtml} + ${(!postContent).rstToHtml} #except EParseError: # c.errorMsg = getCurrentExceptionMsg() - ${xmlEncode(%postContent)} + ${xmlEncode(!postContent)} #end #end if - ${xmlEncode(%postCreation)} + ${xmlEncode(!postCreation)}
diff --git a/forum.nim b/forum.nim index 73f1ede..19d3a50 100644 --- a/forum.nim +++ b/forum.nim @@ -9,7 +9,9 @@ import os, strutils, times, md5, strtabs, cgi, math, db_sqlite, scgi, jester, asyncdispatch, asyncnet, cache, sequtils, - parseutils, utils, random, rst, ranks, recaptcha + parseutils, utils, random, rst, ranks, recaptcha, json + +import redesign/threadlist except User when not defined(windows): import bcrypt # TODO @@ -1024,6 +1026,66 @@ routes: additionalHeaders = genRSSHeaders(c), showRssLinks = true) resp data + get "/karax.html": + resp readFile("redesign/karax.html") + get "/nimforum.css": + resp readFile("redesign/nimforum.css"), "text/css" + get "/nimcache/forum.js": + resp readFile("redesign/nimcache/forum.js"), "application/javascript" + get "/images/crown.png": + resp readFile("redesign/images/crown.png"), "image/png" + + get "/threads.json": + var + start = 0 + count = 30 + parseInt(@"start", start, 0..1_000_000) + parseInt(@"count", start, 0..1_000_000) + + 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 = ?);""" + + 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 + )) + list.threads.add(thread) + + resp $(%list), "application/json" + get "/threadActivity.xml": createTFD() c.isThreadsList = true diff --git a/main.tmpl b/main.tmpl index f295433..55ea1de 100644 --- a/main.tmpl +++ b/main.tmpl @@ -207,20 +207,20 @@ ${recent} # for row in rows(db, query, 10): - ${xmlEncode(%name)} - urn:entry:${%threadid} - # let url = c.genThreadUrl(threadid = %threadid, - # pageNum = $(ceil(parseInt(%postCount) / PostsPerPage).int)) & - # "#" & %postId + ${xmlEncode(!name)} + urn:entry:${!threadid} + # let url = c.genThreadUrl(threadid = !threadid, + # pageNum = $(ceil(parseInt(!postCount) / PostsPerPage).int)) & + # "#" & !postId - ${%threadDate} - ${%threadDate} - ${xmlEncode(%postAuthor)} + ${!threadDate} + ${!threadDate} + ${xmlEncode(!postAuthor)} Posts ${%postCount}, ${xmlEncode(%postAuthor)} said: +>Posts ${!postCount}, ${xmlEncode(!postAuthor)} said: <p> -${xmlEncode(rstToHtml(%postContent))} +${xmlEncode(rstToHtml(!postContent))} # end for @@ -256,20 +256,20 @@ ${xmlEncode(rstToHtml(%postContent))} ${recent} # for row in rows(db, query, 10): - ${xmlEncode(%postHeader)} - urn:entry:${%postId} - # let url = c.genThreadUrl(threadid = %postThread, - # pageNum = $(ceil(parseInt(%postPosition) / PostsPerPage).int)) & - # "#" & %postId + ${xmlEncode(!postHeader)} + urn:entry:${!postId} + # let url = c.genThreadUrl(threadid = !postThread, + # pageNum = $(ceil(parseInt(!postPosition) / PostsPerPage).int)) & + # "#" & !postId - ${%postRssDate} - ${%postRssDate} - ${xmlEncode(%postAuthor)} + ${!postRssDate} + ${!postRssDate} + ${xmlEncode(!postAuthor)} On ${xmlEncode(%postHumanDate)}, ${xmlEncode(%postAuthor)} said: +>On ${xmlEncode(!postHumanDate)}, ${xmlEncode(!postAuthor)} said: <p> -${xmlEncode(rstToHtml(%postContent))} +${xmlEncode(rstToHtml(!postContent))} # end for diff --git a/redesign/forum.nim b/redesign/forum.nim index ca9a847..15080b2 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -1,5 +1,21 @@ -include karax/prelude +import strformat, times, options, json +include karax/prelude +import karax / [vstyles, kajax] + +import threadlist, karaxutils + +type + State = ref object + list: Option[ThreadList] + +proc newState(): State = + State( + list: none[ThreadList]() + ) + +const + baseUrl = "http://localhost:5000/" proc genHeader(): VNode = result = buildHtml(header(id="main-navbar")): @@ -34,9 +50,29 @@ proc genTopButtons(): VNode = section(class="navbar-section") -proc createDom(): VNode = +var state = newState() + +proc onThreadList(httpStatus: int, response: kstring) = + let parsed = parseJson($response) + let list = to(parsed, ThreadList) + + if state.list.isSome: + state.list.get().threads.add(list.threads) + state.list.get().moreCount = list.moreCount + state.list.get().lastVisit = list.lastVisit + else: + state.list = some(list) + +proc render(): VNode = + if state.list.isNone: + ajaxGet(baseUrl & "threads.json", @[], onThreadList) + result = buildHtml(tdiv()): genHeader() genTopButtons() + if state.list.isNone: + tdiv(class="loading loading-lg") + else: + genThreadList(state.list.get()) -setRenderer createDom \ No newline at end of file +setRenderer render \ No newline at end of file diff --git a/redesign/forum.nim.cfg b/redesign/forum.nim.cfg new file mode 100644 index 0000000..6869201 --- /dev/null +++ b/redesign/forum.nim.cfg @@ -0,0 +1 @@ +-d:js \ No newline at end of file diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim new file mode 100644 index 0000000..efda777 --- /dev/null +++ b/redesign/karaxutils.nim @@ -0,0 +1,5 @@ +proc class*(classes: varargs[tuple[name: string, present: bool]], + defaultClasses: string = ""): string = + result = defaultClasses & " " + for class in classes: + if class.present: result.add(class.name & " ") \ No newline at end of file diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim new file mode 100644 index 0000000..594bf1b --- /dev/null +++ b/redesign/threadlist.nim @@ -0,0 +1,115 @@ +import strformat, times + +type + User* = object + name*: string + avatarUrl*: string + isOnline*: bool + + Category* = object + id*: string + color*: string + + Thread* = object + id*: int + topic*: string + category*: Category + users*: seq[User] + replies*: int + views*: int + activity*: int64 ## Unix timestamp + creation*: int64 ## Unix timestamp + isLocked*: bool + isSolved*: bool + + ThreadList* = ref object + threads*: seq[Thread] + lastVisit*: int64 ## Unix timestamp + moreCount*: int ## How many more threads are left + +when defined(js): + include karax/prelude + import karax / [vstyles] + + import karaxutils + + 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") + + proc renderActivity(activity: int64): string = + let currentTime = getTime() + let activityTime = fromUnix(activity) + let duration = currentTime - activityTime + if duration.days > 300: + return activityTime.local().format("MMM yyyy") + elif duration.days > 30 and duration.days < 300: + return activityTime.local().format("MMM dd") + elif duration.days != 0: + return $duration.days & "d" + elif duration.hours != 0: + return $duration.hours & "h" + elif duration.minutes != 0: + return $duration.minutes & "m" + else: + return $duration.seconds & "s" + + proc genThread(thread: Thread, isNew: bool, noBorder: bool): VNode = + result = buildHtml(): + tr(class=class({"no-border": noBorder})): + td(): + if thread.isLocked: + italic(class="fas fa-lock fa-xs") + text thread.topic + td(): + tdiv(class="triangle", + style=style( + (StyleAttr.borderBottom, kstring"0.6rem solid " & thread.category.color) + )): + text thread.category.id + genUserAvatars(thread.users) + td(): text $thread.replies + td(class=class({ + "views-text": thread.views < 999, + "popular-text": thread.views > 999 and thread.views < 5000, + "super-popular-text": thread.views > 5000 + })): + if thread.views > 999: + text fmt"{thread.views/1000:.1f}" + else: + text $thread.views + td(class=class({"text-success": isNew, "text-gray": not isNew})): # TODO: Colors. + text renderActivity(thread.activity) + + proc genThreadList*(list: ThreadList): VNode = + result = buildHtml(): + section(class="container grid-xl"): # TODO: Rename to `.thread-list`. + table(class="table"): + thead(): + tr: + th(text "Topic") + th(text "Category") + th(text "Users") + th(text "Replies") + th(text "Views") + th(text "Activity") + tbody(): + for i in 0 ..< list.threads.len: + let thread = list.threads[i] + let isLastVisit = + i+1 < list.threads.len and list.threads[i].activity < list.lastVisit + let isNew = thread.creation < list.lastVisit + genThread(thread, isNew, noBorder=isLastVisit) + if isLastVisit: + tr(class="last-visit-separator"): + td(colspan="6"): + span(text "last visit") + + if list.moreCount > 0: + tr(class="load-more-separator"): + td(colspan="6"): + span(text "load more threads") From ca0de4d2aef914c36dc584e2a0ecb424fe2c3b1f Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 19:16:12 +0100 Subject: [PATCH 061/396] Small fixes and adjustments to thread list. --- forum.nim | 5 +++-- redesign/threadlist.nim | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/forum.nim b/forum.nim index 19d3a50..c9220c3 100644 --- a/forum.nim +++ b/forum.nim @@ -1050,9 +1050,10 @@ routes: where thread = ? order by creation asc limit 1;""" const usersListQuery = - sql"""select distinct name, email, strftime('%s',lastOnline) + sql"""select distinct name, email, strftime('%s', lastOnline) from person where id in - (select author from post where thread = ?);""" + (select author from post where thread = ?) + limit 5;""" # TODO: Order by most posts. let thrCount = getValue(db, sql"select count(*) from thread;").parseInt() let moreCount = max(0, thrCount - (start + count)) diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 594bf1b..f4646bc 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -40,6 +40,7 @@ when defined(js): img(src=user.avatarUrl, title=user.name) if user.isOnline: italic(class="avatar-presense online") + text " " proc renderActivity(activity: int64): string = let currentTime = getTime() @@ -66,11 +67,12 @@ when defined(js): italic(class="fas fa-lock fa-xs") text thread.topic td(): - tdiv(class="triangle", - style=style( - (StyleAttr.borderBottom, kstring"0.6rem solid " & thread.category.color) - )): - text thread.category.id + if thread.category.id.len > 0: + tdiv(class="triangle", + style=style( + (StyleAttr.borderBottom, kstring"0.6rem solid " & thread.category.color) + )): + text thread.category.id genUserAvatars(thread.users) td(): text $thread.replies td(class=class({ @@ -79,7 +81,7 @@ when defined(js): "super-popular-text": thread.views > 5000 })): if thread.views > 999: - text fmt"{thread.views/1000:.1f}" + text fmt"{thread.views/1000:.1f}k" else: text $thread.views td(class=class({"text-success": isNew, "text-gray": not isNew})): # TODO: Colors. @@ -93,7 +95,7 @@ when defined(js): tr: th(text "Topic") th(text "Category") - th(text "Users") + th(style=style((StyleAttr.width, kstring"8rem"))): text "Users" th(text "Replies") th(text "Views") th(text "Activity") @@ -103,7 +105,8 @@ when defined(js): let isLastVisit = i+1 < list.threads.len and list.threads[i].activity < list.lastVisit let isNew = thread.creation < list.lastVisit - genThread(thread, isNew, noBorder=isLastVisit) + genThread(thread, isNew, + noBorder=isLastVisit or i+1 == list.threads.len) if isLastVisit: tr(class="last-visit-separator"): td(colspan="6"): From 2e95d078e15cdc08e41e97505a854a6aaae196cd Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 22:00:30 +0100 Subject: [PATCH 062/396] Move genTopButtons to threadlist module. --- redesign/forum.nim | 17 ----------------- redesign/threadlist.nim | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index 15080b2..36a9667 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -33,23 +33,6 @@ proc genHeader(): VNode = italic(class="fas fa-sign-in-alt") text " Log in" -proc genTopButtons(): VNode = - result = buildHtml(): - section(class="navbar container grid-xl", id="main-buttons"): - section(class="navbar-section"): - tdiv(class="dropdown"): - a(href="#", class="btn dropdown-toggle"): - text "Filter " - italic(class="fas fa-caret-down") - ul(class="menu"): - li: text "community" - li: text "dev" - button(class="btn btn-primary"): text "Latest" - button(class="btn btn-link"): text "Most Active" - button(class="btn btn-link"): text "Categories" - section(class="navbar-section") - - var state = newState() proc onThreadList(httpStatus: int, response: kstring) = diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index f4646bc..7a38e5e 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -33,6 +33,22 @@ when defined(js): import karaxutils + proc genTopButtons*(): VNode = + result = buildHtml(): + section(class="navbar container grid-xl", id="main-buttons"): + section(class="navbar-section"): + tdiv(class="dropdown"): + a(href="#", class="btn dropdown-toggle"): + text "Filter " + italic(class="fas fa-caret-down") + ul(class="menu"): + li: text "community" + li: text "dev" + button(class="btn btn-primary"): text "Latest" + button(class="btn btn-link"): text "Most Active" + button(class="btn btn-link"): text "Categories" + section(class="navbar-section") + proc genUserAvatars(users: seq[User]): VNode = result = buildHtml(td): for user in users: From 032d70a233182f3d8b5795494ebc2b63437cbe7e Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 22:10:03 +0100 Subject: [PATCH 063/396] Move everything related to `ThreadList` to the threadlist module. --- redesign/forum.nim | 30 +++----------------------- redesign/threadlist.nim | 48 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index 36a9667..a5ebbb9 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -1,21 +1,15 @@ import strformat, times, options, json include karax/prelude -import karax / [vstyles, kajax] + import threadlist, karaxutils type State = ref object - list: Option[ThreadList] proc newState(): State = - State( - list: none[ThreadList]() - ) - -const - baseUrl = "http://localhost:5000/" + State() proc genHeader(): VNode = result = buildHtml(header(id="main-navbar")): @@ -35,27 +29,9 @@ proc genHeader(): VNode = var state = newState() -proc onThreadList(httpStatus: int, response: kstring) = - let parsed = parseJson($response) - let list = to(parsed, ThreadList) - - if state.list.isSome: - state.list.get().threads.add(list.threads) - state.list.get().moreCount = list.moreCount - state.list.get().lastVisit = list.lastVisit - else: - state.list = some(list) - proc render(): VNode = - if state.list.isNone: - ajaxGet(baseUrl & "threads.json", @[], onThreadList) - result = buildHtml(tdiv()): genHeader() - genTopButtons() - if state.list.isNone: - tdiv(class="loading loading-lg") - else: - genThreadList(state.list.get()) + renderThreadList() setRenderer render \ No newline at end of file diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 7a38e5e..7149cec 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -1,4 +1,4 @@ -import strformat, times +import strformat, times, options, json type User* = object @@ -27,13 +27,28 @@ type lastVisit*: int64 ## Unix timestamp moreCount*: int ## How many more threads are left +const + baseUrl = "http://localhost:5000/" + when defined(js): include karax/prelude - import karax / [vstyles] + import karax / [vstyles, kajax, kdom] import karaxutils - proc genTopButtons*(): VNode = + type + State = ref object + list: Option[ThreadList] + + proc newState(): State = + State( + list: none[ThreadList]() + ) + + var + state = newState() + + proc genTopButtons(): VNode = result = buildHtml(): section(class="navbar container grid-xl", id="main-buttons"): section(class="navbar-section"): @@ -103,7 +118,24 @@ when defined(js): td(class=class({"text-success": isNew, "text-gray": not isNew})): # TODO: Colors. text renderActivity(thread.activity) - proc genThreadList*(list: ThreadList): VNode = + proc onThreadList(httpStatus: int, response: kstring) = + let parsed = parseJson($response) + let list = to(parsed, ThreadList) + + if state.list.isSome: + state.list.get().threads.add(list.threads) + state.list.get().moreCount = list.moreCount + state.list.get().lastVisit = list.lastVisit + else: + state.list = some(list) + + proc genThreadList(): VNode = + if state.list.isNone: + ajaxGet(baseUrl & "threads.json", @[], onThreadList) + + return buildHtml(tdiv(class="loading loading-lg")) + + let list = state.list.get() result = buildHtml(): section(class="container grid-xl"): # TODO: Rename to `.thread-list`. table(class="table"): @@ -119,7 +151,8 @@ when defined(js): for i in 0 ..< list.threads.len: let thread = list.threads[i] let isLastVisit = - i+1 < list.threads.len and list.threads[i].activity < list.lastVisit + i+1 < list.threads.len and + list.threads[i].activity < list.lastVisit let isNew = thread.creation < list.lastVisit genThread(thread, isNew, noBorder=isLastVisit or i+1 == list.threads.len) @@ -132,3 +165,8 @@ when defined(js): tr(class="load-more-separator"): td(colspan="6"): span(text "load more threads") + + proc renderThreadList*(): VNode = + result = buildHtml(tdiv): + genTopButtons() + genThreadList() \ No newline at end of file From 656b679a51d423f70a36679696b1a02ab3320283 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 22:38:02 +0100 Subject: [PATCH 064/396] Load more button works. --- redesign/threadlist.nim | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 7149cec..d686567 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -39,10 +39,12 @@ when defined(js): type State = ref object list: Option[ThreadList] + loading: bool proc newState(): State = State( - list: none[ThreadList]() + list: none[ThreadList](), + loading: false ) var @@ -119,6 +121,7 @@ when defined(js): text renderActivity(thread.activity) proc onThreadList(httpStatus: int, response: kstring) = + state.loading = false let parsed = parseJson($response) let list = to(parsed, ThreadList) @@ -129,6 +132,11 @@ when defined(js): else: state.list = some(list) + proc onLoadMore(ev: Event, n: VNode) = + state.loading = true + let start = state.list.get().threads.len + ajaxGet(baseUrl & "threads.json?start=" & $start, @[], onThreadList) + proc genThreadList(): VNode = if state.list.isNone: ajaxGet(baseUrl & "threads.json", @[], onThreadList) @@ -163,8 +171,12 @@ when defined(js): if list.moreCount > 0: tr(class="load-more-separator"): - td(colspan="6"): - span(text "load more threads") + if state.loading: + td(colspan="6"): + tdiv(class="loading loading-lg") + else: + td(colspan="6", onClick=onLoadMore): + span(text "load more threads") proc renderThreadList*(): VNode = result = buildHtml(tdiv): From 40e948bcf8762f1b65d6b93c179610811c92c3d9 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 9 May 2018 22:41:25 +0100 Subject: [PATCH 065/396] Move karax redesign to /karax/ --- forum.nim | 10 +++++----- redesign/threadlist.nim | 7 ++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/forum.nim b/forum.nim index c9220c3..f69298f 100644 --- a/forum.nim +++ b/forum.nim @@ -1026,16 +1026,16 @@ routes: additionalHeaders = genRSSHeaders(c), showRssLinks = true) resp data - get "/karax.html": + get "/karax/": resp readFile("redesign/karax.html") - get "/nimforum.css": + get "/karax/nimforum.css": resp readFile("redesign/nimforum.css"), "text/css" - get "/nimcache/forum.js": + get "/karax/nimcache/forum.js": resp readFile("redesign/nimcache/forum.js"), "application/javascript" - get "/images/crown.png": + get "/karax/images/crown.png": resp readFile("redesign/images/crown.png"), "image/png" - get "/threads.json": + get "/karax/threads.json": var start = 0 count = 30 diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index d686567..396e7b2 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -27,9 +27,6 @@ type lastVisit*: int64 ## Unix timestamp moreCount*: int ## How many more threads are left -const - baseUrl = "http://localhost:5000/" - when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] @@ -135,11 +132,11 @@ when defined(js): proc onLoadMore(ev: Event, n: VNode) = state.loading = true let start = state.list.get().threads.len - ajaxGet(baseUrl & "threads.json?start=" & $start, @[], onThreadList) + ajaxGet("threads.json?start=" & $start, @[], onThreadList) proc genThreadList(): VNode = if state.list.isNone: - ajaxGet(baseUrl & "threads.json", @[], onThreadList) + ajaxGet("threads.json", @[], onThreadList) return buildHtml(tdiv(class="loading loading-lg")) From b2225dec34700f0469408c485d6dc05ad263cabc Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 10 May 2018 00:22:05 +0100 Subject: [PATCH 066/396] Implements navigation. --- redesign/forum.nim | 23 +++++++++++++++++++---- redesign/karaxutils.nim | 36 +++++++++++++++++++++++++++++++++++- redesign/threadlist.nim | 2 +- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index a5ebbb9..93c0da2 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -1,4 +1,5 @@ import strformat, times, options, json +from dom import window, Location include karax/prelude @@ -7,9 +8,21 @@ import threadlist, karaxutils type State = ref object + url: Location proc newState(): State = - State() + State( + url: window.location + ) + +var state = newState() +proc onPopState(event: dom.Event) = + # This event is usually only called when the user moves back in their + # history. I fire it in karaxutils.anchorCB as well to ensure the URL is + # always updated. This should be moved into Karax in the future. + kout(kstring"New URL: ", window.location.href) + state.url = window.location + redraw() proc genHeader(): VNode = result = buildHtml(header(id="main-navbar")): @@ -27,11 +40,13 @@ proc genHeader(): VNode = italic(class="fas fa-sign-in-alt") text " Log in" -var state = newState() - proc render(): VNode = result = buildHtml(tdiv()): genHeader() - renderThreadList() + if "/t/" in state.url.pathname: + text "" + else: + renderThreadList() +window.onPopState = onPopState setRenderer render \ No newline at end of file diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index efda777..3949da0 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -1,5 +1,39 @@ +import strutils +import dom except window + +include karax/prelude +import karax / [kdom] + +const appName = "/karax/" + proc class*(classes: varargs[tuple[name: string, present: bool]], defaultClasses: string = ""): string = result = defaultClasses & " " for class in classes: - if class.present: result.add(class.name & " ") \ No newline at end of file + if class.present: result.add(class.name & " ") + +proc makeUri*(relative: string, appName=appName): string = + ## Concatenates ``relative`` to the current URL in a way that is sane. + var relative = relative + assert appName in $window.location.pathname + if relative[0] == '/': relative = relative[1..^1] + + return $window.location.protocol & "//" & + $window.location.host & + appName & + relative & + $window.location.search & + $window.location.hash + + +proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb? + e.preventDefault() + + # TODO: Why does Karax have it's own Node type? That's just silly. + let url = cast[dom.Node](n.dom).getAttribute(cstring"href") + + # TODO: This was annoying. Karax also shouldn't have its own `window`. + dom.pushState(dom.window.history, 5, cstring"Thread", url) + + # Fire the popState event. + dom.window.dispatchEvent(newEvent("popstate")) \ No newline at end of file diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 396e7b2..3d8bb9f 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -95,7 +95,7 @@ when defined(js): td(): if thread.isLocked: italic(class="fas fa-lock fa-xs") - text thread.topic + a(href=makeUri("/t/" & $thread.id), onClick=anchorCB): text thread.topic td(): if thread.category.id.len > 0: tdiv(class="triangle", From 366bdadc90da4a20c66fc72b0f08c0d240e2a77a Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 10 May 2018 16:05:42 +0100 Subject: [PATCH 067/396] Implements post list in front end and backend. --- forum.nim | 131 ++++++++++++++++++++++++++++------------ redesign/category.nim | 23 +++++++ redesign/error.nim | 17 ++++++ redesign/forum.nim | 4 +- redesign/postlist.nim | 117 +++++++++++++++++++++++++++++++++++ redesign/threadlist.nim | 45 ++++++++------ utils.nim | 5 ++ 7 files changed, 283 insertions(+), 59 deletions(-) create mode 100644 redesign/category.nim create mode 100644 redesign/error.nim create mode 100644 redesign/postlist.nim 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 From cf37fa34c406b2460ad92b96a9d325c131191177 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 10 May 2018 16:24:11 +0100 Subject: [PATCH 068/396] Small style and other fixes. --- forum.nim | 2 +- redesign/forum.nim | 2 +- redesign/nimforum.scss | 20 ++++++++++++++++++-- redesign/threadlist.nim | 2 +- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/forum.nim b/forum.nim index 4bd503d..ef97f85 100644 --- a/forum.nim +++ b/forum.nim @@ -1042,7 +1042,7 @@ proc selectThread(threadRow: seq[string]): Thread = topic: threadRow[1], category: Category(id: "", color: "#ff0000"), # TODO users: @[], - replies: posts[0].parseInt, + replies: posts[0].parseInt-1, views: threadRow[2].parseInt, activity: threadRow[3].parseInt, creation: posts[1].parseInt, diff --git a/redesign/forum.nim b/redesign/forum.nim index f24cfc7..8aaa425 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -28,7 +28,7 @@ proc genHeader(): VNode = result = buildHtml(header(id="main-navbar")): tdiv(class="navbar container grid-xl"): section(class="navbar-section"): - a(href="/"): + a(href=makeUri("/")): img(src="images/crown.png", id="img-logo") # TODO: Customisation. section(class="navbar-section"): tdiv(class="input-group input-inline"): diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 7ed37ed..80fe4c1 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -94,6 +94,17 @@ $logo-height: $navbar-height - 20px; } // - Thread table +.thread-title { + a, a:visited, a:hover { + color: $body-font-color; + text-decoration: none; + } + + a.visited { + color: lighten($body-font-color, 40%); + } +} + $super-popular-color: #f86713; $popular-color: darken($super-popular-color, 25%); $views-color: #545d70; @@ -212,7 +223,12 @@ $views-color: #545d70; .post-title { margin-bottom: $control-padding-y*2; - color: lighten($body-font-color, 20%); + + &, a, a:visited, a:hover { + color: lighten($body-font-color, 20%); + text-decoration: none; + } + .post-username { font-weight: bold; display: inline-block; @@ -253,7 +269,7 @@ $views-color: #545d70; box-shadow: inset 0 0 .4rem .01rem darken($secondary-btn-color, 80%); } - .like-button i { + .like-button i:hover, .like-button i.fas { color: #f783ac; } } diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 3dfab50..58094be 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -96,7 +96,7 @@ when defined(js): proc genThread(thread: Thread, isNew: bool, noBorder: bool): VNode = result = buildHtml(): tr(class=class({"no-border": noBorder})): - td(): + td(class="thread-title"): if thread.isLocked: italic(class="fas fa-lock fa-xs") a(href=makeUri("/t/" & $thread.id), onClick=anchorCB): text thread.topic From 4176bdee3cd199e0bec1f2146f6a5183cc7dbc4a Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 10 May 2018 18:21:12 +0100 Subject: [PATCH 069/396] Use Jester's pattern matcher for simple routing. --- forum.nim | 4 ++-- redesign/forum.nim | 31 +++++++++++++++++++++++++------ redesign/postlist.nim | 4 ++-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/forum.nim b/forum.nim index ef97f85..025e109 100644 --- a/forum.nim +++ b/forum.nim @@ -1046,8 +1046,8 @@ proc selectThread(threadRow: seq[string]): Thread = 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. + isLocked: false, # TODO: + isSolved: false # TODO: Add a field to `post` to identify the solution. ) # Gather the users list. diff --git a/redesign/forum.nim b/redesign/forum.nim index 8aaa425..efa4780 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -1,8 +1,8 @@ -import strformat, times, options, json +import strformat, times, options, json, tables, future from dom import window, Location include karax/prelude - +import jester/patterns import threadlist, postlist, karaxutils @@ -40,13 +40,32 @@ proc genHeader(): VNode = italic(class="fas fa-sign-in-alt") text " Log in" +const appName = "/karax" +type Params = Table[string, string] +type + Route = object + n: string + p: proc (params: Params): VNode + +proc r(n: string, p: proc (params: Params): VNode): Route = Route(n: n, p: p) +proc route(routes: openarray[Route]): VNode = + for route in routes: + let pattern = (appName & route.n).parsePattern() + let (matched, params) = pattern.match($state.url.pathname) + if matched: + return route.p(params) + proc render(): VNode = result = buildHtml(tdiv()): genHeader() - if "/t/" in state.url.pathname: - renderPostList(3806, false) - else: - renderThreadList() + route([ + r("/t/@id?", + (params: Params) => + (kout(params["id"].cstring); + renderPostList(params["id"].parseInt(), false)) + ), + r("/", (params: Params) => renderThreadList()) + ]) window.onPopState = onPopState setRenderer render \ No newline at end of file diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 33ed677..52978bf 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -56,7 +56,7 @@ when defined(js): let parsed = parseJson($response) let list = to(parsed, PostList) - if state.list.isSome: + if state.list.isSome and state.list.get().thread.id == list.thread.id: state.list.get().posts.add(list.posts) # TODO: Incorporate other possible changes? else: @@ -101,7 +101,7 @@ when defined(js): if state.status != Http200: return renderError("Couldn't retrieve posts.") - if state.list.isNone: + if state.list.isNone or state.list.get().thread.id != threadId: ajaxGet(makeUri("posts.json?id=" & $threadId), @[], onPostList) return buildHtml(tdiv(class="loading loading-lg")) From 19a9f24d3d5de9ccb595f75ec8f7edabecf04780 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 10 May 2018 19:07:53 +0100 Subject: [PATCH 070/396] Implements loading of more posts. --- forum.nim | 13 +++++++++++- redesign/karaxutils.nim | 12 +++++++++++ redesign/nimforum.scss | 11 ++++++++++ redesign/postlist.nim | 46 +++++++++++++++++++++++++++++++++++------ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/forum.nim b/forum.nim index 025e109..7f4929b 100644 --- a/forum.nim +++ b/forum.nim @@ -1126,7 +1126,18 @@ routes: order by p.id limit ?, ?""" % modClause ) - var list = PostList(posts: @[], history: @[], thread: thread) + let pstCount = getValue( + db, + sql"select count(*) from post where thread = ?;", + id + ).parseInt() + let moreCount = max(0, pstCount - (start + count)) + + var list = PostList( + posts: @[], + history: @[], + thread: thread, + moreCount: moreCount) for post in getAllRows(db, postsQuery, id, c.userId, c.userId, start, count): list.posts.add(Post( diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index 3949da0..c170a97 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -25,6 +25,18 @@ proc makeUri*(relative: string, appName=appName): string = $window.location.search & $window.location.hash +proc makeUri*(relative: string, params: varargs[(string, string)], + appName=appName): string = + var query = "" + for i in 0 ..< params.len: + let param = params[i] + if i != 0: query.add("&") + query.add(param[0] & "=" & param[1]) + + if query.len > 0: + makeUri(relative & "?" & query, appName) + else: + makeUri(relative, appName) proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb? e.preventDefault() diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 80fe4c1..00925d6 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -369,4 +369,15 @@ blockquote { text-transform: uppercase; font-weight: bold; cursor: pointer; + + .information-main { + width: 100%; + text-align: left; + } + + .more-post-count { + color: rgba(darken($label-color, 35%), 0.5); + margin-right: $control-padding-x*2; + float: right; + } } \ No newline at end of file diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 52978bf..226af98 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -1,5 +1,5 @@ -import options, json, times, httpcore, strformat +import options, json, times, httpcore, strformat, sugar import threadlist, category type @@ -25,6 +25,7 @@ type ## older versions of the thread (title/category ## changes). posts*: seq[Post] + moreCount*: int when defined(js): include karax/prelude @@ -48,7 +49,7 @@ when defined(js): var state = newState() - proc onPostList(httpStatus: int, response: kstring) = + proc onPostList(httpStatus: int, response: kstring, start: int) = state.loading = false state.status = httpStatus.HttpCode if state.status != Http200: return @@ -57,8 +58,12 @@ when defined(js): let list = to(parsed, PostList) if state.list.isSome and state.list.get().thread.id == list.thread.id: - state.list.get().posts.add(list.posts) - # TODO: Incorporate other possible changes? + var old = state.list.get() + for i in 0.. onPostList(s, r, start)) + + proc genLoadMore(start: int): VNode = + result = buildHtml(): + tdiv(class="information load-more-posts", + onClick=onLoadMore, + "data-start" = $start): + tdiv(class="information-icon"): + italic(class="fas fa-comment-dots") + tdiv(class="information-main"): + if state.loading: + tdiv(class="loading loading-lg") + else: + tdiv(class="information-title"): + text "Load more posts " + span(class="more-post-count"): + text "(" & $state.list.get().moreCount & ")" + proc renderPostList*(threadId: int, isLoggedIn: bool): VNode = if state.status != Http200: return renderError("Couldn't retrieve posts.") if state.list.isNone or state.list.get().thread.id != threadId: - ajaxGet(makeUri("posts.json?id=" & $threadId), @[], onPostList) + let uri = makeUri("posts.json", ("id", $threadId)) + ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, 0)) return buildHtml(tdiv(class="loading loading-lg")) @@ -114,4 +145,7 @@ when defined(js): render(list.thread.category) tdiv(class="posts"): for post in list.posts: - genPost(post, list.thread, isLoggedIn) \ No newline at end of file + genPost(post, list.thread, isLoggedIn) + + if list.moreCount > 0: + genLoadMore(list.posts.len) \ No newline at end of file From e790e8ac575823afccd8fec1cb0942393243f2af Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 10 May 2018 19:13:42 +0100 Subject: [PATCH 071/396] Allow refreshes on other URLs too. --- forum.nim | 7 ++++--- redesign/karax.html | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/forum.nim b/forum.nim index 7f4929b..e5e45f9 100644 --- a/forum.nim +++ b/forum.nim @@ -9,7 +9,7 @@ import os, strutils, times, md5, strtabs, cgi, math, db_sqlite, scgi, jester, asyncdispatch, asyncnet, cache, sequtils, - parseutils, utils, random, rst, ranks, recaptcha, json + parseutils, utils, random, rst, ranks, recaptcha, json, re import redesign/threadlist except User import redesign/[category, postlist] @@ -1068,8 +1068,6 @@ routes: additionalHeaders = genRSSHeaders(c), showRssLinks = true) resp data - get "/karax/": - resp readFile("redesign/karax.html") get "/karax/nimforum.css": resp readFile("redesign/nimforum.css"), "text/css" get "/karax/nimcache/forum.js": @@ -1077,6 +1075,7 @@ routes: get "/karax/images/crown.png": resp readFile("redesign/images/crown.png"), "image/png" + get "/karax/threads.json": var start = getInt(@"start", 0) @@ -1154,6 +1153,8 @@ routes: resp $(%list), "application/json" + get re"/karax/(.+)?": + resp readFile("redesign/karax.html") get "/threadActivity.xml": createTFD() diff --git a/redesign/karax.html b/redesign/karax.html index 054116d..24242d2 100644 --- a/redesign/karax.html +++ b/redesign/karax.html @@ -8,6 +8,8 @@ The Nim programming language forum + + From 4c0a88a1316174e02b16420f83401dc40a2db820 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 10 May 2018 23:33:48 +0100 Subject: [PATCH 072/396] Create login modal. --- redesign/forum.nim | 58 ++++++++++++++++++++++++++++++++---------- redesign/nimforum.scss | 6 +++-- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index efa4780..219d75a 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -25,20 +25,50 @@ proc onPopState(event: dom.Event) = redraw() proc genHeader(): VNode = - result = buildHtml(header(id="main-navbar")): - tdiv(class="navbar container grid-xl"): - section(class="navbar-section"): - a(href=makeUri("/")): - img(src="images/crown.png", id="img-logo") # TODO: Customisation. - section(class="navbar-section"): - tdiv(class="input-group input-inline"): - input(class="form-input input-sm", `type`="text", placeholder="search") - button(class="btn btn-primary btn-sm"): - italic(class="fas fa-user-plus") - text " Sign up" - button(class="btn btn-primary btn-sm"): - italic(class="fas fa-sign-in-alt") - text " Log in" + result = buildHtml(tdiv()): + header(id="main-navbar"): + tdiv(class="navbar container grid-xl"): + section(class="navbar-section"): + a(href=makeUri("/")): + img(src="images/crown.png", id="img-logo") # TODO: Customisation. + section(class="navbar-section"): + tdiv(class="input-group input-inline"): + input(class="search-input input-sm", `type`="text", placeholder="search") + button(class="btn btn-primary btn-sm"): + italic(class="fas fa-user-plus") + text " Sign up" + a(href="#login-modal"): + button(class="btn btn-primary btn-sm"): + italic(class="fas fa-sign-in-alt") + text " Log in" + + # Modals + tdiv(class="modal modal-sm", id="login-modal"): + a(href="#", class="modal-overlay", "aria-label"="close") + tdiv(class="modal-container"): + tdiv(class="modal-header"): + a(href="#", class="btn btn-clear float-right", "aria-label"="close") + tdiv(class="modal-title h5"): + text "Log in" + tdiv(class="modal-body"): + tdiv(class="content"): + form(): + tdiv(class="form-group"): + label(class="form-label", `for`="username"): + text "Username" + input(class="form-input", `type`="text", id="username") + tdiv(class="form-group"): + label(class="form-label", `for`="password"): + text "Password" + input(class="form-input", `type`="password", id="password") + button(class="btn btn-link"): + text "Reset your password" + tdiv(class="modal-footer"): + button(class="btn btn-primary"): + text "Log in" + button(class="btn"): + text "Create account" + const appName = "/karax" type Params = Table[string, string] diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 00925d6..a819796 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -60,12 +60,14 @@ $logo-height: $navbar-height - 20px; } // Unfortunately we must colour the controls in the navbar manually. - .form-input { + .search-input { + @extend .form-input; border-color: $navbar-border-color-dark; } - .form-input:focus { + .search-input:focus { box-shadow: none; + border-color: $navbar-border-color-dark; } .btn-primary { From 29eb22cf9c2a3018225151f440d460f3b251d2f9 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 00:32:01 +0100 Subject: [PATCH 073/396] Implements sign up form. --- redesign/forum.nim | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index 219d75a..f0fda2f 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -34,10 +34,11 @@ proc genHeader(): VNode = section(class="navbar-section"): tdiv(class="input-group input-inline"): input(class="search-input input-sm", `type`="text", placeholder="search") - button(class="btn btn-primary btn-sm"): - italic(class="fas fa-user-plus") - text " Sign up" - a(href="#login-modal"): + a(href="#signup-modal", id="signup-btn"): + button(class="btn btn-primary btn-sm"): + italic(class="fas fa-user-plus") + text " Sign up" + a(href="#login-modal", id="login-btn"): button(class="btn btn-primary btn-sm"): italic(class="fas fa-sign-in-alt") text " Log in" @@ -66,8 +67,38 @@ proc genHeader(): VNode = tdiv(class="modal-footer"): button(class="btn btn-primary"): text "Log in" - button(class="btn"): + a(href="#signup-modal"): + button(class="btn"): + text "Create account" + + tdiv(class="modal", id="signup-modal"): + a(href="#", class="modal-overlay", "aria-label"="close") + tdiv(class="modal-container"): + tdiv(class="modal-header"): + a(href="#", class="btn btn-clear float-right", "aria-label"="close") + tdiv(class="modal-title h5"): + text "Create a new account" + tdiv(class="modal-body"): + tdiv(class="content"): + form(): + tdiv(class="form-group"): + label(class="form-label", `for`="email"): + text "Email" + input(class="form-input", `type`="text", id="email") + tdiv(class="form-group"): + label(class="form-label", `for`="username"): + text "Username" + input(class="form-input", `type`="text", id="username") + tdiv(class="form-group"): + label(class="form-label", `for`="password"): + text "Password" + input(class="form-input", `type`="password", id="password") + tdiv(class="modal-footer"): + button(class="btn btn-primary"): text "Create account" + a(href="#login-modal"): + button(class="btn"): + text "Log in" const appName = "/karax" From c0bbce53e94f8dc71a127a949420942bb1afe5ae Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 13:53:26 +0100 Subject: [PATCH 074/396] Implements logging in. --- forum.nim | 34 ++++++++- redesign/error.nim | 35 +++++---- redesign/forum.nim | 84 +-------------------- redesign/header.nim | 157 ++++++++++++++++++++++++++++++++++++++++ redesign/karaxutils.nim | 10 ++- utils.nim | 7 +- 6 files changed, 228 insertions(+), 99 deletions(-) create mode 100644 redesign/header.nim diff --git a/forum.nim b/forum.nim index e5e45f9..b353ddd 100644 --- a/forum.nim +++ b/forum.nim @@ -7,12 +7,14 @@ # import - os, strutils, times, md5, strtabs, cgi, math, db_sqlite, + os, strutils, times, md5, strtabs, math, db_sqlite, scgi, jester, asyncdispatch, asyncnet, cache, sequtils, parseutils, utils, random, rst, ranks, recaptcha, json, re +import cgi except setCookie +import options import redesign/threadlist except User -import redesign/[category, postlist] +import redesign/[category, postlist, error, header] when not defined(windows): import bcrypt # TODO @@ -1153,6 +1155,34 @@ routes: resp $(%list), "application/json" + post "/karax/login": + createTFD() + let formData = request.formData + if login(c, formData["username"].body, formData["password"].body): + setCookie("sid", c.userpass) + resp Http200, "{}", "application/json" + else: + let err = PostError( + errorFields: @["username", "password"], + message: "Invalid username or password" + ) + resp $(%err), "application/json" + + get "/karax/status.json": + createTFD() + let user = + if c.loggedIn(): + some(threadlist.User( + name: c.username, + avatarUrl: c.email.getGravatarUrl(), + isOnline: true + )) + else: + none[threadlist.User]() + + let status = UserStatus(user: user) + resp $(%status), "application/json" + get re"/karax/(.+)?": resp readFile("redesign/karax.html") diff --git a/redesign/error.nim b/redesign/error.nim index a670804..108adbe 100644 --- a/redesign/error.nim +++ b/redesign/error.nim @@ -1,17 +1,22 @@ -include karax/prelude -import karax / [vstyles, kajax, kdom] +type + PostError* = object + errorFields*: seq[string] ## IDs of the fields with an error. + message*: string +when defined(js): + 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 + 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 f0fda2f..637fcf4 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -1,10 +1,11 @@ -import strformat, times, options, json, tables, future +import strformat, times, options, json, tables, sugar from dom import window, Location include karax/prelude import jester/patterns -import threadlist, postlist, karaxutils +import threadlist, postlist, header +import karaxutils type State = ref object @@ -24,83 +25,6 @@ proc onPopState(event: dom.Event) = state.url = window.location redraw() -proc genHeader(): VNode = - result = buildHtml(tdiv()): - header(id="main-navbar"): - tdiv(class="navbar container grid-xl"): - section(class="navbar-section"): - a(href=makeUri("/")): - img(src="images/crown.png", id="img-logo") # TODO: Customisation. - section(class="navbar-section"): - tdiv(class="input-group input-inline"): - input(class="search-input input-sm", `type`="text", placeholder="search") - a(href="#signup-modal", id="signup-btn"): - button(class="btn btn-primary btn-sm"): - italic(class="fas fa-user-plus") - text " Sign up" - a(href="#login-modal", id="login-btn"): - button(class="btn btn-primary btn-sm"): - italic(class="fas fa-sign-in-alt") - text " Log in" - - # Modals - tdiv(class="modal modal-sm", id="login-modal"): - a(href="#", class="modal-overlay", "aria-label"="close") - tdiv(class="modal-container"): - tdiv(class="modal-header"): - a(href="#", class="btn btn-clear float-right", "aria-label"="close") - tdiv(class="modal-title h5"): - text "Log in" - tdiv(class="modal-body"): - tdiv(class="content"): - form(): - tdiv(class="form-group"): - label(class="form-label", `for`="username"): - text "Username" - input(class="form-input", `type`="text", id="username") - tdiv(class="form-group"): - label(class="form-label", `for`="password"): - text "Password" - input(class="form-input", `type`="password", id="password") - button(class="btn btn-link"): - text "Reset your password" - tdiv(class="modal-footer"): - button(class="btn btn-primary"): - text "Log in" - a(href="#signup-modal"): - button(class="btn"): - text "Create account" - - tdiv(class="modal", id="signup-modal"): - a(href="#", class="modal-overlay", "aria-label"="close") - tdiv(class="modal-container"): - tdiv(class="modal-header"): - a(href="#", class="btn btn-clear float-right", "aria-label"="close") - tdiv(class="modal-title h5"): - text "Create a new account" - tdiv(class="modal-body"): - tdiv(class="content"): - form(): - tdiv(class="form-group"): - label(class="form-label", `for`="email"): - text "Email" - input(class="form-input", `type`="text", id="email") - tdiv(class="form-group"): - label(class="form-label", `for`="username"): - text "Username" - input(class="form-input", `type`="text", id="username") - tdiv(class="form-group"): - label(class="form-label", `for`="password"): - text "Password" - input(class="form-input", `type`="password", id="password") - tdiv(class="modal-footer"): - button(class="btn btn-primary"): - text "Create account" - a(href="#login-modal"): - button(class="btn"): - text "Log in" - - const appName = "/karax" type Params = Table[string, string] type @@ -118,7 +42,7 @@ proc route(routes: openarray[Route]): VNode = proc render(): VNode = result = buildHtml(tdiv()): - genHeader() + renderHeader() route([ r("/t/@id?", (params: Params) => diff --git a/redesign/header.nim b/redesign/header.nim new file mode 100644 index 0000000..f9b9d05 --- /dev/null +++ b/redesign/header.nim @@ -0,0 +1,157 @@ +import options, times, httpcore, json, sugar + +import threadlist +type + UserStatus* = object + user*: Option[User] + +when defined(js): + include karax/prelude + import karax / [kajax] + + + import karaxutils + + from dom import setTimeout, window, document, getElementById + + type + State = ref object + data: Option[UserStatus] + loading: bool + status: HttpCode + lastUpdate: Time + + proc newState(): State = + State( + data: none[UserStatus](), + loading: false, + status: Http200 + ) + + var + state = newState() + + proc getStatus + proc onStatus(httpStatus: int, response: kstring) = + state.loading = false + state.status = httpStatus.HttpCode + if state.status != Http200: return + + let parsed = parseJson($response) + state.data = some(to(parsed, UserStatus)) + + state.lastUpdate = getTime() + + proc getStatus = + if state.loading: return + let diff = getTime() - state.lastUpdate + if diff.minutes < 5: + return + + state.loading = true + let uri = makeUri("status.json") + ajaxGet(uri, @[], onStatus) + + proc onLogInPost(httpStatus: int, response: kstring) = + kout(response) + + proc onLogInClick(ev: Event, n: VNode) = + let uri = makeUri("login") + let form = document.getElementById("login-form") + # TODO: This is a hack, karax should support this. + let formData = newFormData(form) + kout(formData.get("username")) + ajaxPost(uri, @[], cast[cstring](formData), onLogInPost) + + proc genLoginModal(): VNode = + result = buildHtml(): + tdiv(class="modal modal-sm", id="login-modal"): + a(href="#", class="modal-overlay", "aria-label"="close") + tdiv(class="modal-container"): + tdiv(class="modal-header"): + a(href="#", class="btn btn-clear float-right", "aria-label"="close") + tdiv(class="modal-title h5"): + text "Log in" + tdiv(class="modal-body"): + tdiv(class="content"): + form(id="login-form"): + tdiv(class="form-group"): + label(class="form-label", `for`="username"): + text "Username" + input(class="form-input", `type`="text", name="username") + tdiv(class="form-group"): + label(class="form-label", `for`="password"): + text "Password" + input(class="form-input", `type`="password", name="password") + a(href="#reset-password-modal"): + text "Reset your password" + tdiv(class="modal-footer"): + button(class="btn btn-primary", onClick=onLogInClick): + text "Log in" + a(href="#signup-modal"): + button(class="btn"): + text "Create account" + + proc genSignUpModal(): VNode = + result = buildHtml(): + tdiv(class="modal", id="signup-modal"): + a(href="#", class="modal-overlay", "aria-label"="close") + tdiv(class="modal-container"): + tdiv(class="modal-header"): + a(href="#", class="btn btn-clear float-right", "aria-label"="close") + tdiv(class="modal-title h5"): + text "Create a new account" + tdiv(class="modal-body"): + tdiv(class="content"): + form(): + tdiv(class="form-group"): + label(class="form-label", `for`="email"): + text "Email" + input(class="form-input", `type`="text", name="email") + tdiv(class="form-group"): + label(class="form-label", `for`="regusername"): + text "Username" + input(class="form-input", `type`="text", name="username") + tdiv(class="form-group"): + label(class="form-label", `for`="regpassword"): + text "Password" + input(class="form-input", `type`="password", name="password") + tdiv(class="modal-footer"): + button(class="btn btn-primary"): + text "Create account" + a(href="#login-modal"): + button(class="btn"): + text "Log in" + + proc renderHeader*(): VNode = + if state.data.isNone: + getStatus() + + let user = state.data.map(x => x.user).flatten + result = buildHtml(tdiv()): # TODO: Why do some buildHtml's need this? + header(id="main-navbar"): + tdiv(class="navbar container grid-xl"): + section(class="navbar-section"): + a(href=makeUri("/")): + img(src="images/crown.png", id="img-logo") # TODO: Customisation. + section(class="navbar-section"): + tdiv(class="input-group input-inline"): + input(class="search-input input-sm", `type`="text", placeholder="search") + if state.loading: + tdiv(class="loading") + elif user.isNone: + a(href="#signup-modal", id="signup-btn"): + button(class="btn btn-primary btn-sm"): + italic(class="fas fa-user-plus") + text " Sign up" + a(href="#login-modal", id="login-btn"): + button(class="btn btn-primary btn-sm"): + italic(class="fas fa-sign-in-alt") + text " Log in" + else: + render(user.get(), "avatar") + + # Modals + genLoginModal() + + genSignUpModal() \ No newline at end of file diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index c170a97..6830aa4 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -48,4 +48,12 @@ proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb? dom.pushState(dom.window.history, 5, cstring"Thread", url) # Fire the popState event. - dom.window.dispatchEvent(newEvent("popstate")) \ No newline at end of file + dom.window.dispatchEvent(newEvent("popstate")) + + +type + FormData* = ref object +proc newFormData*(form: dom.Element): FormData + {.importcpp: "new FormData(@)", constructor.} +proc get*(form: FormData, key: cstring): cstring + {.importcpp: "#.get(@)".} \ No newline at end of file diff --git a/utils.nim b/utils.nim index ca0e992..11f51af 100644 --- a/utils.nim +++ b/utils.nim @@ -1,5 +1,5 @@ import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs, - htmlparser, streams, parseutils + htmlparser, streams, parseutils, options from times import getTime, getGMTime, format proc parseInt*(s: string, value: var int, validRange: Slice[int]) {. @@ -20,6 +20,11 @@ proc getInt*(s: string, default = 0): int = result = default parseInt(s, result, 0..1_000_000_000) +proc `%`*[T](opt: Option[T]): JsonNode = + ## Generic constructor for JSON data. Creates a new ``JNull JsonNode`` + ## if ``opt`` is empty, otherwise it delegates to the underlying value. + if opt.isSome: %opt.get else: newJNull() + type Config* = object smtpAddress: string From f9efbe04d303e604bbcb6ce67eb3cc5372a34381 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 15:49:22 +0100 Subject: [PATCH 075/396] Implements proper error handling for login form. --- forum.nim | 2 +- redesign/header.nim | 77 ++++++++-------------------- redesign/karaxutils.nim | 20 ++++---- redesign/login.nim | 109 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 65 deletions(-) create mode 100644 redesign/login.nim diff --git a/forum.nim b/forum.nim index b353ddd..03b9390 100644 --- a/forum.nim +++ b/forum.nim @@ -1166,7 +1166,7 @@ routes: errorFields: @["username", "password"], message: "Invalid username or password" ) - resp $(%err), "application/json" + resp Http403, $(%err), "application/json" get "/karax/status.json": createTFD() diff --git a/redesign/header.nim b/redesign/header.nim index f9b9d05..1d7b3bb 100644 --- a/redesign/header.nim +++ b/redesign/header.nim @@ -9,10 +9,10 @@ when defined(js): include karax/prelude import karax / [kajax] - + import login import karaxutils - from dom import setTimeout, window, document, getElementById + from dom import setTimeout, window, document, getElementById, focus type State = ref object @@ -20,18 +20,23 @@ when defined(js): loading: bool status: HttpCode lastUpdate: Time + loginModal: LoginModal - proc newState(): State = - State( - data: none[UserStatus](), - loading: false, - status: Http200 - ) - + proc newState(): State var state = newState() proc getStatus + proc newState(): State = + State( + data: none[UserStatus](), + loading: false, + status: Http200, + loginModal: newLoginModal( + () => (state.lastUpdate = fromUnix(0); getStatus()) + ) + ) + proc onStatus(httpStatus: int, response: kstring) = state.loading = false state.status = httpStatus.HttpCode @@ -52,46 +57,6 @@ when defined(js): let uri = makeUri("status.json") ajaxGet(uri, @[], onStatus) - proc onLogInPost(httpStatus: int, response: kstring) = - kout(response) - - proc onLogInClick(ev: Event, n: VNode) = - let uri = makeUri("login") - let form = document.getElementById("login-form") - # TODO: This is a hack, karax should support this. - let formData = newFormData(form) - kout(formData.get("username")) - ajaxPost(uri, @[], cast[cstring](formData), onLogInPost) - - proc genLoginModal(): VNode = - result = buildHtml(): - tdiv(class="modal modal-sm", id="login-modal"): - a(href="#", class="modal-overlay", "aria-label"="close") - tdiv(class="modal-container"): - tdiv(class="modal-header"): - a(href="#", class="btn btn-clear float-right", "aria-label"="close") - tdiv(class="modal-title h5"): - text "Log in" - tdiv(class="modal-body"): - tdiv(class="content"): - form(id="login-form"): - tdiv(class="form-group"): - label(class="form-label", `for`="username"): - text "Username" - input(class="form-input", `type`="text", name="username") - tdiv(class="form-group"): - label(class="form-label", `for`="password"): - text "Password" - input(class="form-input", `type`="password", name="password") - a(href="#reset-password-modal"): - text "Reset your password" - tdiv(class="modal-footer"): - button(class="btn btn-primary", onClick=onLogInClick): - text "Log in" - a(href="#signup-modal"): - button(class="btn"): - text "Create account" - proc genSignUpModal(): VNode = result = buildHtml(): tdiv(class="modal", id="signup-modal"): @@ -136,7 +101,9 @@ when defined(js): img(src="images/crown.png", id="img-logo") # TODO: Customisation. section(class="navbar-section"): tdiv(class="input-group input-inline"): - input(class="search-input input-sm", `type`="text", placeholder="search") + input(class="search-input input-sm", + `type`="text", placeholder="search", + id="search-box") if state.loading: tdiv(class="loading") elif user.isNone: @@ -144,14 +111,14 @@ when defined(js): button(class="btn btn-primary btn-sm"): italic(class="fas fa-user-plus") text " Sign up" - a(href="#login-modal", id="login-btn"): - button(class="btn btn-primary btn-sm"): - italic(class="fas fa-sign-in-alt") - text " Log in" + button(class="btn btn-primary btn-sm", + onClick=(e: Event, n: VNode) => state.loginModal.show()): + italic(class="fas fa-sign-in-alt") + text " Log in" else: render(user.get(), "avatar") # Modals - genLoginModal() + render(state.loginModal) genSignUpModal() \ No newline at end of file diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index 6830aa4..2c7f7ad 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -12,7 +12,7 @@ proc class*(classes: varargs[tuple[name: string, present: bool]], for class in classes: if class.present: result.add(class.name & " ") -proc makeUri*(relative: string, appName=appName): string = +proc makeUri*(relative: string, appName=appName, includeHash=false): string = ## Concatenates ``relative`` to the current URL in a way that is sane. var relative = relative assert appName in $window.location.pathname @@ -23,10 +23,10 @@ proc makeUri*(relative: string, appName=appName): string = appName & relative & $window.location.search & - $window.location.hash + (if includeHash: $window.location.hash else: "") proc makeUri*(relative: string, params: varargs[(string, string)], - appName=appName): string = + appName=appName, includeHash=false): string = var query = "" for i in 0 ..< params.len: let param = params[i] @@ -38,18 +38,20 @@ proc makeUri*(relative: string, params: varargs[(string, string)], else: makeUri(relative, appName) +proc navigateTo*(uri: cstring) = + # TODO: This was annoying. Karax also shouldn't have its own `window`. + dom.pushState(dom.window.history, 0, cstring"", uri) + + # Fire the popState event. + dom.window.dispatchEvent(newEvent("popstate")) + proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb? e.preventDefault() # TODO: Why does Karax have it's own Node type? That's just silly. let url = cast[dom.Node](n.dom).getAttribute(cstring"href") - # TODO: This was annoying. Karax also shouldn't have its own `window`. - dom.pushState(dom.window.history, 5, cstring"Thread", url) - - # Fire the popState event. - dom.window.dispatchEvent(newEvent("popstate")) - + navigateTo(url) type FormData* = ref object diff --git a/redesign/login.nim b/redesign/login.nim new file mode 100644 index 0000000..aa92fac --- /dev/null +++ b/redesign/login.nim @@ -0,0 +1,109 @@ +when defined(js): + import sugar, httpcore, options, json + import dom except Event + + include karax/prelude + import karax / [kajax, kdom] + + import error + import karaxutils + + type + LoginModal* = ref object + shown: bool + onLogIn: proc () + error: Option[PostError] + + proc onLogInPost(httpStatus: int, response: kstring, state: LoginModal) = + let status = httpStatus.HttpCode + if status == Http200: + state.shown = false + state.onLogIn() + else: + # TODO: Karax should pass the content-type... + try: + let parsed = parseJson($response) + let error = to(parsed, PostError) + + state.error = some(error) + except: + kout(getCurrentExceptionMsg().cstring) + state.error = some(PostError( + errorFields: @[], + message: "Unknown error occurred." + )) + + proc onLogInClick(ev: Event, n: VNode, state: LoginModal) = + state.error = none[PostError]() + + let uri = makeUri("login") + let form = dom.document.getElementById("login-form") + # TODO: This is a hack, karax should support this. + let formData = newFormData(form) + ajaxPost(uri, @[], cast[cstring](formData), + (s: int, r: kstring) => onLogInPost(s, r, state)) + + proc onClose(ev: Event, n: VNode, state: LoginModal) = + state.shown = false + ev.preventDefault() + + proc newLoginModal*(onLogIn: proc ()): LoginModal = + LoginModal( + shown: false, + onLogIn: onLogIn + ) + + proc show*(state: LoginModal) = + state.shown = true + + proc genFormField(error: Option[PostError], name, label, typ: string, + isLast: bool): VNode = + let hasError = + not error.isNone and ( + name in error.get().errorFields or + error.get().errorFields.len == 0) + result = buildHtml(): + tdiv(class=class({"has-error": hasError}, "form-group")): + label(class="form-label", `for`=name): + text "Username" + input(class="form-input", `type`="text", name=name) + + if not error.isNone: + let e = error.get() + if (e.errorFields.len == 1 and e.errorFields[0] == name) or isLast: + p(class="form-input-hint"): + text e.message + + proc render*(state: LoginModal): VNode = + result = buildHtml(): + tdiv(class=class({"active": state.shown}, "modal modal-sm"), + id="login-modal"): + a(href="", class="modal-overlay", "aria-label"="close", + onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) + tdiv(class="modal-container"): + tdiv(class="modal-header"): + a(href="", class="btn btn-clear float-right", + "aria-label"="close", + onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) + tdiv(class="modal-title h5"): + text "Log in" + tdiv(class="modal-body"): + tdiv(class="content"): + form(id="login-form"): + genFormField(state.error, "username", "Username", "text", false) + genFormField( + state.error, + "password", + "Password", + "password", + true + ) + a(href="#reset-password-modal"): + text "Reset your password" + tdiv(class="modal-footer"): + button(class="btn btn-primary", + onClick=(ev: Event, n: VNode) => onLogInClick(ev, n, state)): + text "Log in" + a(href="#signup-modal"): + button(class="btn"): + text "Create account" \ No newline at end of file From 01253244f7dff048e75338a88e900c65328820ac Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 16:15:13 +0100 Subject: [PATCH 076/396] Reimplement sign up form. --- redesign/error.nim | 23 ++++++++++- redesign/header.nim | 51 ++++++----------------- redesign/karaxutils.nim | 2 +- redesign/login.nim | 31 ++++---------- redesign/signup.nim | 92 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 63 deletions(-) create mode 100644 redesign/signup.nim diff --git a/redesign/error.nim b/redesign/error.nim index 108adbe..3d540d5 100644 --- a/redesign/error.nim +++ b/redesign/error.nim @@ -1,3 +1,4 @@ +import options type PostError* = object errorFields*: seq[string] ## IDs of the fields with an error. @@ -7,6 +8,8 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] + import karaxutils + proc renderError*(message: string): VNode = result = buildHtml(): tdiv(class="empty error"): @@ -19,4 +22,22 @@ when defined(js): 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 + text "Report issue" + + proc genFormField*(error: Option[PostError], name, label, typ: string, + isLast: bool): VNode = + let hasError = + not error.isNone and ( + name in error.get().errorFields or + error.get().errorFields.len == 0) + result = buildHtml(): + tdiv(class=class({"has-error": hasError}, "form-group")): + label(class="form-label", `for`=name): + text label + input(class="form-input", `type`="text", name=name) + + if not error.isNone: + let e = error.get() + if (e.errorFields.len == 1 and e.errorFields[0] == name) or isLast: + p(class="form-input-hint"): + text e.message \ No newline at end of file diff --git a/redesign/header.nim b/redesign/header.nim index 1d7b3bb..cf07135 100644 --- a/redesign/header.nim +++ b/redesign/header.nim @@ -9,7 +9,7 @@ when defined(js): include karax/prelude import karax / [kajax] - import login + import login, signup import karaxutils from dom import setTimeout, window, document, getElementById, focus @@ -21,6 +21,7 @@ when defined(js): status: HttpCode lastUpdate: Time loginModal: LoginModal + signupModal: SignupModal proc newState(): State var @@ -33,7 +34,12 @@ when defined(js): loading: false, status: Http200, loginModal: newLoginModal( - () => (state.lastUpdate = fromUnix(0); getStatus()) + () => (state.lastUpdate = fromUnix(0); getStatus()), + () => state.signupModal.show() + ), + signupModal: newSignupModal( + () => (state.lastUpdate = fromUnix(0); getStatus()), + () => state.loginModal.show() ) ) @@ -57,37 +63,6 @@ when defined(js): let uri = makeUri("status.json") ajaxGet(uri, @[], onStatus) - proc genSignUpModal(): VNode = - result = buildHtml(): - tdiv(class="modal", id="signup-modal"): - a(href="#", class="modal-overlay", "aria-label"="close") - tdiv(class="modal-container"): - tdiv(class="modal-header"): - a(href="#", class="btn btn-clear float-right", "aria-label"="close") - tdiv(class="modal-title h5"): - text "Create a new account" - tdiv(class="modal-body"): - tdiv(class="content"): - form(): - tdiv(class="form-group"): - label(class="form-label", `for`="email"): - text "Email" - input(class="form-input", `type`="text", name="email") - tdiv(class="form-group"): - label(class="form-label", `for`="regusername"): - text "Username" - input(class="form-input", `type`="text", name="username") - tdiv(class="form-group"): - label(class="form-label", `for`="regpassword"): - text "Password" - input(class="form-input", `type`="password", name="password") - tdiv(class="modal-footer"): - button(class="btn btn-primary"): - text "Create account" - a(href="#login-modal"): - button(class="btn"): - text "Log in" - proc renderHeader*(): VNode = if state.data.isNone: getStatus() @@ -107,10 +82,10 @@ when defined(js): if state.loading: tdiv(class="loading") elif user.isNone: - a(href="#signup-modal", id="signup-btn"): - button(class="btn btn-primary btn-sm"): - italic(class="fas fa-user-plus") - text " Sign up" + button(class="btn btn-primary btn-sm", + onClick=(e: Event, n: VNode) => state.signupModal.show()): + italic(class="fas fa-user-plus") + text " Sign up" button(class="btn btn-primary btn-sm", onClick=(e: Event, n: VNode) => state.loginModal.show()): italic(class="fas fa-sign-in-alt") @@ -121,4 +96,4 @@ when defined(js): # Modals render(state.loginModal) - genSignUpModal() \ No newline at end of file + render(state.signupModal) \ No newline at end of file diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index 2c7f7ad..7ee5eaf 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -1,4 +1,4 @@ -import strutils +import strutils, options import dom except window include karax/prelude diff --git a/redesign/login.nim b/redesign/login.nim index aa92fac..006a610 100644 --- a/redesign/login.nim +++ b/redesign/login.nim @@ -12,6 +12,7 @@ when defined(js): LoginModal* = ref object shown: bool onLogIn: proc () + onSignUp: proc () error: Option[PostError] proc onLogInPost(httpStatus: int, response: kstring, state: LoginModal) = @@ -47,33 +48,16 @@ when defined(js): state.shown = false ev.preventDefault() - proc newLoginModal*(onLogIn: proc ()): LoginModal = + proc newLoginModal*(onLogIn, onSignUp: proc ()): LoginModal = LoginModal( shown: false, - onLogIn: onLogIn + onLogIn: onLogIn, + onSignUp: onSignUp ) proc show*(state: LoginModal) = state.shown = true - proc genFormField(error: Option[PostError], name, label, typ: string, - isLast: bool): VNode = - let hasError = - not error.isNone and ( - name in error.get().errorFields or - error.get().errorFields.len == 0) - result = buildHtml(): - tdiv(class=class({"has-error": hasError}, "form-group")): - label(class="form-label", `for`=name): - text "Username" - input(class="form-input", `type`="text", name=name) - - if not error.isNone: - let e = error.get() - if (e.errorFields.len == 1 and e.errorFields[0] == name) or isLast: - p(class="form-input-hint"): - text e.message - proc render*(state: LoginModal): VNode = result = buildHtml(): tdiv(class=class({"active": state.shown}, "modal modal-sm"), @@ -104,6 +88,7 @@ when defined(js): button(class="btn btn-primary", onClick=(ev: Event, n: VNode) => onLogInClick(ev, n, state)): text "Log in" - a(href="#signup-modal"): - button(class="btn"): - text "Create account" \ No newline at end of file + button(class="btn", + onClick=(ev: Event, n: VNode) => + (state.onSignUp(); state.shown = false)): + text "Create account" \ No newline at end of file diff --git a/redesign/signup.nim b/redesign/signup.nim new file mode 100644 index 0000000..1b0db46 --- /dev/null +++ b/redesign/signup.nim @@ -0,0 +1,92 @@ +when defined(js): + import sugar, httpcore, options, json + import dom except Event + + include karax/prelude + import karax / [kajax, kdom] + + import error + import karaxutils + + type + SignupModal* = ref object + shown: bool + onSignUp, onLogIn: proc () + error: Option[PostError] + + proc onSignUpPost(httpStatus: int, response: kstring, state: SignupModal) = + let status = httpStatus.HttpCode + if status == Http200: + state.shown = false + state.onSignUp() + else: + # TODO: Karax should pass the content-type... + try: + let parsed = parseJson($response) + let error = to(parsed, PostError) + + state.error = some(error) + except: + kout(getCurrentExceptionMsg().cstring) + state.error = some(PostError( + errorFields: @[], + message: "Unknown error occurred." + )) + + proc onSignUpClick(ev: Event, n: VNode, state: SignupModal) = + state.error = none[PostError]() + + let uri = makeUri("signup") + let form = dom.document.getElementById("signup-form") + # TODO: This is a hack, karax should support this. + let formData = newFormData(form) + ajaxPost(uri, @[], cast[cstring](formData), + (s: int, r: kstring) => onSignUpPost(s, r, state)) + + proc onClose(ev: Event, n: VNode, state: SignupModal) = + state.shown = false + ev.preventDefault() + + proc newSignupModal*(onSignUp, onLogIn: proc ()): SignupModal = + SignupModal( + shown: false, + onLogIn: onLogIn, + onSignUp: onSignUp + ) + + proc show*(state: SignupModal) = + state.shown = true + + proc render*(state: SignupModal): VNode = + result = buildHtml(): + tdiv(class=class({"active": state.shown}, "modal modal-sm"), + id="signup-modal"): + a(href="", class="modal-overlay", "aria-label"="close", + onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) + tdiv(class="modal-container"): + tdiv(class="modal-header"): + a(href="", class="btn btn-clear float-right", + "aria-label"="close", + onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) + tdiv(class="modal-title h5"): + text "Create a new account" + tdiv(class="modal-body"): + tdiv(class="content"): + form(id="signup-form"): + genFormField(state.error, "email", "Email", "email", false) + genFormField(state.error, "username", "Username", "text", false) + genFormField( + state.error, + "password", + "Password", + "password", + true + ) + tdiv(class="modal-footer"): + button(class="btn btn-primary", + onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)): + text "Create account" + button(class="btn", + onClick=(ev: Event, n: VNode) => + (state.onLogIn(); state.shown = false)): + text "Log in" \ No newline at end of file From 4d115622d60b8065f31eac23674314f9efdde364 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 16:17:41 +0100 Subject: [PATCH 077/396] Small fix to genFormField. --- redesign/error.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redesign/error.nim b/redesign/error.nim index 3d540d5..fb96758 100644 --- a/redesign/error.nim +++ b/redesign/error.nim @@ -34,7 +34,7 @@ when defined(js): tdiv(class=class({"has-error": hasError}, "form-group")): label(class="form-label", `for`=name): text label - input(class="form-input", `type`="text", name=name) + input(class="form-input", `type`=typ, name=name) if not error.isNone: let e = error.get() From a7b616e63aff646b7e0fb5fac178480585bd9d2a Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 17:33:10 +0100 Subject: [PATCH 078/396] Implements user dropdown and the ability to log out. --- forum.nim | 5 ++++- redesign/header.nim | 16 ++++++++------ redesign/nimforum.scss | 7 ++++++ redesign/threadlist.nim | 2 +- redesign/usermenu.nim | 48 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 redesign/usermenu.nim diff --git a/forum.nim b/forum.nim index 03b9390..ce1bc6f 100644 --- a/forum.nim +++ b/forum.nim @@ -1170,8 +1170,11 @@ routes: get "/karax/status.json": createTFD() + let user = - if c.loggedIn(): + if @"logout" == "true": + logout(c); none[threadlist.User]() + elif c.loggedIn(): some(threadlist.User( name: c.username, avatarUrl: c.email.getGravatarUrl(), diff --git a/redesign/header.nim b/redesign/header.nim index cf07135..9c08d16 100644 --- a/redesign/header.nim +++ b/redesign/header.nim @@ -9,7 +9,7 @@ when defined(js): include karax/prelude import karax / [kajax] - import login, signup + import login, signup, usermenu import karaxutils from dom import setTimeout, window, document, getElementById, focus @@ -22,12 +22,13 @@ when defined(js): lastUpdate: Time loginModal: LoginModal signupModal: SignupModal + userMenu: UserMenu proc newState(): State var state = newState() - proc getStatus + proc getStatus(logout: bool=false) proc newState(): State = State( data: none[UserStatus](), @@ -40,6 +41,9 @@ when defined(js): signupModal: newSignupModal( () => (state.lastUpdate = fromUnix(0); getStatus()), () => state.loginModal.show() + ), + userMenu: newUserMenu( + () => (state.lastUpdate = fromUnix(0); getStatus(logout=true)) ) ) @@ -53,19 +57,19 @@ when defined(js): state.lastUpdate = getTime() - proc getStatus = + proc getStatus(logout: bool=false) = if state.loading: return let diff = getTime() - state.lastUpdate if diff.minutes < 5: return state.loading = true - let uri = makeUri("status.json") + let uri = makeUri("status.json", [("logout", $logout)]) ajaxGet(uri, @[], onStatus) proc renderHeader*(): VNode = if state.data.isNone: - getStatus() + getStatus() # TODO: Call this every render? let user = state.data.map(x => x.user).flatten result = buildHtml(tdiv()): # TODO: Why do some buildHtml's need this? @@ -91,7 +95,7 @@ when defined(js): italic(class="fas fa-sign-in-alt") text " Log in" else: - render(user.get(), "avatar") + render(state.userMenu, user.get()) # Modals render(state.loginModal) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index a819796..99cf68b 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -81,6 +81,13 @@ $logo-height: $navbar-height - 20px; height: $logo-height; } +.menu-right { + // To make sure the user menu doesn't move off the screen. + left: auto; + right: 0; + position: absolute; +} + // - Main buttons #main-buttons { margin-top: $control-padding-y*2; diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 58094be..7ab611f 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -1,4 +1,4 @@ -import strformat, times, options, json, httpcore +import strformat, times, options, json, httpcore, sugar import category diff --git a/redesign/usermenu.nim b/redesign/usermenu.nim new file mode 100644 index 0000000..2bb35a1 --- /dev/null +++ b/redesign/usermenu.nim @@ -0,0 +1,48 @@ + +when defined(js): + import sugar + + include karax/prelude + import karax/[vstyles] + import karaxutils + + import threadlist + type + UserMenu* = ref object + shown: bool + user: User + onLogout: proc () + + proc newUserMenu*(onLogout: proc ()): UserMenu = + UserMenu( + shown: false, + onLogout: onLogout + ) + + proc render*(state: UserMenu, user: User): VNode = + result = buildHtml(): + tdiv(): + figure(class="avatar c-hand", + onClick=(e: Event, n: VNode) => (state.shown = true)): + img(src=user.avatarUrl, title=user.name) + if user.isOnline: + italic(class="avatar-presense online") + + ul(class="menu menu-right", style=style( + StyleAttr.display, if state.shown: "inherit" else: "none" + )): + li(class="menu-item"): + tdiv(class="tile tile-centered"): + tdiv(class="tile-icon"): + img(class="avatar", src=user.avatarUrl, + title=user.name) + tdiv(class="tile-content"): + text user.name + li(class="divider") + li(class="menu-item"): + a(href=makeUri("/profile/" & user.name)): + text "My profile" + li(class="menu-item c-hand"): + a(onClick = (e: Event, n: VNode) => + (state.shown=false; state.onLogout())): + text "Logout" \ No newline at end of file From 84069239150f945176069721cc59880964eb11c0 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 17:37:52 +0100 Subject: [PATCH 079/396] Implements login via enter key. --- redesign/login.nim | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/redesign/login.nim b/redesign/login.nim index 006a610..060c7a3 100644 --- a/redesign/login.nim +++ b/redesign/login.nim @@ -58,6 +58,11 @@ when defined(js): proc show*(state: LoginModal) = state.shown = true + proc onKeyDown(e: Event, n: VNode, state: LoginModal) = + let event = cast[KeyboardEvent](e) + if event.key == "Enter": + onLogInClick(e, n, state) + proc render*(state: LoginModal): VNode = result = buildHtml(): tdiv(class=class({"active": state.shown}, "modal modal-sm"), @@ -73,7 +78,8 @@ when defined(js): text "Log in" tdiv(class="modal-body"): tdiv(class="content"): - form(id="login-form"): + form(id="login-form", + onKeyDown=(ev: Event, n: VNode) => onKeyDown(ev, n, state)): genFormField(state.error, "username", "Username", "text", false) genFormField( state.error, From 17436010a2cc4b41fb58e8e0e625f8689b609f19 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 17:41:06 +0100 Subject: [PATCH 080/396] Show spinner on login. --- redesign/login.nim | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/redesign/login.nim b/redesign/login.nim index 060c7a3..94a9228 100644 --- a/redesign/login.nim +++ b/redesign/login.nim @@ -11,11 +11,13 @@ when defined(js): type LoginModal* = ref object shown: bool + loading: bool onLogIn: proc () onSignUp: proc () error: Option[PostError] proc onLogInPost(httpStatus: int, response: kstring, state: LoginModal) = + state.loading = false let status = httpStatus.HttpCode if status == Http200: state.shown = false @@ -35,6 +37,7 @@ when defined(js): )) proc onLogInClick(ev: Event, n: VNode, state: LoginModal) = + state.loading = true state.error = none[PostError]() let uri = makeUri("login") @@ -91,7 +94,10 @@ when defined(js): a(href="#reset-password-modal"): text "Reset your password" tdiv(class="modal-footer"): - button(class="btn btn-primary", + button(class=class( + {"loading": state.loading}, + "btn btn-primary" + ), onClick=(ev: Event, n: VNode) => onLogInClick(ev, n, state)): text "Log in" button(class="btn", From 594f230480131d28c581379daa8426938f634e7a Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 17:42:19 +0100 Subject: [PATCH 081/396] Allow toggling of menu by clicking on the user icon. --- redesign/usermenu.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redesign/usermenu.nim b/redesign/usermenu.nim index 2bb35a1..9673dcf 100644 --- a/redesign/usermenu.nim +++ b/redesign/usermenu.nim @@ -23,7 +23,7 @@ when defined(js): result = buildHtml(): tdiv(): figure(class="avatar c-hand", - onClick=(e: Event, n: VNode) => (state.shown = true)): + onClick=(e: Event, n: VNode) => (state.shown = not state.shown)): img(src=user.avatarUrl, title=user.name) if user.isOnline: italic(class="avatar-presense online") From f1e2a68d86081861133293977d716ef08352bf50 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 19:49:57 +0100 Subject: [PATCH 082/396] Pass user info to post renderer. --- redesign/forum.nim | 3 +-- redesign/header.nim | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index 637fcf4..686fc2a 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -46,8 +46,7 @@ proc render(): VNode = route([ r("/t/@id?", (params: Params) => - (kout(params["id"].cstring); - renderPostList(params["id"].parseInt(), false)) + (renderPostList(params["id"].parseInt(), isLoggedIn())) ), r("/", (params: Params) => renderThreadList()) ]) diff --git a/redesign/header.nim b/redesign/header.nim index 9c08d16..3ab6845 100644 --- a/redesign/header.nim +++ b/redesign/header.nim @@ -67,6 +67,10 @@ when defined(js): let uri = makeUri("status.json", [("logout", $logout)]) ajaxGet(uri, @[], onStatus) + proc isLoggedIn*(): bool = + let user = state.data.map(x => x.user).flatten + not user.isNone + proc renderHeader*(): VNode = if state.data.isNone: getStatus() # TODO: Call this every render? From ea6ced889c3a50501f05931f014d8ec182741b08 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 20:21:28 +0100 Subject: [PATCH 083/396] Implements rendering of `time-passed` divs. --- redesign/postlist.nim | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 226af98..80e0d3c 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -1,5 +1,5 @@ -import options, json, times, httpcore, strformat, sugar +import options, json, times, httpcore, strformat, sugar, math import threadlist, category type @@ -38,6 +38,7 @@ when defined(js): list: Option[PostList] loading: bool status: HttpCode + replyBoxShown: bool proc newState(): State = State( @@ -127,6 +128,33 @@ when defined(js): span(class="more-post-count"): text "(" & $state.list.get().moreCount & ")" + proc genTimePassed(prevPost: Post, post: Option[Post]): VNode = + var latestTime = + if post.isSome: post.get().info.creation.fromUnix() + else: getTime() + + # TODO: Use `between` once it's merged into stdlib. + var diffStr = "Some time later" + let diff = latestTime - prevPost.info.creation.fromUnix() + if diff.weeks > 48: + let years = diff.weeks div 48 + diffStr = $years & " years later" + elif diff.weeks > 4: + let months = diff.weeks div 4 + diffStr = $months & " months later" + else: + return buildHtml(tdiv()) + + # PROTIP: Good thread ID to test this with is: 1267. + result = buildHtml(): + tdiv(class="information time-passed"): + tdiv(class="information-icon"): + italic(class="fas fa-clock") + tdiv(class="information-main"): + tdiv(class="information-title"): + text diffStr + + proc renderPostList*(threadId: int, isLoggedIn: bool): VNode = if state.status != Http200: return renderError("Couldn't retrieve posts.") @@ -144,8 +172,15 @@ when defined(js): p(): text list.thread.topic render(list.thread.category) tdiv(class="posts"): + var prevPost: Option[Post] = none[Post]() for post in list.posts: + if prevPost.isSome: + genTimePassed(prevPost.get(), some(post)) genPost(post, list.thread, isLoggedIn) + prevPost = some(post) + + if state.replyBoxShown and prevPost.isSome: + genTimePassed(prevPost.get(), none[Post]()) if list.moreCount > 0: - genLoadMore(list.posts.len) \ No newline at end of file + genLoadMore(list.posts.len) From dc7e9fda31b4e439128b7b6d13218cd74663ddd2 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 20:24:53 +0100 Subject: [PATCH 084/396] The plurals in the English language always get you --- redesign/postlist.nim | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 80e0d3c..37caf8e 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -138,10 +138,12 @@ when defined(js): let diff = latestTime - prevPost.info.creation.fromUnix() if diff.weeks > 48: let years = diff.weeks div 48 - diffStr = $years & " years later" + diffStr = $years + diffStr.add(if years == 1: " year later" else: " years later") elif diff.weeks > 4: let months = diff.weeks div 4 - diffStr = $months & " months later" + diffStr = $months + diffStr.add(if months == 1: " month later" else: " months later") else: return buildHtml(tdiv()) From aab10809a24a518185f05ebd302413491672181e Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 20:52:07 +0100 Subject: [PATCH 085/396] Implements reply box UI. --- redesign/nimforum.scss | 6 ++++++ redesign/postlist.nim | 11 ++++++++--- redesign/replybox.nim | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 redesign/replybox.nim diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 99cf68b..b255092 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -389,4 +389,10 @@ blockquote { margin-right: $control-padding-x*2; float: right; } +} + +.form-input { + // For reply text area. + margin-top: $control-padding-y*2; + resize: vertical; } \ No newline at end of file diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 37caf8e..a437088 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -31,7 +31,7 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, error + import karaxutils, error, replybox type State = ref object @@ -39,12 +39,15 @@ when defined(js): loading: bool status: HttpCode replyBoxShown: bool + replyBox: ReplyBox proc newState(): State = State( list: none[PostList](), loading: false, - status: Http200 + status: Http200, + replyBoxShown: true, + replyBox: newReplyBox() ) var @@ -156,7 +159,6 @@ when defined(js): tdiv(class="information-title"): text diffStr - proc renderPostList*(threadId: int, isLoggedIn: bool): VNode = if state.status != Http200: return renderError("Couldn't retrieve posts.") @@ -186,3 +188,6 @@ when defined(js): if list.moreCount > 0: genLoadMore(list.posts.len) + + if state.replyBoxShown: + render(state.replyBox, list.thread) \ No newline at end of file diff --git a/redesign/replybox.nim b/redesign/replybox.nim new file mode 100644 index 0000000..00a69ed --- /dev/null +++ b/redesign/replybox.nim @@ -0,0 +1,40 @@ +when defined(js): + import strformat + + include karax/prelude + import karax / [vstyles, kajax, kdom] + + import karaxutils, threadlist + + type + ReplyBox* = ref object + preview: bool + + proc newReplyBox*(): ReplyBox = + ReplyBox() + + proc render*(state: ReplyBox, thread: Thread): VNode = + result = buildHtml(): + tdiv(class="information no-border"): + tdiv(class="information-icon"): + italic(class="fas fa-reply") + tdiv(class="information-main", style=style(StyleAttr.width, "100%")): + tdiv(class="information-title"): + # text fmt("Replying to \"{thread.topic}\"") + # tdiv(class="information-content"): + tdiv(class="panel"): + tdiv(class="panel-nav"): + ul(class="tab tab-block"): + li(class=class({"active": not state.preview}, "tab-item")): + a(href="#"): + text "Message" + li(class=class({"active": state.preview}, "tab-item")): + a(href="#"): + text "Preview" + tdiv(class="panel-body"): + textarea(class="form-input", rows="5") + tdiv(class="panel-footer"): + button(class="btn btn-primary float-right"): + text "Reply" + button(class="btn btn-link float-right"): + text "Cancel" \ No newline at end of file From fe49dd1e85991a8038392144770cd7a20c9ddeea Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 11 May 2018 22:41:11 +0100 Subject: [PATCH 086/396] Fixes small style regression. --- redesign/nimforum.scss | 2 +- redesign/replybox.nim | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index b255092..34d5cad 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -391,7 +391,7 @@ blockquote { } } -.form-input { +#reply-box .form-input { // For reply text area. margin-top: $control-padding-y*2; resize: vertical; diff --git a/redesign/replybox.nim b/redesign/replybox.nim index 00a69ed..35f3b81 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -22,7 +22,7 @@ when defined(js): tdiv(class="information-title"): # text fmt("Replying to \"{thread.topic}\"") # tdiv(class="information-content"): - tdiv(class="panel"): + tdiv(class="panel", id="reply-box"): tdiv(class="panel-nav"): ul(class="tab tab-block"): li(class=class({"active": not state.preview}, "tab-item")): From 96e78a5b8482516c69bfc9871cc218eda8b28098 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 12 May 2018 14:08:08 +0100 Subject: [PATCH 087/396] Show who you're replying to and scroll to reply box. --- redesign/header.nim | 2 +- redesign/karax.html | 8 +++----- redesign/karaxutils.nim | 3 ++- redesign/post.nim | 18 ++++++++++++++++++ redesign/postlist.nim | 37 +++++++++++++------------------------ redesign/replybox.nim | 39 ++++++++++++++++++++++++++++++++------- redesign/threadlist.nim | 6 ++++++ 7 files changed, 75 insertions(+), 38 deletions(-) create mode 100644 redesign/post.nim diff --git a/redesign/header.nim b/redesign/header.nim index 3ab6845..b049808 100644 --- a/redesign/header.nim +++ b/redesign/header.nim @@ -81,7 +81,7 @@ when defined(js): tdiv(class="navbar container grid-xl"): section(class="navbar-section"): a(href=makeUri("/")): - img(src="images/crown.png", id="img-logo") # TODO: Customisation. + img(src="/karax/images/crown.png", id="img-logo") # TODO: Customisation. section(class="navbar-section"): tdiv(class="input-group input-inline"): input(class="search-input input-sm", diff --git a/redesign/karax.html b/redesign/karax.html index 24242d2..aa4d440 100644 --- a/redesign/karax.html +++ b/redesign/karax.html @@ -8,17 +8,15 @@ The Nim programming language forum - - - + - +
- + diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index 7ee5eaf..28ada03 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -13,7 +13,8 @@ proc class*(classes: varargs[tuple[name: string, present: bool]], if class.present: result.add(class.name & " ") proc makeUri*(relative: string, appName=appName, includeHash=false): string = - ## Concatenates ``relative`` to the current URL in a way that is sane. + ## Concatenates ``relative`` to the current URL in a way that is + ## (possibly) sane. var relative = relative assert appName in $window.location.pathname if relative[0] == '/': relative = relative[1..^1] diff --git a/redesign/post.nim b/redesign/post.nim new file mode 100644 index 0000000..da0ac10 --- /dev/null +++ b/redesign/post.nim @@ -0,0 +1,18 @@ +import threadlist + +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 \ No newline at end of file diff --git a/redesign/postlist.nim b/redesign/postlist.nim index a437088..78abd34 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -1,23 +1,8 @@ import options, json, times, httpcore, strformat, sugar, math -import threadlist, category +import threadlist, category, post 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 @@ -38,7 +23,7 @@ when defined(js): list: Option[PostList] loading: bool status: HttpCode - replyBoxShown: bool + replyingTo: Option[Post] replyBox: ReplyBox proc newState(): State = @@ -46,7 +31,7 @@ when defined(js): list: none[PostList](), loading: false, status: Http200, - replyBoxShown: true, + replyingTo: none[Post](), replyBox: newReplyBox() ) @@ -74,7 +59,12 @@ when defined(js): proc renderPostUrl(post: Post, thread: Thread): string = makeUri(fmt"/t/{thread.id}/p/{post.id}") + proc onReplyClick(e: Event, n: VNode, p: Option[Post]) = + state.replyingTo = p + state.replyBox.show() + proc genPost(post: Post, thread: Thread, isLoggedIn: bool): VNode = + let postCopy = post # TODO: Another workaround here, closure capture :( result = buildHtml(): tdiv(class="post"): tdiv(class="post-icon"): @@ -102,7 +92,8 @@ when defined(js): button(class="btn"): italic(class="far fa-flag") tdiv(class="reply-button"): - button(class="btn"): + button(class="btn", onClick=(e: Event, n: VNode) => + onReplyClick(e, n, some(postCopy))): italic(class="fas fa-reply") text " Reply" @@ -183,11 +174,9 @@ when defined(js): genPost(post, list.thread, isLoggedIn) prevPost = some(post) - if state.replyBoxShown and prevPost.isSome: - genTimePassed(prevPost.get(), none[Post]()) - if list.moreCount > 0: genLoadMore(list.posts.len) + elif prevPost.isSome: + genTimePassed(prevPost.get(), none[Post]()) - if state.replyBoxShown: - render(state.replyBox, list.thread) \ No newline at end of file + render(state.replyBox, list.thread, state.replyingTo) \ No newline at end of file diff --git a/redesign/replybox.nim b/redesign/replybox.nim index 35f3b81..f8718a5 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -1,28 +1,53 @@ when defined(js): - import strformat + import strformat, options + + from dom import getElementById, scrollIntoView, setTimeout include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, threadlist + import karaxutils, threadlist, post type ReplyBox* = ref object + shown: bool preview: bool proc newReplyBox*(): ReplyBox = ReplyBox() - proc render*(state: ReplyBox, thread: Thread): VNode = + proc performScroll() = + let replyBox = dom.document.getElementById("reply-box") + replyBox.scrollIntoView(false) + + proc show*(state: ReplyBox) = + # Scroll to the reply box. + if not state.shown: + # TODO: It would be nice for Karax to give us an event when it renders + # things. That way we can remove this crappy hack. + discard dom.window.setTimeout(performScroll, 50) + else: + performScroll() + + state.shown = true + + proc render*(state: ReplyBox, thread: Thread, post: Option[Post]): VNode = + if not state.shown: + return buildHtml(tdiv(id="reply-box")) + result = buildHtml(): - tdiv(class="information no-border"): + tdiv(class="information no-border", id="reply-box"): tdiv(class="information-icon"): italic(class="fas fa-reply") tdiv(class="information-main", style=style(StyleAttr.width, "100%")): tdiv(class="information-title"): - # text fmt("Replying to \"{thread.topic}\"") - # tdiv(class="information-content"): - tdiv(class="panel", id="reply-box"): + if post.isNone: + text fmt("Replying to \"{thread.topic}\"") + else: + text "Replying to " + renderUserMention(post.get().author) + tdiv(class="information-content"): + tdiv(class="panel"): tdiv(class="panel-nav"): ul(class="tab tab-block"): li(class=class({"active": not state.preview}, "tab-item")): diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 7ab611f..fb5dddf 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -70,6 +70,12 @@ when defined(js): if user.isOnline: italic(class="avatar-presense online") + proc renderUserMention*(user: User): VNode = + result = buildHtml(): + # TODO: Add URL to profile. + span(class="user-mention"): + text "@" & user.name + proc genUserAvatars(users: seq[User]): VNode = result = buildHtml(td): for user in users: From 7faa8a8dae9608cd38bdd9bf4e5d91bfb613b467 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 12 May 2018 14:12:57 +0100 Subject: [PATCH 088/396] Adds margin to reply box panel. --- redesign/nimforum.scss | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 34d5cad..c62b9b3 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -391,8 +391,15 @@ blockquote { } } -#reply-box .form-input { - // For reply text area. - margin-top: $control-padding-y*2; - resize: vertical; +#reply-box { + + .form-input { + // For reply text area. + margin-top: $control-padding-y*2; + resize: vertical; + } + + .panel { + margin-top: $control-padding-y*2; + } } \ No newline at end of file From 9f059bfade54c708c5ec092126fa255de9ecc4b8 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 12 May 2018 14:13:04 +0100 Subject: [PATCH 089/396] Fixes misleading post time rendering. --- redesign/threadlist.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index fb5dddf..224069e 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -86,7 +86,7 @@ when defined(js): let currentTime = getTime() let activityTime = fromUnix(activity) let duration = currentTime - activityTime - if duration.days > 300: + if currentTime.local().year != activityTime.local().year: return activityTime.local().format("MMM yyyy") elif duration.days > 30 and duration.days < 300: return activityTime.local().format("MMM dd") From 6fe8286509f45b6d3c873ed9350f26bf5a0eb720 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 12 May 2018 14:16:23 +0100 Subject: [PATCH 090/396] Fixes reply box's border. --- redesign/postlist.nim | 5 +++-- redesign/replybox.nim | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 78abd34..a6c8755 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -174,9 +174,10 @@ when defined(js): genPost(post, list.thread, isLoggedIn) prevPost = some(post) - if list.moreCount > 0: + let hasMore = list.moreCount > 0 + if hasMore: genLoadMore(list.posts.len) elif prevPost.isSome: genTimePassed(prevPost.get(), none[Post]()) - render(state.replyBox, list.thread, state.replyingTo) \ No newline at end of file + render(state.replyBox, list.thread, state.replyingTo, hasMore) \ No newline at end of file diff --git a/redesign/replybox.nim b/redesign/replybox.nim index f8718a5..883c1a2 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -31,12 +31,13 @@ when defined(js): state.shown = true - proc render*(state: ReplyBox, thread: Thread, post: Option[Post]): VNode = + proc render*(state: ReplyBox, thread: Thread, post: Option[Post], + hasMore: bool): VNode = if not state.shown: return buildHtml(tdiv(id="reply-box")) result = buildHtml(): - tdiv(class="information no-border", id="reply-box"): + tdiv(class=class({"no-border": hasMore}, "information"), id="reply-box"): tdiv(class="information-icon"): italic(class="fas fa-reply") tdiv(class="information-main", style=style(StyleAttr.width, "100%")): From 53c0bd89b9c5d49a496078cd6d127d01399e521d Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 12 May 2018 14:46:13 +0100 Subject: [PATCH 091/396] Use old post anchors. Highlight anchored post. --- redesign/nimforum.scss | 13 +++++++++++++ redesign/post.nim | 8 +++++++- redesign/postlist.nim | 5 +---- redesign/replybox.nim | 5 +++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index c62b9b3..a7fcdcb 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -213,6 +213,19 @@ $views-color: #545d70; @extend .tile; border-top: 1px solid $border-color; padding-top: $control-padding-y-lg; + + &:target .post-main { + animation: highlight 2000ms ease-out; + } +} + +@keyframes highlight { + 0% { + background-color: lighten($primary-color, 20%); + } + 100% { + background-color: inherit; + } } .post-icon { diff --git a/redesign/post.nim b/redesign/post.nim index da0ac10..8d7b8da 100644 --- a/redesign/post.nim +++ b/redesign/post.nim @@ -1,4 +1,7 @@ +import strformat + import threadlist +import karaxutils type PostInfo* = object @@ -15,4 +18,7 @@ type ## 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 \ No newline at end of file + info*: PostInfo + +proc renderPostUrl*(post: Post, thread: Thread): string = + makeUri(fmt"/t/{thread.id}#{post.id}") \ No newline at end of file diff --git a/redesign/postlist.nim b/redesign/postlist.nim index a6c8755..a28585b 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -56,9 +56,6 @@ when defined(js): else: state.list = some(list) - proc renderPostUrl(post: Post, thread: Thread): string = - makeUri(fmt"/t/{thread.id}/p/{post.id}") - proc onReplyClick(e: Event, n: VNode, p: Option[Post]) = state.replyingTo = p state.replyBox.show() @@ -66,7 +63,7 @@ when defined(js): proc genPost(post: Post, thread: Thread, isLoggedIn: bool): VNode = let postCopy = post # TODO: Another workaround here, closure capture :( result = buildHtml(): - tdiv(class="post"): + tdiv(class="post", id = $post.id): tdiv(class="post-icon"): render(post.author, "post-avatar") tdiv(class="post-main"): diff --git a/redesign/replybox.nim b/redesign/replybox.nim index 883c1a2..752e01f 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -47,6 +47,11 @@ when defined(js): else: text "Replying to " renderUserMention(post.get().author) + tdiv(class="post-buttons", + style=style(StyleAttr.marginTop, "-0.3rem")): + a(href=renderPostUrl(post.get(), thread)): + button(class="btn"): + italic(class="fas fa-arrow-up") tdiv(class="information-content"): tdiv(class="panel"): tdiv(class="panel-nav"): From 4ff5df6be26a4f533eaa7a3c22be21548bd6a8a0 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 12 May 2018 16:43:52 +0100 Subject: [PATCH 092/396] Working post preview! --- forum.nim | 25 ++++++++++++++- redesign/karaxutils.nim | 6 +++- redesign/post.nim | 9 ++++-- redesign/replybox.nim | 67 ++++++++++++++++++++++++++++++++++++----- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/forum.nim b/forum.nim index ce1bc6f..4d75d85 100644 --- a/forum.nim +++ b/forum.nim @@ -14,7 +14,7 @@ import cgi except setCookie import options import redesign/threadlist except User -import redesign/[category, postlist, error, header] +import redesign/[category, postlist, error, header, post] when not defined(windows): import bcrypt # TODO @@ -1186,6 +1186,29 @@ routes: let status = UserStatus(user: user) resp $(%status), "application/json" + post "/karax/preview": + 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 + + let msg = formData["msg"].body + try: + let rendered = msg.rstToHtml() + resp Http200, rendered + except EParseError: + let err = PostError( + errorFields: @[], + message: getCurrentExceptionMsg() + ) + resp Http400, $(%err), "application/json" + get re"/karax/(.+)?": resp readFile("redesign/karax.html") diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index 28ada03..8f6334f 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -56,7 +56,11 @@ proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb? type FormData* = ref object +proc newFormData*(): FormData + {.importcpp: "new FormData()", constructor.} proc newFormData*(form: dom.Element): FormData {.importcpp: "new FormData(@)", constructor.} proc get*(form: FormData, key: cstring): cstring - {.importcpp: "#.get(@)".} \ No newline at end of file + {.importcpp: "#.get(@)".} +proc append*(form: FormData, key, val: cstring) + {.importcpp: "#.append(@)".} \ No newline at end of file diff --git a/redesign/post.nim b/redesign/post.nim index 8d7b8da..616fa1d 100644 --- a/redesign/post.nim +++ b/redesign/post.nim @@ -1,7 +1,7 @@ import strformat import threadlist -import karaxutils + type PostInfo* = object @@ -20,5 +20,8 @@ type ## older versions of the post. info*: PostInfo -proc renderPostUrl*(post: Post, thread: Thread): string = - makeUri(fmt"/t/{thread.id}#{post.id}") \ No newline at end of file + +when defined(js): + import karaxutils + proc renderPostUrl*(post: Post, thread: Thread): string = + makeUri(fmt"/t/{thread.id}#{post.id}") \ No newline at end of file diff --git a/redesign/replybox.nim b/redesign/replybox.nim index 752e01f..cd7d2d3 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -1,20 +1,26 @@ when defined(js): - import strformat, options + import strformat, options, httpcore, json, sugar from dom import getElementById, scrollIntoView, setTimeout include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, threadlist, post + import karaxutils, threadlist, post, error type ReplyBox* = ref object shown: bool + text: kstring preview: bool + loading: bool + error: Option[PostError] + rendering: Option[kstring] proc newReplyBox*(): ReplyBox = - ReplyBox() + ReplyBox( + text: "" + ) proc performScroll() = let replyBox = dom.document.getElementById("reply-box") @@ -31,6 +37,39 @@ when defined(js): state.shown = true + proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) = + let status = httpStatus.HttpCode + if status == Http200: + kout(response) + state.rendering = some[kstring](response) + else: + # TODO: login has similar code, abstract this. + try: + let parsed = parseJson($response) + let error = to(parsed, PostError) + + state.error = some(error) + except: + kout(getCurrentExceptionMsg().cstring) + state.error = some(PostError( + errorFields: @[], + message: "Unknown error occurred." + )) + + proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) = + state.preview = true + + let formData = newFormData() + formData.append("msg", state.text) + let uri = makeUri("/preview") + ajaxPost(uri, @[], cast[cstring](formData), + (s: int, r: kstring) => onPreviewPost(s, r, state)) + + proc onChange(e: Event, n: VNode, state: ReplyBox) = + # TODO: There should be a karax-way to do this. I guess I can just call + # `value` on the node? We need to document this better :) + state.text = cast[dom.TextAreaElement](n.dom).value + proc render*(state: ReplyBox, thread: Thread, post: Option[Post], hasMore: bool): VNode = if not state.shown: @@ -56,14 +95,26 @@ when defined(js): tdiv(class="panel"): tdiv(class="panel-nav"): ul(class="tab tab-block"): - li(class=class({"active": not state.preview}, "tab-item")): - a(href="#"): + li(class=class({"active": not state.preview}, "tab-item"), + onClick=(e: Event, n: VNode) => (state.preview = false)): + a(class="c-hand"): text "Message" - li(class=class({"active": state.preview}, "tab-item")): - a(href="#"): + li(class=class({"active": state.preview}, "tab-item"), + onClick=(e: Event, n: VNode) => + onPreviewClick(e, n, state)): + a(class="c-hand"): text "Preview" tdiv(class="panel-body"): - textarea(class="form-input", rows="5") + if state.preview: + if state.loading: + tdiv(class="loading") + elif state.rendering.isSome(): + verbatim(state.rendering.get()) + else: + textarea(class="form-input", rows="5", + onChange=(e: Event, n: VNode) => + onChange(e, n, state), + value=state.text) tdiv(class="panel-footer"): button(class="btn btn-primary float-right"): text "Reply" From 67a5869c10624c56f498b037d2e5101d4413ec82 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 12 May 2018 18:36:51 +0100 Subject: [PATCH 093/396] Implements rendering of RST using server and verbatim node in karax. --- forum.nim | 2 +- redesign/postlist.nim | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/forum.nim b/forum.nim index 4d75d85..b7998e7 100644 --- a/forum.nim +++ b/forum.nim @@ -1149,7 +1149,7 @@ routes: history: @[], # TODO: info: PostInfo( creation: post[2].parseInt, - content: post[1] + content: post[1].rstToHtml() ) )) diff --git a/redesign/postlist.nim b/redesign/postlist.nim index a28585b..8ffc423 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -76,7 +76,7 @@ when defined(js): a(href=renderPostUrl(post, thread), title=title): text renderActivity(post.info.creation) tdiv(class="post-content"): - p(text post.info.content) # TODO: RSTGEN + verbatim(post.info.content) tdiv(class="post-buttons"): tdiv(class="like-button"): button(class="btn"): From e13a4425f721a245c192fb4bcced1a3b77911268 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sun, 13 May 2018 19:26:36 +0100 Subject: [PATCH 094/396] Show proper error when preview fails. --- redesign/replybox.nim | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/redesign/replybox.nim b/redesign/replybox.nim index cd7d2d3..921579e 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -38,6 +38,7 @@ when defined(js): state.shown = true proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) = + state.loading = false let status = httpStatus.HttpCode if status == Http200: kout(response) @@ -58,6 +59,8 @@ when defined(js): proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) = state.preview = true + state.loading = true + state.error = none[PostError]() let formData = newFormData() formData.append("msg", state.text) @@ -108,6 +111,10 @@ when defined(js): if state.preview: if state.loading: tdiv(class="loading") + elif state.error.isSome(): + tdiv(class="toast toast-error", + style=style(StyleAttr.marginTop, "0.4rem")): + text state.error.get().message elif state.rendering.isSome(): verbatim(state.rendering.get()) else: From 8b01e452b602d0d9ff8f1543e72d1a795ae71276 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sun, 13 May 2018 22:59:29 +0100 Subject: [PATCH 095/396] Improves post rendering to support multiple load more buttons. --- forum.nim | 83 ++++++++++++++++++++++++------------ redesign/post.nim | 4 +- redesign/postlist.nim | 99 +++++++++++++++++++++++++------------------ 3 files changed, 116 insertions(+), 70 deletions(-) diff --git a/forum.nim b/forum.nim index b7998e7..9321e0f 100644 --- a/forum.nim +++ b/forum.nim @@ -9,7 +9,7 @@ import os, strutils, times, md5, strtabs, math, db_sqlite, scgi, jester, asyncdispatch, asyncnet, cache, sequtils, - parseutils, utils, random, rst, ranks, recaptcha, json, re + parseutils, utils, random, rst, ranks, recaptcha, json, re, sugar import cgi except setCookie import options @@ -1026,6 +1026,20 @@ proc selectUser(userRow: seq[string]): threadlist.User = isOnline: isOnline ) +proc selectPost(postRow: seq[string], skippedPosts: seq[int]): Post = + return Post( + id: postRow[0].parseInt, + author: selectUser(@[postRow[4], postRow[5], postRow[6]]), + likes: @[], # TODO: + seen: false, # TODO: + history: @[], # TODO: + info: PostInfo( + creation: postRow[2].parseInt, + content: postRow[1].rstToHtml() + ), + moreBefore: skippedPosts + ) + proc selectThread(threadRow: seq[string]): Thread = const postsQuery = sql"""select count(*), strftime('%s', creation) from post @@ -1101,9 +1115,10 @@ routes: createTFD() var id = getInt(@"id", -1) - start = getInt(@"start", 0) - count = getInt(@"count", 5) + anchor = getInt(@"anchor", -1) cond id != -1 + const + count = 10 const threadsQuery = sql"""select id, name, views, strftime('%s', modified) from thread @@ -1124,34 +1139,50 @@ routes: 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 + order by p.id""" % modClause ) - let pstCount = getValue( - db, - sql"select count(*) from post where thread = ?;", - id - ).parseInt() - let moreCount = max(0, pstCount - (start + count)) - var list = PostList( posts: @[], history: @[], - thread: thread, - moreCount: moreCount) - 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].rstToHtml() - ) - )) + thread: thread + ) + let rows = getAllRows(db, postsQuery, id, c.userId, c.userId) + + var skippedPosts: seq[int] = @[] + for i in 0 ..< rows.len: + let id = rows[i][0].parseInt + + let addDetail = i < count or rows.len-i < count or id == anchor + + if addDetail: + let post = selectPost(rows[i], skippedPosts) + list.posts.add(post) + skippedPosts = @[] + else: + skippedPosts.add(id) + + resp $(%list), "application/json" + + get "/karax/specific_posts.json": + createTFD() + var + ids = parseJson(@"ids") + + cond ids.kind == JArray + let intIDs = ids.elems.map(x => x.getInt()) + 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.id in ($#) + order by p.id; + """ % intIDs.join(",")) # TODO: It's horrible that I have to do this. + + var list: seq[Post] = @[] + + for row in db.getAllRows(postsQuery): + list.add(selectPost(row, @[])) resp $(%list), "application/json" diff --git a/redesign/post.nim b/redesign/post.nim index 616fa1d..9ddbbb7 100644 --- a/redesign/post.nim +++ b/redesign/post.nim @@ -8,7 +8,7 @@ type creation*: int64 content*: string - Post* = object + Post* = ref object id*: int author*: User likes*: seq[User] ## Users that liked this post. @@ -19,7 +19,7 @@ type history*: seq[PostInfo] ## If the post was edited this will contain the ## older versions of the post. info*: PostInfo - + moreBefore*: seq[int] when defined(js): import karaxutils diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 8ffc423..c585e64 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -10,7 +10,6 @@ type ## older versions of the thread (title/category ## changes). posts*: seq[Post] - moreCount*: int when defined(js): include karax/prelude @@ -38,7 +37,7 @@ when defined(js): var state = newState() - proc onPostList(httpStatus: int, response: kstring, start: int) = + proc onPostList(httpStatus: int, response: kstring) = state.loading = false state.status = httpStatus.HttpCode if state.status != Http200: return @@ -46,20 +45,62 @@ when defined(js): let parsed = parseJson($response) let list = to(parsed, PostList) - if state.list.isSome and state.list.get().thread.id == list.thread.id: - var old = state.list.get() - for i in 0.. onMorePosts(s, r, start, post) + ) + + proc genLoadMore(post: Post, start: int): VNode = + result = buildHtml(): + tdiv(class="information load-more-posts", + onClick=(e: Event, n: VNode) => onLoadMore(e, n, start, post)): + tdiv(class="information-icon"): + italic(class="fas fa-comment-dots") + tdiv(class="information-main"): + if state.loading: + tdiv(class="loading loading-lg") + else: + tdiv(class="information-title"): + text "Load more posts " + span(class="more-post-count"): + text "(" & $post.moreBefore.len & ")" + proc genPost(post: Post, thread: Thread, isLoggedIn: bool): VNode = let postCopy = post # TODO: Another workaround here, closure capture :( result = buildHtml(): @@ -94,31 +135,6 @@ when defined(js): italic(class="fas fa-reply") text " Reply" - proc onLoadMore(ev: Event, n: VNode) = - if state.loading: return - - state.loading = true - let start = n.getAttr("data-start").parseInt() - let threadId = state.list.get().thread.id - let uri = makeUri("posts.json", [("start", $start), ("id", $threadId)]) - ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, start)) - - proc genLoadMore(start: int): VNode = - result = buildHtml(): - tdiv(class="information load-more-posts", - onClick=onLoadMore, - "data-start" = $start): - tdiv(class="information-icon"): - italic(class="fas fa-comment-dots") - tdiv(class="information-main"): - if state.loading: - tdiv(class="loading loading-lg") - else: - tdiv(class="information-title"): - text "Load more posts " - span(class="more-post-count"): - text "(" & $state.list.get().moreCount & ")" - proc genTimePassed(prevPost: Post, post: Option[Post]): VNode = var latestTime = if post.isSome: post.get().info.creation.fromUnix() @@ -153,7 +169,7 @@ when defined(js): if state.list.isNone or state.list.get().thread.id != threadId: let uri = makeUri("posts.json", ("id", $threadId)) - ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, 0)) + ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r)) return buildHtml(tdiv(class="loading loading-lg")) @@ -165,16 +181,15 @@ when defined(js): render(list.thread.category) tdiv(class="posts"): var prevPost: Option[Post] = none[Post]() - for post in list.posts: + for i, post in list.posts: if prevPost.isSome: genTimePassed(prevPost.get(), some(post)) + if post.moreBefore.len > 0: + genLoadMore(post, i) genPost(post, list.thread, isLoggedIn) prevPost = some(post) - let hasMore = list.moreCount > 0 - if hasMore: - genLoadMore(list.posts.len) - elif prevPost.isSome: + if prevPost.isSome: genTimePassed(prevPost.get(), none[Post]()) - render(state.replyBox, list.thread, state.replyingTo, hasMore) \ No newline at end of file + render(state.replyBox, list.thread, state.replyingTo, false) \ No newline at end of file From ed5f715ae55a693444547bcc437019eff3c1be29 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sun, 13 May 2018 23:15:07 +0100 Subject: [PATCH 096/396] Fixes styles so that content of post doesn't overflow its container. --- redesign/nimforum.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index a7fcdcb..1352f6d 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -241,6 +241,9 @@ $views-color: #545d70; @extend .tile-content; margin-bottom: $control-padding-y-lg*2; + // https://stackoverflow.com/a/41675912/492186 + flex: 1; + min-width: 0; } .post-title { From e1b72ed5662f1b6e9f625ec660a0e3ac5eaedb1f Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 10:53:53 +0100 Subject: [PATCH 097/396] Normalize and adjust and styles. --- redesign/nimforum.scss | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 1352f6d..a511334 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -3,7 +3,7 @@ // Define variables to override default ones $primary-color: #f99c19; $body-font-color: #292929; -$dark-color: #525252; +$dark-color: #505050; $label-color: #7cd2ff; $secondary-btn-color: #f1f1f1; @@ -418,4 +418,13 @@ blockquote { .panel { margin-top: $control-padding-y*2; } +} + +code { + color: $body-font-color; + background-color: $bg-color; +} + +tt { + @extend code; } \ No newline at end of file From 52f1e9c3651e02ce57098770884c2a128d8e73b5 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 12:28:45 +0100 Subject: [PATCH 098/396] Implements syntax highlighting and
style. --- redesign/nimforum.scss | 11 ++++++++++- utils.nim | 9 +++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index a511334..5e87f3e 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -427,4 +427,13 @@ code { tt { @extend code; -} \ No newline at end of file +} + +hr { + background: $border-color; + height: $border-width; + margin: $unit-2 0; + border: 0; +} + +@import "syntax.scss"; \ No newline at end of file diff --git a/utils.nim b/utils.nim index 11f51af..b1c7552 100644 --- a/utils.nim +++ b/utils.nim @@ -38,8 +38,8 @@ type var docConfig: StringTableRef docConfig = rstgen.defaultConfig() -docConfig["doc.listing_start"] = "
"
-docConfig["doc.smiley_format"] = "/images/smilieys/$1.png"
+docConfig["doc.listing_start"] = "
"
+docConfig["doc.listing_end"] = "
" proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config = result = Config(smtpAddress: "", smtpPort: 25, smtpUser: "", @@ -81,7 +81,7 @@ proc blockquoteFinish(currentBlockquote, newNode: var XmlNode, n: XmlNode) = newNode.add(n) proc rstToHtml*(content: string): string = - result = rstgen.rstToHtml(content, {roSupportSmilies, roSupportMarkdown}, + result = rstgen.rstToHtml(content, {roSupportMarkdown}, docConfig) # Bolt on quotes. # TODO: Yes, this is ugly. I wrote it quickly. PRs welcome ;) @@ -116,7 +116,8 @@ proc rstToHtml*(content: string): string = blockquoteFinish(currentBlockquote, newNode, n) else: blockquoteFinish(currentBlockquote, newNode, n) - result = $newNode + result = "" + add(result, newNode, indWidth=0, addNewLines=false) except: echo("[WARNING] Could not parse rst html.") From 53ed3717b889d99073650b24e8752dbaa3de4c7d Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 12:37:15 +0100 Subject: [PATCH 099/396] Hide "Run" button when appropriate and "none" language caption. --- redesign/nimforum.scss | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 5e87f3e..8bbc407 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -356,6 +356,17 @@ blockquote { @extend .toast-success; } +.code { + // Don't show the "none". + &[data-lang="none"]::before { + content: ""; + } + + &:not([data-lang="Nim"]) > .code-buttons { + display: none; + } +} + .information { @extend .tile; border-top: 1px solid $border-color; From eeda9270852c7ec9ca4c3a9d66f15f96526d5fc3 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 14:41:12 +0100 Subject: [PATCH 100/396] Implements mention highlighting in posts. --- forum.nim | 5 +-- utils.nim | 121 +++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 90 insertions(+), 36 deletions(-) diff --git a/forum.nim b/forum.nim index 9321e0f..2752ac6 100644 --- a/forum.nim +++ b/forum.nim @@ -281,9 +281,6 @@ proc validThreadId(c: TForumData): bool = result = getValue(db, sql"select id from thread where id = ?", $c.threadId).len > 0 -const - SecureChars = {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'} - proc setError(c: TForumData, field, msg: string): bool {.inline.} = c.invalidField = field c.errorMsg = "Error: " & msg @@ -292,7 +289,7 @@ proc setError(c: TForumData, field, msg: string): bool {.inline.} = proc register(c: TForumData, name, pass, antibot, userIp, email: string): Future[bool] {.async.} = # Username validation: - if name.len == 0 or not allCharsInSet(name, SecureChars): + if name.len == 0 or not allCharsInSet(name, UsernameIdent): return setError(c, "name", "Invalid username!") if getValue(db, sql"select name from person where name = ?", name).len > 0: return setError(c, "name", "Username already exists!") diff --git a/utils.nim b/utils.nim index b1c7552..690a007 100644 --- a/utils.nim +++ b/utils.nim @@ -2,6 +2,12 @@ import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs, htmlparser, streams, parseutils, options from times import getTime, getGMTime, format +# Used to be: +# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'} +let + UsernameIdent* = IdentChars # TODO: Double check that everyone follows this. + + proc parseInt*(s: string, value: var int, validRange: Slice[int]) {. noSideEffect.} = ## parses `s` into an integer in the range `validRange`. If successful, @@ -80,46 +86,97 @@ proc blockquoteFinish(currentBlockquote, newNode: var XmlNode, n: XmlNode) = currentBlockquote = newElement("blockquote") newNode.add(n) +proc processQuotes(node: XmlNode): XmlNode = + # Bolt on quotes. + # TODO: Yes, this is ugly. I wrote it quickly. PRs welcome ;) + result = newElement("div") + var currentBlockquote = newElement("blockquote") + for n in items(node): + case n.kind + of xnElement: + case n.tag + of "p": + let (nesting, contentNode, _) = processGT(n, "p") + if nesting > 0: + var bq = currentBlockquote + for i in 1 ..< nesting: + var newBq = bq.child("blockquote") + if newBq.isNil: + newBq = newElement("blockquote") + bq.add(newBq) + bq = newBq + bq.insert(contentNode, if bq.len == 0: 0 else: bq.len) + else: + blockquoteFinish(currentBlockquote, result, n) + else: + blockquoteFinish(currentBlockquote, result, n) + of xnText: + if n.text[0] == '\10': + result.add(n) + else: + blockquoteFinish(currentBlockquote, result, n) + else: + blockquoteFinish(currentBlockquote, result, n) + +proc replaceMentions(node: XmlNode): seq[XmlNode] = + assert node.kind == xnText + result = @[] + + var current = "" + var i = 0 + while i < len(node.text): + i += parseUntil(node.text, current, {'@'}, i) + if i >= len(node.text): break + if node.text[i] == '@': + i.inc # Skip @ + var username = "" + i += parseWhile(node.text, username, UsernameIdent, i) + + let el = <>span( + class="user-mention", + data-username=username, + newText("@" & username) + ) + + result.add(newText(current)) + current = "" + result.add(el) + + result.add(newText(current)) + +proc processMentions(node: XmlNode): XmlNode = + case node.kind + of xnText: + result = newElement("span") + for child in replaceMentions(node): + result.add(child) + of xnElement: + case node.tag + of "pre", "code", "tt": + return node + else: + result = newElement(node.tag) + for n in items(node): + result.add(processMentions(n)) + else: + return node + + + proc rstToHtml*(content: string): string = result = rstgen.rstToHtml(content, {roSupportMarkdown}, docConfig) - # Bolt on quotes. - # TODO: Yes, this is ugly. I wrote it quickly. PRs welcome ;) try: var node = parseHtml(newStringStream(result)) - var newNode = newElement("div") if node.kind == xnElement: - var currentBlockquote = newElement("blockquote") - for n in items(node): - case n.kind - of xnElement: - case n.tag - of "p": - let (nesting, contentNode, tag) = processGT(n, "p") - if nesting > 0: - var bq = currentBlockquote - for i in 1 .. Date: Mon, 14 May 2018 18:28:55 +0100 Subject: [PATCH 101/396] Fixes "mention" transformation and parsing quirks. --- utils.nim | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/utils.nim b/utils.nim index 690a007..543626f 100644 --- a/utils.nim +++ b/utils.nim @@ -132,15 +132,18 @@ proc replaceMentions(node: XmlNode): seq[XmlNode] = var username = "" i += parseWhile(node.text, username, UsernameIdent, i) - let el = <>span( - class="user-mention", - data-username=username, - newText("@" & username) - ) + if username.len == 0: + result.add(newText(current & "@")) + else: + let el = <>span( + class="user-mention", + data-username=username, + newText("@" & username) + ) - result.add(newText(current)) - current = "" - result.add(el) + result.add(newText(current)) + current = "" + result.add(el) result.add(newText(current)) @@ -156,27 +159,25 @@ proc processMentions(node: XmlNode): XmlNode = return node else: result = newElement(node.tag) + result.attrs = node.attrs for n in items(node): result.add(processMentions(n)) else: return node - - proc rstToHtml*(content: string): string = result = rstgen.rstToHtml(content, {roSupportMarkdown}, docConfig) try: var node = parseHtml(newStringStream(result)) if node.kind == xnElement: - let quotedNode = processQuotes(node) - let mentionedNode = processMentions(quotedNode) + node = processQuotes(node) - result = "" - add(result, mentionedNode, indWidth=0, addNewLines=false) + node = processMentions(node) + result = "" + add(result, node, indWidth=0, addNewLines=false) except: - raise - # echo("[WARNING] Could not parse rst html.") + echo("[WARNING] Could not parse rst html.") proc sendMail(config: Config, subject, message, recipient: string, from_addr = "forum@nim-lang.org", otherHeaders:seq[(string, string)] = @[]) {.async.} = if config.smtpAddress.len == 0: From 01315d2b34041942bbe7831230fb6199f4b0df42 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 18:29:12 +0100 Subject: [PATCH 102/396] Adds missing syntax.scss syntax highlighting styles file. --- redesign/syntax.scss | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 redesign/syntax.scss diff --git a/redesign/syntax.scss b/redesign/syntax.scss new file mode 100644 index 0000000..14dfa49 --- /dev/null +++ b/redesign/syntax.scss @@ -0,0 +1,13 @@ +pre .Comment { color:#618f0b; font-style:italic; } +pre .Keyword { color:rgb(39, 141, 182); font-weight:bold; } +pre .Type { color:#128B7D; font-weight:bold; } +pre .Operator { font-weight: bold; } +pre .atr { color:#128B7D; font-weight:bold; font-style:italic; } +pre .def { color:#CAD6E4; font-weight:bold; font-style:italic; } +pre .StringLit { color:rgb(190, 15, 15); font-weight:bold; } +pre .DecNumber, pre .FloatNumber { color:#8AB647; } +pre .tab { border-left:1px dotted rgba(67,168,207,0.4); } +pre .EscapeSequence +{ + color: #C08D12; +} \ No newline at end of file From 073f274e0471cb6c6b90d8cf901d73959886457b Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 19:14:04 +0100 Subject: [PATCH 103/396] Small fix to blockquote parser. Decided to leave the blockquote parser alone. It works well enough and the small bugs it contains aren't critical. --- utils.nim | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/utils.nim b/utils.nim index 543626f..8f4d9a8 100644 --- a/utils.nim +++ b/utils.nim @@ -105,7 +105,7 @@ proc processQuotes(node: XmlNode): XmlNode = newBq = newElement("blockquote") bq.add(newBq) bq = newBq - bq.insert(contentNode, if bq.len == 0: 0 else: bq.len) + bq.add(contentNode) else: blockquoteFinish(currentBlockquote, result, n) else: @@ -172,7 +172,6 @@ proc rstToHtml*(content: string): string = var node = parseHtml(newStringStream(result)) if node.kind == xnElement: node = processQuotes(node) - node = processMentions(node) result = "" add(result, node, indWidth=0, addNewLines=false) From 7635478b34ca6167269a247e2217c8163612b39c Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 21:11:18 +0100 Subject: [PATCH 104/396] Implements posting of replies. --- forum.nim | 59 ++++++++++++++++++++++++++++++++++++++ redesign/postlist.nim | 39 ++++++++++++++++--------- redesign/replybox.nim | 66 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 143 insertions(+), 21 deletions(-) diff --git a/forum.nim b/forum.nim index 2752ac6..581510e 100644 --- a/forum.nim +++ b/forum.nim @@ -1069,6 +1069,32 @@ proc selectThread(threadRow: seq[string]): Thread = return thread +proc executeReply(c: TForumData, threadId: int, content: string, + replyingTo: int): int64 = + # TODO: Refactor TForumData. + assert c.loggedIn() + + let subject = "" # TODO: Remove this redundant field. + if rateLimitCheck(c): + raise newException(ForumError, "You're posting too fast!") + + # TODO: Replying to. + let retID = insertID( + db, + crud(crCreate, "post", "author", "ip", "header", "content", "thread"), + c.userId, c.req.ip, subject, content, $threadId, "" + ) + discard tryExec( + db, + crud(crCreate, "post_fts", "id", "header", "content"), + retID.int, subject, content + ) + + exec(db, sql"update thread set modified = DATETIME('now') where id = ?", + $c.threadId) + + return retID + initialise() routes: @@ -1237,6 +1263,39 @@ routes: ) resp Http400, $(%err), "application/json" + post "/karax/createPost": + 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 "threadId" in formData + + let msg = formData["msg"].body + let threadId = getInt(formData["threadId"].body, -1) + cond threadId != -1 + + let replyingTo = + if "replyingTo" in formData: + getInt(formData["replyingTo"].body, -1) + else: + -1 + + try: + let id = executeReply(c, threadId, msg, replyingTo) + resp Http200, $(%id), "application/json" + except ForumError: + let err = PostError( + errorFields: @[], + message: getCurrentExceptionMsg() + ) + resp Http400, $(%err), "application/json" + get re"/karax/(.+)?": resp readFile("redesign/karax.html") diff --git a/redesign/postlist.nim b/redesign/postlist.nim index c585e64..2b667ea 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -25,13 +25,14 @@ when defined(js): replyingTo: Option[Post] replyBox: ReplyBox + proc onReplyPosted(id: int) proc newState(): State = State( list: none[PostList](), loading: false, status: Http200, replyingTo: none[Post](), - replyBox: newReplyBox() + replyBox: newReplyBox(onReplyPosted) ) var @@ -47,7 +48,7 @@ when defined(js): state.list = some(list) - proc onMorePosts(httpStatus: int, response: kstring, start: int, post: Post) = + proc onMorePosts(httpStatus: int, response: kstring, start: int) = state.loading = false state.status = httpStatus.HttpCode if state.status != Http200: return @@ -62,20 +63,21 @@ when defined(js): # Save a list of the IDs which have not yet been loaded into the top-most # post. - for id in post.moreBefore: - if id notin idsLoaded: - state.list.get().posts[start].moreBefore.add(id) - post.moreBefore = @[] + let postIndex = start+list.len + # The following check is necessary because we reuse this proc to load + # a newly created post. + if postIndex < state.list.get().posts.len: + let post = state.list.get().posts[postIndex] + var newPostIds: seq[int] = @[] + for id in post.moreBefore: + if id notin idsLoaded: + newPostIds.add(id) + post.moreBefore = newPostIds - proc onReplyClick(e: Event, n: VNode, p: Option[Post]) = - state.replyingTo = p - state.replyBox.show() - - proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) = + proc loadMore(start: int, ids: seq[int]) = if state.loading: return state.loading = true - let ids = post.moreBefore # TODO: Don't load all! let uri = makeUri( "specific_posts.json", [("ids", $(%ids))] @@ -83,9 +85,20 @@ when defined(js): ajaxGet( uri, @[], - (s: int, r: kstring) => onMorePosts(s, r, start, post) + (s: int, r: kstring) => onMorePosts(s, r, start) ) + proc onReplyPosted(id: int) = + ## Executed when a reply has been successfully posted. + loadMore(state.list.get().posts.len, @[id]) + + proc onReplyClick(e: Event, n: VNode, p: Option[Post]) = + state.replyingTo = p + state.replyBox.show() + + proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) = + loadMore(start, post.moreBefore) # TODO: Don't load all! + proc genLoadMore(post: Post, start: int): VNode = result = buildHtml(): tdiv(class="information load-more-posts", diff --git a/redesign/replybox.nim b/redesign/replybox.nim index 921579e..b92dc88 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -16,10 +16,12 @@ when defined(js): loading: bool error: Option[PostError] rendering: Option[kstring] + onPost: proc (id: int) - proc newReplyBox*(): ReplyBox = + proc newReplyBox*(onPost: proc (id: int)): ReplyBox = ReplyBox( - text: "" + text: "", + onPost: onPost ) proc performScroll() = @@ -68,6 +70,45 @@ when defined(js): ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onPreviewPost(s, r, state)) + proc onReplyPost(httpStatus: int, response: kstring, state: ReplyBox) = + state.loading = false + let status = httpStatus.HttpCode + if status == Http200: + state.text = "" + state.shown = false + state.onPost(parseJson($response).getInt()) + else: + # TODO: login has similar code, abstract this. + try: + let parsed = parseJson($response) + let error = to(parsed, PostError) + + state.error = some(error) + except: + kout(getCurrentExceptionMsg().cstring) + state.error = some(PostError( + errorFields: @[], + message: "Unknown error occurred." + )) + + proc onReplyClick(e: Event, n: VNode, state: ReplyBox, + thread: Thread, replyingTo: Option[Post]) = + state.loading = true + state.error = none[PostError]() + + let formData = newFormData() + formData.append("msg", state.text) + formData.append("threadId", $thread.id) + if replyingTo.isSome: + formData.append("replyingTo", $replyingTo.get().id) + let uri = makeUri("/createPost") + ajaxPost(uri, @[], cast[cstring](formData), + (s: int, r: kstring) => onReplyPost(s, r, state)) + + proc onCancelClick(e: Event, n: VNode, state: ReplyBox) = + # TODO: Double check reply box contents and ask user whether to discard. + state.shown = false + proc onChange(e: Event, n: VNode, state: ReplyBox) = # TODO: There should be a karax-way to do this. I guess I can just call # `value` on the node? We need to document this better :) @@ -111,10 +152,6 @@ when defined(js): if state.preview: if state.loading: tdiv(class="loading") - elif state.error.isSome(): - tdiv(class="toast toast-error", - style=style(StyleAttr.marginTop, "0.4rem")): - text state.error.get().message elif state.rendering.isSome(): verbatim(state.rendering.get()) else: @@ -122,8 +159,21 @@ when defined(js): onChange=(e: Event, n: VNode) => onChange(e, n, state), value=state.text) + + if state.error.isSome(): + tdiv(class="toast toast-error", + style=style(StyleAttr.marginTop, "0.4rem")): + text state.error.get().message + tdiv(class="panel-footer"): - button(class="btn btn-primary float-right"): + button(class=class( + {"loading": state.loading}, + "btn btn-primary float-right" + ), + onClick=(e: Event, n: VNode) => + onReplyClick(e, n, state, thread, post)): text "Reply" - button(class="btn btn-link float-right"): + button(class="btn btn-link float-right", + onClick=(e: Event, n: VNode) => + onCancelClick(e, n, state)): text "Cancel" \ No newline at end of file From 7f895123f96140626fc227e66daf5e93f1dbe1ec Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 21:23:44 +0100 Subject: [PATCH 105/396] Refactor on*Post procedures. --- redesign/error.nim | 21 ++++++++++++++++++++- redesign/login.nim | 17 +---------------- redesign/replybox.nim | 34 ++-------------------------------- 3 files changed, 23 insertions(+), 49 deletions(-) diff --git a/redesign/error.nim b/redesign/error.nim index fb96758..af04e73 100644 --- a/redesign/error.nim +++ b/redesign/error.nim @@ -40,4 +40,23 @@ when defined(js): let e = error.get() if (e.errorFields.len == 1 and e.errorFields[0] == name) or isLast: p(class="form-input-hint"): - text e.message \ No newline at end of file + text e.message + + template postFinished*(onSuccess: untyped): untyped = + state.loading = false + let status = httpStatus.HttpCode + if status == Http200: + onSuccess + else: + # TODO: Karax should pass the content-type... + try: + let parsed = parseJson($response) + let error = to(parsed, PostError) + + state.error = some(error) + except: + kout(getCurrentExceptionMsg().cstring) + state.error = some(PostError( + errorFields: @[], + message: "Unknown error occurred." + )) \ No newline at end of file diff --git a/redesign/login.nim b/redesign/login.nim index 94a9228..6485906 100644 --- a/redesign/login.nim +++ b/redesign/login.nim @@ -17,24 +17,9 @@ when defined(js): error: Option[PostError] proc onLogInPost(httpStatus: int, response: kstring, state: LoginModal) = - state.loading = false - let status = httpStatus.HttpCode - if status == Http200: + postFinished: state.shown = false state.onLogIn() - else: - # TODO: Karax should pass the content-type... - try: - let parsed = parseJson($response) - let error = to(parsed, PostError) - - state.error = some(error) - except: - kout(getCurrentExceptionMsg().cstring) - state.error = some(PostError( - errorFields: @[], - message: "Unknown error occurred." - )) proc onLogInClick(ev: Event, n: VNode, state: LoginModal) = state.loading = true diff --git a/redesign/replybox.nim b/redesign/replybox.nim index b92dc88..04852f2 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -40,24 +40,9 @@ when defined(js): state.shown = true proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) = - state.loading = false - let status = httpStatus.HttpCode - if status == Http200: + postFinished: kout(response) state.rendering = some[kstring](response) - else: - # TODO: login has similar code, abstract this. - try: - let parsed = parseJson($response) - let error = to(parsed, PostError) - - state.error = some(error) - except: - kout(getCurrentExceptionMsg().cstring) - state.error = some(PostError( - errorFields: @[], - message: "Unknown error occurred." - )) proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) = state.preview = true @@ -71,25 +56,10 @@ when defined(js): (s: int, r: kstring) => onPreviewPost(s, r, state)) proc onReplyPost(httpStatus: int, response: kstring, state: ReplyBox) = - state.loading = false - let status = httpStatus.HttpCode - if status == Http200: + postFinished: state.text = "" state.shown = false state.onPost(parseJson($response).getInt()) - else: - # TODO: login has similar code, abstract this. - try: - let parsed = parseJson($response) - let error = to(parsed, PostError) - - state.error = some(error) - except: - kout(getCurrentExceptionMsg().cstring) - state.error = some(PostError( - errorFields: @[], - message: "Unknown error occurred." - )) proc onReplyClick(e: Event, n: VNode, state: ReplyBox, thread: Thread, replyingTo: Option[Post]) = From 681c3ef19dc2fd8a76716304f026e0745b3a6f84 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 21:40:49 +0100 Subject: [PATCH 106/396] Fixes user menu on larger screens. --- redesign/nimforum.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 8bbc407..84ee7f9 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -83,8 +83,10 @@ $logo-height: $navbar-height - 20px; .menu-right { // To make sure the user menu doesn't move off the screen. - left: auto; - right: 0; + @media (max-width: 1600px) { + left: auto; + right: 0; + } position: absolute; } From 6380ea699d9c07c48cf456de62b954ab17dc27a0 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 21:53:13 +0100 Subject: [PATCH 107/396] Use big
to hide user menu in a more intuitive fashion. --- redesign/usermenu.nim | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/redesign/usermenu.nim b/redesign/usermenu.nim index 9673dcf..6d0d8c7 100644 --- a/redesign/usermenu.nim +++ b/redesign/usermenu.nim @@ -19,15 +19,31 @@ when defined(js): onLogout: onLogout ) + proc onClick(e: Event, n: VNode, state: UserMenu) = + state.shown = not state.shown + proc render*(state: UserMenu, user: User): VNode = result = buildHtml(): tdiv(): figure(class="avatar c-hand", - onClick=(e: Event, n: VNode) => (state.shown = not state.shown)): + onClick=(e: Event, n: VNode) => onClick(e, n, state)): img(src=user.avatarUrl, title=user.name) if user.isOnline: italic(class="avatar-presense online") + tdiv(style=style([ + (StyleAttr.width, kstring"999999px"), + (StyleAttr.height, kstring"999999px"), + (StyleAttr.position, kstring"absolute"), + (StyleAttr.left, kstring"0"), + (StyleAttr.top, kstring"0"), + ( + StyleAttr.display, + if state.shown: kstring"block" else: kstring"none" + ) + ]), + onClick=(e: Event, n: VNode) => (state.shown = false)) + ul(class="menu menu-right", style=style( StyleAttr.display, if state.shown: "inherit" else: "none" )): From d345bf76ae62bbd5bbea94f1259e036b31625069 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Mon, 14 May 2018 22:45:27 +0100 Subject: [PATCH 108/396] Implements naive /profile.json endpoint. --- forum.nim | 64 +++++++++++++++++++++++++++++++++++------ ranks.nim | 11 ------- redesign/profile.nim | 11 +++++++ redesign/threadlist.nim | 16 ++++++++++- 4 files changed, 81 insertions(+), 21 deletions(-) delete mode 100644 ranks.nim create mode 100644 redesign/profile.nim diff --git a/forum.nim b/forum.nim index 581510e..716f060 100644 --- a/forum.nim +++ b/forum.nim @@ -9,12 +9,12 @@ import os, strutils, times, md5, strtabs, math, db_sqlite, scgi, jester, asyncdispatch, asyncnet, cache, sequtils, - parseutils, utils, random, rst, ranks, recaptcha, json, re, sugar + parseutils, utils, random, rst, recaptcha, json, re, sugar import cgi except setCookie import options import redesign/threadlist except User -import redesign/[category, postlist, error, header, post] +import redesign/[category, postlist, error, header, post, profile] when not defined(windows): import bcrypt # TODO @@ -1016,17 +1016,17 @@ template createTFD() = #[ 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 + lastOnline: userRow[2].parseInt, + rank: parseEnum[Rank](userRow[3]) ) proc selectPost(postRow: seq[string], skippedPosts: seq[int]): Post = return Post( id: postRow[0].parseInt, - author: selectUser(@[postRow[4], postRow[5], postRow[6]]), + author: selectUser(@[postRow[4], postRow[5], postRow[6], postRow[7]]), likes: @[], # TODO: seen: false, # TODO: history: @[], # TODO: @@ -1043,7 +1043,7 @@ proc selectThread(threadRow: seq[string]): Thread = where thread = ? order by creation asc limit 1;""" const usersListQuery = - sql"""select distinct name, email, strftime('%s', lastOnline) + sql"""select distinct name, email, strftime('%s', lastOnline), status from person where id in (select author from post where thread = ?) limit 5;""" # TODO: Order by most posts. @@ -1158,7 +1158,7 @@ routes: let postsQuery = sql( """select p.id, p.content, strftime('%s', p.creation), p.author, - u.name, u.email, strftime('%s', u.lastOnline) + u.name, u.email, strftime('%s', u.lastOnline), u.status from post p, person u where u.id = p.author and p.thread = ? and $# and (u.status <> 'Spammer' or p.author = ?) @@ -1196,7 +1196,7 @@ routes: let intIDs = ids.elems.map(x => x.getInt()) let postsQuery = sql(""" select p.id, p.content, strftime('%s', p.creation), p.author, - u.name, u.email, strftime('%s', u.lastOnline) + u.name, u.email, strftime('%s', u.lastOnline), u.status from post p, person u where u.id = p.author and p.id in ($#) order by p.id; @@ -1209,6 +1209,51 @@ routes: resp $(%list), "application/json" + get "/karax/profile.json": + createTFD() + var + username = @"username" + + let postsQuery = sql(""" + select p.id, p.content, strftime('%s', p.creation), p.author, + u.name, u.email, strftime('%s', u.lastOnline), u.status + from post p, person u + where u.id = p.author and u.name = ? + order by p.id desc; + """) + + var profile = Profile( + threads: @[], + posts: @[] + ) + + let rows = db.getAllRows(postsQuery, username) + profile.user = selectUser(@[ + rows[0][4], rows[0][5], rows[0][6], rows[0][7] + ]) + + if c.rank >= Admin: + profile.email = some(rows[0][5]) + + for row in rows: + let firstPostForThread = getRow(db, + sql"""select t.id, t.name, t.views, t.modified, p.id + from thread t, post p + where p.thread = t.id + order by p.id limit 1""", row[0]) + + # Check if the thread that contains this post was created by the user. + if firstPostForThread[4] == row[0]: + profile.threads.add( + selectThread(firstPostForThread) + ) + + profile.posts.add( + selectPost(row, @[]) + ) + + resp $(%profile), "application/json" + post "/karax/login": createTFD() let formData = request.formData @@ -1232,7 +1277,8 @@ routes: some(threadlist.User( name: c.username, avatarUrl: c.email.getGravatarUrl(), - isOnline: true + lastOnline: getTime().toUnix(), + rank: c.rank )) else: none[threadlist.User]() diff --git a/ranks.nim b/ranks.nim deleted file mode 100644 index 3b518f4..0000000 --- a/ranks.nim +++ /dev/null @@ -1,11 +0,0 @@ - -type - Rank* = enum ## serialized as 'status' - Spammer ## spammer: every post is invisible - Troll ## troll: cannot write new posts - EmailUnconfirmed ## member with unconfirmed email address - Moderated ## new member: posts manually reviewed before everybody - ## can see them - User ## Ordinary user - Moderator ## Moderator: can ban/moderate users - Admin ## Admin: can do everything diff --git a/redesign/profile.nim b/redesign/profile.nim new file mode 100644 index 0000000..b2fc8c7 --- /dev/null +++ b/redesign/profile.nim @@ -0,0 +1,11 @@ +import options +import threadlist, post +type + Profile* = object + user*: User + joinTime*: int64 + threads*: seq[Thread] + posts*: seq[Post] + # Information that only admins should see. + email*: Option[string] + diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 224069e..35b7d58 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -3,10 +3,21 @@ import strformat, times, options, json, httpcore, sugar import category type + Rank* {.pure.} = enum ## serialized as 'status' + Spammer ## spammer: every post is invisible + Troll ## troll: cannot write new posts + EmailUnconfirmed ## member with unconfirmed email address + Moderated ## new member: posts manually reviewed before everybody + ## can see them + User ## Ordinary user + Moderator ## Moderator: can ban/moderate users + Admin ## Admin: can do everything + User* = object name*: string avatarUrl*: string - isOnline*: bool + lastOnline*: int64 + rank*: Rank Thread* = object id*: int @@ -25,6 +36,9 @@ type lastVisit*: int64 ## Unix timestamp moreCount*: int ## How many more threads are left +proc isOnline*(user: User): bool = + return getTime().toUnix() - user.lastOnline > (60*5) + when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] From 2dd9dd52a2cafc5240a15e773e2785d8800e2abe Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 14:14:37 +0100 Subject: [PATCH 109/396] Implements simple stats in profile view. --- forum.nim | 10 +++--- redesign/forum.nim | 12 +++++-- redesign/nimforum.scss | 52 ++++++++++++++++++++++++++++++- redesign/profile.nim | 71 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/forum.nim b/forum.nim index 716f060..49c85bb 100644 --- a/forum.nim +++ b/forum.nim @@ -1015,10 +1015,10 @@ template createTFD() = #[ DB functions. TODO: Move to another module? ]# -proc selectUser(userRow: seq[string]): threadlist.User = +proc selectUser(userRow: seq[string], avatarSize: int=80): threadlist.User = return threadlist.User( name: userRow[0], - avatarUrl: userRow[1].getGravatarUrl(), + avatarUrl: userRow[1].getGravatarUrl(avatarSize), lastOnline: userRow[2].parseInt, rank: parseEnum[Rank](userRow[3]) ) @@ -1216,7 +1216,8 @@ routes: let postsQuery = sql(""" select p.id, p.content, strftime('%s', p.creation), p.author, - u.name, u.email, strftime('%s', u.lastOnline), u.status + u.name, u.email, strftime('%s', u.lastOnline), u.status, + strftime('%s', u.creation) from post p, person u where u.id = p.author and u.name = ? order by p.id desc; @@ -1230,7 +1231,8 @@ routes: let rows = db.getAllRows(postsQuery, username) profile.user = selectUser(@[ rows[0][4], rows[0][5], rows[0][6], rows[0][7] - ]) + ], avatarSize=200) + profile.joinTime = rows[0][8].parseInt() if c.rank >= Admin: profile.email = some(rows[0][5]) diff --git a/redesign/forum.nim b/redesign/forum.nim index 686fc2a..9de7c75 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -4,16 +4,18 @@ from dom import window, Location include karax/prelude import jester/patterns -import threadlist, postlist, header +import threadlist, postlist, header, profile import karaxutils type State = ref object url: Location + profile: ProfileState proc newState(): State = State( - url: window.location + url: window.location, + profile: newProfileState() ) var state = newState() @@ -44,7 +46,11 @@ proc render(): VNode = result = buildHtml(tdiv()): renderHeader() route([ - r("/t/@id?", + r("/profile/@username", + (params: Params) => + (render(state.profile, params["username"])) + ), + r("/t/@id", (params: Params) => (renderPostList(params["id"].parseInt(), isLoggedIn())) ), diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 84ee7f9..dcab208 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -449,4 +449,54 @@ hr { border: 0; } -@import "syntax.scss"; \ No newline at end of file +@import "syntax.scss"; + +// - Profile view + +.profile { + @extend .tile; + margin-top: $control-padding-y*5; +} + +.profile-icon { + @extend .tile-icon; +} + +.profile-avatar { + @extend .avatar; + @extend .avatar-xl; + + height: 8.2rem; + width: 8.2rem; +} + +.profile-content { + @extend .tile-content; + padding: $control-padding-x $control-padding-y; +} + +.profile-title { + @extend .tile-title; +} + +.profile-stats { + dl { + border-top: 1px solid $border-color; + border-bottom: 1px solid $border-color; + } + + dt { + font-weight: normal; + color: lighten($dark-color, 15%); + } + + dt, dd { + display: inline-block; + margin: 0; + margin-right: $control-padding-x; + } + + dd { + margin-right: $control-padding-x-lg; + } +} \ No newline at end of file diff --git a/redesign/profile.nim b/redesign/profile.nim index b2fc8c7..67cffee 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -1,5 +1,6 @@ -import options -import threadlist, post +import options, httpcore, json, sugar + +import threadlist, post, category, error type Profile* = object user*: User @@ -9,3 +10,69 @@ type # Information that only admins should see. email*: Option[string] +when defined(js): + include karax/prelude + import karax/[kajax] + import karaxutils + + type + ProfileState* = ref object + profile: Option[Profile] + loading: bool + status: HttpCode + + proc newProfileState*(): ProfileState = + ProfileState( + loading: false, + status: Http200 + ) + + proc onProfile(httpStatus: int, response: kstring, state: ProfileState) = + # TODO: Try to abstract these. + state.loading = false + state.status = httpStatus.HttpCode + if state.status != Http200: return + + let parsed = parseJson($response) + let profile = to(parsed, Profile) + + state.profile = some(profile) + + proc render*(state: ProfileState, username: string): VNode = + if state.status != Http200: + return renderError("Couldn't retrieve profile.") + + if state.profile.isNone: + let uri = makeUri("profile.json", ("username", username)) + ajaxGet(uri, @[], (s: int, r: kstring) => onProfile(s, r, state)) + + return buildHtml(tdiv(class="loading loading-lg")) + + let profile = state.profile.get() + result = buildHtml(): + section(class="container grid-xl"): + tdiv(class="profile"): + tdiv(class="profile-icon"): + render(profile.user, "profile-avatar") + tdiv(class="profile-content"): + h2(class="profile-title"): + text profile.user.name + + tdiv(class="profile-stats"): + dl(): + dt(text "Joined") + dd(text threadlist.renderActivity(profile.joinTime)) + dt(text "Last Post") + dd(text renderActivity(profile.posts[0].info.creation)) + dt(text "Last Online") + dd(text renderActivity(profile.user.lastOnline)) + dt(text "Rank") + dd(text $profile.user.rank) + + tdiv(class="columns"): + tdiv(class="column col-6"): + h4(text "Latest Posts") + tdiv(class="column col-6"): + h4(text "Latest Threads") + + From 30721d53d623c365df430eb8fc40321af49c935a Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 15:52:01 +0100 Subject: [PATCH 110/396] Implements posts and threads list in profile page. --- forum.nim | 77 ++++++++++++++++++++++++++++++------------ redesign/nimforum.scss | 14 +++++++- redesign/post.nim | 14 +++++++- redesign/profile.nim | 42 ++++++++++++++++++++--- 4 files changed, 119 insertions(+), 28 deletions(-) diff --git a/forum.nim b/forum.nim index 49c85bb..8cd3311 100644 --- a/forum.nim +++ b/forum.nim @@ -1214,14 +1214,33 @@ routes: var username = @"username" + # Have to do this because SQLITE doesn't support `in` queries with + # multiple columns :/ + # TODO: Figure out a better way. This is horrible. + let creatorSubquery = """ + (select $1 from post p + where p.thread = t.id + order by p.id asc limit 1) + """ + + let threadsFrom = """ + from thread t, post p + where ? in $1 and p.id in $2 + """ % [creatorSubquery % "author", creatorSubquery % "id"] + + let postsFrom = """ + from post p, person u, thread t + where u.id = p.author and p.thread = t.id and u.name = ? + """ + let postsQuery = sql(""" - select p.id, p.content, strftime('%s', p.creation), p.author, + select p.id, strftime('%s', p.creation), u.name, u.email, strftime('%s', u.lastOnline), u.status, - strftime('%s', u.creation) - from post p, person u - where u.id = p.author and u.name = ? - order by p.id desc; - """) + strftime('%s', u.creation), u.id, + t.name, t.id + $1 + order by p.id desc limit 10; + """ % postsFrom) var profile = Profile( threads: @[], @@ -1229,29 +1248,43 @@ routes: ) let rows = db.getAllRows(postsQuery, username) + let userID = rows[0][7] profile.user = selectUser(@[ - rows[0][4], rows[0][5], rows[0][6], rows[0][7] + rows[0][2], rows[0][3], rows[0][4], rows[0][5] ], avatarSize=200) - profile.joinTime = rows[0][8].parseInt() + profile.joinTime = rows[0][7].parseInt() + profile.postCount = + getValue(db, sql("select count(*) " & postsFrom), username).parseInt() + profile.threadCount = + getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt() if c.rank >= Admin: - profile.email = some(rows[0][5]) + profile.email = some(rows[0][3]) for row in rows: - let firstPostForThread = getRow(db, - sql"""select t.id, t.name, t.views, t.modified, p.id - from thread t, post p - where p.thread = t.id - order by p.id limit 1""", row[0]) - - # Check if the thread that contains this post was created by the user. - if firstPostForThread[4] == row[0]: - profile.threads.add( - selectThread(firstPostForThread) - ) - profile.posts.add( - selectPost(row, @[]) + PostLink( + creation: row[1].parseInt(), + topic: row[8], + threadId: row[9].parseInt(), + postId: row[0].parseInt() + ) + ) + + let threadsQuery = sql(""" + select t.id, t.name, strftime('%s', p.creation), p.id + $1 + order by t.id desc + limit 10; + """ % threadsFrom) + for row in db.getAllRows(threadsQuery, userID): + profile.threads.add( + PostLink( + creation: row[2].parseInt(), + topic: row[1], + threadId: row[0].parseInt(), + postId: row[3].parseInt() + ) ) resp $(%profile), "application/json" diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index dcab208..c617c52 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -499,4 +499,16 @@ hr { dd { margin-right: $control-padding-x-lg; } -} \ No newline at end of file +} + +.profile-post { + @extend .post; + + .profile-post-main { + flex: 1; + } + + .profile-post-time { + float: right; + } +} diff --git a/redesign/post.nim b/redesign/post.nim index 9ddbbb7..6a6f10d 100644 --- a/redesign/post.nim +++ b/redesign/post.nim @@ -21,7 +21,19 @@ type info*: PostInfo moreBefore*: seq[int] + PostLink* = object ## Used by profile + creation*: int64 + topic*: string + threadId*: int + postId*: int + when defined(js): import karaxutils + proc renderPostUrl*(threadId, postId: int): string = + makeUri(fmt"/t/{threadId}#{postId}") + proc renderPostUrl*(post: Post, thread: Thread): string = - makeUri(fmt"/t/{thread.id}#{post.id}") \ No newline at end of file + renderPostUrl(thread.id, post.id) + + proc renderPostUrl*(link: PostLink): string = + renderPostUrl(link.threadId, link.postId) \ No newline at end of file diff --git a/redesign/profile.nim b/redesign/profile.nim index 67cffee..91f3a96 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -1,12 +1,14 @@ -import options, httpcore, json, sugar +import options, httpcore, json, sugar, times import threadlist, post, category, error type Profile* = object user*: User joinTime*: int64 - threads*: seq[Thread] - posts*: seq[Post] + threads*: seq[PostLink] + posts*: seq[PostLink] + postCount*: int + threadCount*: int # Information that only admins should see. email*: Option[string] @@ -38,6 +40,20 @@ when defined(js): state.profile = some(profile) + proc genPostLink(link: PostLink): VNode = + let url = renderPostUrl(link) + result = buildHtml(): + tdiv(class="profile-post"): + tdiv(class="profile-post-main"): + tdiv(class="profile-post-title"): + a(href=url): + text link.topic + tdiv(class="profile-post-time"): + let title = link.creation.fromUnix().local. + format("MMM d, yyyy HH:mm") + p(title=title): + text renderActivity(link.creation) + proc render*(state: ProfileState, username: string): VNode = if state.status != Http200: return renderError("Couldn't retrieve profile.") @@ -63,16 +79,34 @@ when defined(js): dt(text "Joined") dd(text threadlist.renderActivity(profile.joinTime)) dt(text "Last Post") - dd(text renderActivity(profile.posts[0].info.creation)) + dd(text renderActivity(profile.posts[0].creation)) dt(text "Last Online") dd(text renderActivity(profile.user.lastOnline)) + dt(text "Posts") + dd(): + if profile.postCount > 999: + text $(profile.postCount / 1000) & "k" + else: + text $profile.postCount + dt(text "Threads") + dd(): + if profile.threadCount > 999: + text $(profile.threadCount / 1000) & "k" + else: + text $profile.threadCount dt(text "Rank") dd(text $profile.user.rank) tdiv(class="columns"): tdiv(class="column col-6"): h4(text "Latest Posts") + tdiv(class="posts"): + for post in profile.posts: + genPostLink(post) tdiv(class="column col-6"): h4(text "Latest Threads") + tdiv(class="posts"): + for thread in profile.threads: + genPostLink(thread) From 87d94397e594286c624d231ec8ee5697551cf8f5 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 16:06:02 +0100 Subject: [PATCH 111/396] Show user emails to Admins only under spoiler class. --- redesign/nimforum.scss | 12 ++++++++++++ redesign/profile.nim | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index c617c52..2c485cf 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -512,3 +512,15 @@ hr { float: right; } } + +.spoiler { + text-shadow: gray 0px 0px 15px; + color: transparent; + -moz-user-select: none; + user-select: none; + cursor: normal; + + &:hover, &:focus { + text-shadow: $body-font-color 0px 0px 0px; + } +} \ No newline at end of file diff --git a/redesign/profile.nim b/redesign/profile.nim index 91f3a96..0ac7b72 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -96,6 +96,10 @@ when defined(js): text $profile.threadCount dt(text "Rank") dd(text $profile.user.rank) + if profile.email.isSome(): + dt(text "Email") + dd(class="spoiler"): + text profile.email.get() tdiv(class="columns"): tdiv(class="column col-6"): From 091d21b50f7e873ff081f1eb96112780ea86fcfc Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 16:27:22 +0100 Subject: [PATCH 112/396] Add links to all user avatars and refactor user things into user module. --- forum.nim | 20 ++++++++++---------- redesign/header.nim | 2 +- redesign/karaxutils.nim | 10 ++++++++-- redesign/post.nim | 5 +---- redesign/postlist.nim | 2 +- redesign/profile.nim | 3 ++- redesign/replybox.nim | 2 +- redesign/threadlist.nim | 34 +--------------------------------- redesign/user.nim | 39 +++++++++++++++++++++++++++++++++++++++ redesign/usermenu.nim | 2 +- 10 files changed, 65 insertions(+), 54 deletions(-) create mode 100644 redesign/user.nim diff --git a/forum.nim b/forum.nim index 8cd3311..9db780e 100644 --- a/forum.nim +++ b/forum.nim @@ -14,7 +14,7 @@ import cgi except setCookie import options import redesign/threadlist except User -import redesign/[category, postlist, error, header, post, profile] +import redesign/[category, postlist, error, header, post, profile, user] when not defined(windows): import bcrypt # TODO @@ -394,7 +394,7 @@ proc getBanErrorMsg(banValue: string; rank: Rank): string = of Troll: return "You have been banned." of EmailUnconfirmed: return "You need to confirm your email first." - of Moderated, User, Moderator, Admin: + of Moderated, Rank.User, Moderator, Admin: return "" proc checkLoggedIn(c: TForumData) = @@ -638,7 +638,7 @@ proc reply(c: TForumData): bool = exec(db, sql"update thread set modified = DATETIME('now') where id = ?", $c.threadId) - if c.rank >= User: + if c.rank >= Rank.User: asyncCheck sendMailToMailingList(c.config, c.username, c.email, subject, content, threadId=c.threadId, postId=c.postID, is_reply=true, threadUrl=c.makeThreadURL()) @@ -661,7 +661,7 @@ proc newThread(c: TForumData): bool = writeToDb(c, crCreate, false) discard tryExec(db, sql"insert into post_fts(post_fts) values('optimize')") discard tryExec(db, sql"insert into post_fts(thread_fts) values('optimize')") - if c.rank >= User: + if c.rank >= Rank.User: asyncCheck sendMailToMailingList(c.config, c.username, c.email, subject, content, threadId=c.threadID, postId=c.postID, is_reply=false, threadUrl=c.makeThreadURL()) @@ -1015,8 +1015,8 @@ template createTFD() = #[ DB functions. TODO: Move to another module? ]# -proc selectUser(userRow: seq[string], avatarSize: int=80): threadlist.User = - return threadlist.User( +proc selectUser(userRow: seq[string], avatarSize: int=80): User = + return User( name: userRow[0], avatarUrl: userRow[1].getGravatarUrl(avatarSize), lastOnline: userRow[2].parseInt, @@ -1252,7 +1252,7 @@ routes: profile.user = selectUser(@[ rows[0][2], rows[0][3], rows[0][4], rows[0][5] ], avatarSize=200) - profile.joinTime = rows[0][7].parseInt() + profile.joinTime = rows[0][6].parseInt() profile.postCount = getValue(db, sql("select count(*) " & postsFrom), username).parseInt() profile.threadCount = @@ -1307,16 +1307,16 @@ routes: let user = if @"logout" == "true": - logout(c); none[threadlist.User]() + logout(c); none[User]() elif c.loggedIn(): - some(threadlist.User( + some(User( name: c.username, avatarUrl: c.email.getGravatarUrl(), lastOnline: getTime().toUnix(), rank: c.rank )) else: - none[threadlist.User]() + none[User]() let status = UserStatus(user: user) resp $(%status), "application/json" diff --git a/redesign/header.nim b/redesign/header.nim index b049808..d594431 100644 --- a/redesign/header.nim +++ b/redesign/header.nim @@ -1,6 +1,6 @@ import options, times, httpcore, json, sugar -import threadlist +import threadlist, user type UserStatus* = object user*: Option[User] diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index 8f6334f..e783b4d 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -1,4 +1,4 @@ -import strutils, options +import strutils, options, strformat import dom except window include karax/prelude @@ -63,4 +63,10 @@ proc newFormData*(form: dom.Element): FormData proc get*(form: FormData, key: cstring): cstring {.importcpp: "#.get(@)".} proc append*(form: FormData, key, val: cstring) - {.importcpp: "#.append(@)".} \ No newline at end of file + {.importcpp: "#.append(@)".} + +proc renderProfileUrl*(username: string): string = + makeUri(fmt"/profile/{username}") + +proc renderPostUrl*(threadId, postId: int): string = + makeUri(fmt"/t/{threadId}#{postId}") \ No newline at end of file diff --git a/redesign/post.nim b/redesign/post.nim index 6a6f10d..1e70da4 100644 --- a/redesign/post.nim +++ b/redesign/post.nim @@ -1,7 +1,6 @@ import strformat -import threadlist - +import user, threadlist type PostInfo* = object @@ -29,8 +28,6 @@ type when defined(js): import karaxutils - proc renderPostUrl*(threadId, postId: int): string = - makeUri(fmt"/t/{threadId}#{postId}") proc renderPostUrl*(post: Post, thread: Thread): string = renderPostUrl(thread.id, post.id) diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 2b667ea..1b7cc1b 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -1,7 +1,7 @@ import options, json, times, httpcore, strformat, sugar, math -import threadlist, category, post +import threadlist, category, post, user type PostList* = ref object diff --git a/redesign/profile.nim b/redesign/profile.nim index 0ac7b72..c0769dd 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -1,6 +1,7 @@ import options, httpcore, json, sugar, times -import threadlist, post, category, error +import threadlist, post, category, error, user + type Profile* = object user*: User diff --git a/redesign/replybox.nim b/redesign/replybox.nim index 04852f2..bbc40c6 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -6,7 +6,7 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, threadlist, post, error + import karaxutils, threadlist, post, error, user type ReplyBox* = ref object diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 35b7d58..385a0c0 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -1,24 +1,8 @@ import strformat, times, options, json, httpcore, sugar -import category +import category, user type - Rank* {.pure.} = enum ## serialized as 'status' - Spammer ## spammer: every post is invisible - Troll ## troll: cannot write new posts - EmailUnconfirmed ## member with unconfirmed email address - Moderated ## new member: posts manually reviewed before everybody - ## can see them - User ## Ordinary user - Moderator ## Moderator: can ban/moderate users - Admin ## Admin: can do everything - - User* = object - name*: string - avatarUrl*: string - lastOnline*: int64 - rank*: Rank - Thread* = object id*: int topic*: string @@ -36,9 +20,6 @@ type lastVisit*: int64 ## Unix timestamp moreCount*: int ## How many more threads are left -proc isOnline*(user: User): bool = - return getTime().toUnix() - user.lastOnline > (60*5) - when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] @@ -77,19 +58,6 @@ 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 renderUserMention*(user: User): VNode = - result = buildHtml(): - # TODO: Add URL to profile. - span(class="user-mention"): - text "@" & user.name - proc genUserAvatars(users: seq[User]): VNode = result = buildHtml(td): for user in users: diff --git a/redesign/user.nim b/redesign/user.nim new file mode 100644 index 0000000..480599a --- /dev/null +++ b/redesign/user.nim @@ -0,0 +1,39 @@ +import times + +type + Rank* {.pure.} = enum ## serialized as 'status' + Spammer ## spammer: every post is invisible + Troll ## troll: cannot write new posts + EmailUnconfirmed ## member with unconfirmed email address + Moderated ## new member: posts manually reviewed before everybody + ## can see them + User ## Ordinary user + Moderator ## Moderator: can ban/moderate users + Admin ## Admin: can do everything + + User* = object + name*: string + avatarUrl*: string + lastOnline*: int64 + rank*: Rank + +proc isOnline*(user: User): bool = + return getTime().toUnix() - user.lastOnline > (60*5) + +when defined(js): + include karax/prelude + import karaxutils + + proc render*(user: User, class: string): VNode = + result = buildHtml(): + a(href=renderProfileUrl(user.name), onClick=anchorCB): + figure(class=class): + img(src=user.avatarUrl, title=user.name) + if user.isOnline: + italic(class="avatar-presense online") + + proc renderUserMention*(user: User): VNode = + result = buildHtml(): + # TODO: Add URL to profile. + span(class="user-mention"): + text "@" & user.name \ No newline at end of file diff --git a/redesign/usermenu.nim b/redesign/usermenu.nim index 6d0d8c7..65ea45c 100644 --- a/redesign/usermenu.nim +++ b/redesign/usermenu.nim @@ -6,7 +6,7 @@ when defined(js): import karax/[vstyles] import karaxutils - import threadlist + import user type UserMenu* = ref object shown: bool From 9ce1ad94c70b0d8be98b103164bf96a199fe19a6 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 16:30:08 +0100 Subject: [PATCH 113/396] Fixes profile view not reloading on new username. --- redesign/profile.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redesign/profile.nim b/redesign/profile.nim index c0769dd..7c78249 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -59,7 +59,7 @@ when defined(js): if state.status != Http200: return renderError("Couldn't retrieve profile.") - if state.profile.isNone: + if state.profile.isNone or state.profile.get().user.name != username: let uri = makeUri("profile.json", ("username", username)) ajaxGet(uri, @[], (s: int, r: kstring) => onProfile(s, r, state)) From b30bedd65ec189873b55c5fdeecdc81a12ef15b4 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 18:17:46 +0100 Subject: [PATCH 114/396] Improves time passed messages. --- redesign/postlist.nim | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 1b7cc1b..314ddfa 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -1,5 +1,5 @@ -import options, json, times, httpcore, strformat, sugar, math +import options, json, times, httpcore, strformat, sugar, math, strutils import threadlist, category, post, user type @@ -148,22 +148,36 @@ when defined(js): italic(class="fas fa-reply") text " Reply" - proc genTimePassed(prevPost: Post, post: Option[Post]): VNode = + proc genTimePassed(prevPost: Post, post: Option[Post], last: bool): VNode = var latestTime = if post.isSome: post.get().info.creation.fromUnix() else: getTime() # TODO: Use `between` once it's merged into stdlib. - var diffStr = "Some time later" + let + tmpl = + if last: [ + "A long time since last reply", + "$1 year since last reply", + "$1 years since last reply", + "$1 month since last reply", + "$1 months since last reply", + ] + else: [ + "Some time later", + "$1 year later", "$1 years later", + "$1 month later", "$1 months later" + ] + var diffStr = tmpl[0] let diff = latestTime - prevPost.info.creation.fromUnix() if diff.weeks > 48: let years = diff.weeks div 48 - diffStr = $years - diffStr.add(if years == 1: " year later" else: " years later") + diffStr = + (if years == 1: tmpl[1] else: tmpl[2]) % $years elif diff.weeks > 4: let months = diff.weeks div 4 - diffStr = $months - diffStr.add(if months == 1: " month later" else: " months later") + diffStr = + (if months == 1: tmpl[3] else: tmpl[4]) % $months else: return buildHtml(tdiv()) @@ -196,13 +210,13 @@ when defined(js): var prevPost: Option[Post] = none[Post]() for i, post in list.posts: if prevPost.isSome: - genTimePassed(prevPost.get(), some(post)) + genTimePassed(prevPost.get(), some(post), false) if post.moreBefore.len > 0: genLoadMore(post, i) genPost(post, list.thread, isLoggedIn) prevPost = some(post) if prevPost.isSome: - genTimePassed(prevPost.get(), none[Post]()) + genTimePassed(prevPost.get(), none[Post](), true) render(state.replyBox, list.thread, state.replyingTo, false) \ No newline at end of file From 35930799a97b758e335f6ab8b06c8944f95aafc0 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 18:35:19 +0100 Subject: [PATCH 115/396] Adds "New Thread" button. --- redesign/nimforum.scss | 11 ++++++++++- redesign/threadlist.nim | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 2c485cf..fff7094 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -95,12 +95,21 @@ $logo-height: $navbar-height - 20px; margin-top: $control-padding-y*2; margin-bottom: $control-padding-y*2; - .dropdown > .btn { + .dropdown > .btn, .btn-secondary { background: $secondary-btn-color; border-color: darken($secondary-btn-color, 5%); color: invert($secondary-btn-color); margin-right: $control-padding-x*2; + + &:hover, &:focus { + background: darken($secondary-btn-color, 5%); + border-color: darken($secondary-btn-color, 10%); + } + + &:focus { + @include control-shadow(darken($secondary-btn-color, 40%)); + } } } diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 385a0c0..7095a53 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -56,7 +56,10 @@ when defined(js): button(class="btn btn-primary"): text "Latest" button(class="btn btn-link"): text "Most Active" button(class="btn btn-link"): text "Categories" - section(class="navbar-section") + section(class="navbar-section"): + button(class="btn btn-secondary"): + italic(class="fas fa-plus") + text " New Thread" proc genUserAvatars(users: seq[User]): VNode = result = buildHtml(td): From c966ec8f92ee94b3e18043a8a517a0984dadefe9 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 19:40:07 +0100 Subject: [PATCH 116/396] Implements new thread modal. --- redesign/newthread.nim | 81 +++++++++++++++++++++++++++++++++++ redesign/nimforum.scss | 17 ++++++++ redesign/replybox.nim | 94 +++++++++++++++++++++++------------------ redesign/threadlist.nim | 16 +++++-- 4 files changed, 163 insertions(+), 45 deletions(-) create mode 100644 redesign/newthread.nim diff --git a/redesign/newthread.nim b/redesign/newthread.nim new file mode 100644 index 0000000..0b3751e --- /dev/null +++ b/redesign/newthread.nim @@ -0,0 +1,81 @@ +when defined(js): + import sugar, httpcore, options, json + import dom except Event + + include karax/prelude + import karax / [kajax, kdom] + + import error, replybox, threadlist, post + import karaxutils + + type + NewThreadModal* = ref object + shown: bool + loading: bool + onNewThread: proc (threadId, postId: int) + error: Option[PostError] + replyBox: ReplyBox + + proc onCreatePost(httpStatus: int, response: kstring, state: NewThreadModal) = + postFinished: + state.shown = false + state.onNewThread(0, 0) # TODO + + proc onCreateClick(ev: Event, n: VNode, state: NewThreadModal) = + state.loading = true + state.error = none[PostError]() + + let uri = makeUri("login") + # TODO: This is a hack, karax should support this. + let formData = newFormData() + #formData.append("" TODO + ajaxPost(uri, @[], cast[cstring](formData), + (s: int, r: kstring) => onCreatePost(s, r, state)) + + proc onClose(ev: Event, n: VNode, state: NewThreadModal) = + state.shown = false + ev.preventDefault() + + proc newNewThreadModal*( + onNewThread: proc (threadId, postId: int) + ): NewThreadModal = + NewThreadModal( + shown: false, + onNewThread: onNewThread, + replyBox: newReplyBox(nil) + ) + + proc show*(state: NewThreadModal) = + state.shown = true + + proc render*(state: NewThreadModal): VNode = + result = buildHtml(): + tdiv(class=class({"active": state.shown}, "modal modal-lg"), + id="new-thread-modal"): + a(href="", class="modal-overlay", "aria-label"="close", + onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) + tdiv(class="modal-container"): + tdiv(class="modal-header"): + a(href="", class="btn btn-clear float-right", + "aria-label"="close", + onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) + tdiv(class="modal-title h5"): + text "New Thread" + tdiv(class="modal-body"): + tdiv(class="content"): + input(class="form-input", `type`="text", name="username", + placeholder="Type the title here") + renderContent(state.replyBox, none[Thread](), none[Post]()) + + tdiv(class="modal-footer"): + button(class="btn", + onClick=(ev: Event, n: VNode) => + onClose(ev, n, state)): + text "Cancel" + button(class=class( + {"loading": state.loading}, + "btn btn-primary" + ), + onClick=(ev: Event, n: VNode) => + (onCreateClick(ev, n, state))): + text "Create thread" \ No newline at end of file diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index fff7094..b15b579 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -113,6 +113,23 @@ $logo-height: $navbar-height - 20px; } } +#new-thread-modal { + .modal-container .modal-body { + max-height: none; + } + + .form-input { + margin-bottom: $control-padding-y*2; + } + + textarea.form-input, .panel-body > div { + margin-top: $control-padding-y*2; + resize: none; + min-height: 40vh; + max-height: 45vh; + } +} + // - Thread table .thread-title { a, a:visited, a:hover { diff --git a/redesign/replybox.nim b/redesign/replybox.nim index bbc40c6..7b9d235 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -48,6 +48,7 @@ when defined(js): state.preview = true state.loading = true state.error = none[PostError]() + state.rendering = none[kstring]() let formData = newFormData() formData.append("msg", state.text) @@ -55,6 +56,10 @@ when defined(js): ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onPreviewPost(s, r, state)) + proc onMessageClick(e: Event, n: VNode, state: ReplyBox) = + state.preview = false + state.error = none[PostError]() + proc onReplyPost(httpStatus: int, response: kstring, state: ReplyBox) = postFinished: state.text = "" @@ -84,6 +89,53 @@ when defined(js): # `value` on the node? We need to document this better :) state.text = cast[dom.TextAreaElement](n.dom).value + proc renderContent*(state: ReplyBox, thread: Option[Thread], + post: Option[Post]): VNode = + result = buildHtml(): + tdiv(class="panel"): + tdiv(class="panel-nav"): + ul(class="tab tab-block"): + li(class=class({"active": not state.preview}, "tab-item"), + onClick=(e: Event, n: VNode) => + onMessageClick(e, n, state)): + a(class="c-hand"): + text "Message" + li(class=class({"active": state.preview}, "tab-item"), + onClick=(e: Event, n: VNode) => + onPreviewClick(e, n, state)): + a(class="c-hand"): + text "Preview" + tdiv(class="panel-body"): + if state.preview: + if state.loading: + tdiv(class="loading") + elif state.rendering.isSome(): + verbatim(state.rendering.get()) + else: + textarea(class="form-input", rows="5", + onChange=(e: Event, n: VNode) => + onChange(e, n, state), + value=state.text) + + if state.error.isSome(): + p(class="text-error", + style=style(StyleAttr.marginTop, "0.4rem")): + text state.error.get().message + + if thread.isSome: + tdiv(class="panel-footer"): + button(class=class( + {"loading": state.loading}, + "btn btn-primary float-right" + ), + onClick=(e: Event, n: VNode) => + onReplyClick(e, n, state, thread.get(), post)): + text "Reply" + button(class="btn btn-link float-right", + onClick=(e: Event, n: VNode) => + onCancelClick(e, n, state)): + text "Cancel" + proc render*(state: ReplyBox, thread: Thread, post: Option[Post], hasMore: bool): VNode = if not state.shown: @@ -106,44 +158,4 @@ when defined(js): button(class="btn"): italic(class="fas fa-arrow-up") tdiv(class="information-content"): - tdiv(class="panel"): - tdiv(class="panel-nav"): - ul(class="tab tab-block"): - li(class=class({"active": not state.preview}, "tab-item"), - onClick=(e: Event, n: VNode) => (state.preview = false)): - a(class="c-hand"): - text "Message" - li(class=class({"active": state.preview}, "tab-item"), - onClick=(e: Event, n: VNode) => - onPreviewClick(e, n, state)): - a(class="c-hand"): - text "Preview" - tdiv(class="panel-body"): - if state.preview: - if state.loading: - tdiv(class="loading") - elif state.rendering.isSome(): - verbatim(state.rendering.get()) - else: - textarea(class="form-input", rows="5", - onChange=(e: Event, n: VNode) => - onChange(e, n, state), - value=state.text) - - if state.error.isSome(): - tdiv(class="toast toast-error", - style=style(StyleAttr.marginTop, "0.4rem")): - text state.error.get().message - - tdiv(class="panel-footer"): - button(class=class( - {"loading": state.loading}, - "btn btn-primary float-right" - ), - onClick=(e: Event, n: VNode) => - onReplyClick(e, n, state, thread, post)): - text "Reply" - button(class="btn btn-link float-right", - onClick=(e: Event, n: VNode) => - onCancelClick(e, n, state)): - text "Cancel" \ No newline at end of file + renderContent(state, some(thread), post) \ No newline at end of file diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 7095a53..837c994 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -24,24 +24,30 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, error + import karaxutils, error, newthread type State = ref object list: Option[ThreadList] loading: bool status: HttpCode + newThread: NewThreadModal + proc onNewThread(threadId, postId: int) proc newState(): State = State( list: none[ThreadList](), loading: false, - status: Http200 + status: Http200, + newThread: newNewThreadModal(onNewThread) ) var state = newState() + proc onNewThread(threadId, postId: int) = + discard + proc genTopButtons(): VNode = result = buildHtml(): section(class="navbar container grid-xl", id="main-buttons"): @@ -57,7 +63,8 @@ when defined(js): button(class="btn btn-link"): text "Most Active" button(class="btn btn-link"): text "Categories" section(class="navbar-section"): - button(class="btn btn-secondary"): + button(class="btn btn-secondary", + onClick=(e: Event, n: VNode) => state.newThread.show()): italic(class="fas fa-plus") text " New Thread" @@ -174,4 +181,5 @@ when defined(js): proc renderThreadList*(): VNode = result = buildHtml(tdiv): genTopButtons() - genThreadList() \ No newline at end of file + genThreadList() + render(state.newThread) \ No newline at end of file From 76c7f43079b30d0e0a64e4c67daa51e2d1541e49 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 19:52:21 +0100 Subject: [PATCH 117/396] Create separate page for new thread modal. --- redesign/forum.nim | 10 +++++-- redesign/newthread.nim | 60 ++++++++++++----------------------------- redesign/nimforum.scss | 10 ++++--- redesign/threadlist.nim | 17 +++++------- 4 files changed, 39 insertions(+), 58 deletions(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index 9de7c75..8f216d4 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -4,18 +4,20 @@ from dom import window, Location include karax/prelude import jester/patterns -import threadlist, postlist, header, profile +import threadlist, postlist, header, profile, newthread import karaxutils type State = ref object url: Location profile: ProfileState + newThread: NewThread proc newState(): State = State( url: window.location, - profile: newProfileState() + profile: newProfileState(), + newThread: newNewThread() ) var state = newState() @@ -46,6 +48,10 @@ proc render(): VNode = result = buildHtml(tdiv()): renderHeader() route([ + r("/newthread", + (params: Params) => + (render(state.newThread)) + ), r("/profile/@username", (params: Params) => (render(state.profile, params["username"])) diff --git a/redesign/newthread.nim b/redesign/newthread.nim index 0b3751e..315caa4 100644 --- a/redesign/newthread.nim +++ b/redesign/newthread.nim @@ -9,19 +9,17 @@ when defined(js): import karaxutils type - NewThreadModal* = ref object - shown: bool + NewThread* = ref object loading: bool - onNewThread: proc (threadId, postId: int) error: Option[PostError] replyBox: ReplyBox - proc onCreatePost(httpStatus: int, response: kstring, state: NewThreadModal) = + proc onCreatePost(httpStatus: int, response: kstring, state: NewThread) = postFinished: - state.shown = false - state.onNewThread(0, 0) # TODO + # TODO + discard - proc onCreateClick(ev: Event, n: VNode, state: NewThreadModal) = + proc onCreateClick(ev: Event, n: VNode, state: NewThread) = state.loading = true state.error = none[PostError]() @@ -32,46 +30,22 @@ when defined(js): ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onCreatePost(s, r, state)) - proc onClose(ev: Event, n: VNode, state: NewThreadModal) = - state.shown = false - ev.preventDefault() - - proc newNewThreadModal*( - onNewThread: proc (threadId, postId: int) - ): NewThreadModal = - NewThreadModal( - shown: false, - onNewThread: onNewThread, + proc newNewThread*(): NewThread = + NewThread( replyBox: newReplyBox(nil) ) - proc show*(state: NewThreadModal) = - state.shown = true - - proc render*(state: NewThreadModal): VNode = + proc render*(state: NewThread): VNode = result = buildHtml(): - tdiv(class=class({"active": state.shown}, "modal modal-lg"), - id="new-thread-modal"): - a(href="", class="modal-overlay", "aria-label"="close", - onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) - tdiv(class="modal-container"): - tdiv(class="modal-header"): - a(href="", class="btn btn-clear float-right", - "aria-label"="close", - onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) - tdiv(class="modal-title h5"): - text "New Thread" - tdiv(class="modal-body"): - tdiv(class="content"): - input(class="form-input", `type`="text", name="username", - placeholder="Type the title here") - renderContent(state.replyBox, none[Thread](), none[Post]()) - - tdiv(class="modal-footer"): - button(class="btn", - onClick=(ev: Event, n: VNode) => - onClose(ev, n, state)): - text "Cancel" + section(class="container grid-xl"): + tdiv(id="new-thread"): + tdiv(class="title"): + p(): text "New Thread" + tdiv(class="content"): + input(class="form-input", `type`="text", name="username", + placeholder="Type the title here") + renderContent(state.replyBox, none[Thread](), none[Post]()) + tdiv(class="footer"): button(class=class( {"loading": state.loading}, "btn btn-primary" diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index b15b579..169e2b9 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -113,7 +113,7 @@ $logo-height: $navbar-height - 20px; } } -#new-thread-modal { +#new-thread { .modal-container .modal-body { max-height: none; } @@ -124,9 +124,13 @@ $logo-height: $navbar-height - 20px; textarea.form-input, .panel-body > div { margin-top: $control-padding-y*2; - resize: none; + resize: vertical; min-height: 40vh; - max-height: 45vh; + } + + .footer { + float: right; + margin-top: $control-padding-y*2; } } diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 837c994..0ef3aa2 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -24,22 +24,20 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, error, newthread + import karaxutils, error type State = ref object list: Option[ThreadList] loading: bool status: HttpCode - newThread: NewThreadModal proc onNewThread(threadId, postId: int) proc newState(): State = State( list: none[ThreadList](), loading: false, - status: Http200, - newThread: newNewThreadModal(onNewThread) + status: Http200 ) var @@ -63,10 +61,10 @@ when defined(js): button(class="btn btn-link"): text "Most Active" button(class="btn btn-link"): text "Categories" section(class="navbar-section"): - button(class="btn btn-secondary", - onClick=(e: Event, n: VNode) => state.newThread.show()): - italic(class="fas fa-plus") - text " New Thread" + a(href=makeUri("/newthread"), onClick=anchorCB): + button(class="btn btn-secondary"): + italic(class="fas fa-plus") + text " New Thread" proc genUserAvatars(users: seq[User]): VNode = result = buildHtml(td): @@ -181,5 +179,4 @@ when defined(js): proc renderThreadList*(): VNode = result = buildHtml(tdiv): genTopButtons() - genThreadList() - render(state.newThread) \ No newline at end of file + genThreadList() \ No newline at end of file From 702967f62433d886b6bc603ddbaaab9288fd1fcb Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 20:40:59 +0100 Subject: [PATCH 118/396] Implements newthread logic on backend and marries it all together. --- forum.nim | 68 +++++++++++++++++++++++++++++++++++++++--- redesign/newthread.nim | 28 ++++++++++------- redesign/replybox.nim | 2 ++ 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/forum.nim b/forum.nim index 9db780e..a15b6e8 100644 --- a/forum.nim +++ b/forum.nim @@ -77,6 +77,7 @@ type lastIp: string ForumError = object of Exception + data: PostError var db: DbConn @@ -85,6 +86,16 @@ var useCaptcha: bool captcha: ReCaptcha +proc newForumError(message: string, + fields: seq[string] = @[]): ref ForumError = + new(result) + result.msg = message + result.data = + PostError( + errorFields: fields, + message: message + ) + proc init(c: TForumData) = c.userPass = "" c.userName = "" @@ -1076,9 +1087,10 @@ proc executeReply(c: TForumData, threadId: int, content: string, let subject = "" # TODO: Remove this redundant field. if rateLimitCheck(c): - raise newException(ForumError, "You're posting too fast!") + raise newForumError("You're posting too fast!") # TODO: Replying to. + # Verify that content can be parsed as RST. let retID = insertID( db, crud(crCreate, "post", "author", "ip", "header", "content", "thread"), @@ -1095,6 +1107,35 @@ proc executeReply(c: TForumData, threadId: int, content: string, return retID +proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = + const + query = sql""" + insert into thread(name, views, modified) values (?, 0, DATETIME('now')) + """ + + assert c.loggedIn() + + if subject.len <= 2: + raise newForumError("Subject is too short", @["subject"]) + if subject.len > 100: + raise newForumError("Subject is too long", @["subject"]) + + if not validateRst(c, msg): + raise newForumError("Message needs to be valid RST", @["msg"]) + + if rateLimitCheck(c): + raise newForumError("You're posting too fast!") + + result[0] = tryInsertID(db, query, subject).int + if result[0] < 0: + raise newForumError("Subject already exists", @["subject"]) + + discard tryExec(db, crud(crCreate, "thread_fts", "id", "name"), + c.threadID, subject) + result[1] = executeReply(c, result[0].int, msg, -1) + discard tryExec(db, sql"insert into post_fts(post_fts) values('optimize')") + discard tryExec(db, sql"insert into post_fts(thread_fts) values('optimize')") + initialise() routes: @@ -1370,12 +1411,31 @@ routes: try: let id = executeReply(c, threadId, msg, replyingTo) resp Http200, $(%id), "application/json" - except ForumError: + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + + post "/karax/newthread": + createTFD() + if not c.loggedIn(): let err = PostError( errorFields: @[], - message: getCurrentExceptionMsg() + message: "Not logged in." ) - resp Http400, $(%err), "application/json" + resp Http401, $(%err), "application/json" + + let formData = request.formData + cond "msg" in formData + cond "subject" in formData + + let msg = formData["msg"].body + let subject = formData["subject"].body + # TODO: category + + try: + let res = executeNewThread(c, subject, msg) + resp Http200, $(%[res[0], res[1]]), "application/json" + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" get re"/karax/(.+)?": resp readFile("redesign/karax.html") diff --git a/redesign/newthread.nim b/redesign/newthread.nim index 315caa4..c81f3cf 100644 --- a/redesign/newthread.nim +++ b/redesign/newthread.nim @@ -13,28 +13,35 @@ when defined(js): loading: bool error: Option[PostError] replyBox: ReplyBox + subject: kstring + + proc newNewThread*(): NewThread = + NewThread( + replyBox: newReplyBox(nil), + subject: "" + ) + + proc onSubjectChange(e: Event, n: VNode, state: NewThread) = + state.subject = n.value proc onCreatePost(httpStatus: int, response: kstring, state: NewThread) = postFinished: - # TODO - discard + let j = parseJson($response) + let response = to(j, array[2, int]) + navigateTo(renderPostUrl(response[0], response[1])) proc onCreateClick(ev: Event, n: VNode, state: NewThread) = state.loading = true state.error = none[PostError]() - let uri = makeUri("login") + let uri = makeUri("newthread") # TODO: This is a hack, karax should support this. let formData = newFormData() - #formData.append("" TODO + formData.append("subject", state.subject) + formData.append("msg", state.replyBox.getText()) ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onCreatePost(s, r, state)) - proc newNewThread*(): NewThread = - NewThread( - replyBox: newReplyBox(nil) - ) - proc render*(state: NewThread): VNode = result = buildHtml(): section(class="container grid-xl"): @@ -43,7 +50,8 @@ when defined(js): p(): text "New Thread" tdiv(class="content"): input(class="form-input", `type`="text", name="username", - placeholder="Type the title here") + placeholder="Type the title here", + onChange=(e: Event, n: VNode) => onSubjectChange(e, n, state)) renderContent(state.replyBox, none[Thread](), none[Post]()) tdiv(class="footer"): button(class=class( diff --git a/redesign/replybox.nim b/redesign/replybox.nim index 7b9d235..99400f8 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -39,6 +39,8 @@ when defined(js): state.shown = true + proc getText*(state: ReplyBox): kstring = state.text + proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) = postFinished: kout(response) From f6e6929c25e97ed8907bfa73156b1e9d72029527 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 20:46:42 +0100 Subject: [PATCH 119/396] Show newthread error underneath subject. --- forum.nim | 3 +++ redesign/newthread.nim | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/forum.nim b/forum.nim index a15b6e8..8881498 100644 --- a/forum.nim +++ b/forum.nim @@ -1120,6 +1120,9 @@ proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = if subject.len > 100: raise newForumError("Subject is too long", @["subject"]) + if msg.len == 0: + raise newForumError("Message is empty", @["msg"]) + if not validateRst(c, msg): raise newForumError("Message needs to be valid RST", @["msg"]) diff --git a/redesign/newthread.nim b/redesign/newthread.nim index c81f3cf..8ce4623 100644 --- a/redesign/newthread.nim +++ b/redesign/newthread.nim @@ -52,8 +52,12 @@ when defined(js): input(class="form-input", `type`="text", name="username", placeholder="Type the title here", onChange=(e: Event, n: VNode) => onSubjectChange(e, n, state)) + if state.error.isSome(): + p(class="text-error"): + text state.error.get().message renderContent(state.replyBox, none[Thread](), none[Post]()) tdiv(class="footer"): + button(class=class( {"loading": state.loading}, "btn btn-primary" From bd150d04de85acf4c7f1ad39223d6bb8ccd19a3f Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 21:28:51 +0100 Subject: [PATCH 120/396] Improve users list query and get thread authors as well. --- forum.nim | 25 ++++++++++++++++++++----- redesign/threadlist.nim | 10 ++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/forum.nim b/forum.nim index 8881498..df5b5eb 100644 --- a/forum.nim +++ b/forum.nim @@ -1054,10 +1054,21 @@ proc selectThread(threadRow: seq[string]): Thread = where thread = ? order by creation asc limit 1;""" const usersListQuery = - sql"""select distinct name, email, strftime('%s', lastOnline), status - from person where id in - (select author from post where thread = ?) - limit 5;""" # TODO: Order by most posts. + sql""" + select name, email, strftime('%s', lastOnline), status, count(*) + from person u, post p where p.author = u.id and p.thread = ? + group by name order by count(*) desc limit 5; + """ + const authorQuery = + sql""" + select name, email, strftime('%s', lastOnline), status + from person where id in ( + select author from post + where thread = ? + order by id + limit 1 + ) + """ let posts = getRow(db, postsQuery, threadRow[0]) @@ -1071,13 +1082,17 @@ proc selectThread(threadRow: seq[string]): Thread = activity: threadRow[3].parseInt, creation: posts[1].parseInt, isLocked: false, # TODO: - isSolved: false # TODO: Add a field to `post` to identify the solution. + isSolved: false, # TODO: Add a field to `post` to identify the solution. + isDeleted: false # TODO: ) # Gather the users list. for user in getAllRows(db, usersListQuery, thread.id): thread.users.add(selectUser(user)) + # Grab the author. + thread.author = selectUser(getRow(db, authorQuery, thread.id)) + return thread proc executeReply(c: TForumData, threadId: int, content: string, diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 0ef3aa2..3895cab 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -7,6 +7,7 @@ type id*: int topic*: string category*: Category + author*: User users*: seq[User] replies*: int views*: int @@ -14,12 +15,17 @@ type creation*: int64 ## Unix timestamp isLocked*: bool isSolved*: bool + isDeleted*: bool ThreadList* = ref object threads*: seq[Thread] lastVisit*: int64 ## Unix timestamp moreCount*: int ## How many more threads are left +proc isInvisible*(thread: Thread): bool = + ## Determines whether the specified thread is under moderation. + thread.author.rank <= Moderated + when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] @@ -95,6 +101,10 @@ when defined(js): td(class="thread-title"): if thread.isLocked: italic(class="fas fa-lock fa-xs") + if thread.isInvisible: + italic(class="fas fa-eye-slash fa-xs") + if thread.isSolved: + italic(class="fas fa-check-square fa-xs") a(href=makeUri("/t/" & $thread.id), onClick=anchorCB): text thread.topic td(): render(thread.category) From bc104ff41da1c9d0a13e52eb935e92e0d6aed380 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Tue, 15 May 2018 22:13:53 +0100 Subject: [PATCH 121/396] Implements anchor awareness. --- redesign/forum.nim | 9 ++- redesign/karaxutils.nim | 131 +++++++++++++++++++++++----------------- redesign/postlist.nim | 26 ++++++-- utils.nim | 20 +----- 4 files changed, 107 insertions(+), 79 deletions(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index 8f216d4..1c08ec4 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -58,7 +58,14 @@ proc render(): VNode = ), r("/t/@id", (params: Params) => - (renderPostList(params["id"].parseInt(), isLoggedIn())) + ( + let postId = getInt(($state.url.hash).substr(1), 0); + renderPostList( + params["id"].parseInt(), + if postId == 0: none[int]() else: some[int](postId), + isLoggedIn() + ) + ) ), r("/", (params: Params) => renderThreadList()) ]) diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index e783b4d..eeb0e92 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -1,72 +1,91 @@ -import strutils, options, strformat +import strutils, options, strformat, parseutils import dom except window -include karax/prelude -import karax / [kdom] +proc parseInt*(s: string, value: var int, validRange: Slice[int]) {. + noSideEffect.} = + ## parses `s` into an integer in the range `validRange`. If successful, + ## `value` is modified to contain the result. Otherwise no exception is + ## raised and `value` is not touched; this way a reasonable default value + ## won't be overwritten. + var x = value + try: + discard parseutils.parseInt(s, x, 0) + except OverflowError: + discard + if x in validRange: value = x -const appName = "/karax/" +proc getInt*(s: string, default = 0): int = + ## Safely parses an int and returns it. + result = default + parseInt(s, result, 0..1_000_000_000) -proc class*(classes: varargs[tuple[name: string, present: bool]], - defaultClasses: string = ""): string = - result = defaultClasses & " " - for class in classes: - if class.present: result.add(class.name & " ") +when defined(js): + include karax/prelude + import karax / [kdom] -proc makeUri*(relative: string, appName=appName, includeHash=false): string = - ## Concatenates ``relative`` to the current URL in a way that is - ## (possibly) sane. - var relative = relative - assert appName in $window.location.pathname - if relative[0] == '/': relative = relative[1..^1] + const appName = "/karax/" - return $window.location.protocol & "//" & - $window.location.host & - appName & - relative & - $window.location.search & - (if includeHash: $window.location.hash else: "") + proc class*(classes: varargs[tuple[name: string, present: bool]], + defaultClasses: string = ""): string = + result = defaultClasses & " " + for class in classes: + if class.present: result.add(class.name & " ") -proc makeUri*(relative: string, params: varargs[(string, string)], - appName=appName, includeHash=false): string = - var query = "" - for i in 0 ..< params.len: - let param = params[i] - if i != 0: query.add("&") - query.add(param[0] & "=" & param[1]) + proc makeUri*(relative: string, appName=appName, includeHash=false): string = + ## Concatenates ``relative`` to the current URL in a way that is + ## (possibly) sane. + var relative = relative + assert appName in $window.location.pathname + if relative[0] == '/': relative = relative[1..^1] - if query.len > 0: - makeUri(relative & "?" & query, appName) - else: - makeUri(relative, appName) + return $window.location.protocol & "//" & + $window.location.host & + appName & + relative & + $window.location.search & + (if includeHash: $window.location.hash else: "") -proc navigateTo*(uri: cstring) = - # TODO: This was annoying. Karax also shouldn't have its own `window`. - dom.pushState(dom.window.history, 0, cstring"", uri) + proc makeUri*(relative: string, params: varargs[(string, string)], + appName=appName, includeHash=false): string = + var query = "" + for i in 0 ..< params.len: + let param = params[i] + if i != 0: query.add("&") + query.add(param[0] & "=" & param[1]) - # Fire the popState event. - dom.window.dispatchEvent(newEvent("popstate")) + if query.len > 0: + makeUri(relative & "?" & query, appName) + else: + makeUri(relative, appName) -proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb? - e.preventDefault() + proc navigateTo*(uri: cstring) = + # TODO: This was annoying. Karax also shouldn't have its own `window`. + dom.pushState(dom.window.history, 0, cstring"", uri) - # TODO: Why does Karax have it's own Node type? That's just silly. - let url = cast[dom.Node](n.dom).getAttribute(cstring"href") + # Fire the popState event. + dom.window.dispatchEvent(newEvent("popstate")) - navigateTo(url) + proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb? + e.preventDefault() -type - FormData* = ref object -proc newFormData*(): FormData - {.importcpp: "new FormData()", constructor.} -proc newFormData*(form: dom.Element): FormData - {.importcpp: "new FormData(@)", constructor.} -proc get*(form: FormData, key: cstring): cstring - {.importcpp: "#.get(@)".} -proc append*(form: FormData, key, val: cstring) - {.importcpp: "#.append(@)".} + # TODO: Why does Karax have it's own Node type? That's just silly. + let url = cast[dom.Node](n.dom).getAttribute(cstring"href") -proc renderProfileUrl*(username: string): string = - makeUri(fmt"/profile/{username}") + navigateTo(url) -proc renderPostUrl*(threadId, postId: int): string = - makeUri(fmt"/t/{threadId}#{postId}") \ No newline at end of file + type + FormData* = ref object + proc newFormData*(): FormData + {.importcpp: "new FormData()", constructor.} + proc newFormData*(form: dom.Element): FormData + {.importcpp: "new FormData(@)", constructor.} + proc get*(form: FormData, key: cstring): cstring + {.importcpp: "#.get(@)".} + proc append*(form: FormData, key, val: cstring) + {.importcpp: "#.append(@)".} + + proc renderProfileUrl*(username: string): string = + makeUri(fmt"/profile/{username}") + + proc renderPostUrl*(threadId, postId: int): string = + makeUri(fmt"/t/{threadId}#{postId}") \ No newline at end of file diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 314ddfa..ee41d12 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -12,6 +12,8 @@ type posts*: seq[Post] when defined(js): + from dom import nil + include karax/prelude import karax / [vstyles, kajax, kdom] @@ -38,7 +40,7 @@ when defined(js): var state = newState() - proc onPostList(httpStatus: int, response: kstring) = + proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) = state.loading = false state.status = httpStatus.HttpCode if state.status != Http200: return @@ -48,6 +50,18 @@ when defined(js): state.list = some(list) + # The anchor should be jumped to once all the posts have been loaded. + if postId.isSome(): + discard setTimeout( + () => ( + # Would have used scrollIntoView but then the `:target` selector + # isn't activated. + window.location.hash = ""; + window.location.hash = "#" & $postId.get() + ), + 100 + ) + proc onMorePosts(httpStatus: int, response: kstring, start: int) = state.loading = false state.status = httpStatus.HttpCode @@ -190,13 +204,17 @@ when defined(js): tdiv(class="information-title"): text diffStr - proc renderPostList*(threadId: int, isLoggedIn: bool): VNode = + proc renderPostList*(threadId: int, postId: Option[int], + isLoggedIn: bool): VNode = if state.status != Http200: return renderError("Couldn't retrieve posts.") if state.list.isNone or state.list.get().thread.id != threadId: - let uri = makeUri("posts.json", ("id", $threadId)) - ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r)) + var params = @[("id", $threadId)] + if postId.isSome(): + params.add(("anchor", $postId.get())) + let uri = makeUri("posts.json", params) + ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, postId)) return buildHtml(tdiv(class="loading loading-lg")) diff --git a/utils.nim b/utils.nim index 8f4d9a8..2d6e97e 100644 --- a/utils.nim +++ b/utils.nim @@ -7,24 +7,8 @@ from times import getTime, getGMTime, format let UsernameIdent* = IdentChars # TODO: Double check that everyone follows this. - -proc parseInt*(s: string, value: var int, validRange: Slice[int]) {. - noSideEffect.} = - ## parses `s` into an integer in the range `validRange`. If successful, - ## `value` is modified to contain the result. Otherwise no exception is - ## raised and `value` is not touched; this way a reasonable default value - ## won't be overwritten. - var x = value - try: - discard parseutils.parseInt(s, x, 0) - except OverflowError: - 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) +import redesign/karaxutils +export parseInt proc `%`*[T](opt: Option[T]): JsonNode = ## Generic constructor for JSON data. Creates a new ``JNull JsonNode`` From fe7c39b538462d228b144b525961d479fb89c8ac Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 12:53:07 +0100 Subject: [PATCH 122/396] Fixes user presence on threadlist. --- redesign/threadlist.nim | 2 +- redesign/user.nim | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 3895cab..7036fe6 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -75,7 +75,7 @@ when defined(js): proc genUserAvatars(users: seq[User]): VNode = result = buildHtml(td): for user in users: - render(user, "avatar avatar-sm") + render(user, "avatar avatar-sm", showStatus=true) text " " proc renderActivity*(activity: int64): string = diff --git a/redesign/user.nim b/redesign/user.nim index 480599a..545a4a6 100644 --- a/redesign/user.nim +++ b/redesign/user.nim @@ -18,19 +18,19 @@ type rank*: Rank proc isOnline*(user: User): bool = - return getTime().toUnix() - user.lastOnline > (60*5) + return getTime().toUnix() - user.lastOnline < (60*5) when defined(js): include karax/prelude import karaxutils - proc render*(user: User, class: string): VNode = + proc render*(user: User, class: string, showStatus=false): VNode = result = buildHtml(): a(href=renderProfileUrl(user.name), onClick=anchorCB): figure(class=class): img(src=user.avatarUrl, title=user.name) - if user.isOnline: - italic(class="avatar-presense online") + if user.isOnline and showStatus: + italic(class="avatar-presence online") proc renderUserMention*(user: User): VNode = result = buildHtml(): From 3a394386d4e3a34031ba7644860ee41d9a587d1c Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 14:29:01 +0100 Subject: [PATCH 123/396] Implements registration. Refactors login backend code. --- forum.nim | 234 ++++++++++++++++++++-------------------- redesign/error.nim | 3 +- redesign/header.nim | 4 +- redesign/karax.html | 1 - redesign/karaxutils.nim | 3 +- redesign/nimforum.scss | 8 ++ redesign/signup.nim | 31 +++--- 7 files changed, 146 insertions(+), 138 deletions(-) diff --git a/forum.nim b/forum.nim index df5b5eb..3f951bc 100644 --- a/forum.nim +++ b/forum.nim @@ -14,7 +14,9 @@ import cgi except setCookie import options import redesign/threadlist except User -import redesign/[category, postlist, error, header, post, profile, user] +import redesign/[ + category, postlist, error, header, post, profile, user, karaxutils +] when not defined(windows): import bcrypt # TODO @@ -297,63 +299,6 @@ proc setError(c: TForumData, field, msg: string): bool {.inline.} = c.errorMsg = "Error: " & msg return false -proc register(c: TForumData, name, pass, antibot, userIp, - email: string): Future[bool] {.async.} = - # Username validation: - if name.len == 0 or not allCharsInSet(name, UsernameIdent): - return setError(c, "name", "Invalid username!") - if getValue(db, sql"select name from person where name = ?", name).len > 0: - return setError(c, "name", "Username already exists!") - - # Password validation: - if pass.len < 4: - return setError(c, "new_password", "Invalid password!") - - # captcha validation: - if useCaptcha: - var captchaValid: bool = false - try: - captchaValid = await captcha.verify(antibot, userIp) - except: - echo("[ERROR] Error checking captcha: " & getCurrentExceptionMsg()) - captchaValid = false - - if not captchaValid: - return setError(c, "g-recaptcha-response", "Answer to captcha incorrect!") - - # email validation - if not ('@' in email and '.' in email): - return setError(c, "email", "Invalid email address") - - # perform registration: - var salt = makeSalt() - let password = makePassword(pass, salt) - - # Send activation email. - let epoch = $int(epochTime()) - let activateUrl = c.req.makeUri("/activateEmail?nick=$1&epoch=$2&ident=$3" % - [encodeUrl(name), encodeUrl(epoch), - encodeUrl(makeIdentHash(name, password, epoch, salt))]) - - let emailSentFut = sendEmailActivation(c.config, email, name, activateUrl) - # Block until we send the email. - # TODO: This is a workaround for 'var T' not being usable in async procs. - while not emailSentFut.finished: - poll() - when not defined(dev): - if emailSentFut.failed: - echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) - return setError(c, "email", "Couldn't send activation email") - - # add account to person table - exec(db, - sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " & - "VALUES (?, ?, ?, ?, ?, DATETIME('now'))"), name, - password, email, salt, - when defined(dev): $Moderated else: $EmailUnconfirmed) - - return true - proc resetPassword(c: TForumData, nick, antibot, userIp: string): Future[bool] {.async.} = # captcha validation: if useCaptcha: @@ -678,34 +623,6 @@ proc newThread(c: TForumData): bool = threadUrl=c.makeThreadURL()) result = true -proc login(c: TForumData, name, pass: string): bool = - # get form data: - const query = - sql"select id, name, password, email, salt, status, ban from person where name = ?" - if name.len == 0: - return c.setError("name", "Username cannot be nil.") - var success = false - for row in fastRows(db, query, name): - if row[2] == makePassword(pass, row[4], row[2]): - c.rank = parseEnum[Rank](row[5]) - let ban = getBanErrorMsg(row[6], c.rank) - if ban.len > 0: - return c.setError("name", ban) - c.userid = row[0] - c.username = row[1] - c.userpass = row[2] - c.email = row[3] - success = true - break - if success: - # create session: - exec(db, - sql"insert into session (ip, password, userid) values (?, ?, ?)", - c.req.ip, c.userpass, c.userid) - return true - else: - return c.setError("password", "Login failed!") - proc verifyIdentHash(c: TForumData, name, epoch, ident: string): bool = const query = sql"select password, salt, strftime('%s', lastOnline) from person where name = ?" @@ -1154,6 +1071,81 @@ proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = discard tryExec(db, sql"insert into post_fts(post_fts) values('optimize')") discard tryExec(db, sql"insert into post_fts(thread_fts) values('optimize')") +proc executeLogin(c: TForumData, username, password: string): string = + ## Performs a login with the specified details. + ## + ## Optionally, `username` may contain the email of the user instead. + const query = + sql""" + select id, name, password, email, salt + from person where name = ? or email = ? + """ + if username.len == 0: + raise newForumError("Username cannot be empty", @["username"]) + + for row in fastRows(db, query, username, username): + if row[2] == makePassword(password, row[4], row[2]): + exec( + db, + sql"insert into session (ip, password, userid) values (?, ?, ?)", + c.req.ip, row[2], row[0] + ) + return row[2] + + raise newForumError("Invalid username or password") + +proc executeRegister(c: TForumData, name, pass, antibot, userIp, + email: string): Future[string] {.async.} = + ## Registers a new user and returns a new session key for that user's + ## session if registration was successful. Exceptions are raised otherwise. + + # Username validation: + if name.len == 0 or not allCharsInSet(name, UsernameIdent): + raise newForumError("Invalid username", @["username"]) + if getValue(db, sql"select name from person where name = ?", name).len > 0: + raise newForumError("Username already exists", @["username"]) + + # Password validation: + if pass.len < 4: + raise newForumError("Please choose a longer password", @["password"]) + + # captcha validation: + if useCaptcha: + var verifyFut = captcha.verify(antibot, userIp) + yield verifyFut + if verifyFut.failed: + raise newForumError( + "Invalid recaptcha answer", @[] + ) + + # email validation + if not ('@' in email and '.' in email): + raise newForumError("Invalid email", @["email"]) + + # perform registration: + var salt = makeSalt() + let password = makePassword(pass, salt) + + # Send activation email. + let epoch = $int(epochTime()) + let activateUrl = c.req.makeUri("/activateEmail?nick=$1&epoch=$2&ident=$3" % + [encodeUrl(name), encodeUrl(epoch), + encodeUrl(makeIdentHash(name, password, epoch, salt))]) + + let emailSentFut = sendEmailActivation(c.config, email, name, activateUrl) + yield emailSentFut + if emailSentFut.failed: + echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) + raise newForumError("Couldn't send activation email", @["email"]) + + # Add account to person table + exec(db, + sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " & + "VALUES (?, ?, ?, ?, ?, DATETIME('now'))"), name, + password, email, salt, $Moderated) + + return password + initialise() routes: @@ -1351,15 +1343,39 @@ routes: post "/karax/login": createTFD() let formData = request.formData - if login(c, formData["username"].body, formData["password"].body): - setCookie("sid", c.userpass) - resp Http200, "{}", "application/json" - else: - let err = PostError( - errorFields: @["username", "password"], - message: "Invalid username or password" + cond "username" in formData + cond "password" in formData + try: + let session = executeLogin( + c, + formData["username"].body, + formData["password"].body ) - resp Http403, $(%err), "application/json" + setCookie("sid", session) + resp Http200, "{}", "application/json" + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + + post "/karax/signup": + createTFD() + let formData = request.formData + let username = formData["username"].body + let password = formData["password"].body + try: + discard await executeRegister( + c, + username, + password, + formData["g-recaptcha-response"].body, + request.host, + formData["email"].body + ) + let session = executeLogin(c, username, password) + setCookie("sid", session) + resp Http200, "{}", "application/json" + except ForumError: + let exc = (ref ForumError)(getCurrentException()) + resp Http400, $(%exc.data), "application/json" get "/karax/status.json": createTFD() @@ -1377,7 +1393,14 @@ routes: else: none[User]() - let status = UserStatus(user: user) + let status = UserStatus( + user: user, + recaptchaSiteKey: + if useCaptcha: + some(config.recaptchaSiteKey) + else: + none[string]() + ) resp $(%status), "application/json" post "/karax/preview": @@ -1566,27 +1589,6 @@ routes: resp genMain(c, body(), "Nim Forum - " & (if c.isPreview: "Preview" else: "Error")) - post "/dologin": - createTFD() - if login(c, @"name", @"password"): - finishLogin() - else: - c.isThreadsList = true - var count = 0 - let threadList = genThreadsList(c, count) - let data = genMain(c, threadList, - additionalHeaders = genRSSHeaders(c), showRssLinks = true) - resp data - - post "/doregister": - createTFD() - if await c.register(@"name", @"new_password", @"g-recaptcha-response", request.host, @"email"): - resp genMain(c, "You are now registered. You must now confirm your" & - " email address by clicking the link sent to " & @"email", - "Registration successful - Nim Forum") - else: - resp c.genMain(genFormRegister(c)) - post "/donewthread": createTFD() if newThread(c): diff --git a/redesign/error.nim b/redesign/error.nim index af04e73..e921a1a 100644 --- a/redesign/error.nim +++ b/redesign/error.nim @@ -38,7 +38,8 @@ when defined(js): if not error.isNone: let e = error.get() - if (e.errorFields.len == 1 and e.errorFields[0] == name) or isLast: + if (e.errorFields.len == 1 and e.errorFields[0] == name) or + (isLast and e.errorFields.len == 0): p(class="form-input-hint"): text e.message diff --git a/redesign/header.nim b/redesign/header.nim index d594431..d4d764f 100644 --- a/redesign/header.nim +++ b/redesign/header.nim @@ -4,6 +4,7 @@ import threadlist, user type UserStatus* = object user*: Option[User] + recaptchaSiteKey*: Option[string] when defined(js): include karax/prelude @@ -104,4 +105,5 @@ when defined(js): # Modals render(state.loginModal) - render(state.signupModal) \ No newline at end of file + if state.data.isSome(): + render(state.signupModal, state.data.get().recaptchaSiteKey) \ No newline at end of file diff --git a/redesign/karax.html b/redesign/karax.html index aa4d440..8c35343 100644 --- a/redesign/karax.html +++ b/redesign/karax.html @@ -14,7 +14,6 @@ -
diff --git a/redesign/karaxutils.nim b/redesign/karaxutils.nim index eeb0e92..e34b7a2 100644 --- a/redesign/karaxutils.nim +++ b/redesign/karaxutils.nim @@ -1,5 +1,4 @@ import strutils, options, strformat, parseutils -import dom except window proc parseInt*(s: string, value: var int, validRange: Slice[int]) {. noSideEffect.} = @@ -23,6 +22,8 @@ when defined(js): include karax/prelude import karax / [kdom] + import dom except window + const appName = "/karax/" proc class*(classes: varargs[tuple[name: string, present: bool]], diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 169e2b9..cc823b5 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -553,4 +553,12 @@ hr { &:hover, &:focus { text-shadow: $body-font-color 0px 0px 0px; } +} + +// - Sign up modal + +#signup-modal { + .modal-container .modal-body { + max-height: 60vh; + } } \ No newline at end of file diff --git a/redesign/signup.nim b/redesign/signup.nim index 1b0db46..93ac84f 100644 --- a/redesign/signup.nim +++ b/redesign/signup.nim @@ -11,29 +11,17 @@ when defined(js): type SignupModal* = ref object shown: bool + loading: bool onSignUp, onLogIn: proc () error: Option[PostError] proc onSignUpPost(httpStatus: int, response: kstring, state: SignupModal) = - let status = httpStatus.HttpCode - if status == Http200: + postFinished: state.shown = false state.onSignUp() - else: - # TODO: Karax should pass the content-type... - try: - let parsed = parseJson($response) - let error = to(parsed, PostError) - - state.error = some(error) - except: - kout(getCurrentExceptionMsg().cstring) - state.error = some(PostError( - errorFields: @[], - message: "Unknown error occurred." - )) proc onSignUpClick(ev: Event, n: VNode, state: SignupModal) = + state.loading = true state.error = none[PostError]() let uri = makeUri("signup") @@ -57,9 +45,11 @@ when defined(js): proc show*(state: SignupModal) = state.shown = true - proc render*(state: SignupModal): VNode = + proc render*(state: SignupModal, recaptchaSiteKey: Option[string]): VNode = + setForeignNodeId("recaptcha") + result = buildHtml(): - tdiv(class=class({"active": state.shown}, "modal modal-sm"), + tdiv(class=class({"active": state.shown}, "modal"), id="signup-modal"): a(href="", class="modal-overlay", "aria-label"="close", onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) @@ -82,8 +72,13 @@ when defined(js): "password", true ) + if recaptchaSiteKey.isSome: + tdiv(id="recaptcha"): + tdiv(class="g-recaptcha", + "data-sitekey"=recaptchaSiteKey.get()) + script(src="https://www.google.com/recaptcha/api.js") tdiv(class="modal-footer"): - button(class="btn btn-primary", + button(class=class({"loading": state.loading}, "btn btn-primary"), onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)): text "Create account" button(class="btn", From c338d5e930e22b8a8c26d65a5b70d103f493c8c2 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 14:39:14 +0100 Subject: [PATCH 124/396] Fixes crash with profile view on new profile. --- forum.nim | 28 ++++++++++++++++------------ redesign/profile.nim | 28 +++++++++++++++------------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/forum.nim b/forum.nim index 3f951bc..c55f074 100644 --- a/forum.nim +++ b/forum.nim @@ -1286,38 +1286,42 @@ routes: let postsQuery = sql(""" select p.id, strftime('%s', p.creation), - u.name, u.email, strftime('%s', u.lastOnline), u.status, - strftime('%s', u.creation), u.id, t.name, t.id $1 order by p.id desc limit 10; """ % postsFrom) + let userQuery = sql(""" + select name, email, strftime('%s', lastOnline), status, + strftime('%s', creation), id + from person + where name = ? + """) + var profile = Profile( threads: @[], posts: @[] ) - let rows = db.getAllRows(postsQuery, username) - let userID = rows[0][7] - profile.user = selectUser(@[ - rows[0][2], rows[0][3], rows[0][4], rows[0][5] - ], avatarSize=200) - profile.joinTime = rows[0][6].parseInt() + let userRow = db.getRow(userQuery, username) + + let userID = userRow[^1] + profile.user = selectUser(userRow, avatarSize=200) + profile.joinTime = userRow[4].parseInt() profile.postCount = getValue(db, sql("select count(*) " & postsFrom), username).parseInt() profile.threadCount = getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt() if c.rank >= Admin: - profile.email = some(rows[0][3]) + profile.email = some(userRow[1]) - for row in rows: + for row in db.getAllRows(postsQuery, username): profile.posts.add( PostLink( creation: row[1].parseInt(), - topic: row[8], - threadId: row[9].parseInt(), + topic: row[2], + threadId: row[3].parseInt(), postId: row[0].parseInt() ) ) diff --git a/redesign/profile.nim b/redesign/profile.nim index 7c78249..7ab23ac 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -79,8 +79,9 @@ when defined(js): dl(): dt(text "Joined") dd(text threadlist.renderActivity(profile.joinTime)) - dt(text "Last Post") - dd(text renderActivity(profile.posts[0].creation)) + if profile.posts.len > 0: + dt(text "Last Post") + dd(text renderActivity(profile.posts[0].creation)) dt(text "Last Online") dd(text renderActivity(profile.user.lastOnline)) dt(text "Posts") @@ -102,16 +103,17 @@ when defined(js): dd(class="spoiler"): text profile.email.get() - tdiv(class="columns"): - tdiv(class="column col-6"): - h4(text "Latest Posts") - tdiv(class="posts"): - for post in profile.posts: - genPostLink(post) - tdiv(class="column col-6"): - h4(text "Latest Threads") - tdiv(class="posts"): - for thread in profile.threads: - genPostLink(thread) + if profile.posts.len > 0 or profile.threads.len > 0: + tdiv(class="columns"): + tdiv(class="column col-6"): + h4(text "Latest Posts") + tdiv(class="posts"): + for post in profile.posts: + genPostLink(post) + tdiv(class="column col-6"): + h4(text "Latest Threads") + tdiv(class="posts"): + for thread in profile.threads: + genPostLink(thread) From c6b42c5979f32397f6ab1eadc89385749df487d9 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 14:49:59 +0100 Subject: [PATCH 125/396] Prevent registration with duplicate emails. --- forum.nim | 14 +++++++++----- redesign/nimforum.scss | 5 +++++ redesign/threadlist.nim | 13 +++++++++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/forum.nim b/forum.nim index c55f074..85fd345 100644 --- a/forum.nim +++ b/forum.nim @@ -1099,6 +1099,14 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, ## Registers a new user and returns a new session key for that user's ## session if registration was successful. Exceptions are raised otherwise. + # email validation + if not ('@' in email and '.' in email): + raise newForumError("Invalid email", @["email"]) + if getValue( + db, sql"select email from person where email = ?", email + ).len > 0: + raise newForumError("Email already exists", @["email"]) + # Username validation: if name.len == 0 or not allCharsInSet(name, UsernameIdent): raise newForumError("Invalid username", @["username"]) @@ -1118,10 +1126,6 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, "Invalid recaptcha answer", @[] ) - # email validation - if not ('@' in email and '.' in email): - raise newForumError("Invalid email", @["email"]) - # perform registration: var salt = makeSalt() let password = makePassword(pass, salt) @@ -1142,7 +1146,7 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, exec(db, sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " & "VALUES (?, ?, ?, ?, ?, DATETIME('now'))"), name, - password, email, salt, $Moderated) + password, email, salt, $EmailUnconfirmed) return password diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index cc823b5..45b8018 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -144,6 +144,11 @@ $logo-height: $navbar-height - 20px; a.visited { color: lighten($body-font-color, 40%); } + + i { + // Icon + margin-right: $control-padding-x-sm; + } } $super-popular-color: #f86713; diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index 7036fe6..dbc1dac 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -100,12 +100,17 @@ when defined(js): tr(class=class({"no-border": noBorder})): td(class="thread-title"): if thread.isLocked: - italic(class="fas fa-lock fa-xs") + italic(class="fas fa-lock fa-xs", + title="Thread cannot be replied to") if thread.isInvisible: - italic(class="fas fa-eye-slash fa-xs") + italic(class="fas fa-eye-slash fa-xs", + title="Thread is moderated") if thread.isSolved: - italic(class="fas fa-check-square fa-xs") - a(href=makeUri("/t/" & $thread.id), onClick=anchorCB): text thread.topic + italic(class="fas fa-check-square fa-xs", + title="Thread has a solution") + a(href=makeUri("/t/" & $thread.id), + onClick=anchorCB): + text thread.topic td(): render(thread.category) genUserAvatars(thread.users) From 60694d9fbdf9e13cea138768bb77fba4c84b0a61 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 15:13:41 +0100 Subject: [PATCH 126/396] Moderated posts are now shown to correct people in correct circumstances. --- forum.nim | 10 ++-------- redesign/forum.nim | 2 +- redesign/header.nim | 6 ++++-- redesign/postlist.nim | 12 ++++++++++++ redesign/threadlist.nim | 42 ++++++++++++++++++++++++++--------------- 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/forum.nim b/forum.nim index 85fd345..3a6f689 100644 --- a/forum.nim +++ b/forum.nim @@ -1205,19 +1205,13 @@ routes: 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), u.status 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""" % modClause + where u.id = p.author and p.thread = ? + order by p.id""" ) var list = PostList( diff --git a/redesign/forum.nim b/redesign/forum.nim index 1c08ec4..42d979a 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -67,7 +67,7 @@ proc render(): VNode = ) ) ), - r("/", (params: Params) => renderThreadList()) + r("/", (params: Params) => renderThreadList(getLoggedInUser())) ]) window.onPopState = onPopState diff --git a/redesign/header.nim b/redesign/header.nim index d4d764f..a09221a 100644 --- a/redesign/header.nim +++ b/redesign/header.nim @@ -68,9 +68,11 @@ when defined(js): let uri = makeUri("status.json", [("logout", $logout)]) ajaxGet(uri, @[], onStatus) + proc getLoggedInUser*(): Option[User] = + state.data.map(x => x.user).flatten + proc isLoggedIn*(): bool = - let user = state.data.map(x => x.user).flatten - not user.isNone + not getLoggedInUser().isNone proc renderHeader*(): VNode = if state.data.isNone: diff --git a/redesign/postlist.nim b/redesign/postlist.nim index ee41d12..5f90fd8 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -223,6 +223,18 @@ when defined(js): section(class="container grid-xl"): tdiv(class="title"): p(): text list.thread.topic + if list.thread.isLocked: + italic(class="fas fa-lock fa-xs", + title="Thread cannot be replied to") + text "Locked" + if list.thread.isModerated: + italic(class="fas fa-eye-slash fa-xs", + title="Thread is moderated") + text "Moderated" + if list.thread.isSolved: + italic(class="fas fa-check-square fa-xs", + title="Thread has a solution") + text "Solved" render(list.thread.category) tdiv(class="posts"): var prevPost: Option[Post] = none[Post]() diff --git a/redesign/threadlist.nim b/redesign/threadlist.nim index dbc1dac..9f2465e 100644 --- a/redesign/threadlist.nim +++ b/redesign/threadlist.nim @@ -22,7 +22,7 @@ type lastVisit*: int64 ## Unix timestamp moreCount*: int ## How many more threads are left -proc isInvisible*(thread: Thread): bool = +proc isModerated*(thread: Thread): bool = ## Determines whether the specified thread is under moderation. thread.author.rank <= Moderated @@ -30,7 +30,7 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, error + import karaxutils, error, user type State = ref object @@ -38,7 +38,6 @@ when defined(js): loading: bool status: HttpCode - proc onNewThread(threadId, postId: int) proc newState(): State = State( list: none[ThreadList](), @@ -49,10 +48,20 @@ when defined(js): var state = newState() - proc onNewThread(threadId, postId: int) = - discard + proc visibleTo(thread: Thread, user: Option[User]): bool = + ## Determines whether the specified thread should be shown to the user. + ## + ## The rules for this are determined by the rank of the user, their + ## settings (TODO), and whether the thread's creator is moderated or not. + if user.isNone(): return not thread.isModerated - proc genTopButtons(): VNode = + let rank = user.get().rank + if rank < Moderator and thread.isModerated: + return thread.author == user.get() + + return true + + proc genTopButtons(currentUser: Option[User]): VNode = result = buildHtml(): section(class="navbar container grid-xl", id="main-buttons"): section(class="navbar-section"): @@ -67,10 +76,11 @@ when defined(js): button(class="btn btn-link"): text "Most Active" button(class="btn btn-link"): text "Categories" section(class="navbar-section"): - a(href=makeUri("/newthread"), onClick=anchorCB): - button(class="btn btn-secondary"): - italic(class="fas fa-plus") - text " New Thread" + if currentUser.isSome(): + a(href=makeUri("/newthread"), onClick=anchorCB): + button(class="btn btn-secondary"): + italic(class="fas fa-plus") + text " New Thread" proc genUserAvatars(users: seq[User]): VNode = result = buildHtml(td): @@ -102,7 +112,7 @@ when defined(js): if thread.isLocked: italic(class="fas fa-lock fa-xs", title="Thread cannot be replied to") - if thread.isInvisible: + if thread.isModerated: italic(class="fas fa-eye-slash fa-xs", title="Thread is moderated") if thread.isSolved: @@ -147,7 +157,7 @@ when defined(js): let start = state.list.get().threads.len ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList) - proc genThreadList(): VNode = + proc genThreadList(currentUser: Option[User]): VNode = if state.status != Http200: return renderError("Couldn't retrieve threads.") @@ -171,6 +181,8 @@ when defined(js): tbody(): for i in 0 ..< list.threads.len: let thread = list.threads[i] + if not visibleTo(thread, currentUser): continue + let isLastVisit = i+1 < list.threads.len and list.threads[i].activity < list.lastVisit @@ -191,7 +203,7 @@ when defined(js): td(colspan="6", onClick=onLoadMore): span(text "load more threads") - proc renderThreadList*(): VNode = + proc renderThreadList*(currentUser: Option[User]): VNode = result = buildHtml(tdiv): - genTopButtons() - genThreadList() \ No newline at end of file + genTopButtons(currentUser) + genThreadList(currentUser) \ No newline at end of file From 84caff7c97ceb13c9be80f60d743caeeee817149 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 17:43:01 +0100 Subject: [PATCH 127/396] Adds global reply button to postlist. --- redesign/nimforum.scss | 49 +++++++++++++++++++++++++++++------------- redesign/postlist.nim | 7 ++++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index 45b8018..fe4bb53 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -91,25 +91,29 @@ $logo-height: $navbar-height - 20px; } // - Main buttons +.btn-secondary { + background: $secondary-btn-color; + border-color: darken($secondary-btn-color, 5%); + color: invert($secondary-btn-color); + + margin-right: $control-padding-x*2; + + &:hover, &:focus { + background: darken($secondary-btn-color, 5%); + border-color: darken($secondary-btn-color, 10%); + } + + &:focus { + @include control-shadow(darken($secondary-btn-color, 40%)); + } +} + #main-buttons { margin-top: $control-padding-y*2; margin-bottom: $control-padding-y*2; - .dropdown > .btn, .btn-secondary { - background: $secondary-btn-color; - border-color: darken($secondary-btn-color, 5%); - color: invert($secondary-btn-color); - - margin-right: $control-padding-x*2; - - &:hover, &:focus { - background: darken($secondary-btn-color, 5%); - border-color: darken($secondary-btn-color, 10%); - } - - &:focus { - @include control-shadow(darken($secondary-btn-color, 40%)); - } + .dropdown > .btn { + @extend .btn-secondary; } } @@ -244,6 +248,8 @@ $views-color: #545d70; @extend .container; margin: 0; padding: 0; + + margin-bottom: 10rem; // Just some empty space at the bottom. } .post { @@ -336,6 +342,19 @@ $views-color: #545d70; } } +#thread-buttons { + border-top: 1px solid $border-color; + width: 100%; + padding-top: $control-padding-y; + padding-bottom: $control-padding-y; + @extend .clearfix; + + .btn { + float: right; + margin-right: 0; + } +} + blockquote { border-left: 0.2rem solid darken($bg-color, 10%); background-color: $bg-color; diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 5f90fd8..824aa94 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -249,4 +249,11 @@ when defined(js): if prevPost.isSome: genTimePassed(prevPost.get(), none[Post](), true) + tdiv(id="thread-buttons"): + button(class="btn btn-secondary", + onClick=(e: Event, n: VNode) => + onReplyClick(e, n, none[Post]())): + italic(class="fas fa-reply") + text " Reply" + render(state.replyBox, list.thread, state.replyingTo, false) \ No newline at end of file From c5fd70d275c87456de197714f64ac28630537947 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 18:45:42 +0100 Subject: [PATCH 128/396] Implements edit box and rearranges post buttons. --- forum.nim | 15 +++++++++++ redesign/editbox.nim | 43 +++++++++++++++++++++++++++++ redesign/forum.nim | 2 +- redesign/postlist.nim | 63 ++++++++++++++++++++++++++++++++----------- redesign/replybox.nim | 1 + 5 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 redesign/editbox.nim diff --git a/forum.nim b/forum.nim index 3a6f689..633c212 100644 --- a/forum.nim +++ b/forum.nim @@ -1258,6 +1258,21 @@ routes: resp $(%list), "application/json" + get "/karax/post.rst": + createTFD() + let postId = getInt(@"id", -1) + cond postId != -1 + + let postQuery = sql""" + select content from post where id = ?; + """ + + let content = getValue(db, postQuery, postId) + if content.len == 0: + resp Http404, "Post not found" + else: + resp content, "text/x-rst" + get "/karax/profile.json": createTFD() var diff --git a/redesign/editbox.nim b/redesign/editbox.nim new file mode 100644 index 0000000..2cdf9ce --- /dev/null +++ b/redesign/editbox.nim @@ -0,0 +1,43 @@ +when defined(js): + import httpcore, options, sugar + + include karax/prelude + import karax/kajax + + import replybox, post, karaxutils, threadlist + + type + EditBox* = ref object + box: ReplyBox + post: Option[Post] + rawContent: Option[kstring] ## The raw rst for a post (needs to be loaded) + status: HttpCode + + proc newEditBox*(): EditBox = + EditBox( + box: newReplyBox(nil) + ) + + proc onRawContent(httpStatus: int, response: kstring, state: EditBox) = + state.status = httpStatus.HttpCode + if state.status != Http200: return + + state.rawContent = some(response) + + proc render*(state: EditBox, post: Post): VNode = + if state.post.isNone() or state.post.get().id != post.id: + state.post = some(post) + 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 diff --git a/redesign/forum.nim b/redesign/forum.nim index 42d979a..f61c50d 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -63,7 +63,7 @@ proc render(): VNode = renderPostList( params["id"].parseInt(), if postId == 0: none[int]() else: some[int](postId), - isLoggedIn() + getLoggedInUser() ) ) ), diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 824aa94..2563a74 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -17,7 +17,7 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, error, replybox + import karaxutils, error, replybox, editbox type State = ref object @@ -26,6 +26,8 @@ when defined(js): status: HttpCode replyingTo: Option[Post] replyBox: ReplyBox + editing: Option[Post] ## If in edit mode, this contains the post. + editBox: EditBox proc onReplyPosted(id: int) proc newState(): State = @@ -34,7 +36,8 @@ when defined(js): loading: false, status: Http200, replyingTo: none[Post](), - replyBox: newReplyBox(onReplyPosted) + replyBox: newReplyBox(onReplyPosted), + editBox: newEditBox() ) var @@ -106,10 +109,21 @@ when defined(js): ## Executed when a reply has been successfully posted. loadMore(state.list.get().posts.len, @[id]) + proc onEditPosted(id: int, content: string) = + ## Executed when an edit has been successfully posted. + 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 onReplyClick(e: Event, n: VNode, p: Option[Post]) = state.replyingTo = p state.replyBox.show() + proc onEditClick(e: Event, n: VNode, p: Option[Post]) = + state.editing = p + proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) = loadMore(start, post.moreBefore) # TODO: Don't load all! @@ -128,8 +142,12 @@ when defined(js): span(class="more-post-count"): text "(" & $post.moreBefore.len & ")" - proc genPost(post: Post, thread: Thread, isLoggedIn: bool): VNode = + proc genPost(post: Post, thread: Thread, currentUser: Option[User]): VNode = let postCopy = post # TODO: Another workaround here, closure capture :( + let loggedIn = currentUser.isSome() + let authoredByUser = + loggedIn and currentUser.get().name == post.author.name + result = buildHtml(): tdiv(class="post", id = $post.id): tdiv(class="post-icon"): @@ -144,18 +162,33 @@ when defined(js): a(href=renderPostUrl(post, thread), title=title): text renderActivity(post.info.creation) tdiv(class="post-content"): - verbatim(post.info.content) + if state.editing.isSome() and state.editing.get() == post: + render(state.editBox, postCopy) + else: + verbatim(post.info.content) 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"): + if authoredByUser: + tdiv(class="edit-button", onClick=(e: Event, n: VNode) => + onEditClick(e, n, some(postCopy))): button(class="btn"): - italic(class="far fa-flag") + 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="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(postCopy))): @@ -205,7 +238,7 @@ when defined(js): text diffStr proc renderPostList*(threadId: int, postId: Option[int], - isLoggedIn: bool): VNode = + currentUser: Option[User]): VNode = if state.status != Http200: return renderError("Couldn't retrieve posts.") @@ -243,7 +276,7 @@ when defined(js): genTimePassed(prevPost.get(), some(post), false) if post.moreBefore.len > 0: genLoadMore(post, i) - genPost(post, list.thread, isLoggedIn) + genPost(post, list.thread, currentUser) prevPost = some(post) if prevPost.isSome: diff --git a/redesign/replybox.nim b/redesign/replybox.nim index 99400f8..af46fe6 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -40,6 +40,7 @@ when defined(js): state.shown = true proc getText*(state: ReplyBox): kstring = state.text + proc setText*(state: ReplyBox, text: kstring) = state.text = text proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) = postFinished: From 1be362259c54da8c2fbc396c35c27039b2c81251 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 19:14:28 +0100 Subject: [PATCH 129/396] Implements Cancel/Save buttons for edit box. --- redesign/nimforum.scss | 32 +++++++++++----- redesign/postlist.nim | 85 +++++++++++++++++++++++++++--------------- redesign/replybox.nim | 2 +- 3 files changed, 78 insertions(+), 41 deletions(-) diff --git a/redesign/nimforum.scss b/redesign/nimforum.scss index fe4bb53..a3413c6 100644 --- a/redesign/nimforum.scss +++ b/redesign/nimforum.scss @@ -101,6 +101,8 @@ $logo-height: $navbar-height - 20px; &:hover, &:focus { background: darken($secondary-btn-color, 5%); border-color: darken($secondary-btn-color, 10%); + + color: invert($secondary-btn-color); } &:focus { @@ -127,8 +129,6 @@ $logo-height: $navbar-height - 20px; } textarea.form-input, .panel-body > div { - margin-top: $control-padding-y*2; - resize: vertical; min-height: 40vh; } @@ -474,14 +474,12 @@ blockquote { } } +.form-input.post-text-area { + margin-top: $control-padding-y*2; + resize: vertical; +} + #reply-box { - - .form-input { - // For reply text area. - margin-top: $control-padding-y*2; - resize: vertical; - } - .panel { margin-top: $control-padding-y*2; } @@ -503,6 +501,22 @@ hr { border: 0; } +.edit-box { + margin-bottom: $control-padding-y; + + .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 2563a74..461f587 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -117,12 +117,15 @@ when defined(js): 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() - proc onEditClick(e: Event, n: VNode, p: Option[Post]) = - state.editing = p + proc onEditClick(e: Event, n: VNode, p: Post) = + state.editing = some(p) proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) = loadMore(start, post.moreBefore) # TODO: Don't load all! @@ -142,12 +145,58 @@ when defined(js): span(class="more-post-count"): text "(" & $post.moreBefore.len & ")" - proc genPost(post: Post, thread: Thread, currentUser: Option[User]): VNode = - let postCopy = post # TODO: Another workaround here, closure capture :( + proc genPostButtons(post: Post, currentUser: Option[User]): Vnode = let loggedIn = currentUser.isSome() let authoredByUser = 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") + + if loggedIn: + 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 :( + result = buildHtml(): tdiv(class="post", id = $post.id): tdiv(class="post-icon"): @@ -166,34 +215,8 @@ when defined(js): render(state.editBox, postCopy) else: verbatim(post.info.content) - tdiv(class="post-buttons"): - if authoredByUser: - tdiv(class="edit-button", onClick=(e: Event, n: VNode) => - onEditClick(e, n, some(postCopy))): - 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="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(postCopy))): - italic(class="fas fa-reply") - text " Reply" + genPostButtons(postCopy, currentUser) proc genTimePassed(prevPost: Post, post: Option[Post], last: bool): VNode = var latestTime = diff --git a/redesign/replybox.nim b/redesign/replybox.nim index af46fe6..5a1e032 100644 --- a/redesign/replybox.nim +++ b/redesign/replybox.nim @@ -115,7 +115,7 @@ when defined(js): elif state.rendering.isSome(): verbatim(state.rendering.get()) else: - textarea(class="form-input", rows="5", + textarea(class="form-input post-text-area", rows="5", onChange=(e: Event, n: VNode) => onChange(e, n, state), value=state.text) From fc7dabdddaa1f0d088f749ed96c8e7354bb5b712 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 22:41:36 +0100 Subject: [PATCH 130/396] Finalises implementation of post editing. --- forum.nim | 67 +++++++++++++++++++++++++++++++++++ redesign/editbox.nim | 66 ++++++++++++++++++++++++++++++----- redesign/nimforum.scss | 23 +++++++----- redesign/postlist.nim | 79 +++++++++++++++++++----------------------- redesign/user.nim | 2 +- 5 files changed, 174 insertions(+), 63 deletions(-) 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 From 2b8c6d585f365cc4668b50dc6037e83156c890f1 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 22:50:55 +0100 Subject: [PATCH 131/396] Show post edit buttons for admins too. --- redesign/postlist.nim | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 795cb94..5620a06 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -151,13 +151,16 @@ when defined(js): let loggedIn = currentUser.isSome() let authoredByUser = loggedIn and currentUser.get().name == post.author.name + let currentAdmin = + currentUser.isSome() and currentUser.get().rank == Admin + # Don't show buttons if the post is being edited. if state.editing.isSome() and state.editing.get() == post: return buildHtml(tdiv()) result = buildHtml(): tdiv(class="post-buttons"): - if authoredByUser: + if authoredByUser or currentAdmin: tdiv(class="edit-button", onClick=(e: Event, n: VNode) => onEditClick(e, n, post)): button(class="btn"): From 765b43cf4a27461fb941357088d108908cdd5484 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 22:55:25 +0100 Subject: [PATCH 132/396] Fixes like button not showing up for admins. --- redesign/postlist.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 5620a06..6446c88 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -168,7 +168,8 @@ when defined(js): tdiv(class="delete-button"): button(class="btn"): italic(class="far fa-trash-alt") - else: + + if not authoredByUser: tdiv(class="like-button"): button(class="btn"): span(class="like-count"): From 5c86ae5d129b9cb9dbc6ac141f3505dbcdcfab04 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Wed, 16 May 2018 23:06:21 +0100 Subject: [PATCH 133/396] Add small TODO for edit box. --- redesign/postlist.nim | 3 +++ 1 file changed, 3 insertions(+) diff --git a/redesign/postlist.nim b/redesign/postlist.nim index 6446c88..2bce6f7 100644 --- a/redesign/postlist.nim +++ b/redesign/postlist.nim @@ -129,6 +129,9 @@ when defined(js): proc onEditClick(e: Event, n: VNode, p: Post) = state.editing = some(p) + # TODO: Ensure the edit box is as big as its content. Auto resize the + # text area. + proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) = loadMore(start, post.moreBefore) # TODO: Don't load all! From 87605e7d9456cb388734b2cbfc0711988a28e651 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 00:10:36 +0100 Subject: [PATCH 134/396] Some work on settings tab in profile page. --- redesign/forum.nim | 2 +- redesign/profile.nim | 96 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/redesign/forum.nim b/redesign/forum.nim index f61c50d..ee74f8f 100644 --- a/redesign/forum.nim +++ b/redesign/forum.nim @@ -54,7 +54,7 @@ proc render(): VNode = ), r("/profile/@username", (params: Params) => - (render(state.profile, params["username"])) + (render(state.profile, params["username"], getLoggedInUser())) ), r("/t/@id", (params: Params) => diff --git a/redesign/profile.nim b/redesign/profile.nim index 7ab23ac..6760de1 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -19,15 +19,20 @@ when defined(js): import karaxutils type + ProfileTab* = enum + Overview, Settings + ProfileState* = ref object profile: Option[Profile] + currentTab: ProfileTab loading: bool status: HttpCode proc newProfileState*(): ProfileState = ProfileState( loading: false, - status: Http200 + status: Http200, + currentTab: Overview ) proc onProfile(httpStatus: int, response: kstring, state: ProfileState) = @@ -55,7 +60,11 @@ when defined(js): p(title=title): text renderActivity(link.creation) - proc render*(state: ProfileState, username: string): VNode = + proc render*( + state: ProfileState, + username: string, + currentUser: Option[User] + ): VNode = if state.status != Http200: return renderError("Couldn't retrieve profile.") @@ -66,6 +75,19 @@ when defined(js): return buildHtml(tdiv(class="loading loading-lg")) let profile = state.profile.get() + let isAdmin = currentUser.isSome() and currentUser.get().rank == Admin + + # TODO: Surely there is a better way to handle this. + let rankSelect = buildHtml(): + if isAdmin: + select(class="form-select", value = $profile.user.rank): + for r in Rank: + option(text $r) + else: + select(class="form-select", value = $profile.user.rank, disabled=""): + for r in Rank: + option(text $r) + result = buildHtml(): section(class="container grid-xl"): tdiv(class="profile"): @@ -103,17 +125,65 @@ when defined(js): dd(class="spoiler"): text profile.email.get() - if profile.posts.len > 0 or profile.threads.len > 0: + if currentUser.isSome(): + let user = currentUser.get() + if user.name == profile.user.name or user.rank == Admin: + ul(class="tab"): + li(class=class( + {"active": state.currentTab == Overview}, + "tab-item" + ), + onClick=(e: Event, n: VNode) => (state.currentTab = Overview) + ): + a(): + text "Overview" + li(class=class( + {"active": state.currentTab == Settings}, + "tab-item" + ), + onClick=(e: Event, n: VNode) => (state.currentTab = Settings) + ): + a(): + text "Settings" + + case state.currentTab + of Overview: + if profile.posts.len > 0 or profile.threads.len > 0: + tdiv(class="columns"): + tdiv(class="column col-6"): + h4(text "Latest Posts") + tdiv(class="posts"): + for post in profile.posts: + genPostLink(post) + tdiv(class="column col-6"): + h4(text "Latest Threads") + tdiv(class="posts"): + for thread in profile.threads: + genPostLink(thread) + of Settings: tdiv(class="columns"): tdiv(class="column col-6"): - h4(text "Latest Posts") - tdiv(class="posts"): - for post in profile.posts: - genPostLink(post) - tdiv(class="column col-6"): - h4(text "Latest Threads") - tdiv(class="posts"): - for thread in profile.threads: - genPostLink(thread) - + form(class="form-horizontal"): + tdiv(class="form-group"): + tdiv(class="col-3 col-sm-12"): + label(class="form-label"): + text "Username" + tdiv(class="col-9 col-sm-12"): + input(class="form-input", + `type`="text", + value=profile.user.name, + disabled="") + tdiv(class="form-group"): + tdiv(class="col-3 col-sm-12"): + label(class="form-label"): + text "Email" + tdiv(class="col-9 col-sm-12"): + input(class="form-input", + `type`="text", value=profile.email.get()) + tdiv(class="form-group"): + tdiv(class="col-3 col-sm-12"): + label(class="form-label"): + text "Rank" + tdiv(class="col-9 col-sm-12"): + rankSelect From 81e5bf5af04091b350f8c9d610160e6e115ac6ad Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 12:54:50 +0100 Subject: [PATCH 135/396] Improvements to profile settings. --- forum.nim | 2 +- redesign/profile.nim | 90 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/forum.nim b/forum.nim index 0f11d6c..b270d0e 100644 --- a/forum.nim +++ b/forum.nim @@ -1365,7 +1365,7 @@ routes: profile.threadCount = getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt() - if c.rank >= Admin: + if c.rank >= Admin or c.username == username: profile.email = some(userRow[1]) for row in db.getAllRows(postsQuery, username): diff --git a/redesign/profile.nim b/redesign/profile.nim index 6760de1..6d4fa05 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -1,4 +1,4 @@ -import options, httpcore, json, sugar, times +import options, httpcore, json, sugar, times, strformat import threadlist, post, category, error, user @@ -22,8 +22,13 @@ when defined(js): ProfileTab* = enum Overview, Settings + ProfileSettings* = object + email: kstring + rank: Rank + ProfileState* = ref object profile: Option[Profile] + settings: ProfileSettings currentTab: ProfileTab loading: bool status: HttpCode @@ -32,9 +37,21 @@ when defined(js): ProfileState( loading: false, status: Http200, - currentTab: Overview + currentTab: Overview, + settings: ProfileSettings( + email: "", + rank: Spammer + ) ) + proc resetSettings(state: ProfileState) = + let profile = state.profile.get() + if profile.email.isSome(): + state.settings = ProfileSettings( + email: profile.email.get(), + rank: profile.user.rank + ) + proc onProfile(httpStatus: int, response: kstring, state: ProfileState) = # TODO: Try to abstract these. state.loading = false @@ -45,6 +62,7 @@ when defined(js): let profile = to(parsed, Profile) state.profile = some(profile) + resetSettings(state) proc genPostLink(link: PostLink): VNode = let url = renderPostUrl(link) @@ -60,6 +78,14 @@ when defined(js): p(title=title): text renderActivity(link.creation) + proc onEmailChange(event: Event, node: VNode, state: ProfileState) = + state.settings.email = node.value + + if state.settings.email != state.profile.get().email.get(): + state.settings.rank = EmailUnconfirmed + else: + state.settings.rank = state.profile.get().user.rank + proc render*( state: ProfileState, username: string, @@ -77,16 +103,34 @@ when defined(js): let profile = state.profile.get() let isAdmin = currentUser.isSome() and currentUser.get().rank == Admin - # TODO: Surely there is a better way to handle this. - let rankSelect = buildHtml(): + let rankSelect = buildHtml(tdiv()): if isAdmin: - select(class="form-select", value = $profile.user.rank): + select(class="form-select", value = $state.settings.rank): for r in Rank: option(text $r) + p(class="form-input-hint text-warning"): + text "As an admin you can modify anyone's rank. Remember: with " & + "great power comes great responsibility." else: - select(class="form-select", value = $profile.user.rank, disabled=""): - for r in Rank: - option(text $r) + input(class="form-input", + `type`="text", value = $state.settings.rank, disabled="") + p(class="form-input-hint"): + text "Your rank determines the actions you can perform " & + "on the forum." + case state.settings.rank: + of Spammer, Troll: + p(class="form-input-hint text-warning"): + text "Your account was banned." + of EmailUnconfirmed: + p(class="form-input-hint text-warning"): + text "You cannot post until you confirm your email." + of Moderated: + p(class="form-input-hint text-warning"): + text "Your account is under moderation. This is a spam prevention "& + "measure. You can write posts but only moderators and admins "& + "will see them until your account is verified by them." + else: + discard result = buildHtml(): section(class="container grid-xl"): @@ -135,7 +179,7 @@ when defined(js): ), onClick=(e: Event, n: VNode) => (state.currentTab = Overview) ): - a(): + a(class="c-hand"): text "Overview" li(class=class( {"active": state.currentTab == Settings}, @@ -143,8 +187,9 @@ when defined(js): ), onClick=(e: Event, n: VNode) => (state.currentTab = Settings) ): - a(): - text "Settings" + a(class="c-hand"): + italic(class="fas fa-cog") + text " Settings" case state.currentTab of Overview: @@ -173,13 +218,26 @@ when defined(js): `type`="text", value=profile.user.name, disabled="") + p(class="form-input-hint"): + text fmt("Users can refer to you by writing" & + " @{profile.user.name} in their posts.") tdiv(class="form-group"): tdiv(class="col-3 col-sm-12"): label(class="form-label"): text "Email" tdiv(class="col-9 col-sm-12"): input(class="form-input", - `type`="text", value=profile.email.get()) + `type`="text", value=state.settings.email, + oninput=(e: Event, n: VNode) => + onEmailChange(e, n, state) + ) + p(class="form-input-hint"): + text "Your avatar is linked to this email and can be " & + "changed at " + a(href="https://gravatar.com/emails"): + text "gravatar.com" + text ". Note that any changes to your email will " & + "require email verification." tdiv(class="form-group"): tdiv(class="col-3 col-sm-12"): label(class="form-label"): @@ -187,3 +245,11 @@ when defined(js): tdiv(class="col-9 col-sm-12"): rankSelect + tdiv(class="float-right"): + button(class="btn btn-link", + onClick=(e: Event, n: VNode) => (resetSettings(state))): + text "Cancel" + + button(class="btn btn-primary"): + italic(class="fas fa-check") + text " Save" \ No newline at end of file From 581eba73e359fd7665957be623b764994666113d Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 13:42:35 +0100 Subject: [PATCH 136/396] Implements post button for requesting a password reset. --- redesign/error.nim | 1 + redesign/postbutton.nim | 77 +++++++++++++++++++++++++++++++++++++++++ redesign/profile.nim | 13 ++++++- 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 redesign/postbutton.nim diff --git a/redesign/error.nim b/redesign/error.nim index e921a1a..a534bde 100644 --- a/redesign/error.nim +++ b/redesign/error.nim @@ -5,6 +5,7 @@ type message*: string when defined(js): + import json include karax/prelude import karax / [vstyles, kajax, kdom] diff --git a/redesign/postbutton.nim b/redesign/postbutton.nim new file mode 100644 index 0000000..913b392 --- /dev/null +++ b/redesign/postbutton.nim @@ -0,0 +1,77 @@ +## Simple generic button that can be clicked to make a post request. +## The button will show a loading indicator and a tick on success. +## +## Used for password reset emails. + +import options, httpcore, json, sugar +when defined(js): + include karax/prelude + import karax/[kajax, kdom] + + import error, karaxutils + + type + PostButton* = ref object + uri, title, icon: string + formData: FormData + error: Option[PostError] + loading: bool + posted: bool + + proc newPostButton*(uri: string, formData: FormData, + title: string, icon: string): PostButton = + PostButton( + uri: uri, + formData: formData, + title: title, + icon: icon + ) + + proc newResetPasswordButton*(email: string): PostButton = + var formData = newFormData() + formData.append("email", email) + result = newPostButton( + makeUri("/resetPassword"), + formData, + "Send password reset email", + "fas fa-envelope", + ) + + proc onPost(httpStatus: int, response: kstring, state: PostButton) = + postFinished: + discard + + proc onClick(ev: Event, n: VNode, state: PostButton) = + if state.loading or state.posted: return + + state.loading = true + state.posted = true + state.error = none[PostError]() + + # TODO: This is a hack, karax should support this. + ajaxPost(state.uri, @[], cast[cstring](state.formData), + (s: int, r: kstring) => onPost(s, r, state)) + + ev.preventDefault() + + proc render*(state: PostButton, disabled: bool): VNode = + result = buildHtml(tdiv()): + button(class=class({ + "loading": state.loading, + "disabled": disabled + }, + "btn btn-secondary" + ), + onClick=(e: Event, n: VNode) => (onClick(e, n, state))): + if state.posted: + if state.error.isNone(): + italic(class="fas fa-check") + else: + italic(class="fas fa-times") + else: + italic(class=state.icon) + text " " & state.title + + if state.error.isSome(): + p(class="text-error"): + text state.error.get().message \ No newline at end of file diff --git a/redesign/profile.nim b/redesign/profile.nim index 6d4fa05..b531aa4 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -16,7 +16,7 @@ type when defined(js): include karax/prelude import karax/[kajax] - import karaxutils + import karaxutils, postbutton type ProfileTab* = enum @@ -32,6 +32,7 @@ when defined(js): currentTab: ProfileTab loading: bool status: HttpCode + resetPassword: Option[PostButton] proc newProfileState*(): ProfileState = ProfileState( @@ -63,6 +64,8 @@ when defined(js): state.profile = some(profile) resetSettings(state) + if profile.email.isSome(): + state.resetPassword = some(newResetPasswordButton(profile.email.get())) proc genPostLink(link: PostLink): VNode = let url = renderPostUrl(link) @@ -244,6 +247,14 @@ when defined(js): text "Rank" tdiv(class="col-9 col-sm-12"): rankSelect + if state.resetPassword.isSome(): + tdiv(class="form-group"): + tdiv(class="col-3 col-sm-12"): + label(class="form-label"): + text "Password" + tdiv(class="col-9 col-sm-12"): + render(state.resetPassword.get(), + disabled=state.settings.rank==EmailUnconfirmed) tdiv(class="float-right"): button(class="btn btn-link", From 37d9fb3bb756bce2e0f8b4a505c24bc76c9c3a5d Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 16:11:01 +0100 Subject: [PATCH 137/396] Removes email from profile stats. --- redesign/profile.nim | 4 ---- redesign/user.nim | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/redesign/profile.nim b/redesign/profile.nim index b531aa4..0794c9d 100644 --- a/redesign/profile.nim +++ b/redesign/profile.nim @@ -167,10 +167,6 @@ when defined(js): text $profile.threadCount dt(text "Rank") dd(text $profile.user.rank) - if profile.email.isSome(): - dt(text "Email") - dd(class="spoiler"): - text profile.email.get() if currentUser.isSome(): let user = currentUser.get() diff --git a/redesign/user.nim b/redesign/user.nim index ba8b494..6966a7b 100644 --- a/redesign/user.nim +++ b/redesign/user.nim @@ -4,6 +4,7 @@ type Rank* {.pure.} = enum ## serialized as 'status' Spammer ## spammer: every post is invisible Troll ## troll: cannot write new posts + Banned ## A non-specific ban EmailUnconfirmed ## member with unconfirmed email address Moderated ## new member: posts manually reviewed before everybody ## can see them From 825c1d654896d7e92c66ecca61a751441350405f Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 16:16:45 +0100 Subject: [PATCH 138/396] s/redesign/frontend/ --- forum.nim | 15 +++++++-------- {redesign => frontend}/builder.nim | 0 {redesign => frontend}/category.nim | 0 {redesign => frontend}/editbox.nim | 0 {redesign => frontend}/error.nim | 0 {redesign => frontend}/forum.nim | 0 {redesign => frontend}/forum.nim.cfg | 0 {redesign => frontend}/header.nim | 0 {redesign => frontend}/index.html | 0 {redesign => frontend}/karax.html | 0 {redesign => frontend}/karaxutils.nim | 0 {redesign => frontend}/login.nim | 0 {redesign => frontend}/newthread.nim | 0 {redesign => frontend}/nimforum.scss | 0 {redesign => frontend}/post.nim | 0 {redesign => frontend}/postbutton.nim | 0 {redesign => frontend}/postlist.nim | 0 {redesign => frontend}/profile.nim | 0 {redesign => frontend}/replybox.nim | 0 {redesign => frontend}/signup.nim | 0 {redesign => frontend}/syntax.scss | 0 {redesign => frontend}/thread.html | 0 {redesign => frontend}/threadlist.nim | 0 {redesign => frontend}/user.nim | 0 {redesign => frontend}/usermenu.nim | 0 redesign/spectre | 1 - utils.nim | 2 +- 27 files changed, 8 insertions(+), 10 deletions(-) rename {redesign => frontend}/builder.nim (100%) rename {redesign => frontend}/category.nim (100%) rename {redesign => frontend}/editbox.nim (100%) rename {redesign => frontend}/error.nim (100%) rename {redesign => frontend}/forum.nim (100%) rename {redesign => frontend}/forum.nim.cfg (100%) rename {redesign => frontend}/header.nim (100%) rename {redesign => frontend}/index.html (100%) rename {redesign => frontend}/karax.html (100%) rename {redesign => frontend}/karaxutils.nim (100%) rename {redesign => frontend}/login.nim (100%) rename {redesign => frontend}/newthread.nim (100%) rename {redesign => frontend}/nimforum.scss (100%) rename {redesign => frontend}/post.nim (100%) rename {redesign => frontend}/postbutton.nim (100%) rename {redesign => frontend}/postlist.nim (100%) rename {redesign => frontend}/profile.nim (100%) rename {redesign => frontend}/replybox.nim (100%) rename {redesign => frontend}/signup.nim (100%) rename {redesign => frontend}/syntax.scss (100%) rename {redesign => frontend}/thread.html (100%) rename {redesign => frontend}/threadlist.nim (100%) rename {redesign => frontend}/user.nim (100%) rename {redesign => frontend}/usermenu.nim (100%) delete mode 160000 redesign/spectre diff --git a/forum.nim b/forum.nim index b270d0e..965456c 100644 --- a/forum.nim +++ b/forum.nim @@ -13,8 +13,8 @@ import import cgi except setCookie import options -import redesign/threadlist except User -import redesign/[ +import frontend/threadlist except User +import frontend/[ category, postlist, error, header, post, profile, user, karaxutils ] @@ -346,8 +346,7 @@ proc getBanErrorMsg(banValue: string; rank: Rank): string = if banValue.len > 0: return "You have been banned: " & banValue case rank - of Spammer: return "You are a spammer." - of Troll: return "You have been banned." + of Spammer, Troll, Banned: return "You have been banned." of EmailUnconfirmed: return "You need to confirm your email first." of Moderated, Rank.User, Moderator, Admin: @@ -1202,11 +1201,11 @@ routes: resp data get "/karax/nimforum.css": - resp readFile("redesign/nimforum.css"), "text/css" + resp readFile("frontend/nimforum.css"), "text/css" get "/karax/nimcache/forum.js": - resp readFile("redesign/nimcache/forum.js"), "application/javascript" + resp readFile("frontend/nimcache/forum.js"), "application/javascript" get "/karax/images/crown.png": - resp readFile("redesign/images/crown.png"), "image/png" + resp readFile("frontend/images/crown.png"), "image/png" get "/karax/threads.json": @@ -1563,7 +1562,7 @@ routes: resp Http400, $(%exc.data), "application/json" get re"/karax/(.+)?": - resp readFile("redesign/karax.html") + resp readFile("frontend/karax.html") get "/threadActivity.xml": createTFD() diff --git a/redesign/builder.nim b/frontend/builder.nim similarity index 100% rename from redesign/builder.nim rename to frontend/builder.nim diff --git a/redesign/category.nim b/frontend/category.nim similarity index 100% rename from redesign/category.nim rename to frontend/category.nim diff --git a/redesign/editbox.nim b/frontend/editbox.nim similarity index 100% rename from redesign/editbox.nim rename to frontend/editbox.nim diff --git a/redesign/error.nim b/frontend/error.nim similarity index 100% rename from redesign/error.nim rename to frontend/error.nim diff --git a/redesign/forum.nim b/frontend/forum.nim similarity index 100% rename from redesign/forum.nim rename to frontend/forum.nim diff --git a/redesign/forum.nim.cfg b/frontend/forum.nim.cfg similarity index 100% rename from redesign/forum.nim.cfg rename to frontend/forum.nim.cfg diff --git a/redesign/header.nim b/frontend/header.nim similarity index 100% rename from redesign/header.nim rename to frontend/header.nim diff --git a/redesign/index.html b/frontend/index.html similarity index 100% rename from redesign/index.html rename to frontend/index.html diff --git a/redesign/karax.html b/frontend/karax.html similarity index 100% rename from redesign/karax.html rename to frontend/karax.html diff --git a/redesign/karaxutils.nim b/frontend/karaxutils.nim similarity index 100% rename from redesign/karaxutils.nim rename to frontend/karaxutils.nim diff --git a/redesign/login.nim b/frontend/login.nim similarity index 100% rename from redesign/login.nim rename to frontend/login.nim diff --git a/redesign/newthread.nim b/frontend/newthread.nim similarity index 100% rename from redesign/newthread.nim rename to frontend/newthread.nim diff --git a/redesign/nimforum.scss b/frontend/nimforum.scss similarity index 100% rename from redesign/nimforum.scss rename to frontend/nimforum.scss diff --git a/redesign/post.nim b/frontend/post.nim similarity index 100% rename from redesign/post.nim rename to frontend/post.nim diff --git a/redesign/postbutton.nim b/frontend/postbutton.nim similarity index 100% rename from redesign/postbutton.nim rename to frontend/postbutton.nim diff --git a/redesign/postlist.nim b/frontend/postlist.nim similarity index 100% rename from redesign/postlist.nim rename to frontend/postlist.nim diff --git a/redesign/profile.nim b/frontend/profile.nim similarity index 100% rename from redesign/profile.nim rename to frontend/profile.nim diff --git a/redesign/replybox.nim b/frontend/replybox.nim similarity index 100% rename from redesign/replybox.nim rename to frontend/replybox.nim diff --git a/redesign/signup.nim b/frontend/signup.nim similarity index 100% rename from redesign/signup.nim rename to frontend/signup.nim diff --git a/redesign/syntax.scss b/frontend/syntax.scss similarity index 100% rename from redesign/syntax.scss rename to frontend/syntax.scss diff --git a/redesign/thread.html b/frontend/thread.html similarity index 100% rename from redesign/thread.html rename to frontend/thread.html diff --git a/redesign/threadlist.nim b/frontend/threadlist.nim similarity index 100% rename from redesign/threadlist.nim rename to frontend/threadlist.nim diff --git a/redesign/user.nim b/frontend/user.nim similarity index 100% rename from redesign/user.nim rename to frontend/user.nim diff --git a/redesign/usermenu.nim b/frontend/usermenu.nim similarity index 100% rename from redesign/usermenu.nim rename to frontend/usermenu.nim diff --git a/redesign/spectre b/redesign/spectre deleted file mode 160000 index 7a6af53..0000000 --- a/redesign/spectre +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7a6af53bca72b549b2cbf1948763348bd5b30dcd diff --git a/utils.nim b/utils.nim index 2d6e97e..028c42e 100644 --- a/utils.nim +++ b/utils.nim @@ -7,7 +7,7 @@ from times import getTime, getGMTime, format let UsernameIdent* = IdentChars # TODO: Double check that everyone follows this. -import redesign/karaxutils +import frontend/karaxutils export parseInt proc `%`*[T](opt: Option[T]): JsonNode = From 9f087be6e75f39b601d89c8398e31ec639c016bb Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 16:33:04 +0100 Subject: [PATCH 139/396] Minor cleanup of old useless files. --- cache.nim | 32 - forum.nim | 2 +- public/css/style.css | 764 -------------------- public/images/Feed-icon.svg | 18 - public/images/bg.png | Bin 149129 -> 0 bytes public/images/forum-posts.png | Bin 174 -> 0 bytes public/images/forum-reply.png | Bin 405 -> 0 bytes public/images/forum-views.png | Bin 387 -> 0 bytes public/images/glow-arrow.png | Bin 8078 -> 0 bytes public/images/glow-line.png | Bin 2261 -> 0 bytes public/images/glow-line2.png | Bin 2295 -> 0 bytes public/images/head-link.png | Bin 180 -> 0 bytes public/images/head-link_hover.png | Bin 620 -> 0 bytes public/images/head.png | Bin 164 -> 0 bytes public/images/logo.png | Bin 111615 -> 0 bytes public/images/smilieys/icon_cool.png | Bin 2546 -> 0 bytes public/images/smilieys/icon_e_biggrin.png | Bin 2424 -> 0 bytes public/images/smilieys/icon_e_confused.png | Bin 2455 -> 0 bytes public/images/smilieys/icon_e_sad.png | Bin 2920 -> 0 bytes public/images/smilieys/icon_e_smile.png | Bin 2382 -> 0 bytes public/images/smilieys/icon_e_surprised.png | Bin 2791 -> 0 bytes public/images/smilieys/icon_e_wink.png | Bin 2526 -> 0 bytes public/images/smilieys/icon_exclaim.png | Bin 897 -> 0 bytes public/images/smilieys/icon_mad.png | Bin 2746 -> 0 bytes public/images/smilieys/icon_neutral.png | Bin 2193 -> 0 bytes public/images/smilieys/icon_razz.png | Bin 2508 -> 0 bytes todo.txt | 14 - 27 files changed, 1 insertion(+), 829 deletions(-) delete mode 100644 cache.nim delete mode 100644 public/css/style.css delete mode 100644 public/images/Feed-icon.svg delete mode 100644 public/images/bg.png delete mode 100644 public/images/forum-posts.png delete mode 100644 public/images/forum-reply.png delete mode 100644 public/images/forum-views.png delete mode 100644 public/images/glow-arrow.png delete mode 100644 public/images/glow-line.png delete mode 100644 public/images/glow-line2.png delete mode 100644 public/images/head-link.png delete mode 100644 public/images/head-link_hover.png delete mode 100644 public/images/head.png delete mode 100644 public/images/logo.png delete mode 100644 public/images/smilieys/icon_cool.png delete mode 100644 public/images/smilieys/icon_e_biggrin.png delete mode 100644 public/images/smilieys/icon_e_confused.png delete mode 100644 public/images/smilieys/icon_e_sad.png delete mode 100644 public/images/smilieys/icon_e_smile.png delete mode 100644 public/images/smilieys/icon_e_surprised.png delete mode 100644 public/images/smilieys/icon_e_wink.png delete mode 100644 public/images/smilieys/icon_exclaim.png delete mode 100644 public/images/smilieys/icon_mad.png delete mode 100644 public/images/smilieys/icon_neutral.png delete mode 100644 public/images/smilieys/icon_razz.png delete mode 100644 todo.txt diff --git a/cache.nim b/cache.nim deleted file mode 100644 index 6023393..0000000 --- a/cache.nim +++ /dev/null @@ -1,32 +0,0 @@ -import tables, uri -type - CacheInfo = object - valid: bool - value: string - - CacheHolder = ref object - caches: Table[string, CacheInfo] - -proc normalizePath(x: string): string = - let u = parseUri(x) - result = u.path & (if u.query != "": '?' & u.query else: "") - -proc newCacheHolder*(): CacheHolder = - new result - result.caches = initTable[string, CacheInfo]() - -proc invalidate*(cache: CacheHolder, name: string) = - cache.caches[name.normalizePath()].valid = false - -proc invalidateAll*(cache: CacheHolder) = - for key, val in mpairs(cache.caches): - val.valid = false - -template get*(cache: CacheHolder, name: string, grabValue: untyped): untyped = - ## Check to see if the cache contains value for ``name``. If it does and the - ## cache is valid then doesn't recalculate it but returns the cached version. - mixin normalizePath - let nName = name.normalizePath() - if not (cache.caches.hasKey(nName) and cache.caches[nName].valid): - cache.caches[nName] = CacheInfo(valid: true, value: grabValue) - cache.caches[nName].value diff --git a/forum.nim b/forum.nim index 965456c..987c2aa 100644 --- a/forum.nim +++ b/forum.nim @@ -8,7 +8,7 @@ import os, strutils, times, md5, strtabs, math, db_sqlite, - scgi, jester, asyncdispatch, asyncnet, cache, sequtils, + scgi, jester, asyncdispatch, asyncnet, sequtils, parseutils, utils, random, rst, recaptcha, json, re, sugar import cgi except setCookie import options diff --git a/public/css/style.css b/public/css/style.css deleted file mode 100644 index 26ca19d..0000000 --- a/public/css/style.css +++ /dev/null @@ -1,764 +0,0 @@ - -a, a * { cursor:pointer; } - -html { margin:0; overflow-x:auto; } -body { - overflow-x:hidden; - min-width:1030px; - margin:0; - font: 13pt Helvetica,Arial,sans-serif; - background:#152534 url("/images/bg.png") no-repeat fixed center top; } - -pre { - color: #F5F5F5; - overflow:auto; - margin:0; - padding:15px 10px; - font-size:10pt; - font-style:normal; - line-height:14pt; - background:rgba(0,0,0,.75); - border-left:8px solid rgba(0,0,0,.3); - margin-bottom: 10pt; - font-family: "DejaVu Sans Mono", monospace; -} -pre, pre * { cursor:text; } -pre .Comment { color:#6D6D6D; font-style:italic; } -pre .Keyword { color:#43A8CF; font-weight:bold; } -pre .Type { color:#128B7D; font-weight:bold; } -pre .Operator { font-weight: bold; } -pre .atr { color:#128B7D; font-weight:bold; font-style:italic; } -pre .def { color:#CAD6E4; font-weight:bold; font-style:italic; } -pre .StringLit { color:#854D6A; font-weight:bold; } -pre .DecNumber, pre .FloatNumber { color:#8AB647; } -pre .tab { border-left:1px dotted rgba(67,168,207,0.4); } -pre .end { background:url("/images/tabEnd.png") no-repeat left bottom; } -pre .EscapeSequence -{ - color: #C08D12; -} - -.tall { height:100%; } -.pre { padding:0 5px; font: 11pt "DejaVu Sans Mono",monospace; background:rgba(255,255,255,.30); border-radius:3px; } - -.page-layout { margin:0 auto; width:1000px; } -.docs-layout { margin:0 40px; } -.talk-layout { margin:0 40px; } -.wide-layout { margin:0 auto; } - -#head { - height:100px; - margin-bottom: 40px; - background:url("/images/head.png") repeat-x bottom; } -#head.docs { margin-left:280px; background:rgba(0,0,0,.25) url("/images/head-fade.png") no-repeat right top; } -#head > div { position:relative } - - #head-logo { - position:absolute; - left:-390px; - top:0; - width:917px; - height:268px; - pointer-events:none; - background:url("/images/logo.png") no-repeat; } - #head.docs #head-logo { left:-381px; position:fixed; } - #head.forum #head-logo { left:-370px; } - - #head-logo-link { - position:absolute; - display:block; - top:10px; - left:10px; - width:236px; - height:85px; } - #head.docs #head-logo-link { left:-260px; } - #head.forum #head-logo-link { left:30px; } - - #head-links { position:absolute; right:0; bottom:13px; } - #head.docs #head-links, - #head.forum #head-links { right:20px; } - #head-links > a { - display:block; - float:left; - padding:10px 25px 25px 25px; - color:rgba(255,255,255,.5); - font-size:14pt; - text-decoration:none; - letter-spacing:1px; - background:url("/images/head-link.png") no-repeat center bottom; - transition: - color 0.3s ease-in-out, - text-shadow 0.4s ease-in-out; } - #head-links > a:hover, - #head-links > a.active { - position: relative; - color:#1cb3ec; - text-shadow:0 0 4px rgba(28,179,236,.8); - background-image:url("/images/head-link_hover.png"); } - - #head-links > a.active:after { - display: block; - content: ""; - width: 771px; - background: url("/images/glow-arrow.png") no-repeat left; - height: 41px; - position: absolute; - left: 50%; - bottom: -49px; - transform: translateX(-618px); } - - #head-banner { width:200px; height:100px; background:#000; } - - #glow-line-vert { - position:fixed; - top:100px; - left:280px; - width:3px; - height:844px; - background:url("/images/glow-line-vert.png") no-repeat; } - - -#body { z-index:1; position:relative; background:rgba(220,231,248,.6); color:black; } -#body.docs { margin:0 40px 20px 320px; } -#body.forum { margin:0 40px 20px 40px; min-height: 700px; } - - #body-border { - position:absolute; - top:-25px; - left:0; - right:0; - height:35px; - background:rgba(0,0,0,.25); } - - #body-border-left { - position:absolute; - left:-25px; - top:-25px; - bottom:-25px; - width:35px; - background:rgba(0,0,0,.25); } - - #body-border-right { - position:absolute; - right:-25px; - top:-25px; - bottom:-25px; - width:35px; - background:rgba(0,0,0,.25); } - - #body-border-bottom { - position:absolute; - left:10px; - right:10px; - bottom:-25px; - height:35px; - background:rgba(0,0,0,.25); } - - #body.docs #body-border, - #body.forum #body-border { left:10px; right:10px; } - - #glow-line { - position:absolute; - top:-27px; - left:100px; - right:-25px; - height:3px; - background:url("/images/glow-line.png") no-repeat left; } - #glow-line-bottom { - position:absolute; - bottom:-27px; - left:-25px; - right:100px; - height:3px; - background:url("/images/glow-line2.png") no-repeat right; } - - #content { padding:40px 0; } - #content.page { width:680px; min-height:800px; padding-left:20px; } - #content h1 { font-size:20pt; letter-spacing:1px; color:rgba(0,0,0,.75); } - #content h2 { font-size:16pt; letter-spacing:1px; color:rgba(0,0,0,.7); margin-top:40px; } - #content p { color: #1D1D1D; margin: 5pt 0pt; } - #content a { color:#CEDAE9; text-decoration:none; } - #content a:hover { color:#fff; } - #content ul { padding-left:20px; } - #content li { margin-bottom:10px; text-align:justify; } - - #talk-heads { overflow:auto; margin:0 8px 0 8px; } - #talk-heads > div { float:left; font-size:120%; font-weight:bold; } - #talk-heads > .topic { width:45%; } - #talk-heads > .detail { width:15%; } - #talk-heads > .activity { width:25%; } - #talk-heads > .users { width:15%; } - #talk-heads > div > div { margin:0 10px 10px 10px; padding:0 10px 10px 10px; border-bottom:1px dashed rgba(0,0,0,0.4); } - #talk-heads > .topic > div { margin-left:0; } - #talk-heads > .activity > div { margin-right:0; } - - #talk-thread > div { - background-color: rgba(255, 255, 255, 0.5); - } - #talk-thread > div, - #talk-threads > div { - position:relative; - margin:5px 0; - overflow:auto; - border-radius:3px; - border:8px solid rgba(0,0,0,.8); - border-top:none; - border-bottom:none; - } - #talk-threads > div - { - line-height: 150%; - background:rgba(0,0,0,0.1); - } - #talk-threads > div:nth-child(odd) { background:rgba(0,0,0,0.2); } - #talk-thread > div > div, - #talk-threads > div > div - { - float:left; - text-overflow: ellipsis; - overflow: hidden; - font-size: 13pt; - } - #talk-threads > div > div > div { margin: 5px 10px; } - #talk-thread > div > div > div { margin: 15px 10px; } - #talk-thread > div > .topic - { - margin-top: 15pt; - white-space: normal; - } - #talk-thread > div > .topic > div - { - margin-left: 15px; - } - #talk-thread > div > .topic > div > span.date - { - position: absolute; - top: 5px; - right: 10pt; - border-bottom: 1px dashed; - color: #3D3D3D; - } - #talk-threads > div > .topic { width:45%; } - #talk-threads > div > .users { width:15%; overflow:hidden; height: 30px; } - #talk-threads > div > .users > div > img - { - margin-bottom: -4pt; - cursor: help; - width: 20px; - } - #talk-threads > div > .detail { width:16%; overflow:hidden; } - #talk-thread > div > .author, - #talk-threads > div > .activity { - overflow:hidden; - background:rgba(0,0,0,0.8); - color: white; - - } - #talk-thread > div > .author { - width: 15%; - } - #talk-threads > div > .activity { - width:24%; - font-size: 9pt; - } - #talk-threads > div > .activity a - { - color: #1CB3EC; - } - #talk-threads > div > .activity a:hover - { - color: #ffffff; - } - #talk-thread > div > .author { - height: 100%; - position: absolute; - } - #talk-thread > div > .author a, - #talk-threads > div > .author a { color:#1cb3ec !important; } - #talk-thread > div > .author a:hover, - #talk-threads > div > .author a:hover { color:#fff !important; } - #talk-threads > div > .topic .pages { float:right; } - #talk-threads > div > .topic .pages > a - { - margin-right: 5pt; - } - #talk-threads > div > .topic > div > a - { - font-weight:bold; - white-space: nowrap; - } - #talk-threads > div > .topic > div > a:visited { color: #1a1a1a; } - #talk-threads > div > .detail > div { float:left; margin:0; } - #talk-threads > div > .detail > div > div { margin-left:15px; padding: 5px 5px 5px 22px; } - #talk-threads > div > .detail > div { width:50%; } - #talk-threads > div > .detail > div:first-child > div { background:url("/images/forum-views.png") no-repeat left; cursor: help; } - #talk-threads > div > .detail > div:last-child > div { background:url("/images/forum-posts.png") no-repeat left; cursor: help; } - - #talk-thread > div { margin:20px 0; min-height:160px; padding-bottom: 10pt; } - #talk-thread > div > .author > div > .avatar { margin-top:20px; } - #talk-thread > div > .author > div > .name { } - #talk-thread > div > .author > div > .date { font-size: 8pt; color: white; } - #talk-thread > div > .topic { width:85%; padding-bottom:10px; margin-left: 15%; } - #talk-thread > div > .topic pre, #markhelp pre.listing { - overflow:auto; - margin:0; - padding:15px 10px; - font-size:10pt; - font-style:normal; - line-height:14pt; - background:rgba(0,0,0,.75); - border-left:8px solid rgba(0,0,0,.3); - margin-bottom: 10pt; - font-family: "DejaVu Sans Mono", monospace; - } - #talk-thread > div > .topic a, #talk-thread > div > .topic a:visited, - #markhelp a, #markhelp a:visited - { - color: #3680C9; - text-decoration: none; - } - #talk-thread > div > .topic a:hover - { - text-decoration: underline; - } - #talk-head, - #talk-info { - overflow:auto; - border-radius:3px; - border:8px solid rgba(0,0,0,.2); - border-top:none; - border-bottom:none; - background:rgba(0,0,0,0.1); } - #talk-head { margin-bottom:20px; } - #talk-info { margin-top:20px; } - #talk-head > div, - #talk-info > div { float:left; } - #talk-head > .info, - #talk-info > .info { width:80%; } - #talk-head > .info-post, - #talk-info > .info-post { width: 85%; } - #talk-head > .user, - #talk-info > .user { width:20%; background:rgba(0,0,0,.2); } - #talk-head > .user-post, - #talk-info > .user-post { width: 15%; background:rgba(0,0,0,.2); } - #talk-info > .user-post .reply { font-weight:bold; padding-left:22px; background:url("/images/forum-reply.png") no-repeat left; } - #talk-info > .user-post a span - { - color: #CEDAE9 !important; - } - #talk-info > .user-post > a > div:hover > span - { - color: #fff !important; - } - #talk-head > div > div, - #talk-info > div > div, - #talk-info > div > a > div { padding:5px 20px; color: #1a1a1a; } - #talk-head > div > div { color: #353535; } - #talk-head > .detail > div { float:left; margin:0; } - #talk-head > .detail > div > div { padding-left:22px; } - #talk-head > .detail > div:first-child > div { background:url("/images/forum-views.png") no-repeat left; } - #talk-head > .detail > div:last-child > div { background:url("/images/forum-posts.png") no-repeat left; } - - #talk-nav { margin:20px 8px 0 8px; padding-top:10px; border-top:1px dashed rgba(0,0,0,0.4); text-align:center; } - #talk-nav > a.active { text-decoration:underline !important; } - #talk-nav > a, #talk-nav > span, #talk-info > .info-post > div > a, - #talk-info > .info-post > div > span { margin-left: 5pt; } - - .standout { - padding:5px 30px; - margin-bottom:20px; - border:8px solid rgba(0,0,0,.8); - border-right-width:16px; - border-top-width:0; - border-bottom-width:0; - border-radius:3px; - background:rgba(0,0,0,0.1); - box-shadow:1px 3px 12px rgba(0,0,0,.4); } - .standout h3 { margin-bottom:10px; padding-bottom:10px; border-bottom:1px dashed rgba(0,0,0,.8); } - .standout li { margin:0 !important; padding-top:10px; border-top:1px dashed rgba(0,0,0,.2); } - .standout ul { padding-bottom:5px; } - .standout ul.tools { list-style:url("/images/docs-tools.png"); } - .standout ul.library { list-style:url("/images/docs-library.png"); } - .standout ul.internal { list-style:url("/images/docs-internal.png"); } - .standout ul.tutorial { list-style:url("/images/docs-tutorial.png"); } - .standout ul.example { list-style:url("/images/docs-example.png"); } - .standout li:first-child { padding-top:0; border-top:none; } - .standout li p { margin:0 0 10px 0 !important; line-height:130%; } - .standout li > a { font-weight:bold; } - - .forum-user-info, - .forum-user-info * { cursor:help } - -#foot { height:150px; position:relative; top:-10px; letter-spacing:1px; } -#foot.home { background:url("/images/foot.png") repeat-x top; height:200px; } -#foot.docs { margin-left:320px; margin-right:40px; } -#foot.forum { margin-left:40px; margin-right:40px; } -#foot > div { position:relative; } -#foot.home > div { width:960px; } -#foot h4 { font-size:11pt; color:rgba(255,255,255,.4); margin:40px 0 6px 0; } -#foot a:hover { color:#fff; } - - #foot-links { float:left; } - #foot-links > div { float:left; padding:0 40px 0 0; line-height:120%; } - #foot-links a { display:block; font-size:10pt; color:rgba(255,255,255,.3); text-decoration:none; } - #foot-legal { float:right; font-size:10pt; color:rgba(255,255,255,.3); line-height:150%; text-align:right; } - #foot-legal a { color:inherit; text-decoration:none; } - #foot-legal > h4 > a { color:inherit; } - - #mascot { - z-index:2; - position:absolute; - top:-340px; - right:25px; - width:202px; - height:319px; - background:url("/images/mascot.png") no-repeat; } - -article#content -{ - width: 80%; - display: inline-block; -} - -div#sidebar -{ - background-color: rgba(255, 255, 255, 0.1); - - border-left: 8px solid rgba(0, 0, 0, 0.8); - border-right: 8px solid rgba(0, 0, 0, 0.8); - border-bottom: 8px solid rgba(0, 0, 0, 0.8); - border-radius: 3px; - - width: 15%; - margin-top: 40px; - - display: inline-block; - float: right; - - color: #FFF; -} - -div#sidebar .title -{ - background-color: rgba(0, 0, 0, 0.8); - color: #FFF; - text-align: center; - padding: 10pt; -} - -div#sidebar .content -{ - padding: 12pt; - overflow: auto; - -} - -div#sidebar .content .button, .runDiv>button, .runDiv>a -{ - background-color: rgba(0,0,0,0.2); - text-decoration: none; - color: #FFF; - padding: 4pt; - float: right; - border-bottom: 2px solid rgba(0,0,0,0.24); - font-size: 11pt; - margin-top: 5pt; -} - -div#sidebar .content .button:hover -{ - border-bottom: 2px solid rgba(0,0,0,0.5); -} - -div#sidebar .content input -{ - width: 99%; - margin-bottom: 10pt; - margin-top: 2pt; - - border: 1px solid #6D6D6D; - font-size: 12pt; -} - -div#sidebar .content a.avatar img -{ - float: left; - margin-top: 5pt; -} - -div#sidebar .content a.user -{ - background-color: rgba(0, 0, 0, 0.8); - color: #1cb3ec; - padding: 5pt; - width: 93%; - display: block; - text-align: center; - text-decoration: none; -} - -div#sidebar .content a.user:hover -{ - color: #FFF; -} - -div#sidebar .user .button -{ - float: left; - margin-top: 5pt; - width: 52.5%; -} - -div#sidebar .user .logout -{ - clear: left; - width: 52pt; - text-align: center; - margin-left: 0pt; -} - -div#sidebar .user .avatar > img -{ - margin-right: 5pt; -} - -div#sidebar .content .search -{ - text-align: center; - margin: auto; - display: block; - width: 95%; -} - -div#sidebar .content a#passreset { - color: #CEDAE9; - font-size: 9pt; - display: block; - text-decoration: none; - margin-top: -4pt; -} - -div#sidebar .content a#passreset:hover { - color: #fff; -} - - - -span.error -{ - float: left; - width: 100%; - color: #FF4848; - text-align: center; - font-size: 10pt; - background-color: rgba(0,0,0,0.8); - padding: 5pt 0pt; - font-weight: bold; -} - -section#body #content span.error -{ - width: 25%; - margin-top: 5px; - margin-bottom: 5px; -} - -article#content form -{ - border-right: 8px solid rgba(0, 0, 0, 0.2); - background-color: rgba(255, 255, 255, 0.1); - padding: 10pt 20pt; -} - -article#content form > input, article#content form > textarea -{ - border: 1px solid #6D6D6D; -} - -article#content form > input[type=text] -{ - width: 70%; - min-width: 500px; -} - -article#content form > textarea -{ - width: 100%; - height: 200px; -} - -article#content form > input:focus, article#content form > textarea:focus -{ - border: 1px solid #1cb3ec; -} - -hr -{ - border: 1px solid #3D3D3D; -} - -.activity .isoDate -{ - display: none; -} - - -/* highlighting current post */ - -div:target { - background: rgba(139, 218, 255, 0.25) !important; -} - -/* full-text search */ - -.searchResults h4 b, -.searchResults h5 b { - border-bottom: 1px dotted #ffffff; -} -.titleHeader { - margin-right: 1em; - color: #121212; - font-weight: bold; -} - -.postTitle b { - border-bottom: 1px solid #D7300C; -} - -.postTitle a:hover { - text-decoration: none !important; - border-bottom: 1px solid #D7300C; -} - -.searchForm { - margin-top: 0px; - margin-right: 1em; - margin-bottom: 0px; - margin-left: 1em; -} - -.searchHelp { - color: #000000 !important; - float: right; - font-size: 11px; - left: -17px; - top: 3px; - position: relative; - text-decoration: none; - text-shadow: #FFFF00 1px 1px 2px; - cursor: help; -} - -#talk-thread.searchResults > div > div > div { - margin: 15px 8px; -} - -form.searchNav { - display: inline; - border: none !important; - background: transparent !important; -} - -.searchNav input { - background: #858C97; - color: #000000; - border: 1px solid #333; -} - -.clear { - clear: both; - height: 1px; -} - -img.smiley { - width: 20px; - height: 20px; - vertical-align: middle; - margin: 0; -} - -img.rssfeed { - width: 16px; - float: right; - margin-top: 10px; -} - - -#markhelp { - width: 80%; - background-color: #cbcfd6; - padding: 2pt 10pt; - margin-top: 10pt; -} - -#markhelp .markheading { - background-color: #6fa1ff; - text-align: center; -} - -#markhelp table.rst { - width: 100%; - margin: 10px 0px; - font-size: 12pt; - border-collapse: collapse; -} - -#markhelp table tr, #markhelp table td { - width: 50%; - border: 1px solid #7d7d7d; -} - -#markhelp table td { - padding:4px 9px; -} - -blockquote { - padding: 0px 8px; - margin: 10px 0px; - border-left: 2px solid rgb(61, 61, 61); - color: rgb(109, 109, 109); -} - -blockquote p { - color: rgb(109, 109, 109) !important; - -} - -.runDiv > hr { - border: 1px solid #80828d; -} - -.runDiv > a { - color: #FFF !important; - padding: 3.5pt; - margin-right: 3pt; -} - -.runDiv > button, .runDiv > a { - cursor: pointer; - border: none; /* remove border from runDiv>button */ - border-bottom: 2px solid rgba(0,0,0,0.24); - background-color: #80828d; -} - -.runDiv>button:hover, .runDiv > a:hover { - text-decoration: none !important; - border-bottom: 2px solid rgba(255, 255, 255, 0.5); -} - -.runDiv > .resDiv { - width: 80%; - padding: 0.2em 1em 0.2em 1em; - display: inline-block; -} - -.successComp { - color: lightgreen; -} -.failedComp { - color: lightcoral; -} -.date > a, .date > a:hover, .date > a:visited { - color: #3D3D3D !important; - text-decoration: none !important; -} \ No newline at end of file diff --git a/public/images/Feed-icon.svg b/public/images/Feed-icon.svg deleted file mode 100644 index b325149..0000000 --- a/public/images/Feed-icon.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/public/images/bg.png b/public/images/bg.png deleted file mode 100644 index 91f335913d7c01b14e7b063354768f1f9b955291..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 149129 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

_;vtA}lQoQQ}qP3*`qO(sKh6w*9 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 N002ovPDHLkV1kn-m2Ch3 diff --git a/public/images/forum-posts.png b/public/images/forum-posts.png deleted file mode 100644 index 4c4c63b06f1f472393b8dbde2e4da822dbd3560a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^0w6XA8<1SE`<)7qVoUONcVYMsf(!O8p9~b?Ebxdd z2GVaqm~nOfyScI&x{QBZMM^Smg`H)0Bv9FR$0rX*D47#hQZU- K&t;ucLK6VE8ZSx! diff --git a/public/images/forum-reply.png b/public/images/forum-reply.png deleted file mode 100644 index 12e9c60ed9bcbaf0e3bfdbc9824f64b5f563ce61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 405 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf2?p zUk71ECym(^Ktah8*NBqf{Irtt#G+J&^73-M%)IR4pH{X^)F~tM@TPfchjX;2tv+_BIoVdx?cbDFWw{MD zeVr49m9MaQ6qsnMxowc^KXP;G=Ub&O(|`LP;Nt5(IsafNJ%I)}|m>%}eR(CZS zRVMRs&il{Fa$JI!ZC9YwLcJu`zbDkE3BE4OX8nG|WAWUIw|eU~Hi%r$khD!ISjm0N z`^$thkFR_^oU2u;JbTv1tN2gKvE3Ct$@6&6@$8FpjvuY*QT#S7E=nZv&L4%UdmKBs v6I*TWb$s0=Xdv(UWsPBioBV9IWxMaUTgmY*%DMj!7=#R-u6{1-oD!MaT+^qm#z$3ZS55iEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$QVa}?44y8I zAsXkWUb5wO43uF1uvucS%EU;`=FZ=?DHB}9eoBb(Z1R|tpWqOfqL37#@g(*5^&S7* z|F34h{@~jBy#LQD&+XKApSf(MN{rwN2l;OWEsP+J9BZ=ZAaj?&XnB_(1Y!ok5IRab)<;NaZ5clR#@#J_uM za~8GUeei8HRbe=P{=V{BKc(EY5W1_G!0&p@{QclUc{6?Q8VS7AwUr5$fh1%UB2RBv z)NycF7}a5l&wS?(vi-cwrZPH~7?@O}-f`C1%gPF*XJ>;@)si{`D(4abtVqxR{tL$; ztxw(j4SKA4P~vc%?oHP@|BmFWQtwR3;k(6jC5 zXv`RY&!Bs+$9oy5Umf{Oh7O)0s7JgCTZC%V{WcZY>=r3bz}~4QC+N|8Fq+MQ4`Z=0s=O{=4PQ}Ql#JhnmQ^=MXn@g%Cs zEDGBChwH_H5Xdv(^yexKBWLWCocHc9Rz80Sri;FLO|@(Cu;y(8s14k^|FydQV*wwR zt*~SDnAUKk*;^V9D#||=vy$D;+%b4{pEw`VEj`s=_l#j08@i-__U4QN;r&I4zsn>s z{k2+urCN8#0-JZ!9XLnTB}@5w+nDk-tMuk;+O<}inYqXD*Cx+$DBZqzWsmK#*tSDj zgFUn~Os`{R9ep*;Zb!BDn>Dr#q-M5H(M$@>wg-ZI_miM*sqqvaQ*k1PUtpO{n7DdX z{XH~PbCrL7MpN~xvuC?J9ISgyqQUzYEBU?Ddq2VmQLHK`oU`WHBUPk4`a@7&Z@`fB zdR6mMM)cuOeV&j`4Ep_Y^ak&5UiWr2m=^K26Uzdkr)qJ|UQ{4ER3WzD+xm0J9gS#M z)TsEO1MFAO#6{m}%nZo_VI4%BC{K2{H%~@GXef+T2k;S^-dj$UyZ)u$7sFe2AQ8x|XH-OJkjQ*$J8X z*En^6fFVziZ_t|;6v*SW$Ar~f#-L3vZdgR|!)H#f;%q>VsS9~VLQeJhzoxz4dKTH+ zB(mhuarJ)Oh)-af$FNDtU|fWik3pJ8{ftvTXz^(>#R8D~SYnMQNdD@2U6LQ=Eb-Ml zNTyCnod~mSV?!?hP|X1K#3-Nu9T!Sh537EAWX*Z96)$Oij3QMg8T9D2*118~MkcE&e;{0LM@Z1awh>SW^B~`LT*&Xq7f}$x+-8~8BYw}@cXllX6m(Y-&~SugqPVy#xCSj zDm$2BiXET?!y!6!8ARDNT4mM|>mm94Ssc9{Lnyi!>nh%=@u?@4NoxGB&6=qrDMj^C zxxP+Fs=G8BevqRqxQXS79`vjF_WdmVy=j`c-Hywy@h5;GiZ#>n zS6p}$@jmfzfTW93^W&UEoxN0E=O3#tMALOF?Z`!9L-IQd9Dz|`^uM?hkoCTC9-wY}hB!C({>Hk`>a({usgQNkqA$ z))tlWdO#hW`H_h_w_fO-a<6K}9{UwJoGpjvMSSY1gJ=R&C+)N}%dK>slS}L`_Js6- zE9r+7cIEwbMf>;c^Yt`feCldU-XU_4_93jm^**l-PvM9Uo$O1H5@+Un>RI}vy~^I1 zX!*$rqU^|Lk(!8ac{uWn{MOmu9BsiqPFWkeltcW?l*8t-Arkt5Ow!uA>cyv~FV#bJ zWuvHTJ9zZI)eU7|d4HLn!w@~Aq$*Mg;JLSKosIoI` zR@zzn5Mx@aob`m2%@^VM_(;dMB3tH0S}+W8 zQilofdrJ;f19nI{hcPi6JQ>f?UWpJ%Fd^n9Y?i?S^zHd zTh&S13ppCqqM4zice^6Y;MGeBM?Iu`gzP2Ijabfn3+OUM-1*QUvHhW*_4?9l{+-GY zMr~*!FP_AbIt4C-yq%n;MMA3U(XP~fll1Gw-LTZ zq(q{~WW#TxrTE$=ws)P>HldfbhrQ<`Q!ZU8zr*HbxN#rap(+kSRuvB!vt@zfe8(Fh z_SCLvh5te7Mrp45dg^@Y4BXm|bd@ ziP>Cna1XDi-$x#R?4{4nJ9JvRgA**V%cZn<7eBIdEU>pE%gpn=(lX6{2>1H0^etmf z#LE?k%k!NtiCC8h9fE6kS+}h%IOr+)bht`DaVMMasfmZi{Q zy(q*QA(YNqBvfY{t5{|;QZ*Y&*JErCi8?0qh&~=#r`e!I-l?c>wvVpg*b$OYMJ|a0 z#~mg_GqS2$mv>|#8}Gr@L~|8fJDEZoBQ}{-ouc}{#$_JiG)mBlucBcz&aany8S>#( zeP62po--8Xg`e={3RK*VN|5n2Dtl*w6Jj+pPP+ay&MrC1rbgTBhh9!>+f9a}ffVKd z=GSq{KZ=oLaB$7rS4x^=(48KdAI!_ILIoPeXXmRsR+~&MmKSTszrV5?Xbe4-bo51H zsB`+~x`;=#bFMZ~j>x%b$(7d!SypUe+wWa&{ip?QuiVSGj&mbQE@@WG+luXSEU_a3 zi1TM@3;rcqam;WY@tU!0cHh$2T%VOzT;38qJRj)h2n%ffm z=Q!llXXVZEQk6nKQNT(UQ9wge7j?OqbXUv>w4sPe%71e3oU89we4$D+qWJ54)rb@6 zV-YcecO3Yi+`{vF>rvI-!!m=gN)Fs0C}o|90B}N$I{OG3SY^#J*9g($#^Y&-1W-fdY|F0 z9rSGk$hH@>S=$!wBo~Scsn(5i%>--A%;XALvN$vM;U|`t7X;qtm_Q1-sI76i?8$m7 zg8L2NXtx58l(iQw>L~9kSO(zoI}w*?oEHU%QR=+@j0wLy-nNr6!a}ka{xraLj-5t` z+46`_z8pP_Sh}detocQ-U9tmmkam9f%X`ffict4*UoMN4HH*%ujs#y#WBa6&~0u+i2VsXmt}Ha^zqH+ zz-E=-uB&nherq>xIEa$npqX){m$tiYGM`L+-pJ5Bw3z(YW@C8auxB6j<57B%!}2V% zKK{YR&2%nFNj1zm80DokKqnz5qw{>tK*rVQ#{6nX=G4Wx4`WfMU+aGnpByZ4&g9mJ zmH9s0>fih)sIirCzikO5-dsC zRF{bMr}8RBHmSt>76pz{ZqAuS1SSeEVIc7=z z^L+eB7_M{uNY$Vj(mn5d+}>bx%M`dH?AlPTdd6rEX2eVzGy#0 z4@frgwYwq7O0G8WGvUfCyv zJ5qz}KT=QAvE}Jpy)C@z+{sQZ%+;}M+{g(wwmykRDlk|wIAf?DlVI0i-^0tx@A2lL zFlUrHEH+x3Zxu?VdZkT5m6v)`Zim9}aWW*bH_@;kI+icyx;d8MR3JW(h{@i1VFF&bQqv81RFKf`t^Bf~{f*mzdT!T>9F`VJ zVD?$sBVP@{uJ@646)_OZ{*Jz>KKSX!fNPDUV~#4l^-^f-rg>7^m1oi}!nU(4p(~ui zb}iN_>(P7M;O2to{E3a)Kw5T@_kT*Ay4;k)DOYEsZ$S=pbhi1pciZIWVLNgm@rA|2 zmn_Ah$o5lg0KLT|OAKjlc+A=1a4&@g#gR94rs;8Om+~99nTL!7^Ji%l`?i3>LOWZk zUM3&0qn1KPgI`xmGdOp_So+>4dWHV-_>CEgs+WXTBEB(J1=m=O3=9`#Br03bkrZd9 zHtW8W9W3W|BQ;9iL)-G| z?B^9c-Btg~Vr#$zoaH73mCg;?nFoRk@r!= zJS?t^14qe6D#xlz%5D0lZHPn4A=8@;j*=mpsP9b3t9!^Mq1!7uWlWS@K*50a#JBv* zn_i;V11|e>dAzjr6Q^KW+cD+JmrCmr5i;I8t@80HpGcOLQBQ>nUuK+$kX3CnVB)oZ zcX)TbGV~bZ;?0#!l=Rp9Ny@Fc(93) z&C$bS@5bskL?RLEaxqE#iY~>u3D!L^`h3`OmaZaHVRu?=(Q)wHDHVmRKA&)+Ny~)0 z1Mt#-{Z=vo$%X#guW!I-J@4pvXCVoB`u`9L5PqK!IM*OZspV zbv~m)J!wp-;m`*rE9d3;zHo&^hZs^IqZh>TfU#EL?ATJN!;KhuoQ$lvNmHyO;p>eg^8-QTUB1WZQ<9+4FE)0?`DTGnpPN4dK7f9bDGkFo3+dPtkzZHHm7E%|5Dd!|f;Hc-9bE7)$#PkFvYJZPmaj9BpluuO{vIj049R(ja( zHgU>ws}Qh$L+&9LC@FVChLT)63&(ru_Z+h3>eIz18))n*dU{Kpu|a1i8~8Z#dvwik zEH{U2HCGC|l1q3ue07$eC&S>A`Zj+VQ{0q&uWI^I9C5o;> z)AibYzv_z7BYH0U=^g^-{4VT1cH);BmaoM2?2YAwP4Mmh;t_li^C1*b-MFzt>0Zmk z4LCoqT^tIkdF*lyIm9-pzWu5Rl-QIHVtQfqdu5nb;oYKA~S z{W>>7LOI$;w$O1x=&DJnwBOZ6GOf;ohl?lQluF*uqd`t?HJ~$YysamGPZa@#!bU|i!7A%A{_%ASA(byGomkWsZW(E ztKD-E%!ezO?te0!1%@;Rz;3-^rS|)Fpbtp`;NAL|hOC?lzDDk%W#F8l#F{Jr= zU1Es<%;xT+^MV8L(`xEn3kkGxV=%Df{piK_G2_ovsHtuqfCqaedo6He4sBKInfdBq z`C4=D_Ta_RUGHf4u!r$wSWy zyi1@q#F9X@)a)Z|C}AdKw$f+o*`DgaHy%(uho9>}#lS&LZ8xezU;o@!gJK}mdm9=Q z49puD6(Yrrx1j*&s|dE%-97zTu_M=Z$+3}fDCxnT_{#Bxeih$nB;RKO%aDW`+pNDc zM|Z&w%=fx`5K9%@8Wfa8XAeT{q_Q!w0o9=HvkZcO&8IWut)P#TxZW+m5wqIBn_mPM zscqJhWz}zfHg!^$35$mPtODagOaK&IA<>S>xTt?H-PDjDL3o<4*dz+j!rx(R4PJkJ zPb}@MjTbZaoDj8M!~zJ zt*hCeTutWhB){@s}4+Jrn{OXrl;uZKq`NT8RpvI2bnf)fw$ z{|tc{=CJK312xDO6o5H+Y_tAH9pS8LK&@y>WEB|9yLgEW<*JwsE|Vo>Up*IC)%Dlb z0_dp1^3q@-|D$!F3AAc{^BL42J826I;Q)-OL4}CJt^FO?A~h6U_uEro3Z)?ARc%Qx zFJ6MovySV0r?yI#>o3!IZAcbkCU+Lxi%XbEP7Sx!OJLN3Y=h8 zHT*lbyt^Zmf$nqqM)pG>N>phe2xY4d?2AnTD40r##sRe~ll$vudc8NPi@bPK*OY@J!tW|05@^iM?jHXyb97KPkccjQa(Es% zZ1qXNol6fut?}7bjjG?GJODWN6`=llbjk{hV5A8vo@U+B(}n|e5blLEHb=F99s5EH za93;zD%^YX;6G!eC?Xl;08~gB5O$iL>j}}^rYPWW5+**NQZZ4ZLUIVmPd=$>b#}j` z&BzWO@ZwQGiw_(u2uN#vmna8!^ z3%RouK?PTiO?|_EY%Bd==>cL#<-9$dBmZMt5(mJMiUWXm7iP?qw19P*P(4V1U5-uP zFPZLd-ip9x}yLF~Y2o4|3{@!OB)s+i0TJYU`=da3Y0+@{qLBE5i z{|D*`w8Nz0RR+jdw$wY$Xm9FmI3n67@Mx`TJ~V(>T<*sDYk7Ns@u}gSU=68JP$4az zCdssB+OMbY=Dy`6U%c746zo9*YwD7LpnsOnzsq^1z-3(!iV|1gh(u>f>ZbH6qAuly zs`(KWi~ri-x{{z=anR{XaIwqIW!f}N!|*Cm2=Fo)mKXh>QDs9r#1YhRLv>(7`Z!

SB%ADnC-=EqhoL zxDo1nm6|u&W0?QZ_-pvjkmKDc{$Js`;<0tGv&00pj(&QKKxD?1yVu?=6%&$yn|)gq zw!?3W-ZQ}d11rbY@!>&CfH@c_PMYa%%Fhe`sm1^Dyjat$wUCvFk~@f=u0oPKKJ5Z* z&90~VpN_(815rRl3m7o(vpnCj@Fcs&BT{BrX~W40$-15`#<#2O1%Oo`UK{uJ&fM%t z{(oGNmj(}VZVg#J?nkj4-EKG`d#+?|vPA`ema5nPGAkf=uk{?rGI)M^oXLfO`uwhPj-#%u11pDGqy7*0UGIwk diff --git a/public/images/glow-line.png b/public/images/glow-line.png deleted file mode 100644 index 6607bdee90e9b89858ca7cd0c243c7830c71a4e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2261 zcmV;`2rBo9P)OxV zSEvt@yMEgdTR&fmL*{|j_OwjT1QR%fus<>fJ^lEl7DitZ4n}qExaEA6PBmpdTZ>3J zKNT~>y@rxN&O{gM0T@bXLdy$^(Goc&d4YppvK9pDIy0L#?UM&xv+ zx#N?Gevk4pJlJ{5foXe=SzWDsvCpd4Ze;y)V9&3YYL-1uh*9s z@35-p@B+fYM_cT_A?)_HPdD*TiK9<8>W1O#HTnqr z4%58)x)e?k~Pd#aPWFGF|qxh8+S7xL>vxO0c} z^V3l7j+7^-;Q%P(o61LLDveZjUWR%px=T7ejE?6TO{LsSG(H)MTeoWQ>70I}L7L_$HNs+;NL5-Weu+bxKKP;WngQxPG8=W{L!ri&op z87)FA1!BZqp3&6X2F)%M0hg}Y?f;Im*MeM9uN$D0`tIJLc}Dh4e#Q@xHh1&)suKG7 ztl8(1ibM5C5Txbx*ad{}e5t~gOBbrLsvki@AiK(Uh}qI?tRg}K?jz%#DOb7WK5T@{ z%)U$MZK?O0US9p83)q@(ge@8wEp1JiF95{`>bj6+W1+CvKZVMlc~h(vTg&{J)tu`z zv4xU=KnT5@!7RT3

? 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 RY4HF6002ovPDHLkV1h;FVOszI diff --git a/public/images/head-link.png b/public/images/head-link.png deleted file mode 100644 index 8e55b94b4aa84f9abe5fa4263106aff5b15e58b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^_CU|gW!U_%O?XxI14-? zi-9_Tvb@(Boit`w00r4gJbhi+?{V;Ot6TrPJ6#PZ{5-w@ diff --git a/public/images/head-link_hover.png b/public/images/head-link_hover.png deleted file mode 100644 index 4180fbc1f6831d8c8fab7ef05ee08c720b650943..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 620 zcmV-y0+aoTP)xfCgiLS0%%ot+fj{4w~q zZP|hxC+&=qjYdkbGe{`=ytw$X|QQ^H|_AHX~_%U&wH=$UJRv_5y>!A z3NBD-gQ_9pMh&CZPwqyNrpUYIaDWH7fG@_|aS=j8;Nod(eE#ftB3l!a1^O^uxDK=y zFIzMJU6I!%E~n5xax8H*rW2vUB*fq~IHg0yrW(b71^$$?xFmV^r9jx*vRM-=Ci7Pl zBk+s9(LZvS>^+NI$cLO!0-{z=7pWR8&|n&Gw%uSY&%8~W%Po_M9lP%D+T7nujKHr| z54O>No(CJqgIw6f$f-NPFj8UFp(S$E%Mj*UCVMRtF7_=ZtHkwhK@Ir*-gc9t(64Nu zf6MhNgo%A9Vj1~zw3%uD1pwX&t4`?SEVc}(2gk$}AHu$j0pxmQ9?|SKHflc?dUu4NBX04Lnh{uKWXlKi30~!N7B6pANjgjygOdnB3AwM!z0) zu_cFwOAamt1F_`vthdk-xDH0^Ev|`uz25e`*f@jnV)G5PnrNUVI-xZH0000|s?uXD00$(}UFNr1&sfF)7kj?(TES}cw~jBIW(2L_~EX6*rLX7F_Nb6Mw<&;$Uc ClPk3V diff --git a/public/images/logo.png b/public/images/logo.png deleted file mode 100644 index 2594249cf54a9ab9cbe8c374aee56b4cbdf8269f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 111615 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 g=CV-?)28KoG(uB#dBxbM9Gj9Y% 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 P)63$gD$djf0`1 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 diff --git a/public/images/smilieys/icon_cool.png b/public/images/smilieys/icon_cool.png deleted file mode 100644 index 37e55e35b6ccc668e08a2ddc1b9f63c13383c32f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2546 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 V_9{I_@IL?m002ovPDHLkV1k+To}K^z diff --git a/public/images/smilieys/icon_e_sad.png b/public/images/smilieys/icon_e_sad.png deleted file mode 100644 index 1ef02913f3c6febd1f25d77c279953d334bc6dcc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2920 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 diff --git a/public/images/smilieys/icon_e_surprised.png b/public/images/smilieys/icon_e_surprised.png deleted file mode 100644 index 60a82d4bb883a0f57abd1d2e49eac999505bd854..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2791 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 diff --git a/public/images/smilieys/icon_e_wink.png b/public/images/smilieys/icon_e_wink.png deleted file mode 100644 index e32171de6f7855083b29e5bc5610b5746f23e24a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2526 zcmV<42_g20P)t}& zYaDpAt|BJjKW!;c!rnEHfFUCW3SW(e4_}@H*)K&w%^N=jb9wTJlVCNf4Pr$FA*x1Pli>$x*z_4F^JFJdogMhJz*FGbQL9+dV(kGuT*n)v?M(S z7OL(R1n#oeW{w}V$=+TS`eOQ*q zOCq4ZTs^6yfU)`B{nFQCctNJ3ty}N_l>c!u)ctuX>|6IJ9NIJun*Wg)34!q@^(UeU zxK*sW;S0WCNpwKq%YSh%>|g&VbQUv_of)WD5<|dky(-feyf)1rJe~hij02X6|JeIZ`c29YrKC@n>8NJ=lpE!0C(GQ-_+>s`g*3s_wLudIn^(sU(8Jy zi{J;d0{ff51YkekkTwz_#CyWrFB#>46-@8`U3 z#pCyKHOGcuKL|V09)e?8v67DJE1egu%Hec{AYx*Do>ff5mU(p#g;AqST zr}+wSFQ*fnU1z|4B6C>a`?2IE(uGp&BK98G`uqeqxiwBgm8ewGe?s7w6C9_u$R^-6HeF(iEwXoi7sZw2A}YjBya+JwOp5U>dc+doxKsneFDKZK zuaivzWtt_J!o`x1roIPi-<(3KkCszo8C&R)hz zQ{@91Zy# zsB;&ac)^n;%zDBd2M#S2O+fMM4@+vfH$U?e=o`E~B7ur0E*LHmXvM2FYZYzzOs7}^ zECzQWKzz?A7rw6J2gzRR%QbLSjYZYx;rA;gWT}5sJ z>?bzf0cE#CoMsL$0gE%S8BnzJVOaKa zw?t>im@H|SqG;7Aoh=eb7^oclKzMWW_3w>6b!4PpL;BY^w6A_KuxA1=+cIphJ&nzH-&O5vm~~EjEb|<5xf^LU&GI9B2geQQX$kNg#fZX&wAiuMG*9ku=D{T zni*^-0**)}0EeU=UxZ@FB_lt0%oY(Vkr~-Fqykq;C7@kL$AI-F;mqH`dApJ8|G{Ac zXWP-pJojEfkX=OG;2Nm}ko8vZL};q_F++?Ak<^$nOJOQu?+T^{1wRI3(#%jd=j)sx zf|;O2DgjtRlM9Vu0yGsviqLI6qmy$CEahXD0K1WW&Mk_TB*n-fpndFmffcIR?|I4M zSm2oCkl?v6F@|if|6D+?GRPr-v_f9tF=Er7<|Y5p7%nCU8jXp{2qperKtkoeHcaD& zsB#D*5%hoW#lyjk(>RSFgakCw(pSLYC+kkMDxa}z|j#Ukv};0qEZ$r*A8Ap626Ez*U*gM&-C^47NBPxNZM zhF|%qlSHk^;9g*xtbnLv!1uh8o0ClSN{aaT-clTe)7njFgDYDldPm4ymN*%O5}Ondh|bpAihy^k08%6a!eH7-_mhj(*L6wZN_&<8W6Bg+i;~^NOjZl#dC6Hp>pesnMRTI|KYv)e!r1lg zlj8-LO(LN{T0Q)2aZHU}dewP3B-|?U6;4L7+EW*XU|HI|G>x9{v8?)FWQgXHFir$kG-LNdgRkU&fAh#awzCxk#s zYxM2vM*q>-@|dq-37bQGL{OC(kB-G4nFK2-Ih>f`(a%TZ!_yXH$`3JZW}(W|B$PEo z$Z2L$Z#e;TP{SJ=Pmg=r;e>CR%aRvk3f+#R)M0US8UyGxl;Ta4O-zPl6Q1MKIHsz@ o6t)9@Ur);M1&07*qoM6N<$f&yp50RR91 diff --git a/public/images/smilieys/icon_exclaim.png b/public/images/smilieys/icon_exclaim.png deleted file mode 100644 index 9391f2bea947414c2d869a08d3f1d1e8a2ba391e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 897 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+yeRz!)Fk6XN>+|Nm7Cq5B;iR%vT* zQ&HKkpm0paaF!n0 z*{^K0M@j#nywM&x!-Gn?AT3G;`xNx{$Q$lcHrS`6w_gdU-C(bx{(c1@TYsN|0Z`3e zdHubL2KzvQK!*N4MZJAW`g?(FB|V^Cph}>|eF`8gdx54w6et2^p=^*|1)xzt5ujc$ z0vZcc05%h(5oj7n3S=+PK!_Tk0*Dk)9K?od1TjEdpcaS-hym3C)(){0rXFr1+yuM~ z?R_tGfFWK|666=mz|6wN&cVsWFCZ)`E-57=E3c@etfHo=rE6enZfWi8;_Bw%;~x+h z8WtWKmz0s2mtR>|-_X?6J7MCi*>mU3U$kV|s?}@Ou3Nux^VS_ZckSJG@X+BSM^BtO zd+z+zYu9hyx^w@*qsLF4K70P+<%f@7zW@C7=kLE|Q=9$*qdL~p#W6%e^6i0#=D~>! zY#-9KH)nd!Y6;u2M5J5guFe{-1s$QTBC1D4w>|%FFB|*5_P+nz&vyG>%&D{Z8Z>?L z1=aTJd?)8l*S!!^ao*zw`$?YOy=fd>Tjn`^Xt`F$^l#dW6Nm5nTjd?NuP3|r!}qy; zuRm<}J0AMuw}U(TpS=qoEB!fpv5@cIyo(n(>K|{(5@6X%n_rS=Jw`FIf2;yq_EOIiTm_jZ;pAW)=_Rukv-=k~>?!vhVJZ_&;o=$!zUD%qRW; PBbvd})z4*}Q$iB}CJTCj diff --git a/public/images/smilieys/icon_mad.png b/public/images/smilieys/icon_mad.png deleted file mode 100644 index 3029c96f15110bc4c101388e92ef46ac425a6757..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2746 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 Wty2bOP1`E~0000 Date: Thu, 17 May 2018 16:37:11 +0100 Subject: [PATCH 140/396] Reintroduce spectre submodule. --- .gitmodules | 4 ++-- frontend/spectre | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 160000 frontend/spectre diff --git a/.gitmodules b/.gitmodules index ef41742..78fdace 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "redesign/spectre"] - path = redesign/spectre +[submodule "frontend/spectre"] + path = frontend/spectre url = https://github.com/picturepan2/spectre diff --git a/frontend/spectre b/frontend/spectre new file mode 160000 index 0000000..7a6af53 --- /dev/null +++ b/frontend/spectre @@ -0,0 +1 @@ +Subproject commit 7a6af53bca72b549b2cbf1948763348bd5b30dcd From 840d8164ebd27c369c5f2a5c84346cf33410087f Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 17:13:02 +0100 Subject: [PATCH 141/396] Moves the new design to `/`. --- forum.nim | 181 +++++----------------------------------- frontend/forum.nim | 12 ++- frontend/header.nim | 2 +- frontend/karax.html | 6 +- frontend/karaxutils.nim | 2 +- 5 files changed, 34 insertions(+), 169 deletions(-) diff --git a/forum.nim b/forum.nim index 987c2aa..0a3f4db 100644 --- a/forum.nim +++ b/forum.nim @@ -1191,24 +1191,19 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, initialise() routes: - get "/": - createTFD() - c.isThreadsList = true - var count = 0 - let threadList = genThreadsList(c, count) - let data = genMain(c, threadList, - additionalHeaders = genRSSHeaders(c), showRssLinks = true) - resp data - get "/karax/nimforum.css": + get "/nimforum.css": resp readFile("frontend/nimforum.css"), "text/css" - get "/karax/nimcache/forum.js": + get "/nimcache/forum.js": resp readFile("frontend/nimcache/forum.js"), "application/javascript" - get "/karax/images/crown.png": - resp readFile("frontend/images/crown.png"), "image/png" + get re"/images/(.+?\.png)/?": + let path = "frontend/images/" & request.matches[0] + if fileExists(path): + resp readFile(path), "image/png" + else: + resp Http404, "No such file." - - get "/karax/threads.json": + get "/threads.json": var start = getInt(@"start", 0) count = getInt(@"count", 30) @@ -1227,7 +1222,7 @@ routes: resp $(%list), "application/json" - get "/karax/posts.json": + get "/posts.json": createTFD() var id = getInt(@"id", -1) @@ -1274,7 +1269,7 @@ routes: resp $(%list), "application/json" - get "/karax/specific_posts.json": + get "/specific_posts.json": createTFD() var ids = parseJson(@"ids") @@ -1296,7 +1291,7 @@ routes: resp $(%list), "application/json" - get "/karax/post.rst": + get "/post.rst": createTFD() let postId = getInt(@"id", -1) cond postId != -1 @@ -1311,7 +1306,7 @@ routes: else: resp content, "text/x-rst" - get "/karax/profile.json": + get "/profile.json": createTFD() var username = @"username" @@ -1395,7 +1390,7 @@ routes: resp $(%profile), "application/json" - post "/karax/login": + post "/login": createTFD() let formData = request.formData cond "username" in formData @@ -1411,7 +1406,7 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" - post "/karax/signup": + post "/signup": createTFD() let formData = request.formData let username = formData["username"].body @@ -1432,7 +1427,7 @@ routes: let exc = (ref ForumError)(getCurrentException()) resp Http400, $(%exc.data), "application/json" - get "/karax/status.json": + get "/status.json": createTFD() let user = @@ -1458,7 +1453,7 @@ routes: ) resp $(%status), "application/json" - post "/karax/preview": + post "/preview": createTFD() if not c.loggedIn(): let err = PostError( @@ -1481,7 +1476,7 @@ routes: ) resp Http400, $(%err), "application/json" - post "/karax/createPost": + post "/createPost": createTFD() if not c.loggedIn(): let err = PostError( @@ -1510,7 +1505,7 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" - post "/karax/updatePost": + post "/updatePost": createTFD() if not c.loggedIn(): let err = PostError( @@ -1538,7 +1533,7 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" - post "/karax/newthread": + post "/newthread": createTFD() if not c.loggedIn(): let err = PostError( @@ -1561,7 +1556,7 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" - get re"/karax/(.+)?": + get re"/(.+)?": resp readFile("frontend/karax.html") get "/threadActivity.xml": @@ -1573,140 +1568,6 @@ routes: createTFD() resp genPostsRSS(c), "application/atom+xml" - get "/t/@threadid/?@page?/?@postid?/?": - createTFD() - parseInt(@"threadid", c.threadId, -1..1000_000) - - if c.threadId == unselectedThread: - # Thread has just been deleted. - redirect(uri("/")) - - if @"page".len > 0: - parseInt(@"page", c.pageNum, 0..1000_000) - if @"postid".len > 0: - parseInt(@"postid", c.postId, 0..1000_000) - cond(c.pageNum > 0) - var count = 0 - var pSubject = getThreadTitle(c.threadid, c.pageNum) - cond validThreadId(c) - gatherTotalPosts(c) - if (@"action").len > 0: - var title = "" - case @"action" - of "reply": - let subject = getValue(db, - sql"select header from post where id = (select max(id) from post where thread = ?)", - $c.threadId).prependRe - body = genPostsList(c, $c.threadId, count) - cond count != 0 - body.add genFormPost(c, "doreply", "Reply", subject, "", false) - title = "Replying to thread: " & pSubject - of "edit": - cond c.postId != -1 - const query = sql"select header, content from post where id = ?" - let row = getRow(db, query, $c.postId) - let header = ||row[0] - let content = ||row[1] - body = genFormPost(c, "doedit", "Edit", header, content, true) - title = "Editing post" - else: discard - resp c.genMain(body, title & " - Nim Forum") - else: - incrementViews(c) - let posts = genPostsList(c, $c.threadId, count) - cond count != 0 - resp genMain(c, posts, pSubject & " - Nim Forum") - - get "/page/?@page?/?": - createTFD() - c.isThreadsList = true - cond(@"page" != "") - parseInt(@"page", c.pageNum, 0..1000_000) - cond(c.pageNum > 0) - var count = 0 - let list = genThreadsList(c, count) - if count == 0: - pass() - resp genMain(c, list, "Page " & $c.pageNum & " - Nim Forum", - genRSSHeaders(c), showRssLinks = true) - - get "/profile/@nick/?": - createTFD() - cond(@"nick" != "") - var userinfo: TUserInfo - if gatherUserInfo(c, @"nick", userinfo): - resp genMain(c, c.genProfile(userinfo), - @"nick" & "'s profile - Nim Forum") - else: - halt() - - get "/login/?": - createTFD() - resp genMain(c, genFormLogin(c), "Log in - Nim Forum") - - get "/logout/?": - createTFD() - logout(c) - redirect(uri("/")) - - get "/register/?": - createTFD() - resp genMain(c, genFormRegister(c), "Register - Nim Forum") - - template readIDs() = - # Retrieve the threadid, postid and pagenum - if (@"threadid").len > 0: - parseInt(@"threadid", c.threadId, -1..1000_000) - if (@"postid").len > 0: - parseInt(@"postid", c.postId, -1..1000_000) - - template finishLogin() = - setCookie("sid", c.userpass, daysForward(7)) - redirect(uri("/")) - - template handleError(action: string, topText: string, isEdit: bool) = - if c.isPreview: - body().add genPostPreview(c, @"subject", @"content", - c.userName, $getGMTime(getTime())) - body().add genFormPost(c, action, topText, reuseText, reuseText, isEdit) - resp genMain(c, body(), "Nim Forum - " & - (if c.isPreview: "Preview" else: "Error")) - - post "/donewthread": - createTFD() - if newThread(c): - redirect(uri("/")) - else: - body = "" - handleError("donewthread", "New thread", false) - - post "/doreply": - createTFD() - readIDs() - if reply(c): - redirect(c.genThreadUrl(pageNum = $(c.getPagesInThread+1)) & "#" & $c.postId) - else: - var count = 0 - if c.isPreview: - c.pageNum = c.getPagesInThread+1 - body = genPostsList(c, $c.threadId, count) - handleError("doreply", "Reply", false) - - post "/doedit": - createTFD() - readIDs() - if edit(c, c.postId): - redirect(c.genThreadUrl(postId = $c.postId, - pageNum = $(c.getPagesInThread+1))) - else: - body = "" - handleError("doedit", "Edit", true) - - get "/newthread/?": - createTFD() - resp genMain(c, genFormPost(c, "donewthread", "New thread", "", "", false), - "New Thread - Nim Forum") - get "/deleteAll/?": createTFD() cond(@"nick" != "") diff --git a/frontend/forum.nim b/frontend/forum.nim index ee74f8f..c86386c 100644 --- a/frontend/forum.nim +++ b/frontend/forum.nim @@ -4,7 +4,7 @@ from dom import window, Location include karax/prelude import jester/patterns -import threadlist, postlist, header, profile, newthread +import threadlist, postlist, header, profile, newthread, error import karaxutils type @@ -29,7 +29,6 @@ proc onPopState(event: dom.Event) = state.url = window.location redraw() -const appName = "/karax" type Params = Table[string, string] type Route = object @@ -38,12 +37,17 @@ type proc r(n: string, p: proc (params: Params): VNode): Route = Route(n: n, p: p) proc route(routes: openarray[Route]): VNode = + let path = + if state.url.pathname.len == 0: "/" else: $state.url.pathname + let prefix = if appName == "/": "" else: appName for route in routes: - let pattern = (appName & route.n).parsePattern() - let (matched, params) = pattern.match($state.url.pathname) + let pattern = (prefix & route.n).parsePattern() + let (matched, params) = pattern.match(path) if matched: return route.p(params) + return renderError("Unmatched route: " & path) + proc render(): VNode = result = buildHtml(tdiv()): renderHeader() diff --git a/frontend/header.nim b/frontend/header.nim index a09221a..cffa5e9 100644 --- a/frontend/header.nim +++ b/frontend/header.nim @@ -84,7 +84,7 @@ when defined(js): tdiv(class="navbar container grid-xl"): section(class="navbar-section"): a(href=makeUri("/")): - img(src="/karax/images/crown.png", id="img-logo") # TODO: Customisation. + img(src="/images/logo.png", id="img-logo") # TODO: Customisation. section(class="navbar-section"): tdiv(class="input-group input-inline"): input(class="search-input input-sm", diff --git a/frontend/karax.html b/frontend/karax.html index 8c35343..85ea520 100644 --- a/frontend/karax.html +++ b/frontend/karax.html @@ -8,14 +8,14 @@ The Nim programming language forum - + - +

- + diff --git a/frontend/karaxutils.nim b/frontend/karaxutils.nim index e34b7a2..94372f0 100644 --- a/frontend/karaxutils.nim +++ b/frontend/karaxutils.nim @@ -24,7 +24,7 @@ when defined(js): import dom except window - const appName = "/karax/" + const appName* = "/" proc class*(classes: varargs[tuple[name: string, present: bool]], defaultClasses: string = ""): string = From 21b8165751b4d2ea391e4fa805f54f810ff6bc74 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 17:27:08 +0100 Subject: [PATCH 142/396] Hide moderated posts. Add indicators of user rank on posts. --- frontend/nimforum.scss | 4 ++++ frontend/post.nim | 5 +++++ frontend/postlist.nim | 11 +++++++++++ frontend/threadlist.nim | 7 +++++-- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/nimforum.scss b/frontend/nimforum.scss index e6f5376..00d17ef 100644 --- a/frontend/nimforum.scss +++ b/frontend/nimforum.scss @@ -300,6 +300,10 @@ $views-color: #545d70; .post-username { font-weight: bold; display: inline-block; + + i { + margin-left: $control-padding-x; + } } .post-time { diff --git a/frontend/post.nim b/frontend/post.nim index 1e70da4..0817c00 100644 --- a/frontend/post.nim +++ b/frontend/post.nim @@ -19,6 +19,7 @@ type ## older versions of the post. info*: PostInfo moreBefore*: seq[int] + isDeleted*: bool PostLink* = object ## Used by profile creation*: int64 @@ -26,6 +27,10 @@ type threadId*: int postId*: int +proc isModerated*(post: Post): bool = + ## Determines whether the specified thread is under moderation. + post.author.rank <= Moderated + when defined(js): import karaxutils diff --git a/frontend/postlist.nim b/frontend/postlist.nim index 2bce6f7..2226f1d 100644 --- a/frontend/postlist.nim +++ b/frontend/postlist.nim @@ -203,6 +203,15 @@ when defined(js): tdiv(class="post-title"): tdiv(class="post-username"): text post.author.name + if post.isModerated: + italic(class="fas fa-eye-slash", + title="User is moderated") + if post.author.rank == Moderator: + italic(class="fas fa-shield-alt", + title="User is a moderator") + if post.author.rank == Admin: + italic(class="fas fa-chess-knight", + title="User is an admin") tdiv(class="post-time"): let title = post.info.creation.fromUnix().local. format("MMM d, yyyy HH:mm") @@ -293,6 +302,8 @@ when defined(js): tdiv(class="posts"): var prevPost: Option[Post] = none[Post]() for i, post in list.posts: + if not post.visibleTo(currentUser): continue + if prevPost.isSome: genTimePassed(prevPost.get(), some(post), false) if post.moreBefore.len > 0: diff --git a/frontend/threadlist.nim b/frontend/threadlist.nim index 9f2465e..482b6e0 100644 --- a/frontend/threadlist.nim +++ b/frontend/threadlist.nim @@ -48,11 +48,14 @@ when defined(js): var state = newState() - proc visibleTo(thread: Thread, user: Option[User]): bool = - ## Determines whether the specified thread should be shown to the user. + proc visibleTo*[T](thread: T, user: Option[User]): bool = + ## Determines whether the specified thread (or post) should be + ## shown to the user. This procedure is generic and works on any + ## object with a `isModerated` proc. ## ## The rules for this are determined by the rank of the user, their ## settings (TODO), and whether the thread's creator is moderated or not. + mixin isModerated if user.isNone(): return not thread.isModerated let rank = user.get().rank From 3804faab3b329b115a2fee45a5678f5ab01d01ba Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 17:35:54 +0100 Subject: [PATCH 143/396] Fixes issues caused by incorrect user comparisons. --- frontend/user.nim | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/user.nim b/frontend/user.nim index 6966a7b..2cfcb8e 100644 --- a/frontend/user.nim +++ b/frontend/user.nim @@ -21,6 +21,9 @@ type proc isOnline*(user: User): bool = return getTime().toUnix() - user.lastOnline < (60*5) +proc `==`*(u1, u2: User): bool = + u1.name == u2.name + when defined(js): include karax/prelude import karaxutils From 7eb6b081adbbcdccbb4f75b9859fb6ffeaf9079f Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 19:22:46 +0100 Subject: [PATCH 144/396] Fixes forum to work in dev mode. Creates setup_nimforum script. --- createdb.nim | 128 ------------------------- forms.tmpl | 5 - forum.nim | 98 +++++-------------- frontend/header.nim | 4 +- setup_nimforum.nim | 228 ++++++++++++++++++++++++++++++++++++++++++++ static/license.rst | 10 +- utils.nim | 2 + 7 files changed, 259 insertions(+), 216 deletions(-) delete mode 100644 createdb.nim create mode 100644 setup_nimforum.nim diff --git a/createdb.nim b/createdb.nim deleted file mode 100644 index 83e4b86..0000000 --- a/createdb.nim +++ /dev/null @@ -1,128 +0,0 @@ -# -# -# The Nim Forum -# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta -# Look at license.txt for more info. -# All rights reserved. -# - -import strutils, db_sqlite - -var db = open(connection="nimforum.db", user="postgres", password="", - database="nimforum") - -const - TUserName = "varchar(20)" - TPassword = "varchar(32)" - TEmail = "varchar(30)" - -db.exec(sql""" -create table if not exists thread( - id integer primary key, - name varchar(100) not null, - views integer not null, - modified timestamp not null default (DATETIME('now')) -);""", []) - -db.exec(sql""" -create unique index if not exists ThreadNameIx on thread (name); -""", []) - -db.exec(sql(""" -create table if not exists person( - id integer primary key, - name $# not null, - password $# not null, - email $# not null, - creation timestamp not null default (DATETIME('now')), - salt varbin(128) not null, - status varchar(30) not null, - lastOnline timestamp not null default (DATETIME('now')) -);""" % [TUserName, TPassword, TEmail]), []) -# echo "person table already exists" - -db.exec(sql(""" -alter table person -add ban varchar(128) not null default '' -""")) - -db.exec(sql""" -create unique index if not exists UserNameIx on person (name); -""", []) - -# ----------------------- Forum ------------------------------------------------ - - -if not db.tryExec(sql""" -create table if not exists post( - id integer primary key, - author integer not null, - ip inet not null, - header varchar(100) not null, - content varchar(1000) not null, - thread integer not null, - creation timestamp not null default (DATETIME('now')), - - foreign key (thread) references thread(id), - foreign key (author) references person(id) -);""", []): - echo "post table already exists" - -# -------------------- Session ------------------------------------------------- - -if not db.tryExec(sql(""" -create table if not exists session( - id integer primary key, - ip inet not null, - password $# not null, - userid integer not null, - lastModified timestamp not null default (DATETIME('now')), - foreign key (userid) references person(id) -);""" % [TPassword]), []): - echo "session table already exists" - -if not db.tryExec(sql""" -create table if not exists antibot( - id integer primary key, - ip inet not null, - answer varchar(30) not null, - created timestamp not null default (DATETIME('now')) -);""", []): - echo "antibot table already exists" - - -db.exec sql"create index PersonStatusIdx on person(status);" -db.exec sql"create index PostByAuthorIdx on post(thread, author);" - -# -------------------- Search -------------------------------------------------- - -if not db.tryExec(sql""" - CREATE VIRTUAL TABLE thread_fts USING fts4 ( - id INTEGER PRIMARY KEY, - name VARCHAR(100) NOT NULL - );""", []): - echo "thread_fts table already exists or fts4 not supported" -else: - db.exec(sql""" - INSERT INTO thread_fts - SELECT id, name FROM thread; - """, []) -if not db.tryExec(sql""" - CREATE VIRTUAL TABLE post_fts USING fts4 ( - id INTEGER PRIMARY KEY, - header VARCHAR(100) NOT NULL, - content VARCHAR(1000) NOT NULL - );""", []): - echo "post_fts table already exists or fts4 not supported" -else: - db.exec(sql""" - INSERT INTO post_fts - SELECT id, header, content FROM post; - """, []) - - -# ------------------------------------------------------------------------------ - -#discard stdin.readline() - -close(db) diff --git a/forms.tmpl b/forms.tmpl index c88ac9a..f50fb25 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -320,12 +320,9 @@ ${fieldValid(c, "email", "E-Mail:")} ${textWidget(c, "email", reuseText, maxlength=300)} - #if useCaptcha: ${fieldValid(c, "g-recaptcha-response", "Captcha:")} ${captcha.render(includeNoScript=true)} - - #end if #if c.errorMsg != "":
@@ -494,12 +491,10 @@ ${fieldValid(c, "nick", "Your nickname:")} - #if useCaptcha: ${fieldValid(c, "g-recaptcha-response", "Captcha:")} ${captcha.render(includeNoScript=true)} - #end if #if c.errorMsg != "":
diff --git a/forum.nim b/forum.nim index 0a3f4db..7273959 100644 --- a/forum.nim +++ b/forum.nim @@ -13,6 +13,8 @@ import import cgi except setCookie import options +import auth + import frontend/threadlist except User import frontend/[ category, postlist, error, header, post, profile, user, karaxutils @@ -85,7 +87,6 @@ var db: DbConn isFTSAvailable: bool config: Config - useCaptcha: bool captcha: ReCaptcha proc newForumError(message: string, @@ -230,62 +231,7 @@ proc genGravatar(email: string, size: int = 80): string = result = "" % [$size, $size, getGravatarUrl(email, size)] -proc randomSalt(): string = - result = "" - for i in 0..127: - var r = random(225) - if r >= 32 and r <= 126: - result.add(chr(random(225))) -proc devRandomSalt(): string = - when defined(posix): - result = "" - var f = open("/dev/urandom") - var randomBytes: array[0..127, char] - discard f.readBuffer(addr(randomBytes), 128) - for i in 0..127: - if ord(randomBytes[i]) >= 32 and ord(randomBytes[i]) <= 126: - result.add(randomBytes[i]) - f.close() - else: - result = randomSalt() - -proc makeSalt(): string = - ## Creates a salt using a cryptographically secure random number generator. - ## - ## Ensures that the resulting salt contains no ``\0``. - try: - result = devRandomSalt() - except IOError: - result = randomSalt() - - var newResult = "" - for i in 0 .. 0: var captchaValid: bool = false try: captchaValid = await captcha.verify(antibot, userIp) @@ -365,15 +311,10 @@ proc checkLoggedIn(c: TForumData) = c.req.ip, pass) let row = getRow(db, - sql"select name, email, status, ban from person where id = ?", c.userid) + sql"select name, email, status from person where id = ?", c.userid) c.username = ||row[0] c.email = ||row[1] c.rank = parseEnum[Rank](||row[2]) - let ban = getBanErrorMsg(||row[3], c.rank) - if ban.len > 0: - discard c.setError("name", ban) - logout(c) - return # Update lastOnline db.exec(sql"update person set lastOnline = DATETIME('now') where id = ?", @@ -917,16 +858,13 @@ proc initialise() = database="nimforum") isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & "type='table' AND name='post_fts'")).len == 1 + config = loadConfig() if len(config.recaptchaSecretKey) > 0 and len(config.recaptchaSiteKey) > 0: - useCaptcha = true captcha = initReCaptcha(config.recaptchaSecretKey, config.recaptchaSiteKey) else: - useCaptcha = false - var http = true - if paramCount() > 0: - if paramStr(1) == "scgi": - http = false + doAssert config.isDev, "Recaptcha required for production!" + echo("[WARNING] No recaptcha secret key specified.") template createTFD() = var c {.inject.}: TForumData @@ -1027,13 +965,13 @@ proc executeReply(c: TForumData, threadId: int, content: string, # Verify that content can be parsed as RST. let retID = insertID( db, - crud(crCreate, "post", "author", "ip", "header", "content", "thread"), - c.userId, c.req.ip, subject, content, $threadId, "" + crud(crCreate, "post", "author", "ip", "content", "thread"), + c.userId, c.req.ip, content, $threadId, "" ) discard tryExec( db, - crud(crCreate, "post_fts", "id", "header", "content"), - retID.int, subject, content + crud(crCreate, "post_fts", "id", "content"), + retID.int, content ) exec(db, sql"update thread set modified = DATETIME('now') where id = ?", @@ -1156,7 +1094,7 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, raise newForumError("Please choose a longer password", @["password"]) # captcha validation: - if useCaptcha: + if config.recaptchaSecretKey.len > 0: var verifyFut = captcha.verify(antibot, userIp) yield verifyFut if verifyFut.failed: @@ -1409,14 +1347,22 @@ routes: post "/signup": createTFD() let formData = request.formData + if not config.isDev: + cond "g-recaptcha-response" in formData + let username = formData["username"].body let password = formData["password"].body + let recaptcha = + if "g-recaptcha-response" in formData: + formData["g-recaptcha-response"].body + else: + "" try: discard await executeRegister( c, username, password, - formData["g-recaptcha-response"].body, + recaptcha, request.host, formData["email"].body ) @@ -1446,7 +1392,7 @@ routes: let status = UserStatus( user: user, recaptchaSiteKey: - if useCaptcha: + if not config.isDev: some(config.recaptchaSiteKey) else: none[string]() diff --git a/frontend/header.nim b/frontend/header.nim index cffa5e9..6d38306 100644 --- a/frontend/header.nim +++ b/frontend/header.nim @@ -75,8 +75,8 @@ when defined(js): not getLoggedInUser().isNone proc renderHeader*(): VNode = - if state.data.isNone: - getStatus() # TODO: Call this every render? + if state.data.isNone and state.status == Http200: + getStatus() let user = state.data.map(x => x.user).flatten result = buildHtml(tdiv()): # TODO: Why do some buildHtml's need this? diff --git a/setup_nimforum.nim b/setup_nimforum.nim new file mode 100644 index 0000000..3cc7847 --- /dev/null +++ b/setup_nimforum.nim @@ -0,0 +1,228 @@ +# +# +# The Nim Forum +# (c) Copyright 2018 Andreas Rumpf, Dominik Picheta +# Look at license.txt for more info. +# All rights reserved. +# +# Script to initialise the nimforum. + +import strutils, db_sqlite, os, times, json + +import auth, frontend/user + +proc backup(path: string) = + if existsFile(path): + let backupPath = path & "." & $getTime().toUnix() + echo(path, " already exists. Moving to ", backupPath) + moveFile(path, backupPath) + +proc initialiseDb(admin: tuple[username, password, email: string]) = + let path = getCurrentDir() / "nimforum.db" + backup(path) + + var db = open(connection="nimforum.db", user="", password="", + database="nimforum") + + const + userNameType = "varchar(20)" + passwordType = "varchar(50)" + emailType = "varchar(254)" # https://stackoverflow.com/a/574698/492186 + + # -- Category + + db.exec(sql""" + create table category( + id integer primary key, + name varchar(100) not null, + description varchar(500) not null, + color varchar(10) not null + ); + + insert into category (id, name, description, color) + values (0, 'Default', '', ''); + """) + + # -- Thread + + db.exec(sql""" + create table thread( + id integer primary key, + name varchar(100) not null, + views integer not null, + modified timestamp not null default (DATETIME('now')), + category integer not null default 0, + isLocked boolean not null default 0, + solution integer, + isDeleted boolean not null default 0, + + foreign key (category) references category(id), + foreign key (solution) references post(id) + );""", []) + + db.exec(sql""" + create unique index ThreadNameIx on thread (name); + """, []) + + # -- Person + + db.exec(sql(""" + create table person( + id integer primary key, + name $# not null, + password $# not null, + email $# not null, + creation timestamp not null default (DATETIME('now')), + salt varbin(128) not null, + status varchar(30) not null, + lastOnline timestamp not null default (DATETIME('now')), + isDeleted boolean not null default 0 + );""" % [userNameType, passwordType, emailType]), []) + + db.exec(sql""" + create unique index UserNameIx on person (name); + """, []) + db.exec sql"create index PersonStatusIdx on person(status);" + + # Create default user. + let salt = makeSalt() + let password = makePassword(admin.password, salt) + db.exec(sql""" + insert into person (id, name, password, email, salt, status) + values (0, ?, ?, ?, ?, ?); + """, admin.username, password, admin.email, salt, $Admin) + + # -- Post + + db.exec(sql""" + create table post( + id integer primary key, + author integer not null, + ip inet not null, + content varchar(1000) not null, + thread integer not null, + creation timestamp not null default (DATETIME('now')), + isDeleted boolean not null default 0, + + foreign key (thread) references thread(id), + foreign key (author) references person(id) + );""", []) + + db.exec sql"create index PostByAuthorIdx on post(thread, author);" + + db.exec(sql""" + create table postRevision( + id integer primary key, + creation timestamp not null default (DATETIME('now')), + original integer not null, + content varchar(1000) not null, + + foreign key (original) references post(id) + ) + """) + + # -- Session + + db.exec(sql(""" + create table session( + id integer primary key, + ip inet not null, + password $# not null, + userid integer not null, + lastModified timestamp not null default (DATETIME('now')), + foreign key (userid) references person(id) + );""" % [passwordType]), []) + + # -- Likes + + db.exec(sql(""" + create table like( + id integer primary key, + author integer not null, + post integer not null, + creation timestamp not null default (DATETIME('now')), + + foreign key (author) references person(id), + foreign key (post) references post(id) + ) + """)) + + # -- Report + + db.exec(sql(""" + create table report( + id integer primary key, + author integer not null, + post integer not null, + kind varchar(30) not null, + content varchar(500) not null default '', + + foreign key (author) references person(id), + foreign key (post) references post(id) + ) + """)) + + # -- FTS + + if not db.tryExec(sql""" + CREATE VIRTUAL TABLE thread_fts USING fts4 ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL + );""", []): + echo "thread_fts table already exists or fts4 not supported" + else: + db.exec(sql""" + INSERT INTO thread_fts + SELECT id, name FROM thread; + """, []) + if not db.tryExec(sql""" + CREATE VIRTUAL TABLE post_fts USING fts4 ( + id INTEGER PRIMARY KEY, + content VARCHAR(1000) NOT NULL + );""", []): + echo "post_fts table already exists or fts4 not supported" + else: + db.exec(sql""" + INSERT INTO post_fts + SELECT id, content FROM post; + """, []) + + close(db) + +proc initialiseConfig( + name, hostname: string, + recaptcha: tuple[siteKey, secretKey: string], + smtp: tuple[address, user, password: string], + isDev: bool +) = + let path = getCurrentDir() / "forum.json" + backup(path) + + var j = %{ + "name": %name, + "hostname": %hostname, + "recaptchaSiteKey": %recaptcha.siteKey, + "recaptchaSecretKey": %recaptcha.secretKey, + "smtpAddress": %smtp.address, + "smtpUser": %smtp.user, + "smtpPassword": %smtp.password, + "isDev": %isDev + } + + writeFile(path, $j) + +when isMainModule: + if paramCount() > 0 and paramStr(1) == "--dev": + echo("Initialising nimforum for development...") + initialiseConfig( + "Development Forum", + "localhost.local", + recaptcha=("", ""), + smtp=("", "", ""), + isDev=true + ) + + initialiseDb( + admin=("admin", "admin", "admin@localhost.local") + ) + diff --git a/static/license.rst b/static/license.rst index 15534b7..3a15ec1 100644 --- a/static/license.rst +++ b/static/license.rst @@ -1,7 +1,7 @@ Forum content license ===================== -All the content contributed to the Nimrod Forum is `cc-wiki (aka cc-by-sa) +All the content contributed to the Nim Forum is `cc-wiki (aka cc-by-sa) `_ licensed, intended to be **shared and remixed**. In the future we may even provide all this data as a convenient data dump. @@ -16,13 +16,13 @@ attribution**:: Let us clarify what we mean by attribution. If you republish this content, we require that you: -* **Visually indicate that the content is from the Nimrod Forum**. It doesn’t +* **Visually indicate that the content is from the Nim Forum**. It doesn’t have to be obnoxious; a discreet text blurb is fine. * **Hyperlink directly to the original post** (e.g., - http://forum.nimrod-lang.org/t/186) + http://forum.nim-lang.org/t/186) * **Show the author names** for every post. * **Hyperlink each author name** directly back to their user profile page - (e.g., http://forum.nimrod-lang.org/profile/Araq) + (e.g., http://forum.nim-lang.org/profile/Araq) By “directly”, we mean each hyperlink must point directly to our domain in standard HTML visible even with JavaScript disabled, and not use a tinyurl or @@ -38,5 +38,5 @@ Feel free to remix and reuse to your heart’s content, as long as a good faith effort is made to attribute the content! Content previous to the forum license change of -http://forum.nimrod-lang.org/t/186 remains under the original authors' +http://forum.nim-lang.org/t/186 remains under the original authors' copyright, and therefore you cannot reuse it. diff --git a/utils.nim b/utils.nim index 028c42e..fea585e 100644 --- a/utils.nim +++ b/utils.nim @@ -24,6 +24,7 @@ type mlistAddress: string recaptchaSecretKey*: string recaptchaSiteKey*: string + isDev*: bool var docConfig: StringTableRef @@ -43,6 +44,7 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config = result.mlistAddress = root{"mlistAddress"}.getStr("") result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("") result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("") + result.isDev = root{"isDev"}.getBool() except: echo("[WARNING] Couldn't read config file: ", filename) From 87952e8d4de59feadd3778e8f518607487f1ea6d Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 19:30:00 +0100 Subject: [PATCH 145/396] Fixes category creation in setup script. --- setup_nimforum.nim | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup_nimforum.nim b/setup_nimforum.nim index 3cc7847..c233a55 100644 --- a/setup_nimforum.nim +++ b/setup_nimforum.nim @@ -38,7 +38,9 @@ proc initialiseDb(admin: tuple[username, password, email: string]) = description varchar(500) not null, color varchar(10) not null ); + """) + db.exec(sql""" insert into category (id, name, description, color) values (0, 'Default', '', ''); """) From 3810220d3758d2b3cf8e10382b5cc0433321cc48 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 19:32:30 +0100 Subject: [PATCH 146/396] Adds missing auth module. --- auth.nim | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 auth.nim diff --git a/auth.nim b/auth.nim new file mode 100644 index 0000000..3da20a8 --- /dev/null +++ b/auth.nim @@ -0,0 +1,60 @@ +import random, md5 + +import bcrypt + +proc randomSalt(): string = + result = "" + for i in 0..127: + var r = rand(225) + if r >= 32 and r <= 126: + result.add(chr(rand(225))) + +proc devRandomSalt(): string = + when defined(posix): + result = "" + var f = open("/dev/urandom") + var randomBytes: array[0..127, char] + discard f.readBuffer(addr(randomBytes), 128) + for i in 0..127: + if ord(randomBytes[i]) >= 32 and ord(randomBytes[i]) <= 126: + result.add(randomBytes[i]) + f.close() + else: + result = randomSalt() + +proc makeSalt*(): string = + ## Creates a salt using a cryptographically secure random number generator. + ## + ## Ensures that the resulting salt contains no ``\0``. + try: + result = devRandomSalt() + except IOError: + result = randomSalt() + + var newResult = "" + for i in 0 ..< result.len: + if result[i] != '\0': + newResult.add result[i] + return newResult + +proc makePassword*(password, salt: string, comparingTo = ""): string = + ## Creates an MD5 hash by combining password and salt. + when defined(windows): + result = getMD5(salt & getMD5(password)) + else: + let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8) + result = hash(getMD5(salt & getMD5(password)), bcryptSalt) + +proc makeIdentHash*(user, password, epoch, secret: string, + comparingTo = ""): string = + ## Creates a hash verifying the identity of a user. Used for password reset + ## links and email activation links. + ## If ``epoch`` is smaller than the epoch of the user's last login then + ## the link is invalid. + ## The ``secret`` is the 'salt' field in the ``person`` table. + echo(user, password, epoch, secret) + when defined(windows): + result = getMD5(user & password & epoch & secret) + else: + let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8) + result = hash(user & password & epoch & secret, bcryptSalt) \ No newline at end of file From e04403c7f1b4f31d11da578a5c99bf11af894392 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 19:40:14 +0100 Subject: [PATCH 147/396] Adds replyingTo field to post. --- setup_nimforum.nim | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup_nimforum.nim b/setup_nimforum.nim index c233a55..6004d0c 100644 --- a/setup_nimforum.nim +++ b/setup_nimforum.nim @@ -105,9 +105,11 @@ proc initialiseDb(admin: tuple[username, password, email: string]) = thread integer not null, creation timestamp not null default (DATETIME('now')), isDeleted boolean not null default 0, + replyingTo integer, foreign key (thread) references thread(id), - foreign key (author) references person(id) + foreign key (author) references person(id), + foreign key (replyingTo) references post(id) );""", []) db.exec sql"create index PostByAuthorIdx on post(thread, author);" From 416655764d26ef3c90f6f223b8be092d15a05188 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 19:54:15 +0100 Subject: [PATCH 148/396] Ensure deleted posts and threads are not accessible. --- forum.nim | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/forum.nim b/forum.nim index 7273959..27e2a73 100644 --- a/forum.nim +++ b/forum.nim @@ -1148,7 +1148,8 @@ routes: const threadsQuery = sql"""select id, name, views, strftime('%s', modified) from thread - order by modified desc limit ?, ?;""" # TODO: Moderation + where isDeleted = 0 + order by modified desc limit ?, ?;""" let thrCount = getValue(db, sql"select count(*) from thread;").parseInt() let moreCount = max(0, thrCount - (start + count)) @@ -1171,7 +1172,7 @@ routes: const threadsQuery = sql"""select id, name, views, strftime('%s', modified) from thread - where id = ?;""" + where id = ? and isDeleted = 0;""" let threadRow = getRow(db, threadsQuery, id) let thread = selectThread(threadRow) @@ -1181,7 +1182,7 @@ routes: """select p.id, p.content, strftime('%s', p.creation), p.author, u.name, u.email, strftime('%s', u.lastOnline), u.status from post p, person u - where u.id = p.author and p.thread = ? + where u.id = p.author and p.thread = ? and p.isDeleted = 0 order by p.id""" ) From 79c47d47f3dbe5ec41449791a08d837aee1c1299 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 20:09:35 +0100 Subject: [PATCH 149/396] Implements proper 404s. --- forum.nim | 27 +++++++++++++++++++++++++++ frontend/error.nim | 17 ++++++++++++++++- frontend/forum.nim | 3 +++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/forum.nim b/forum.nim index 27e2a73..0e06a3b 100644 --- a/forum.nim +++ b/forum.nim @@ -1503,6 +1503,33 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" + get "/t/@id": + cond "id" in request.params + + const threadsQuery = + sql"""select id from thread where id = ? and isDeleted = 0;""" + + let value = getValue(db, threadsQuery, @"id") + if value == @"id": + pass + else: + redirect uri("/404") + + get "/profile/@username": + cond "username" in request.params + + const threadsQuery = + sql"""select name from person where name = ? and isDeleted = 0;""" + + let value = getValue(db, threadsQuery, @"username") + if value == @"username": + pass + else: + redirect uri("/404") + + get "/404": + resp Http404, readFile("frontend/karax.html") + get re"/(.+)?": resp readFile("frontend/karax.html") diff --git a/frontend/error.nim b/frontend/error.nim index a534bde..6a68df4 100644 --- a/frontend/error.nim +++ b/frontend/error.nim @@ -61,4 +61,19 @@ when defined(js): state.error = some(PostError( errorFields: @[], message: "Unknown error occurred." - )) \ No newline at end of file + )) + + proc render404*(): VNode = + result = buildHtml(): + tdiv(class="empty error"): + tdiv(class="empty icon"): + italic(class="fas fa-bug fa-5x") + p(class="empty-title h5"): + text "404 Not Found" + p(class="empty-subtitle"): + text "Cannot find what you are looking for, it might have been " & + "deleted. Sorry!" + tdiv(class="empty-action"): + a(href="/", onClick=anchorCB): + button(class="btn btn-primary"): + text "Go back home" \ No newline at end of file diff --git a/frontend/forum.nim b/frontend/forum.nim index c86386c..1ae644e 100644 --- a/frontend/forum.nim +++ b/frontend/forum.nim @@ -71,6 +71,9 @@ proc render(): VNode = ) ) ), + r("/404", + (params: Params) => render404() + ), r("/", (params: Params) => renderThreadList(getLoggedInUser())) ]) From c4df36d461b31652d9f96c92c238f6d1ed9b66aa Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 20:14:41 +0100 Subject: [PATCH 150/396] Fixes thread activity not updating. --- forum.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forum.nim b/forum.nim index 0e06a3b..cf4a160 100644 --- a/forum.nim +++ b/forum.nim @@ -975,7 +975,7 @@ proc executeReply(c: TForumData, threadId: int, content: string, ) exec(db, sql"update thread set modified = DATETIME('now') where id = ?", - $c.threadId) + $threadId) return retID From e34501a61a0ec09353245e4dbfda27c735bfec60 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 20:52:59 +0100 Subject: [PATCH 151/396] Implements replyingTo in the backend. --- forum.nim | 52 +++++++++++++++++++++++++++++++++++++---------- frontend/post.nim | 4 +++- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/forum.nim b/forum.nim index cf4a160..0802078 100644 --- a/forum.nim +++ b/forum.nim @@ -888,10 +888,12 @@ proc selectUser(userRow: seq[string], avatarSize: int=80): User = rank: parseEnum[Rank](userRow[3]) ) -proc selectPost(postRow: seq[string], skippedPosts: seq[int]): Post = +proc selectPost(postRow: seq[string], skippedPosts: seq[int], + replyingTo: Option[PostLink]): Post = return Post( id: postRow[0].parseInt, - author: selectUser(@[postRow[4], postRow[5], postRow[6], postRow[7]]), + replyingTo: replyingTo, + author: selectUser(@[postRow[5], postRow[6], postRow[7], postRow[8]]), likes: @[], # TODO: seen: false, # TODO: history: @[], # TODO: @@ -902,6 +904,28 @@ proc selectPost(postRow: seq[string], skippedPosts: seq[int]): Post = moreBefore: skippedPosts ) +proc selectReplyingTo(replyingTo: string): Option[PostLink] = + if replyingTo.len == 0: return + + const replyingToQuery = sql""" + select p.id, strftime('%s', p.creation), p.thread, + u.name, u.email, strftime('%s', u.lastOnline), u.status, + t.name + from post p, person u, thread t + where p.thread = t.id and p.author = u.id and p.id = ? and p.isDeleted = 0; + """ + + let row = getRow(db, replyingToQuery, replyingTo) + if row[0].len == 0: return + + return some(PostLink( + creation: row[1].parseInt(), + topic: row[^1], + threadId: row[2].parseInt(), + postId: row[0].parseInt(), + author: some(selectUser(@[row[3], row[4], row[5], row[6]])) + )) + proc selectThread(threadRow: seq[string]): Thread = const postsQuery = sql"""select count(*), strftime('%s', creation) from post @@ -950,23 +974,23 @@ proc selectThread(threadRow: seq[string]): Thread = return thread proc executeReply(c: TForumData, threadId: int, content: string, - replyingTo: int): int64 = + replyingTo: Option[int]): int64 = # TODO: Refactor TForumData. assert c.loggedIn() - let subject = "" # TODO: Remove this redundant field. 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( db, - crud(crCreate, "post", "author", "ip", "content", "thread"), - c.userId, c.req.ip, content, $threadId, "" + crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"), + c.userId, c.req.ip, content, $threadId, + if replyingTo.isSome(): $replyingTo.get() + else: nil ) discard tryExec( db, @@ -1043,7 +1067,7 @@ proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = discard tryExec(db, crud(crCreate, "thread_fts", "id", "name"), c.threadID, subject) - result[1] = executeReply(c, result[0].int, msg, -1) + result[1] = executeReply(c, result[0].int, msg, none[int]()) discard tryExec(db, sql"insert into post_fts(post_fts) values('optimize')") discard tryExec(db, sql"insert into post_fts(thread_fts) values('optimize')") @@ -1180,6 +1204,7 @@ routes: let postsQuery = sql( """select p.id, p.content, strftime('%s', p.creation), p.author, + p.replyingTo, u.name, u.email, strftime('%s', u.lastOnline), u.status from post p, person u where u.id = p.author and p.thread = ? and p.isDeleted = 0 @@ -1200,7 +1225,8 @@ routes: let addDetail = i < count or rows.len-i < count or id == anchor if addDetail: - let post = selectPost(rows[i], skippedPosts) + let replyingTo = selectReplyingTo(rows[i][4]) + let post = selectPost(rows[i], skippedPosts, replyingTo) list.posts.add(post) skippedPosts = @[] else: @@ -1217,6 +1243,7 @@ routes: let intIDs = ids.elems.map(x => x.getInt()) let postsQuery = sql(""" select p.id, p.content, strftime('%s', p.creation), p.author, + p.replyingTo, u.name, u.email, strftime('%s', u.lastOnline), u.status from post p, person u where u.id = p.author and p.id in ($#) @@ -1226,7 +1253,7 @@ routes: var list: seq[Post] = @[] for row in db.getAllRows(postsQuery): - list.add(selectPost(row, @[])) + list.add(selectPost(row, @[], selectReplyingTo(row[4]))) resp $(%list), "application/json" @@ -1440,11 +1467,14 @@ routes: let threadId = getInt(formData["threadId"].body, -1) cond threadId != -1 - let replyingTo = + let replyingToId = if "replyingTo" in formData: getInt(formData["replyingTo"].body, -1) else: -1 + let replyingTo = + if replyingToId == -1: none[int]() + else: some(replyingToId) try: let id = executeReply(c, threadId, msg, replyingTo) diff --git a/frontend/post.nim b/frontend/post.nim index 0817c00..c2fda99 100644 --- a/frontend/post.nim +++ b/frontend/post.nim @@ -1,4 +1,4 @@ -import strformat +import strformat, options import user, threadlist @@ -20,12 +20,14 @@ type info*: PostInfo moreBefore*: seq[int] isDeleted*: bool + replyingTo*: Option[PostLink] PostLink* = object ## Used by profile creation*: int64 topic*: string threadId*: int postId*: int + author*: Option[User] ## Only used for `replyingTo`. proc isModerated*(post: Post): bool = ## Determines whether the specified thread is under moderation. From 1ecd8daa7c79791ce0d965ced444422019f7160a Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 21:19:40 +0100 Subject: [PATCH 152/396] Implements categories in threads list. --- forum.nim | 22 +++++++++++++++------- frontend/category.nim | 19 ++++++++++++------- frontend/post.nim | 1 - frontend/threadlist.nim | 1 - 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/forum.nim b/forum.nim index 0802078..9ebc960 100644 --- a/forum.nim +++ b/forum.nim @@ -953,15 +953,19 @@ proc selectThread(threadRow: seq[string]): Thread = var thread = Thread( id: threadRow[0].parseInt, topic: threadRow[1], - category: Category(id: "", color: "#ff0000"), # TODO + category: Category( + id: threadRow[5].parseInt, + name: threadRow[6], + description: threadRow[7], + color: threadRow[8] + ), users: @[], replies: posts[0].parseInt-1, views: threadRow[2].parseInt, activity: threadRow[3].parseInt, creation: posts[1].parseInt, - isLocked: false, # TODO: + isLocked: threadRow[4] == "1", isSolved: false, # TODO: Add a field to `post` to identify the solution. - isDeleted: false # TODO: ) # Gather the users list. @@ -1171,8 +1175,10 @@ routes: count = getInt(@"count", 30) const threadsQuery = - sql"""select id, name, views, strftime('%s', modified) from thread - where isDeleted = 0 + sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, + c.id, c.name, c.description, c.color + from thread t, category c + where isDeleted = 0 and category = c.id order by modified desc limit ?, ?;""" let thrCount = getValue(db, sql"select count(*) from thread;").parseInt() @@ -1195,8 +1201,10 @@ routes: count = 10 const threadsQuery = - sql"""select id, name, views, strftime('%s', modified) from thread - where id = ? and isDeleted = 0;""" + sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, + c.id, c.name, c.description, c.color + from thread t, category c + where t.id = ? and isDeleted = 0 and category = c.id;""" let threadRow = getRow(db, threadsQuery, id) let thread = selectThread(threadRow) diff --git a/frontend/category.nim b/frontend/category.nim index 70c1293..6c20f94 100644 --- a/frontend/category.nim +++ b/frontend/category.nim @@ -1,7 +1,9 @@ type Category* = object - id*: string + id*: int + name*: string + description*: string color*: string @@ -13,11 +15,14 @@ when defined(js): 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 + if category.name.len >= 0: + tdiv(class="category", + "data-color"="#" & category.color): + tdiv(class="triangle", + style=style( + (StyleAttr.borderBottom, + kstring"0.6rem solid #" & category.color) + )) + text category.name else: span() \ No newline at end of file diff --git a/frontend/post.nim b/frontend/post.nim index c2fda99..dbde4af 100644 --- a/frontend/post.nim +++ b/frontend/post.nim @@ -19,7 +19,6 @@ type ## older versions of the post. info*: PostInfo moreBefore*: seq[int] - isDeleted*: bool replyingTo*: Option[PostLink] PostLink* = object ## Used by profile diff --git a/frontend/threadlist.nim b/frontend/threadlist.nim index 482b6e0..f311a0c 100644 --- a/frontend/threadlist.nim +++ b/frontend/threadlist.nim @@ -15,7 +15,6 @@ type creation*: int64 ## Unix timestamp isLocked*: bool isSolved*: bool - isDeleted*: bool ThreadList* = ref object threads*: seq[Thread] From 8d41060c54da620a71eed1395a1f955931a85343 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Thu, 17 May 2018 22:44:30 +0100 Subject: [PATCH 153/396] Implements display of replyingTo in front end. --- frontend/nimforum.scss | 11 ++++++++++- frontend/postlist.nim | 8 +++++++- frontend/user.nim | 7 ++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/nimforum.scss b/frontend/nimforum.scss index 00d17ef..50481b6 100644 --- a/frontend/nimforum.scss +++ b/frontend/nimforum.scss @@ -306,8 +306,17 @@ $views-color: #545d70; } } - .post-time { + .post-metadata { float: right; + + .post-replyingTo { + display: inline-block; + margin-right: $control-padding-x; + + i.fa-reply { + transform: rotate(180deg); + } + } } } diff --git a/frontend/postlist.nim b/frontend/postlist.nim index 2226f1d..ab987ec 100644 --- a/frontend/postlist.nim +++ b/frontend/postlist.nim @@ -212,7 +212,13 @@ when defined(js): if post.author.rank == Admin: italic(class="fas fa-chess-knight", title="User is an admin") - tdiv(class="post-time"): + tdiv(class="post-metadata"): + if post.replyingTo.isSome(): + let replyingTo = post.replyingTo.get() + tdiv(class="post-replyingTo"): + a(href=renderPostUrl(replyingTo)): + italic(class="fas fa-reply") + renderUserMention(replyingTo.author.get()) let title = post.info.creation.fromUnix().local. format("MMM d, yyyy HH:mm") a(href=renderPostUrl(post, thread), title=title): diff --git a/frontend/user.nim b/frontend/user.nim index 2cfcb8e..7daa27e 100644 --- a/frontend/user.nim +++ b/frontend/user.nim @@ -38,6 +38,7 @@ when defined(js): proc renderUserMention*(user: User): VNode = result = buildHtml(): - # TODO: Add URL to profile. - span(class="user-mention"): - text "@" & user.name \ No newline at end of file + a(class="user-mention", + href=makeUri("/profile/" & user.name), + onClick=anchorCB): + text "@" & user.name \ No newline at end of file From 7f5e68331c719813f115a8309b9cca6435e7e2ca Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 11:59:20 +0100 Subject: [PATCH 154/396] Adds needsPasswordReseti DB field for future use. --- setup_nimforum.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup_nimforum.nim b/setup_nimforum.nim index 6004d0c..22eb38b 100644 --- a/setup_nimforum.nim +++ b/setup_nimforum.nim @@ -78,7 +78,8 @@ proc initialiseDb(admin: tuple[username, password, email: string]) = salt varbin(128) not null, status varchar(30) not null, lastOnline timestamp not null default (DATETIME('now')), - isDeleted boolean not null default 0 + isDeleted boolean not null default 0, + needsPasswordReset boolean not null default 0 );""" % [userNameType, passwordType, emailType]), []) db.exec(sql""" From 8acaca298bd70e5807e0b0ba00f4451a8f068c64 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 12:01:00 +0100 Subject: [PATCH 155/396] Fixes bug with thread list loading twice on refresh. --- frontend/threadlist.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/threadlist.nim b/frontend/threadlist.nim index f311a0c..a50b66e 100644 --- a/frontend/threadlist.nim +++ b/frontend/threadlist.nim @@ -163,7 +163,7 @@ when defined(js): if state.status != Http200: return renderError("Couldn't retrieve threads.") - if state.list.isNone: + if state.list.isNone and (not state.loading): ajaxGet(makeUri("threads.json"), @[], onThreadList) return buildHtml(tdiv(class="loading loading-lg")) From d5f1c674c5f37cf9bfb35060f648ec438848879f Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 14:03:35 +0100 Subject: [PATCH 156/396] Implements edit history. --- forum.nim | 43 +++++++++++++++++++++++++++++++++++------- frontend/editbox.nim | 3 +++ frontend/nimforum.scss | 13 +++++++++++++ frontend/post.nim | 3 +++ frontend/postlist.nim | 20 ++++++++++++++++++-- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/forum.nim b/forum.nim index 9ebc960..3633ac1 100644 --- a/forum.nim +++ b/forum.nim @@ -889,14 +889,14 @@ proc selectUser(userRow: seq[string], avatarSize: int=80): User = ) proc selectPost(postRow: seq[string], skippedPosts: seq[int], - replyingTo: Option[PostLink]): Post = + replyingTo: Option[PostLink], history: seq[PostInfo]): Post = return Post( id: postRow[0].parseInt, replyingTo: replyingTo, author: selectUser(@[postRow[5], postRow[6], postRow[7], postRow[8]]), likes: @[], # TODO: seen: false, # TODO: - history: @[], # TODO: + history: history, info: PostInfo( creation: postRow[2].parseInt, content: postRow[1].rstToHtml() @@ -926,6 +926,20 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] = author: some(selectUser(@[row[3], row[4], row[5], row[6]])) )) +proc selectHistory(postId: int): seq[PostInfo] = + const historyQuery = sql""" + select strftime('%s', creation), content from postRevision + where original = ? + order by creation asc; + """ + + result = @[] + for row in getAllRows(db, historyQuery, $postId): + result.add(PostInfo( + creation: row[0].parseInt(), + content: row[1].rstToHtml() + )) + proc selectThread(threadRow: seq[string]): Thread = const postsQuery = sql"""select count(*), strftime('%s', creation) from post @@ -1032,7 +1046,15 @@ proc updatePost(c: TForumData, postId: int, content: string, raise newForumError("Message needs to be valid RST", @["msg"]) # Update post. - exec(db, crud(crUpdate, "post", "content"), content, $postId) + # - We create a new postRevision entry for our edit. + exec( + db, + crud(crCreate, "postRevision", "content", "original"), + content, + $postId + ) + # - We set the FTS to the latest content as searching for past edits is not + # supported. exec(db, crud(crUpdate, "post_fts", "content"), content, $postId) # Check if post is the first post of the thread. if subject.isSome(): @@ -1234,7 +1256,8 @@ routes: if addDetail: let replyingTo = selectReplyingTo(rows[i][4]) - let post = selectPost(rows[i], skippedPosts, replyingTo) + let history = selectHistory(rows[i][0].parseInt()) + let post = selectPost(rows[i], skippedPosts, replyingTo, history) list.posts.add(post) skippedPosts = @[] else: @@ -1261,7 +1284,8 @@ routes: var list: seq[Post] = @[] for row in db.getAllRows(postsQuery): - list.add(selectPost(row, @[], selectReplyingTo(row[4]))) + let history = selectHistory(row[0].parseInt()) + list.add(selectPost(row, @[], selectReplyingTo(row[4]), history)) resp $(%list), "application/json" @@ -1271,10 +1295,15 @@ routes: cond postId != -1 let postQuery = sql""" - select content from post where id = ?; + select content from ( + select content, creation from post where id = ? + union + select content, creation from postRevision where original = ? + ) + order by creation desc limit 1; """ - let content = getValue(db, postQuery, postId) + let content = getValue(db, postQuery, postId, postId) if content.len == 0: resp Http404, "Post not found" else: diff --git a/frontend/editbox.nim b/frontend/editbox.nim index 861c629..e6c6c62 100644 --- a/frontend/editbox.nim +++ b/frontend/editbox.nim @@ -58,6 +58,9 @@ when defined(js): (s: int, r: kstring) => onEditPost(s, r, state)) proc render*(state: EditBox, post: Post): VNode = + if state.status != Http200: + return renderError("Couldn't retrieve raw post") + if state.rawContent.isNone() or state.post.id != post.id: state.post = post state.rawContent = none[kstring]() diff --git a/frontend/nimforum.scss b/frontend/nimforum.scss index 50481b6..2fc3ad3 100644 --- a/frontend/nimforum.scss +++ b/frontend/nimforum.scss @@ -317,6 +317,19 @@ $views-color: #545d70; transform: rotate(180deg); } } + + .post-history { + display: inline-block; + margin-right: $control-padding-x; + + i { + font-size: 90%; + } + + .edit-count { + margin-right: $control-padding-x-sm/2; + } + } } } diff --git a/frontend/post.nim b/frontend/post.nim index dbde4af..77b97f8 100644 --- a/frontend/post.nim +++ b/frontend/post.nim @@ -28,6 +28,9 @@ type postId*: int author*: Option[User] ## Only used for `replyingTo`. +proc lastEdit*(post: Post): PostInfo = + post.history[^1] + proc isModerated*(post: Post): bool = ## Determines whether the specified thread is under moderation. post.author.rank <= Moderated diff --git a/frontend/postlist.nim b/frontend/postlist.nim index ab987ec..88fcbe3 100644 --- a/frontend/postlist.nim +++ b/frontend/postlist.nim @@ -119,7 +119,10 @@ when defined(js): let list = state.list.get() for i in 0 ..< list.posts.len: if list.posts[i].id == id: - list.posts[i].info.content = content + list.posts[i].history.add(PostInfo( + creation: getTime().toUnix(), + content: content + )) break proc onReplyClick(e: Event, n: VNode, p: Option[Post]) = @@ -219,6 +222,14 @@ when defined(js): a(href=renderPostUrl(replyingTo)): italic(class="fas fa-reply") renderUserMention(replyingTo.author.get()) + if post.history.len > 0: + let title = post.lastEdit.creation.fromUnix().local. + format("'Last modified' MMM d, yyyy HH:mm") + tdiv(class="post-history", title=title): + span(class="edit-count"): + text $post.history.len + italic(class="fas fa-pencil-alt") + let title = post.info.creation.fromUnix().local. format("MMM d, yyyy HH:mm") a(href=renderPostUrl(post, thread), title=title): @@ -227,7 +238,12 @@ when defined(js): if state.editing.isSome() and state.editing.get() == post: render(state.editBox, postCopy) else: - verbatim(post.info.content) + let content = + if post.history.len > 0: + post.lastEdit.content + else: + post.info.content + verbatim(content) genPostButtons(postCopy, currentUser) From 5c9f1bb85e8c26cb4f94909dc1b42967b786922f Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 14:16:19 +0100 Subject: [PATCH 157/396] Rearrange post buttons. --- frontend/postlist.nim | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/postlist.nim b/frontend/postlist.nim index 88fcbe3..b323d1e 100644 --- a/frontend/postlist.nim +++ b/frontend/postlist.nim @@ -175,20 +175,18 @@ when defined(js): button(class="btn"): italic(class="far fa-trash-alt") - if not authoredByUser: - 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="flag-button"): - button(class="btn"): - italic(class="far fa-flag") + 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="flag-button"): + button(class="btn"): + italic(class="far fa-flag") + tdiv(class="reply-button"): button(class="btn", onClick=(e: Event, n: VNode) => onReplyClick(e, n, some(post))): From a0655e049de50262187e6d2e95f4da75218b2732 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 17:04:34 +0100 Subject: [PATCH 158/396] Implements likes fully in frontend and backend. --- forum.nim | 93 +++++++++++++++++++++++++++++++++++++---- frontend/nimforum.scss | 4 ++ frontend/post.nim | 10 +++++ frontend/postbutton.nim | 68 +++++++++++++++++++++++++++++- frontend/postlist.nim | 13 +++--- 5 files changed, 169 insertions(+), 19 deletions(-) diff --git a/forum.nim b/forum.nim index 3633ac1..924d657 100644 --- a/forum.nim +++ b/forum.nim @@ -889,12 +889,13 @@ proc selectUser(userRow: seq[string], avatarSize: int=80): User = ) proc selectPost(postRow: seq[string], skippedPosts: seq[int], - replyingTo: Option[PostLink], history: seq[PostInfo]): Post = + replyingTo: Option[PostLink], history: seq[PostInfo], + likes: seq[User]): Post = return Post( id: postRow[0].parseInt, replyingTo: replyingTo, author: selectUser(@[postRow[5], postRow[6], postRow[7], postRow[8]]), - likes: @[], # TODO: + likes: likes, seen: false, # TODO: history: history, info: PostInfo( @@ -940,6 +941,18 @@ proc selectHistory(postId: int): seq[PostInfo] = content: row[1].rstToHtml() )) +proc selectLikes(postId: int): seq[User] = + const likeQuery = sql""" + select u.name, u.email, strftime('%s', u.lastOnline), u.status + from like h, person u + where h.post = ? and h.author = u.id + order by h.creation asc; + """ + + result = @[] + for row in getAllRows(db, likeQuery, $postId): + result.add(selectUser(row)) + proc selectThread(threadRow: seq[string]): Thread = const postsQuery = sql"""select count(*), strftime('%s', creation) from post @@ -1169,13 +1182,44 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, raise newForumError("Couldn't send activation email", @["email"]) # Add account to person table - exec(db, - sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " & - "VALUES (?, ?, ?, ?, ?, DATETIME('now'))"), name, - password, email, salt, $EmailUnconfirmed) + exec(db, sql""" + INSERT INTO person(name, password, email, salt, status, lastOnline) + VALUES (?, ?, ?, ?, ?, DATETIME('now')) + """, name, password, email, salt, $EmailUnconfirmed) return password +proc executeLike(c: TForumData, postId: int) = + # Verify the post exists and doesn't belong to the current user. + const postQuery = sql""" + select u.name from post p, person u + where p.id = ? and p.author = u.id and p.isDeleted = 0; + """ + + let postAuthor = getValue(db, postQuery, postId) + if postAuthor.len == 0: + raise newForumError("Specified post ID does not exist.", @["id"]) + + if postAuthor == c.username: + raise newForumError("You cannot like your own post.") + + # Save the like. + exec(db, crud(crCreate, "like", "author", "post"), c.userid, postId) + +proc executeUnlike(c: TForumData, postId: int) = + # Verify the post and like exists for the current user. + const likeQuery = sql""" + select l.id from like l, person u + where l.post = ? and l.author = u.id and u.name = ?; + """ + + let likeId = getValue(db, likeQuery, postId, c.username) + if likeId.len == 0: + raise newForumError("Like doesn't exist.", @["id"]) + + # Delete the like. + exec(db, crud(crDelete, "like"), likeId) + initialise() routes: @@ -1256,8 +1300,11 @@ routes: if addDetail: let replyingTo = selectReplyingTo(rows[i][4]) - let history = selectHistory(rows[i][0].parseInt()) - let post = selectPost(rows[i], skippedPosts, replyingTo, history) + let history = selectHistory(id) + let likes = selectLikes(id) + let post = selectPost( + rows[i], skippedPosts, replyingTo, history, likes + ) list.posts.add(post) skippedPosts = @[] else: @@ -1285,7 +1332,8 @@ routes: for row in db.getAllRows(postsQuery): let history = selectHistory(row[0].parseInt()) - list.add(selectPost(row, @[], selectReplyingTo(row[4]), history)) + let likes = selectLikes(row[0].parseInt()) + list.add(selectPost(row, @[], selectReplyingTo(row[4]), history, likes)) resp $(%list), "application/json" @@ -1570,6 +1618,33 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" + post re"/(like|unlike)": + createTFD() + if not c.loggedIn(): + let err = PostError( + errorFields: @[], + message: "Not logged in." + ) + resp Http401, $(%err), "application/json" + + let formData = request.formData + cond "id" in formData + + let postId = getInt(formData["id"].body, -1) + cond postId != -1 + + try: + case request.path + of "/like": + executeLike(c, postId) + of "/unlike": + executeUnlike(c, postId) + else: + assert false + resp Http200, "{}", "application/json" + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + get "/t/@id": cond "id" in request.params diff --git a/frontend/nimforum.scss b/frontend/nimforum.scss index 2fc3ad3..143eaad 100644 --- a/frontend/nimforum.scss +++ b/frontend/nimforum.scss @@ -366,6 +366,10 @@ $views-color: #545d70; .like-button i:hover, .like-button i.fas { color: #f783ac; } + + .like-count { + margin-right: $control-padding-x-sm; + } } #thread-buttons { diff --git a/frontend/post.nim b/frontend/post.nim index 77b97f8..7e46a6b 100644 --- a/frontend/post.nim +++ b/frontend/post.nim @@ -35,6 +35,16 @@ proc isModerated*(post: Post): bool = ## Determines whether the specified thread is under moderation. post.author.rank <= Moderated +proc isLikedBy*(post: Post, user: Option[User]): bool = + ## Determines whether the specified user has liked the post. + if user.isNone(): return false + + for u in post.likes: + if u.name == user.get().name: + return true + + return false + when defined(js): import karaxutils diff --git a/frontend/postbutton.nim b/frontend/postbutton.nim index 913b392..8bc89ef 100644 --- a/frontend/postbutton.nim +++ b/frontend/postbutton.nim @@ -8,7 +8,7 @@ when defined(js): include karax/prelude import karax/[kajax, kdom] - import error, karaxutils + import error, karaxutils, post, user type PostButton* = ref object @@ -74,4 +74,68 @@ when defined(js): if state.error.isSome(): p(class="text-error"): - text state.error.get().message \ No newline at end of file + text state.error.get().message + + + type + LikeButton* = ref object + error: Option[PostError] + loading: bool + + proc newLikeButton*(): LikeButton = + LikeButton() + + proc onPost(httpStatus: int, response: kstring, state: LikeButton, + post: Post, user: User) = + postFinished: + if post.isLikedBy(some(user)): + var newLikes: seq[User] = @[] + for like in post.likes: + if like.name != user.name: + newLikes.add(like) + post.likes = newLikes + else: + post.likes.add(user) + + proc onClick(ev: Event, n: VNode, state: LikeButton, post: Post, + currentUser: Option[User]) = + if state.loading: return + if currentUser.isNone(): + state.error = some[PostError](PostError(message: "Not logged in.")) + + state.loading = true + state.error = none[PostError]() + + # TODO: This is a hack, karax should support this. + var formData = newFormData() + formData.append("id", $post.id) + let uri = + if post.isLikedBy(currentUser): + makeUri("/unlike") + else: + makeUri("/like") + ajaxPost(uri, @[], cast[cstring](formData), + (s: int, r: kstring) => + onPost(s, r, state, post, currentUser.get())) + + ev.preventDefault() + + proc render*(state: LikeButton, post: Post, + currentUser: Option[User]): VNode = + + let liked = isLikedBy(post, currentUser) + let tooltip = + if state.error.isSome(): state.error.get().message + else: "" + + result = buildHtml(): + tdiv(class="like-button"): + button(class=class({"tooltip": state.error.isSome()}, "btn"), + onClick=(e: Event, n: VNode) => + (onClick(e, n, state, post, currentUser)), + "data-tooltip"=tooltip): + if post.likes.len > 0: + span(class="like-count"): + text $post.likes.len + + italic(class=class({"far": not liked, "fas": liked}, "fa-heart")) \ No newline at end of file diff --git a/frontend/postlist.nim b/frontend/postlist.nim index b323d1e..13c91d3 100644 --- a/frontend/postlist.nim +++ b/frontend/postlist.nim @@ -17,7 +17,7 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, error, replybox, editbox + import karaxutils, error, replybox, editbox, postbutton type State = ref object @@ -28,6 +28,7 @@ when defined(js): replyBox: ReplyBox editing: Option[Post] ## If in edit mode, this contains the post. editBox: EditBox + likeButton: LikeButton proc onReplyPosted(id: int) proc onEditPosted(id: int, content: string, subject: Option[string]) @@ -39,7 +40,8 @@ when defined(js): status: Http200, replyingTo: none[Post](), replyBox: newReplyBox(onReplyPosted), - editBox: newEditBox(onEditPosted, onEditCancelled) + editBox: newEditBox(onEditPosted, onEditCancelled), + likeButton: newLikeButton() ) var @@ -175,12 +177,7 @@ when defined(js): button(class="btn"): italic(class="far fa-trash-alt") - 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") + render(state.likeButton, post, currentUser) if loggedIn: tdiv(class="flag-button"): From dd9be8f639008d7217b5abe6fbc29d1e00e37f7e Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 17:11:22 +0100 Subject: [PATCH 159/396] Reset LikeButton error on mouse leave. --- frontend/postbutton.nim | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/postbutton.nim b/frontend/postbutton.nim index 8bc89ef..4e146b6 100644 --- a/frontend/postbutton.nim +++ b/frontend/postbutton.nim @@ -102,6 +102,7 @@ when defined(js): if state.loading: return if currentUser.isNone(): state.error = some[PostError](PostError(message: "Not logged in.")) + return state.loading = true state.error = none[PostError]() @@ -133,7 +134,9 @@ when defined(js): button(class=class({"tooltip": state.error.isSome()}, "btn"), onClick=(e: Event, n: VNode) => (onClick(e, n, state, post, currentUser)), - "data-tooltip"=tooltip): + "data-tooltip"=tooltip, + onmouseleave=(e: Event, n: VNode) => + (state.error = none[PostError]())): if post.likes.len > 0: span(class="like-count"): text $post.likes.len From 41a6790fe8b3faf58fcb52c1cb581329e0ec5cb2 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 19:54:33 +0100 Subject: [PATCH 160/396] Implements delete button fully in frontend and backend for posts and thread. --- forum.nim | 76 ++++++++++++++++++++---- frontend/delete.nim | 132 ++++++++++++++++++++++++++++++++++++++++++ frontend/postlist.nim | 31 ++++++++-- 3 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 frontend/delete.nim diff --git a/forum.nim b/forum.nim index 924d657..4084888 100644 --- a/forum.nim +++ b/forum.nim @@ -953,6 +953,20 @@ proc selectLikes(postId: int): seq[User] = for row in getAllRows(db, likeQuery, $postId): result.add(selectUser(row)) +proc selectThreadAuthor(threadId: int): User = + const authorQuery = + sql""" + select name, email, strftime('%s', lastOnline), status + from person where id in ( + select author from post + where thread = ? + order by id + limit 1 + ) + """ + + return selectUser(getRow(db, authorQuery, threadId)) + proc selectThread(threadRow: seq[string]): Thread = const postsQuery = sql"""select count(*), strftime('%s', creation) from post @@ -964,16 +978,6 @@ proc selectThread(threadRow: seq[string]): Thread = from person u, post p where p.author = u.id and p.thread = ? group by name order by count(*) desc limit 5; """ - const authorQuery = - sql""" - select name, email, strftime('%s', lastOnline), status - from person where id in ( - select author from post - where thread = ? - order by id - limit 1 - ) - """ let posts = getRow(db, postsQuery, threadRow[0]) @@ -1000,7 +1004,7 @@ proc selectThread(threadRow: seq[string]): Thread = thread.users.add(selectUser(user)) # Grab the author. - thread.author = selectUser(getRow(db, authorQuery, thread.id)) + thread.author = selectThreadAuthor(thread.id) return thread @@ -1220,6 +1224,29 @@ proc executeUnlike(c: TForumData, postId: int) = # Delete the like. exec(db, crud(crDelete, "like"), likeId) +proc executeDeletePost(c: TForumData, postId: int) = + # Verify that this post belongs to the user. + const postQuery = sql""" + select p.id from post p + where p.author = ? and p.id = ? + """ + let id = getValue(db, postQuery, postId, c.username) + + if id.len == 0 and c.rank < Admin: + raise newForumError("You cannot delete this post") + + # Set the `isDeleted` flag. + exec(db, crud(crUpdate, "post", "isDeleted"), "1", postId) + +proc executeDeleteThread(c: TForumData, threadId: int) = + # Verify that this thread belongs to the user. + let author = selectThreadAuthor(threadId) + if author.name != c.username and c.rank < Admin: + raise newForumError("You cannot delete this thread") + + # Set the `isDeleted` flag. + exec(db, crud(crUpdate, "thread", "isDeleted"), "1", threadId) + initialise() routes: @@ -1645,6 +1672,33 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" + post re"/delete(Post|Thread)": + createTFD() + if not c.loggedIn(): + let err = PostError( + errorFields: @[], + message: "Not logged in." + ) + resp Http401, $(%err), "application/json" + + let formData = request.formData + cond "id" in formData + + let id = getInt(formData["id"].body, -1) + cond id != -1 + + try: + case request.path + of "/deletePost": + executeDeletePost(c, id) + of "/deleteThread": + executeDeleteThread(c, id) + else: + assert false + resp Http200, "{}", "application/json" + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + get "/t/@id": cond "id" in request.params diff --git a/frontend/delete.nim b/frontend/delete.nim new file mode 100644 index 0000000..789aebc --- /dev/null +++ b/frontend/delete.nim @@ -0,0 +1,132 @@ +when defined(js): + import sugar, httpcore, options, json + import dom except Event + + include karax/prelude + import karax / [kajax, kdom] + + import error, post, threadlist, user + import karaxutils + + type + DeleteKind* = enum + DeleteUser, DeletePost, DeleteThread + + DeleteModal* = ref object + shown: bool + loading: bool + onDeletePost: proc (post: Post) + onDeleteThread: proc (thread: Thread) + onDeleteUser: proc (user: User) + error: Option[PostError] + case kind: DeleteKind + of DeleteUser: + user: User + of DeletePost: + post: Post + of DeleteThread: + thread: Thread + + proc onDeletePost(httpStatus: int, response: kstring, state: DeleteModal) = + postFinished: + state.shown = false + case state.kind + of DeleteUser: + state.onDeleteUser(state.user) + of DeletePost: + state.onDeletePost(state.post) + of DeleteThread: + state.onDeleteThread(state.thread) + + proc onDelete(ev: Event, n: VNode, state: DeleteModal) = + state.loading = true + state.error = none[PostError]() + + let uri = + case state.kind + of DeleteUser: + makeUri("/deleteUser") + of DeleteThread: + makeUri("/deleteThread") + of DeletePost: + makeUri("/deletePost") + # TODO: This is a hack, karax should support this. + let formData = newFormData() + case state.kind + of DeleteUser: + formData.append("username", state.user.name) + of DeletePost: + formData.append("id", $state.post.id) + of DeleteThread: + formData.append("id", $state.thread.id) + ajaxPost(uri, @[], cast[cstring](formData), + (s: int, r: kstring) => onDeletePost(s, r, state)) + + proc onClose(ev: Event, n: VNode, state: DeleteModal) = + state.shown = false + ev.preventDefault() + + proc newDeleteModal*( + onDeletePost: proc (post: Post), + onDeleteThread: proc (thread: Thread), + onDeleteUser: proc (user: User), + ): DeleteModal = + DeleteModal( + shown: false, + onDeletePost: onDeletePost, + onDeleteThread: onDeleteThread, + onDeleteUser: onDeleteUser, + ) + + proc show*(state: DeleteModal, thing: User | Post | Thread) = + state.shown = true + state.error = none[PostError]() + when thing is User: + state.kind = DeleteUser + state.user = thing + when thing is Post: + state.kind = DeletePost + state.post = thing + when thing is Thread: + state.kind = DeleteThread + state.thread = thing + + proc render*(state: DeleteModal): VNode = + result = buildHtml(): + tdiv(class=class({"active": state.shown}, "modal modal-sm"), + id="login-modal"): + a(href="", class="modal-overlay", "aria-label"="close", + onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) + tdiv(class="modal-container"): + tdiv(class="modal-header"): + a(href="", class="btn btn-clear float-right", + "aria-label"="close", + onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) + tdiv(class="modal-title h5"): + text "Delete" + tdiv(class="modal-body"): + tdiv(class="content"): + p(): + text "Are you sure you want to delete this " + case state.kind + of DeleteUser: + text "user account?" + of DeleteThread: + text "thread?" + of DeletePost: + text "post?" + tdiv(class="modal-footer"): + if state.error.isSome(): + p(class="text-error"): + text state.error.get().message + + button(class=class( + {"loading": state.loading}, + "btn btn-primary" + ), + onClick=(ev: Event, n: VNode) => onDelete(ev, n, state)): + italic(class="fas fa-trash-alt") + text " Delete" + button(class="btn", + onClick=(ev: Event, n: VNode) => (state.shown = false)): + text "Cancel" \ No newline at end of file diff --git a/frontend/postlist.nim b/frontend/postlist.nim index 13c91d3..68f2e85 100644 --- a/frontend/postlist.nim +++ b/frontend/postlist.nim @@ -1,5 +1,6 @@ import options, json, times, httpcore, strformat, sugar, math, strutils +import sequtils import threadlist, category, post, user type @@ -17,7 +18,7 @@ when defined(js): include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, error, replybox, editbox, postbutton + import karaxutils, error, replybox, editbox, postbutton, delete type State = ref object @@ -29,10 +30,13 @@ when defined(js): editing: Option[Post] ## If in edit mode, this contains the post. editBox: EditBox likeButton: LikeButton + deleteModal: DeleteModal proc onReplyPosted(id: int) proc onEditPosted(id: int, content: string, subject: Option[string]) proc onEditCancelled() + proc onDeletePost(post: Post) + proc onDeleteThread(thread: Thread) proc newState(): State = State( list: none[PostList](), @@ -41,7 +45,8 @@ when defined(js): replyingTo: none[Post](), replyBox: newReplyBox(onReplyPosted), editBox: newEditBox(onEditPosted, onEditCancelled), - likeButton: newLikeButton() + likeButton: newLikeButton(), + deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil) ) var @@ -137,6 +142,21 @@ when defined(js): # TODO: Ensure the edit box is as big as its content. Auto resize the # text area. + proc onDeletePost(post: Post) = + state.list.get().posts.keepIf( + x => x.id != post.id + ) + + proc onDeleteThread(thread: Thread) = + window.location.href = makeUri("/") + + proc onDeleteClick(e: Event, n: VNode, p: Post) = + let list = state.list.get() + if list.posts[0].id == p.id: + state.deleteModal.show(list.thread) + else: + state.deleteModal.show(p) + proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) = loadMore(start, post.moreBefore) # TODO: Don't load all! @@ -173,7 +193,8 @@ when defined(js): onEditClick(e, n, post)): button(class="btn"): italic(class="far fa-edit") - tdiv(class="delete-button"): + tdiv(class="delete-button", + onClick=(e: Event, n: VNode) => onDeleteClick(e, n, post)): button(class="btn"): italic(class="far fa-trash-alt") @@ -338,4 +359,6 @@ when defined(js): italic(class="fas fa-reply") text " Reply" - render(state.replyBox, list.thread, state.replyingTo, false) \ No newline at end of file + render(state.replyBox, list.thread, state.replyingTo, false) + + render(state.deleteModal) \ No newline at end of file From 8518c70a661077350509421d131d0679977d9e82 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 20:48:01 +0100 Subject: [PATCH 161/396] Rearranges directories and files. --- .gitmodules | 3 ++ editdb.nim | 19 ----------- nimforum.nimble | 33 ++++++++++++++++---- {frontend => public/css}/nimforum.scss | 0 {frontend => public/css}/spectre | 0 {frontend => public/css}/syntax.scss | 0 {frontend => public}/karax.html | 0 {static => public}/license.rst | 0 {static => public}/rst.rst | 0 {static => public}/search-help.rst | 0 auth.nim => src/auth.nim | 0 forms.tmpl => src/forms.tmpl | 0 forum.nim => src/forum.nim | 20 +++++++----- forum.nim.cfg => src/forum.nim.cfg | 0 {frontend => src/frontend}/builder.nim | 0 {frontend => src/frontend}/category.nim | 0 {frontend => src/frontend}/delete.nim | 0 {frontend => src/frontend}/editbox.nim | 0 {frontend => src/frontend}/error.nim | 0 {frontend => src/frontend}/forum.nim | 0 {frontend => src/frontend}/forum.nim.cfg | 0 {frontend => src/frontend}/header.nim | 0 {frontend => src/frontend}/index.html | 0 {frontend => src/frontend}/karaxutils.nim | 0 {frontend => src/frontend}/login.nim | 0 {frontend => src/frontend}/newthread.nim | 0 {frontend => src/frontend}/post.nim | 0 {frontend => src/frontend}/postbutton.nim | 0 {frontend => src/frontend}/postlist.nim | 0 {frontend => src/frontend}/profile.nim | 0 {frontend => src/frontend}/replybox.nim | 0 {frontend => src/frontend}/signup.nim | 0 {frontend => src/frontend}/thread.html | 0 {frontend => src/frontend}/threadlist.nim | 0 {frontend => src/frontend}/user.nim | 0 {frontend => src/frontend}/usermenu.nim | 0 fts.sql => src/fts.sql | 0 main.tmpl => src/main.tmpl | 0 setup_nimforum.nim => src/setup_nimforum.nim | 0 utils.nim => src/utils.nim | 0 40 files changed, 43 insertions(+), 32 deletions(-) delete mode 100644 editdb.nim rename {frontend => public/css}/nimforum.scss (100%) rename {frontend => public/css}/spectre (100%) rename {frontend => public/css}/syntax.scss (100%) rename {frontend => public}/karax.html (100%) rename {static => public}/license.rst (100%) rename {static => public}/rst.rst (100%) rename {static => public}/search-help.rst (100%) rename auth.nim => src/auth.nim (100%) rename forms.tmpl => src/forms.tmpl (100%) rename forum.nim => src/forum.nim (99%) rename forum.nim.cfg => src/forum.nim.cfg (100%) rename {frontend => src/frontend}/builder.nim (100%) rename {frontend => src/frontend}/category.nim (100%) rename {frontend => src/frontend}/delete.nim (100%) rename {frontend => src/frontend}/editbox.nim (100%) rename {frontend => src/frontend}/error.nim (100%) rename {frontend => src/frontend}/forum.nim (100%) rename {frontend => src/frontend}/forum.nim.cfg (100%) rename {frontend => src/frontend}/header.nim (100%) rename {frontend => src/frontend}/index.html (100%) rename {frontend => src/frontend}/karaxutils.nim (100%) rename {frontend => src/frontend}/login.nim (100%) rename {frontend => src/frontend}/newthread.nim (100%) rename {frontend => src/frontend}/post.nim (100%) rename {frontend => src/frontend}/postbutton.nim (100%) rename {frontend => src/frontend}/postlist.nim (100%) rename {frontend => src/frontend}/profile.nim (100%) rename {frontend => src/frontend}/replybox.nim (100%) rename {frontend => src/frontend}/signup.nim (100%) rename {frontend => src/frontend}/thread.html (100%) rename {frontend => src/frontend}/threadlist.nim (100%) rename {frontend => src/frontend}/user.nim (100%) rename {frontend => src/frontend}/usermenu.nim (100%) rename fts.sql => src/fts.sql (100%) rename main.tmpl => src/main.tmpl (100%) rename setup_nimforum.nim => src/setup_nimforum.nim (100%) rename utils.nim => src/utils.nim (100%) diff --git a/.gitmodules b/.gitmodules index 78fdace..6ea9ea9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "frontend/spectre"] path = frontend/spectre url = https://github.com/picturepan2/spectre +[submodule "public/css/spectre"] + path = public/css/spectre + url = https://github.com/picturepan2/spectre diff --git a/editdb.nim b/editdb.nim deleted file mode 100644 index 7588822..0000000 --- a/editdb.nim +++ /dev/null @@ -1,19 +0,0 @@ - -import strutils, db_sqlite, ranks - -var db = open(connection="nimforum.db", user="postgres", password="", - database="nimforum") - -when false: - db.exec(sql("update person set status = ?"), $User) - db.exec(sql("update person set status = ? where ban <> ''"), $Troll) - db.exec(sql("update person set status = ? where ban like '%spam%'"), $Spammer) - db.exec(sql("update person set status = ? where ban = 'DEACTIVATED' or ban = 'EMAILCONFIRMATION'"), $EmailUnconfirmed) - db.exec(sql("update person set status = ? where admin = 'true'"), $Admin) -else: - db.exec sql"create index PersonStatusIdx on person(status);" - db.exec sql"create index PostByAuthorIdx on post(thread, author);" - db.exec sql"update person set name = 'cheatfate' where name = 'ka';" - - -close(db) diff --git a/nimforum.nimble b/nimforum.nimble index b6943e0..a0c17e7 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -1,11 +1,32 @@ -[Package] -name = "nimforum" +# Package version = "0.1.0" author = "Dominik Picheta" -description = "Nim forum" +description = "The Nim forum" license = "MIT" -bin = "forum" +srcDir = "src" -[Deps] -Requires: "nim >= 0.14.0, jester#head, bcrypt#head, recaptcha >= 1.0.0" +bin = @["forum"] + +skipExt = @["nim"] + +# Dependencies + +requires "nim >= 0.14.0" +requires "jester#head" +requires "bcrypt#head" +requires "recaptcha >= 1.0.0" +requires "sass" + +requires "karax" + +# Tasks + +task backend, "Runs the forum backend": + exec "nimble c src/forum.nim" + exec "./src/forum" + +task frontend, "Builds the necessary JS frontend": + exec "nimble js src/frontend/forum.nim" + mkDir "public/js" + cpFile "src/frontend/nimcache/forum.js", "public/js/forum.js" \ No newline at end of file diff --git a/frontend/nimforum.scss b/public/css/nimforum.scss similarity index 100% rename from frontend/nimforum.scss rename to public/css/nimforum.scss diff --git a/frontend/spectre b/public/css/spectre similarity index 100% rename from frontend/spectre rename to public/css/spectre diff --git a/frontend/syntax.scss b/public/css/syntax.scss similarity index 100% rename from frontend/syntax.scss rename to public/css/syntax.scss diff --git a/frontend/karax.html b/public/karax.html similarity index 100% rename from frontend/karax.html rename to public/karax.html diff --git a/static/license.rst b/public/license.rst similarity index 100% rename from static/license.rst rename to public/license.rst diff --git a/static/rst.rst b/public/rst.rst similarity index 100% rename from static/rst.rst rename to public/rst.rst diff --git a/static/search-help.rst b/public/search-help.rst similarity index 100% rename from static/search-help.rst rename to public/search-help.rst diff --git a/auth.nim b/src/auth.nim similarity index 100% rename from auth.nim rename to src/auth.nim diff --git a/forms.tmpl b/src/forms.tmpl similarity index 100% rename from forms.tmpl rename to src/forms.tmpl diff --git a/forum.nim b/src/forum.nim similarity index 99% rename from forum.nim rename to src/forum.nim index 4084888..dacceff 100644 --- a/forum.nim +++ b/src/forum.nim @@ -13,6 +13,8 @@ import import cgi except setCookie import options +import sass + import auth import frontend/threadlist except User @@ -866,6 +868,10 @@ proc initialise() = doAssert config.isDev, "Recaptcha required for production!" echo("[WARNING] No recaptcha secret key specified.") + let cssLoc = "public" / "css" + if not existsFile(cssLoc / "nimforum.css"): + sass.compileFile(cssLoc / "nimforum.scss", cssLoc / "nimforum.css") + template createTFD() = var c {.inject.}: TForumData new(c) @@ -1252,11 +1258,11 @@ initialise() routes: get "/nimforum.css": - resp readFile("frontend/nimforum.css"), "text/css" + resp readFile("public/css/nimforum.css"), "text/css" get "/nimcache/forum.js": - resp readFile("frontend/nimcache/forum.js"), "application/javascript" + resp readFile("public/js/forum.js"), "application/javascript" get re"/images/(.+?\.png)/?": - let path = "frontend/images/" & request.matches[0] + let path = "public/images/" & request.matches[0] if fileExists(path): resp readFile(path), "image/png" else: @@ -1724,10 +1730,10 @@ routes: redirect uri("/404") get "/404": - resp Http404, readFile("frontend/karax.html") + resp Http404, readFile("public/karax.html") get re"/(.+)?": - resp readFile("frontend/karax.html") + resp readFile("public/karax.html") get "/threadActivity.xml": createTFD() @@ -1870,10 +1876,10 @@ routes: else: resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum") - const licenseRst = slurp("static/license.rst") get "/license": createTFD() - resp genMain(c, rstToHtml(licenseRst), "Content license - Nim Forum") + resp genMain(c, rstToHtml(readFile("static/license.rst")), + "Content license - Nim Forum") post "/search/?@page?": cond isFTSAvailable diff --git a/forum.nim.cfg b/src/forum.nim.cfg similarity index 100% rename from forum.nim.cfg rename to src/forum.nim.cfg diff --git a/frontend/builder.nim b/src/frontend/builder.nim similarity index 100% rename from frontend/builder.nim rename to src/frontend/builder.nim diff --git a/frontend/category.nim b/src/frontend/category.nim similarity index 100% rename from frontend/category.nim rename to src/frontend/category.nim diff --git a/frontend/delete.nim b/src/frontend/delete.nim similarity index 100% rename from frontend/delete.nim rename to src/frontend/delete.nim diff --git a/frontend/editbox.nim b/src/frontend/editbox.nim similarity index 100% rename from frontend/editbox.nim rename to src/frontend/editbox.nim diff --git a/frontend/error.nim b/src/frontend/error.nim similarity index 100% rename from frontend/error.nim rename to src/frontend/error.nim diff --git a/frontend/forum.nim b/src/frontend/forum.nim similarity index 100% rename from frontend/forum.nim rename to src/frontend/forum.nim diff --git a/frontend/forum.nim.cfg b/src/frontend/forum.nim.cfg similarity index 100% rename from frontend/forum.nim.cfg rename to src/frontend/forum.nim.cfg diff --git a/frontend/header.nim b/src/frontend/header.nim similarity index 100% rename from frontend/header.nim rename to src/frontend/header.nim diff --git a/frontend/index.html b/src/frontend/index.html similarity index 100% rename from frontend/index.html rename to src/frontend/index.html diff --git a/frontend/karaxutils.nim b/src/frontend/karaxutils.nim similarity index 100% rename from frontend/karaxutils.nim rename to src/frontend/karaxutils.nim diff --git a/frontend/login.nim b/src/frontend/login.nim similarity index 100% rename from frontend/login.nim rename to src/frontend/login.nim diff --git a/frontend/newthread.nim b/src/frontend/newthread.nim similarity index 100% rename from frontend/newthread.nim rename to src/frontend/newthread.nim diff --git a/frontend/post.nim b/src/frontend/post.nim similarity index 100% rename from frontend/post.nim rename to src/frontend/post.nim diff --git a/frontend/postbutton.nim b/src/frontend/postbutton.nim similarity index 100% rename from frontend/postbutton.nim rename to src/frontend/postbutton.nim diff --git a/frontend/postlist.nim b/src/frontend/postlist.nim similarity index 100% rename from frontend/postlist.nim rename to src/frontend/postlist.nim diff --git a/frontend/profile.nim b/src/frontend/profile.nim similarity index 100% rename from frontend/profile.nim rename to src/frontend/profile.nim diff --git a/frontend/replybox.nim b/src/frontend/replybox.nim similarity index 100% rename from frontend/replybox.nim rename to src/frontend/replybox.nim diff --git a/frontend/signup.nim b/src/frontend/signup.nim similarity index 100% rename from frontend/signup.nim rename to src/frontend/signup.nim diff --git a/frontend/thread.html b/src/frontend/thread.html similarity index 100% rename from frontend/thread.html rename to src/frontend/thread.html diff --git a/frontend/threadlist.nim b/src/frontend/threadlist.nim similarity index 100% rename from frontend/threadlist.nim rename to src/frontend/threadlist.nim diff --git a/frontend/user.nim b/src/frontend/user.nim similarity index 100% rename from frontend/user.nim rename to src/frontend/user.nim diff --git a/frontend/usermenu.nim b/src/frontend/usermenu.nim similarity index 100% rename from frontend/usermenu.nim rename to src/frontend/usermenu.nim diff --git a/fts.sql b/src/fts.sql similarity index 100% rename from fts.sql rename to src/fts.sql diff --git a/main.tmpl b/src/main.tmpl similarity index 100% rename from main.tmpl rename to src/main.tmpl diff --git a/setup_nimforum.nim b/src/setup_nimforum.nim similarity index 100% rename from setup_nimforum.nim rename to src/setup_nimforum.nim diff --git a/utils.nim b/src/utils.nim similarity index 100% rename from utils.nim rename to src/utils.nim From 770b5cddab6fd48b54cc0f5504c15632c080c202 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 20:53:07 +0100 Subject: [PATCH 162/396] Fix rare threadlist race condition properly. --- src/frontend/threadlist.nim | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/frontend/threadlist.nim b/src/frontend/threadlist.nim index a50b66e..68391ff 100644 --- a/src/frontend/threadlist.nim +++ b/src/frontend/threadlist.nim @@ -163,8 +163,10 @@ when defined(js): if state.status != Http200: return renderError("Couldn't retrieve threads.") - if state.list.isNone and (not state.loading): - ajaxGet(makeUri("threads.json"), @[], onThreadList) + if state.list.isNone: + if not state.loading: + state.loading = true + ajaxGet(makeUri("threads.json"), @[], onThreadList) return buildHtml(tdiv(class="loading loading-lg")) From c6ed9e80c939a4a0e1dd14947ba99989eefa26df Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 18 May 2018 22:33:56 +0100 Subject: [PATCH 163/396] Implements web driver test suite and simple test. --- nimforum.nimble | 24 +++++++++- src/forum.nim | 9 ++-- src/frontend/header.nim | 4 +- src/setup_nimforum.nim | 75 +++++++++++++++++++++++--------- src/utils.nim | 2 + tests/browsertester.nim | 68 +++++++++++++++++++++++++++++ tests/browsertester.nims | 1 + tests/browsertests/scenario1.nim | 29 ++++++++++++ 8 files changed, 183 insertions(+), 29 deletions(-) create mode 100644 tests/browsertester.nim create mode 100644 tests/browsertester.nims create mode 100644 tests/browsertests/scenario1.nim diff --git a/nimforum.nimble b/nimforum.nimble index a0c17e7..7e0555a 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -20,13 +20,33 @@ requires "sass" requires "karax" +requires "webdriver" + # Tasks -task backend, "Runs the forum backend": +task backend, "Compiles and runs the forum backend": exec "nimble c src/forum.nim" exec "./src/forum" +task runbackend, "Runs the forum backend": + exec "./src/forum" + task frontend, "Builds the necessary JS frontend": exec "nimble js src/frontend/forum.nim" mkDir "public/js" - cpFile "src/frontend/nimcache/forum.js", "public/js/forum.js" \ No newline at end of file + cpFile "src/frontend/nimcache/forum.js", "public/js/forum.js" + +task testdb, "Creates a test DB": + exec "nimble c src/setup_nimforum" + exec "./src/setup_nimforum --test" + +task devdb, "Creates a test DB": + exec "nimble c src/setup_nimforum" + exec "./src/setup_nimforum --dev" + +task test, "Runs tester": + exec "nimble c src/forum.nim" + exec "nimble c -r tests/browsertester" + +task fasttest, "Runs tester without recompiling backend": + exec "nimble c -r tests/browsertester" \ No newline at end of file diff --git a/src/forum.nim b/src/forum.nim index dacceff..6c423b0 100644 --- a/src/forum.nim +++ b/src/forum.nim @@ -856,10 +856,6 @@ proc prependRe(s: string): string = proc initialise() = randomize() - db = open(connection="nimforum.db", user="postgres", password="", - database="nimforum") - isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & - "type='table' AND name='post_fts'")).len == 1 config = loadConfig() if len(config.recaptchaSecretKey) > 0 and len(config.recaptchaSiteKey) > 0: @@ -868,6 +864,11 @@ proc initialise() = doAssert config.isDev, "Recaptcha required for production!" echo("[WARNING] No recaptcha secret key specified.") + db = open(connection=config.dbPath, user="", password="", + database="nimforum") + isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & + "type='table' AND name='post_fts'")).len == 1 + let cssLoc = "public" / "css" if not existsFile(cssLoc / "nimforum.css"): sass.compileFile(cssLoc / "nimforum.scss", cssLoc / "nimforum.css") diff --git a/src/frontend/header.nim b/src/frontend/header.nim index 6d38306..88e49b8 100644 --- a/src/frontend/header.nim +++ b/src/frontend/header.nim @@ -93,11 +93,11 @@ when defined(js): if state.loading: tdiv(class="loading") elif user.isNone: - button(class="btn btn-primary btn-sm", + button(id="signup-btn", class="btn btn-primary btn-sm", onClick=(e: Event, n: VNode) => state.signupModal.show()): italic(class="fas fa-user-plus") text " Sign up" - button(class="btn btn-primary btn-sm", + button(id="login-btn", class="btn btn-primary btn-sm", onClick=(e: Event, n: VNode) => state.loginModal.show()): italic(class="fas fa-sign-in-alt") text " Log in" diff --git a/src/setup_nimforum.nim b/src/setup_nimforum.nim index 22eb38b..2cb51f0 100644 --- a/src/setup_nimforum.nim +++ b/src/setup_nimforum.nim @@ -7,21 +7,30 @@ # # Script to initialise the nimforum. -import strutils, db_sqlite, os, times, json +import strutils, db_sqlite, os, times, json, options import auth, frontend/user -proc backup(path: string) = +proc backup(path: string, contents: Option[string]=none[string]()) = if existsFile(path): + if contents.isSome() and readFile(path) == contents.get(): + # Don't backup if the files are equivalent. + echo("Not backing up because new file is the same.") + return + let backupPath = path & "." & $getTime().toUnix() echo(path, " already exists. Moving to ", backupPath) moveFile(path, backupPath) -proc initialiseDb(admin: tuple[username, password, email: string]) = - let path = getCurrentDir() / "nimforum.db" - backup(path) +proc initialiseDb(admin: tuple[username, password, email: string], + filename="nimforum.db") = + let path = getCurrentDir() / filename + if "-dev" notin filename and "-test" notin filename: + backup(path) - var db = open(connection="nimforum.db", user="", password="", + removeFile(path) + + var db = open(connection=path, user="", password="", database="nimforum") const @@ -198,10 +207,10 @@ proc initialiseConfig( name, hostname: string, recaptcha: tuple[siteKey, secretKey: string], smtp: tuple[address, user, password: string], - isDev: bool + isDev: bool, + dbPath: string ) = let path = getCurrentDir() / "forum.json" - backup(path) var j = %{ "name": %name, @@ -211,23 +220,47 @@ proc initialiseConfig( "smtpAddress": %smtp.address, "smtpUser": %smtp.user, "smtpPassword": %smtp.password, - "isDev": %isDev + "isDev": %isDev, + "dbPath": %dbPath } + backup(path, some($j)) writeFile(path, $j) when isMainModule: - if paramCount() > 0 and paramStr(1) == "--dev": - echo("Initialising nimforum for development...") - initialiseConfig( - "Development Forum", - "localhost.local", - recaptcha=("", ""), - smtp=("", "", ""), - isDev=true - ) + if paramCount() > 0: + case paramStr(1) + of "--dev": + let dbPath = "nimforum-dev.db" + echo("Initialising nimforum for development...") + initialiseConfig( + "Development Forum", + "localhost.local", + recaptcha=("", ""), + smtp=("", "", ""), + isDev=true, + dbPath + ) - initialiseDb( - admin=("admin", "admin", "admin@localhost.local") - ) + initialiseDb( + admin=("admin", "admin", "admin@localhost.local"), + dbPath + ) + of "--test": + let dbPath = "nimforum-test.db" + echo("Initialising nimforum for testing...") + initialiseConfig( + "Test Forum", + "localhost.local", + recaptcha=("", ""), + smtp=("", "", ""), + isDev=true, + dbPath + ) + initialiseDb( + admin=("admin", "admin", "admin@localhost.local"), + dbPath + ) + else: + quit("--dev|--test") diff --git a/src/utils.nim b/src/utils.nim index fea585e..f8cdbbb 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -25,6 +25,7 @@ type recaptchaSecretKey*: string recaptchaSiteKey*: string isDev*: bool + dbPath*: string var docConfig: StringTableRef @@ -45,6 +46,7 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config = result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("") result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("") result.isDev = root{"isDev"}.getBool() + result.dbPath = root{"dbPath"}.getStr("nimforum.db") except: echo("[WARNING] Couldn't read config file: ", filename) diff --git a/tests/browsertester.nim b/tests/browsertester.nim new file mode 100644 index 0000000..017003b --- /dev/null +++ b/tests/browsertester.nim @@ -0,0 +1,68 @@ +import options, osproc, streams, threadpool, os, strformat, httpclient + +import webdriver + +proc runProcess(cmd: string) = + let p = startProcess( + cmd, + options={ + poStdErrToStdOut, + poEvalCommand + } + ) + + let o = p.outputStream + while p.running and (not o.atEnd): + echo cmd.substr(0, 10), ": ", o.readLine() + + p.close() + +const backend = "forum" +const port = 5000 +const baseUrl = "http://localhost:" & $port & "/" +template withBackend(body: untyped): untyped = + ## Starts a new backend instance with a fresh DB. + doAssert(execCmd("nimble testdb") == QuitSuccess) + + spawn runProcess("nimble runbackend") + defer: + discard execCmd("killall " & backend) + + echo("Waiting for server...") + var success = false + for i in 0..5: + sleep(5000) + try: + let client = newHttpClient() + doAssert client.getContent(baseUrl).len > 0 + success = true + break + except: + echo("Failed to getContent") + + doAssert success + + body + +import browsertests/scenario1 + +when isMainModule: + spawn runProcess("geckodriver -p 4444 --log config") + defer: + discard execCmd("killall geckodriver") + + doAssert(execCmd("nimble frontend") == QuitSuccess) + echo("Waiting for geckodriver to startup...") + sleep(5000) + + try: + let driver = newWebDriver() + let session = driver.createSession() + + withBackend: + scenario1.test(session, baseUrl) + + session.close() + except: + sleep(10000) # See if we can grab any more output. + raise \ No newline at end of file diff --git a/tests/browsertester.nims b/tests/browsertester.nims new file mode 100644 index 0000000..9d57ecf --- /dev/null +++ b/tests/browsertester.nims @@ -0,0 +1 @@ +--threads:on \ No newline at end of file diff --git a/tests/browsertests/scenario1.nim b/tests/browsertests/scenario1.nim new file mode 100644 index 0000000..491b78e --- /dev/null +++ b/tests/browsertests/scenario1.nim @@ -0,0 +1,29 @@ +import unittest, options, os + +import webdriver + +proc waitForLoad(session: Session) = + sleep(2000) + + while true: + let loading = session.findElement(".loading") + if loading.isNone: return + sleep(1000) + +proc test*(session: Session, baseUrl: string) = + session.navigate(baseUrl) + + waitForLoad(session) + + # Sanity checks + test "shows sign up": + let signUp = session.findElement("#signup-btn") + check signUp.get().getText() == "Sign up" + + test "shows log in": + let signUp = session.findElement("#login-btn") + check signUp.get().getText() == "Log in" + + test "is empty": + let thread = session.findElement("tr > td.thread-title") + check thread.isNone() \ No newline at end of file From e5772b8579d47ab8f62d0b6acec1ed6a53585a0c Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 13:18:57 +0100 Subject: [PATCH 164/396] Implements login test. --- nimforum.nimble | 2 +- tests/browsertests/scenario1.nim | 47 +++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/nimforum.nimble b/nimforum.nimble index 7e0555a..7f61a77 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -20,7 +20,7 @@ requires "sass" requires "karax" -requires "webdriver" +requires "webdriver#a2be578" # Tasks diff --git a/tests/browsertests/scenario1.nim b/tests/browsertests/scenario1.nim index 491b78e..b710de7 100644 --- a/tests/browsertests/scenario1.nim +++ b/tests/browsertests/scenario1.nim @@ -2,13 +2,18 @@ import unittest, options, os import webdriver -proc waitForLoad(session: Session) = +proc waitForLoad(session: Session, timeout=20000) = + var waitTime = 0 sleep(2000) while true: let loading = session.findElement(".loading") if loading.isNone: return sleep(1000) + waitTime += 1000 + + if waitTime > timeout: + doAssert false, "Wait for load time exceeded" proc test*(session: Session, baseUrl: string) = session.navigate(baseUrl) @@ -21,9 +26,43 @@ proc test*(session: Session, baseUrl: string) = check signUp.get().getText() == "Sign up" test "shows log in": - let signUp = session.findElement("#login-btn") - check signUp.get().getText() == "Log in" + let logIn = session.findElement("#login-btn") + check logIn.get().getText() == "Log in" test "is empty": let thread = session.findElement("tr > td.thread-title") - check thread.isNone() \ No newline at end of file + check thread.isNone() + + # Logging in + test "can login": + let logIn = session.findElement("#login-btn").get() + logIn.click() + + let usernameField = session.findElement( + "#login-form input[name='username']" + ) + check usernameField.isSome() + let passwordField = session.findElement( + "#login-form input[name='password']" + ) + check passwordField.isSome() + + usernameField.get().sendKeys("admin") + passwordField.get().sendKeys("admin") + passwordField.get().click() # Focus field. + session.press(Key.Enter) + + waitForLoad(session, 5000) + + # Verify that the user menu has been initialised properly. + let profileButton = session.findElement( + "#main-navbar figure.avatar" + ).get() + profileButton.click() + + let profileName = session.findElement( + "#main-navbar .menu-right div.tile-content" + ).get() + + check profileName.getText() == "admin" + From e3055920ea7136b3fcc70f41e702e9f7f86d1541 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 13:23:00 +0100 Subject: [PATCH 165/396] Adds travis.yml file. --- .travis.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5a6e20c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,29 @@ +os: + - linux + +language: c + +cache: + directories: + - "$HOME/.nimble" + - "$HOME/.choosenim" + +addons: + firefox: "60.0.1" + +before_install: + - wget https://github.com/mozilla/geckodriver/releases/download/v0.20.1/geckodriver-v0.20.1-linux64.tar.gz + - mkdir geckodriver + - tar -xzf geckodriver-v0.20.1-linux64.tar.gz -C geckodriver + - export PATH=$PATH:$PWD/geckodriver + +install: + - | + curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh + sh init.sh -y + - export PATH=$HOME/.nimble/bin:$PATH + - nimble refresh -y + +script: + - export MOZ_HEADLESS=1 + - nimble -y test \ No newline at end of file From 8e7142420d4b3a8f30614031625747b709514f5c Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 13:48:45 +0100 Subject: [PATCH 166/396] Pin the Nim version inside the travis file. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 5a6e20c..ea2ba65 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ before_install: - export PATH=$PATH:$PWD/geckodriver install: + - export CHOOSENIM_CHOOSE_VERSION="#afee505a4586" - | curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh sh init.sh -y From dff0e8911561d2ff45cab3a9bc8a6379447aef38 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 14:05:55 +0100 Subject: [PATCH 167/396] Pin more dependencies. --- nimforum.nimble | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nimforum.nimble b/nimforum.nimble index 7f61a77..01b1285 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -13,12 +13,12 @@ skipExt = @["nim"] # Dependencies requires "nim >= 0.14.0" -requires "jester#head" +requires "jester#d7e2c85a6a72a541dfb" requires "bcrypt#head" -requires "recaptcha >= 1.0.0" +requires "recaptcha 1.0.2" requires "sass" -requires "karax" +requires "https://github.com/dom96/karax#7a884fb" requires "webdriver#a2be578" From 54a7060dbaec6b5c9eeb1308db56f366adb35c1a Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 15:41:11 +0100 Subject: [PATCH 168/396] Refactors and improves profile settings tab. --- src/frontend/post.nim | 11 ++ src/frontend/postbutton.nim | 1 + src/frontend/profile.nim | 138 ++--------------------- src/frontend/profilesettings.nim | 184 +++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 130 deletions(-) create mode 100644 src/frontend/profilesettings.nim diff --git a/src/frontend/post.nim b/src/frontend/post.nim index 7e46a6b..c32b490 100644 --- a/src/frontend/post.nim +++ b/src/frontend/post.nim @@ -45,6 +45,17 @@ proc isLikedBy*(post: Post, user: Option[User]): bool = return false +type + Profile* = object + user*: User + joinTime*: int64 + threads*: seq[PostLink] + posts*: seq[PostLink] + postCount*: int + threadCount*: int + # Information that only admins should see. + email*: Option[string] + when defined(js): import karaxutils diff --git a/src/frontend/postbutton.nim b/src/frontend/postbutton.nim index 4e146b6..292c136 100644 --- a/src/frontend/postbutton.nim +++ b/src/frontend/postbutton.nim @@ -62,6 +62,7 @@ when defined(js): }, "btn btn-secondary" ), + `type`="button", onClick=(e: Event, n: VNode) => (onClick(e, n, state))): if state.posted: if state.error.isNone(): diff --git a/src/frontend/profile.nim b/src/frontend/profile.nim index 0794c9d..322e0ec 100644 --- a/src/frontend/profile.nim +++ b/src/frontend/profile.nim @@ -1,58 +1,30 @@ -import options, httpcore, json, sugar, times, strformat +import options, httpcore, json, sugar, times, strformat, strutils import threadlist, post, category, error, user -type - Profile* = object - user*: User - joinTime*: int64 - threads*: seq[PostLink] - posts*: seq[PostLink] - postCount*: int - threadCount*: int - # Information that only admins should see. - email*: Option[string] - when defined(js): include karax/prelude - import karax/[kajax] - import karaxutils, postbutton + import karax/[kajax, kdom] + import karaxutils, postbutton, delete, profilesettings type ProfileTab* = enum Overview, Settings - ProfileSettings* = object - email: kstring - rank: Rank - ProfileState* = ref object profile: Option[Profile] - settings: ProfileSettings + settings: Option[ProfileSettings] currentTab: ProfileTab loading: bool status: HttpCode - resetPassword: Option[PostButton] proc newProfileState*(): ProfileState = ProfileState( loading: false, status: Http200, - currentTab: Overview, - settings: ProfileSettings( - email: "", - rank: Spammer - ) + currentTab: Overview ) - proc resetSettings(state: ProfileState) = - let profile = state.profile.get() - if profile.email.isSome(): - state.settings = ProfileSettings( - email: profile.email.get(), - rank: profile.user.rank - ) - proc onProfile(httpStatus: int, response: kstring, state: ProfileState) = # TODO: Try to abstract these. state.loading = false @@ -63,9 +35,7 @@ when defined(js): let profile = to(parsed, Profile) state.profile = some(profile) - resetSettings(state) - if profile.email.isSome(): - state.resetPassword = some(newResetPasswordButton(profile.email.get())) + state.settings = some(newProfileSettings(profile)) proc genPostLink(link: PostLink): VNode = let url = renderPostUrl(link) @@ -81,14 +51,6 @@ when defined(js): p(title=title): text renderActivity(link.creation) - proc onEmailChange(event: Event, node: VNode, state: ProfileState) = - state.settings.email = node.value - - if state.settings.email != state.profile.get().email.get(): - state.settings.rank = EmailUnconfirmed - else: - state.settings.rank = state.profile.get().user.rank - proc render*( state: ProfileState, username: string, @@ -104,37 +66,6 @@ when defined(js): return buildHtml(tdiv(class="loading loading-lg")) let profile = state.profile.get() - let isAdmin = currentUser.isSome() and currentUser.get().rank == Admin - - let rankSelect = buildHtml(tdiv()): - if isAdmin: - select(class="form-select", value = $state.settings.rank): - for r in Rank: - option(text $r) - p(class="form-input-hint text-warning"): - text "As an admin you can modify anyone's rank. Remember: with " & - "great power comes great responsibility." - else: - input(class="form-input", - `type`="text", value = $state.settings.rank, disabled="") - p(class="form-input-hint"): - text "Your rank determines the actions you can perform " & - "on the forum." - case state.settings.rank: - of Spammer, Troll: - p(class="form-input-hint text-warning"): - text "Your account was banned." - of EmailUnconfirmed: - p(class="form-input-hint text-warning"): - text "You cannot post until you confirm your email." - of Moderated: - p(class="form-input-hint text-warning"): - text "Your account is under moderation. This is a spam prevention "& - "measure. You can write posts but only moderators and admins "& - "will see them until your account is verified by them." - else: - discard - result = buildHtml(): section(class="container grid-xl"): tdiv(class="profile"): @@ -205,58 +136,5 @@ when defined(js): for thread in profile.threads: genPostLink(thread) of Settings: - tdiv(class="columns"): - tdiv(class="column col-6"): - form(class="form-horizontal"): - tdiv(class="form-group"): - tdiv(class="col-3 col-sm-12"): - label(class="form-label"): - text "Username" - tdiv(class="col-9 col-sm-12"): - input(class="form-input", - `type`="text", - value=profile.user.name, - disabled="") - p(class="form-input-hint"): - text fmt("Users can refer to you by writing" & - " @{profile.user.name} in their posts.") - tdiv(class="form-group"): - tdiv(class="col-3 col-sm-12"): - label(class="form-label"): - text "Email" - tdiv(class="col-9 col-sm-12"): - input(class="form-input", - `type`="text", value=state.settings.email, - oninput=(e: Event, n: VNode) => - onEmailChange(e, n, state) - ) - p(class="form-input-hint"): - text "Your avatar is linked to this email and can be " & - "changed at " - a(href="https://gravatar.com/emails"): - text "gravatar.com" - text ". Note that any changes to your email will " & - "require email verification." - tdiv(class="form-group"): - tdiv(class="col-3 col-sm-12"): - label(class="form-label"): - text "Rank" - tdiv(class="col-9 col-sm-12"): - rankSelect - if state.resetPassword.isSome(): - tdiv(class="form-group"): - tdiv(class="col-3 col-sm-12"): - label(class="form-label"): - text "Password" - tdiv(class="col-9 col-sm-12"): - render(state.resetPassword.get(), - disabled=state.settings.rank==EmailUnconfirmed) - - tdiv(class="float-right"): - button(class="btn btn-link", - onClick=(e: Event, n: VNode) => (resetSettings(state))): - text "Cancel" - - button(class="btn btn-primary"): - italic(class="fas fa-check") - text " Save" \ No newline at end of file + if state.settings.isSome(): + render(state.settings.get(), currentUser) \ No newline at end of file diff --git a/src/frontend/profilesettings.nim b/src/frontend/profilesettings.nim new file mode 100644 index 0000000..6884df9 --- /dev/null +++ b/src/frontend/profilesettings.nim @@ -0,0 +1,184 @@ +when defined(js): + import httpcore, options, sugar, json, strutils, strformat + + include karax/prelude + import karax/[kajax, kdom] + + import replybox, post, karaxutils, postbutton, error, delete, user + + type + ProfileSettings* = ref object + loading: bool + status: HttpCode + error: Option[PostError] + email: kstring + rank: Rank + deleteModal: DeleteModal + resetPassword: PostButton + profile: Profile + + proc onUserDelete(user: User) = + window.location.href = makeUri("/") + + proc resetSettings(state: ProfileSettings) = + let profile = state.profile + if profile.email.isSome(): + state.email = profile.email.get() + state.rank = profile.user.rank + + proc newProfileSettings*(profile: Profile): ProfileSettings = + result = ProfileSettings( + status: Http200, + deleteModal: newDeleteModal(nil, nil, onUserDelete), + resetPassword: newResetPasswordButton(profile.email.get()), + profile: profile + ) + resetSettings(result) + + proc onProfilePost(httpStatus: int, response: kstring, + state: ProfileSettings) = + postFinished: + discard + + proc onEmailChange(event: Event, node: VNode, state: ProfileSettings) = + state.email = node.value + + if state.profile.user.rank != Admin: + if state.email != state.profile.email.get(): + state.rank = EmailUnconfirmed + else: + state.rank = state.profile.user.rank + + proc onRankChange(event: Event, node: VNode, state: ProfileSettings) = + state.rank = parseEnum[Rank]($node.value) + + proc save(state: ProfileSettings) = + if state.loading: + return + state.loading = true + state.error = none[PostError]() + + let formData = newFormData() + formData.append("email", state.email) + formData.append("rank", $state.rank) + formData.append("username", $state.profile.user.name) + let uri = makeUri("/saveProfile") + ajaxPost(uri, @[], cast[cstring](formData), + (s: int, r: kstring) => onProfilePost(s, r, state)) + + proc render*(state: ProfileSettings, + currentUser: Option[User]): VNode = + if state.status != Http200: + return renderError("Couldn't save profile") + + let isAdmin = currentUser.isSome() and currentUser.get().rank == Admin + let canResetPassword = state.profile.user.rank > EmailUnconfirmed + + let rankSelect = buildHtml(tdiv()): + if isAdmin: + select(id="rank-field", + class="form-select", value = $state.rank, + onchange=(e: Event, n: VNode) => onRankChange(e, n, state)): + for r in Rank: + option(text $r) + p(class="form-input-hint text-warning"): + text "As an admin you can modify anyone's rank. Remember: with " & + "great power comes great responsibility." + else: + input(id="rank-field", class="form-input", + `type`="text", disabled="", value = $state.rank) + p(class="form-input-hint"): + text "Your rank determines the actions you can perform " & + "on the forum." + case state.rank: + of Spammer, Troll: + p(class="form-input-hint text-warning"): + text "Your account was banned." + of EmailUnconfirmed: + p(class="form-input-hint text-warning"): + text "You cannot post until you confirm your email." + of Moderated: + p(class="form-input-hint text-warning"): + text "Your account is under moderation. This is a spam prevention "& + "measure. You can write posts but only moderators and admins "& + "will see them until your account is verified by them." + else: + discard + + result = buildHtml(): + tdiv(class="columns"): + tdiv(class="column col-6"): + form(class="form-horizontal"): + tdiv(class="form-group"): + tdiv(class="col-3 col-sm-12"): + label(class="form-label"): + text "Username" + tdiv(class="col-9 col-sm-12"): + input(class="form-input", + `type`="text", + value=state.profile.user.name, + disabled="") + p(class="form-input-hint"): + text fmt("Users can refer to you by writing" & + " @{state.profile.user.name} in their posts.") + tdiv(class="form-group"): + tdiv(class="col-3 col-sm-12"): + label(class="form-label"): + text "Email" + tdiv(class="col-9 col-sm-12"): + input(id="email-input", class="form-input", + `type`="text", value=state.email, + oninput=(e: Event, n: VNode) => + onEmailChange(e, n, state) + ) + p(class="form-input-hint"): + text "Your avatar is linked to this email and can be " & + "changed at " + a(href="https://gravatar.com/emails"): + text "gravatar.com" + text ". Note that any changes to your email will " & + "require email verification." + tdiv(class="form-group"): + tdiv(class="col-3 col-sm-12"): + label(class="form-label"): + text "Rank" + tdiv(class="col-9 col-sm-12"): + rankSelect + tdiv(class="form-group"): + tdiv(class="col-3 col-sm-12"): + label(class="form-label"): + text "Password" + tdiv(class="col-9 col-sm-12"): + render(state.resetPassword, + disabled=not canResetPassword) + tdiv(class="form-group"): + tdiv(class="col-3 col-sm-12"): + label(class="form-label"): + text "Account" + tdiv(class="col-9 col-sm-12"): + button(class="btn btn-secondary", `type`="button", + onClick=(e: Event, n: VNode) => + (state.deleteModal.show(state.profile.user))): + italic(class="fas fa-times") + text " Delete account" + + tdiv(class="float-right"): + button(class="btn btn-link", + onClick=(e: Event, n: VNode) => (resetSettings(state))): + text "Cancel" + + button(class="btn btn-primary", + onClick=(e: Event, n: VNode) => save(state)): + italic(class="fas fa-check") + text " Save" + + render(state.deleteModal) + + # TODO: I really should just be able to set the `value` attr. + # TODO: This doesn't work when settings are reset for some reason. + let rankField = getVNodeById("rank-field") + if not rankField.isNil: + rankField.setInputText($state.rank) + let emailField = getVNodeById("email-field") + if not emailField.isNil: + emailField.setInputText($state.email) \ No newline at end of file From b4f96c8071f1a60ca6ecd419b6a227492addec6d Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 15:47:18 +0100 Subject: [PATCH 169/396] Attempt to run on xenial so that we can install libsass. --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index ea2ba65..b258c22 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,5 @@ +dist: xenial + os: - linux @@ -12,6 +14,8 @@ addons: firefox: "60.0.1" before_install: + - sudo apt-get -qq update + - sudo apt-get install -y libsass-dev - wget https://github.com/mozilla/geckodriver/releases/download/v0.20.1/geckodriver-v0.20.1-linux64.tar.gz - mkdir geckodriver - tar -xzf geckodriver-v0.20.1-linux64.tar.gz -C geckodriver From dc72aeb677bcc546f95c90c38c0c5f266136ba8f Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 17:04:43 +0100 Subject: [PATCH 170/396] Prevent stalls due to no `-y` flag. --- .travis.yml | 2 +- nimforum.nimble | 4 ++-- tests/browsertester.nim | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index b258c22..d76eab8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -dist: xenial +dist: xenial # Needed for libsass-dev :/ os: - linux diff --git a/nimforum.nimble b/nimforum.nimble index 01b1285..a0f9cae 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -45,8 +45,8 @@ task devdb, "Creates a test DB": exec "./src/setup_nimforum --dev" task test, "Runs tester": - exec "nimble c src/forum.nim" - exec "nimble c -r tests/browsertester" + exec "nimble c -y src/forum.nim" + exec "nimble c -y -r tests/browsertester" task fasttest, "Runs tester without recompiling backend": exec "nimble c -r tests/browsertester" \ No newline at end of file diff --git a/tests/browsertester.nim b/tests/browsertester.nim index 017003b..1258bf9 100644 --- a/tests/browsertester.nim +++ b/tests/browsertester.nim @@ -24,7 +24,7 @@ template withBackend(body: untyped): untyped = ## Starts a new backend instance with a fresh DB. doAssert(execCmd("nimble testdb") == QuitSuccess) - spawn runProcess("nimble runbackend") + spawn runProcess("nimble -y runbackend") defer: discard execCmd("killall " & backend) @@ -51,7 +51,7 @@ when isMainModule: defer: discard execCmd("killall geckodriver") - doAssert(execCmd("nimble frontend") == QuitSuccess) + doAssert(execCmd("nimble -y frontend") == QuitSuccess) echo("Waiting for geckodriver to startup...") sleep(5000) From 2ab20bf7a5d8f3e8404acc3837dcb0ce0afd1d05 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 18:47:03 +0100 Subject: [PATCH 171/396] Implements deleteUser and updateProfile in backend. --- src/forum.nim | 107 +++++++++++++++++++++++++++---- src/frontend/profile.nim | 3 +- src/frontend/profilesettings.nim | 26 ++++++-- 3 files changed, 117 insertions(+), 19 deletions(-) diff --git a/src/forum.nim b/src/forum.nim index 6c423b0..86b95d0 100644 --- a/src/forum.nim +++ b/src/forum.nim @@ -1144,6 +1144,19 @@ proc executeLogin(c: TForumData, username, password: string): string = raise newForumError("Invalid username or password") +proc sendEmailActivation(c: TForumData, name, password, + email, salt: string) {.async.} = + let epoch = $int(epochTime()) + let activateUrl = c.req.makeUri("/activateEmail?nick=$1&epoch=$2&ident=$3" % + [encodeUrl(name), encodeUrl(epoch), + encodeUrl(makeIdentHash(name, password, epoch, salt))]) + + let emailSentFut = sendEmailActivation(c.config, email, name, activateUrl) + yield emailSentFut + if emailSentFut.failed: + echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) + raise newForumError("Couldn't send activation email", @["email"]) + proc executeRegister(c: TForumData, name, pass, antibot, userIp, email: string): Future[string] {.async.} = ## Registers a new user and returns a new session key for that user's @@ -1158,7 +1171,7 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, raise newForumError("Email already exists", @["email"]) # Username validation: - if name.len == 0 or not allCharsInSet(name, UsernameIdent): + if name.len == 0 or not allCharsInSet(name, UsernameIdent) or name.len > 20: raise newForumError("Invalid username", @["username"]) if getValue(db, sql"select name from person where name = ?", name).len > 0: raise newForumError("Username already exists", @["username"]) @@ -1181,16 +1194,7 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, let password = makePassword(pass, salt) # Send activation email. - let epoch = $int(epochTime()) - let activateUrl = c.req.makeUri("/activateEmail?nick=$1&epoch=$2&ident=$3" % - [encodeUrl(name), encodeUrl(epoch), - encodeUrl(makeIdentHash(name, password, epoch, salt))]) - - let emailSentFut = sendEmailActivation(c.config, email, name, activateUrl) - yield emailSentFut - if emailSentFut.failed: - echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) - raise newForumError("Couldn't send activation email", @["email"]) + await sendEmailActivation(c, name, password, email, salt) # Add account to person table exec(db, sql""" @@ -1254,6 +1258,42 @@ proc executeDeleteThread(c: TForumData, threadId: int) = # Set the `isDeleted` flag. exec(db, crud(crUpdate, "thread", "isDeleted"), "1", threadId) +proc executeDeleteUser(c: TForumData, username: string) = + # Verify that the current user has the permissions to do this. + if username != c.username and c.rank < Admin: + raise newForumError("You cannot delete this user.") + + # Set the `isDeleted` flag. + exec(db, sql"update person set isDeleted = 1 where name = ?;", username) + +proc updateProfile( + c: TForumData, username, email: string, rank: Rank +) {.async.} = + if c.rank < rank: + raise newForumError("You cannot set a rank that is higher than yours.") + + if c.username != username and c.rank < Moderator: + raise newForumError("You can't change this profile.") + + # Make sure the rank is set to EmailUnconfirmed when the email changes. + if c.rank < Moderator: + let row = getRow( + db, + sql"select name, password, email, salt from person where name = ?", + username + ) + if row[2] != email: + if rank != EmailUnconfirmed: + raise newForumError("Rank needs a change when setting new email.") + + await sendEmailActivation(c, row[0], row[1], row[2], row[3]) + + exec( + db, + sql"update person set status = ?, email = ? where name = ?;", + $rank, email, username + ) + initialise() routes: @@ -1706,6 +1746,51 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" + post re"/deleteUser": + createTFD() + if not c.loggedIn(): + let err = PostError( + errorFields: @[], + message: "Not logged in." + ) + resp Http401, $(%err), "application/json" + + let formData = request.formData + cond "username" in formData + + let username = formData["username"].body + + try: + executeDeleteUser(c, username) + resp Http200, "{}", "application/json" + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + + post re"/saveProfile": + createTFD() + if not c.loggedIn(): + let err = PostError( + errorFields: @[], + message: "Not logged in." + ) + resp Http401, $(%err), "application/json" + + let formData = request.formData + cond "username" in formData + cond "email" in formData + cond "rank" in formData + + let username = formData["username"].body + let email = formData["email"].body + let rank = parseEnum[Rank](formData["rank"].body) + + try: + await updateProfile(c, username, email, rank) + resp Http200, "{}", "application/json" + except ForumError: + let exc = (ref ForumError)(getCurrentException()) + resp Http400, $(%exc.data), "application/json" + get "/t/@id": cond "id" in request.params diff --git a/src/frontend/profile.nim b/src/frontend/profile.nim index 322e0ec..56e0d43 100644 --- a/src/frontend/profile.nim +++ b/src/frontend/profile.nim @@ -35,7 +35,8 @@ when defined(js): let profile = to(parsed, Profile) state.profile = some(profile) - state.settings = some(newProfileSettings(profile)) + if profile.email.isSome(): + state.settings = some(newProfileSettings(profile)) proc genPostLink(link: PostLink): VNode = let url = renderPostUrl(link) diff --git a/src/frontend/profilesettings.nim b/src/frontend/profilesettings.nim index 6884df9..382d11e 100644 --- a/src/frontend/profilesettings.nim +++ b/src/frontend/profilesettings.nim @@ -26,6 +26,8 @@ when defined(js): state.email = profile.email.get() state.rank = profile.user.rank + state.error = none[PostError]() + proc newProfileSettings*(profile: Profile): ProfileSettings = result = ProfileSettings( status: Http200, @@ -38,7 +40,8 @@ when defined(js): proc onProfilePost(httpStatus: int, response: kstring, state: ProfileSettings) = postFinished: - discard + state.profile.email = some($state.email) + state.profile.user.rank = state.rank proc onEmailChange(event: Event, node: VNode, state: ProfileSettings) = state.email = node.value @@ -66,11 +69,12 @@ when defined(js): ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onProfilePost(s, r, state)) + proc needsSave(state: ProfileSettings): bool = + state.email != state.profile.email.get() or + state.rank != state.profile.user.rank + proc render*(state: ProfileSettings, currentUser: Option[User]): VNode = - if state.status != Http200: - return renderError("Couldn't save profile") - let isAdmin = currentUser.isSome() and currentUser.get().rank == Admin let canResetPassword = state.profile.user.rank > EmailUnconfirmed @@ -163,13 +167,21 @@ when defined(js): text " Delete account" tdiv(class="float-right"): - button(class="btn btn-link", + if state.error.isSome(): + span(class="text-error"): + text state.error.get().message + + button(class=class( + {"disabled": not needsSave(state)}, "btn btn-link" + ), onClick=(e: Event, n: VNode) => (resetSettings(state))): text "Cancel" - button(class="btn btn-primary", + button(class=class( + {"disabled": not needsSave(state)}, "btn btn-primary" + ), onClick=(e: Event, n: VNode) => save(state)): - italic(class="fas fa-check") + italic(class="fas fa-save") text " Save" render(state.deleteModal) From aa9f24e1d752a9598f9959f4648b2871bc0305f5 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 19:05:34 +0100 Subject: [PATCH 172/396] Switch permissions around. EmailUnconfirmed's are now visible but cannot post. --- src/forum.nim | 14 ++++++++++++++ src/frontend/user.nim | 9 ++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/forum.nim b/src/forum.nim index 86b95d0..24606a1 100644 --- a/src/forum.nim +++ b/src/forum.nim @@ -1020,6 +1020,13 @@ proc executeReply(c: TForumData, threadId: int, content: string, # TODO: Refactor TForumData. assert c.loggedIn() + if not canPost(c.rank): + case c.rank + of EmailUnconfirmed: + raise newForumError("You need to confirm your email before you can post") + else: + raise newForumError("You are not allowed to post") + if rateLimitCheck(c): raise newForumError("You're posting too fast!") @@ -1097,6 +1104,13 @@ proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = assert c.loggedIn() + if not canPost(c.rank): + case c.rank + of EmailUnconfirmed: + raise newForumError("You need to confirm your email before you can post") + else: + raise newForumError("You are not allowed to post") + if subject.len <= 2: raise newForumError("Subject is too short", @["subject"]) if subject.len > 100: diff --git a/src/frontend/user.nim b/src/frontend/user.nim index 7daa27e..4b2d212 100644 --- a/src/frontend/user.nim +++ b/src/frontend/user.nim @@ -5,9 +5,12 @@ type Spammer ## spammer: every post is invisible Troll ## troll: cannot write new posts Banned ## A non-specific ban - EmailUnconfirmed ## member with unconfirmed email address Moderated ## new member: posts manually reviewed before everybody ## can see them + EmailUnconfirmed ## member with unconfirmed email address. Their posts + ## are visible, but cannot make new posts. This is so that + ## when a user with existing posts changes their email, + ## their posts don't disappear. User ## Ordinary user Moderator ## Moderator: can change a user's rank Admin ## Admin: can do everything @@ -24,6 +27,10 @@ proc isOnline*(user: User): bool = proc `==`*(u1, u2: User): bool = u1.name == u2.name +proc canPost*(rank: Rank): bool = + ## Determines whether the specified rank can make new posts. + rank >= Rank.User or rank == Moderated + when defined(js): include karax/prelude import karaxutils From 2fbebfa3f9b8d4fd25852e7f4b4eba869f120037 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 19:50:46 +0100 Subject: [PATCH 173/396] Take deleted accounts into account. --- src/forum.nim | 42 +++++++++++++++++++++++++------------ src/frontend/editbox.nim | 2 +- src/frontend/error.nim | 39 ++++++++++++++++++---------------- src/frontend/forum.nim | 4 ++-- src/frontend/postlist.nim | 2 +- src/frontend/profile.nim | 2 +- src/frontend/threadlist.nim | 2 +- src/frontend/user.nim | 1 + 8 files changed, 57 insertions(+), 37 deletions(-) diff --git a/src/forum.nim b/src/forum.nim index 24606a1..4dafeca 100644 --- a/src/forum.nim +++ b/src/forum.nim @@ -888,20 +888,26 @@ template createTFD() = #[ DB functions. TODO: Move to another module? ]# proc selectUser(userRow: seq[string], avatarSize: int=80): User = - return User( + result = User( name: userRow[0], avatarUrl: userRow[1].getGravatarUrl(avatarSize), lastOnline: userRow[2].parseInt, - rank: parseEnum[Rank](userRow[3]) + rank: parseEnum[Rank](userRow[3]), + isDeleted: userRow[4] == "1" ) + # Don't give data about a deleted user. + if result.isDeleted: + result.name = "DeletedUser" + result.avatarUrl = getGravatarUrl(result.name & userRow[1], avatarSize) + proc selectPost(postRow: seq[string], skippedPosts: seq[int], replyingTo: Option[PostLink], history: seq[PostInfo], likes: seq[User]): Post = return Post( id: postRow[0].parseInt, replyingTo: replyingTo, - author: selectUser(@[postRow[5], postRow[6], postRow[7], postRow[8]]), + author: selectUser(postRow[5..9]), likes: likes, seen: false, # TODO: history: history, @@ -918,6 +924,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] = const replyingToQuery = sql""" select p.id, strftime('%s', p.creation), p.thread, u.name, u.email, strftime('%s', u.lastOnline), u.status, + u.isDeleted, t.name from post p, person u, thread t where p.thread = t.id and p.author = u.id and p.id = ? and p.isDeleted = 0; @@ -931,7 +938,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] = topic: row[^1], threadId: row[2].parseInt(), postId: row[0].parseInt(), - author: some(selectUser(@[row[3], row[4], row[5], row[6]])) + author: some(selectUser(row[3..7])) )) proc selectHistory(postId: int): seq[PostInfo] = @@ -950,7 +957,8 @@ proc selectHistory(postId: int): seq[PostInfo] = proc selectLikes(postId: int): seq[User] = const likeQuery = sql""" - select u.name, u.email, strftime('%s', u.lastOnline), u.status + select u.name, u.email, strftime('%s', u.lastOnline), u.status, + u.isDeleted from like h, person u where h.post = ? and h.author = u.id order by h.creation asc; @@ -963,7 +971,7 @@ proc selectLikes(postId: int): seq[User] = proc selectThreadAuthor(threadId: int): User = const authorQuery = sql""" - select name, email, strftime('%s', lastOnline), status + select name, email, strftime('%s', lastOnline), status, isDeleted from person where id in ( select author from post where thread = ? @@ -981,7 +989,8 @@ proc selectThread(threadRow: seq[string]): Thread = order by creation asc limit 1;""" const usersListQuery = sql""" - select name, email, strftime('%s', lastOnline), status, count(*) + select name, email, strftime('%s', lastOnline), status, u.isDeleted, + count(*) from person u, post p where p.author = u.id and p.thread = ? group by name order by count(*) desc limit 5; """ @@ -1142,7 +1151,7 @@ proc executeLogin(c: TForumData, username, password: string): string = const query = sql""" select id, name, password, email, salt - from person where name = ? or email = ? + from person where (name = ? or email = ?) and isDeleted = 0 """ if username.len == 0: raise newForumError("Username cannot be empty", @["username"]) @@ -1280,6 +1289,8 @@ proc executeDeleteUser(c: TForumData, username: string) = # Set the `isDeleted` flag. exec(db, sql"update person set isDeleted = 1 where name = ?;", username) + logout(c) + proc updateProfile( c: TForumData, username, email: string, rank: Rank ) {.async.} = @@ -1367,7 +1378,8 @@ routes: sql( """select p.id, p.content, strftime('%s', p.creation), p.author, p.replyingTo, - u.name, u.email, strftime('%s', u.lastOnline), u.status + u.name, u.email, strftime('%s', u.lastOnline), u.status, + u.isDeleted from post p, person u where u.id = p.author and p.thread = ? and p.isDeleted = 0 order by p.id""" @@ -1410,7 +1422,8 @@ routes: let postsQuery = sql(""" select p.id, p.content, strftime('%s', p.creation), p.author, p.replyingTo, - u.name, u.email, strftime('%s', u.lastOnline), u.status + u.name, u.email, strftime('%s', u.lastOnline), u.status, + u.isDeleted from post p, person u where u.id = p.author and p.id in ($#) order by p.id; @@ -1477,10 +1490,10 @@ routes: """ % postsFrom) let userQuery = sql(""" - select name, email, strftime('%s', lastOnline), status, + select name, email, strftime('%s', lastOnline), status, isDeleted, strftime('%s', creation), id from person - where name = ? + where name = ? and isDeleted = 0 """) var profile = Profile( @@ -1491,8 +1504,11 @@ routes: let userRow = db.getRow(userQuery, username) let userID = userRow[^1] + if userID.len == 0: + halt() + profile.user = selectUser(userRow, avatarSize=200) - profile.joinTime = userRow[4].parseInt() + profile.joinTime = userRow[^2].parseInt() profile.postCount = getValue(db, sql("select count(*) " & postsFrom), username).parseInt() profile.threadCount = diff --git a/src/frontend/editbox.nim b/src/frontend/editbox.nim index e6c6c62..c05c03d 100644 --- a/src/frontend/editbox.nim +++ b/src/frontend/editbox.nim @@ -59,7 +59,7 @@ when defined(js): proc render*(state: EditBox, post: Post): VNode = if state.status != Http200: - return renderError("Couldn't retrieve raw post") + return renderError("Couldn't retrieve raw post", state.status) if state.rawContent.isNone() or state.post.id != post.id: state.post = post diff --git a/src/frontend/error.nim b/src/frontend/error.nim index 6a68df4..a70708d 100644 --- a/src/frontend/error.nim +++ b/src/frontend/error.nim @@ -1,4 +1,4 @@ -import options +import options, httpcore type PostError* = object errorFields*: seq[string] ## IDs of the fields with an error. @@ -11,7 +11,25 @@ when defined(js): import karaxutils - proc renderError*(message: string): VNode = + proc render404*(): VNode = + result = buildHtml(): + tdiv(class="empty error"): + tdiv(class="empty icon"): + italic(class="fas fa-bug fa-5x") + p(class="empty-title h5"): + text "404 Not Found" + p(class="empty-subtitle"): + text "Cannot find what you are looking for, it might have been " & + "deleted. Sorry!" + tdiv(class="empty-action"): + a(href="/", onClick=anchorCB): + button(class="btn btn-primary"): + text "Go back home" + + proc renderError*(message: string, status: HttpCode): VNode = + if status == Http404: + return render404() + result = buildHtml(): tdiv(class="empty error"): tdiv(class="empty icon"): @@ -61,19 +79,4 @@ when defined(js): state.error = some(PostError( errorFields: @[], message: "Unknown error occurred." - )) - - proc render404*(): VNode = - result = buildHtml(): - tdiv(class="empty error"): - tdiv(class="empty icon"): - italic(class="fas fa-bug fa-5x") - p(class="empty-title h5"): - text "404 Not Found" - p(class="empty-subtitle"): - text "Cannot find what you are looking for, it might have been " & - "deleted. Sorry!" - tdiv(class="empty-action"): - a(href="/", onClick=anchorCB): - button(class="btn btn-primary"): - text "Go back home" \ No newline at end of file + )) \ No newline at end of file diff --git a/src/frontend/forum.nim b/src/frontend/forum.nim index 1ae644e..031590b 100644 --- a/src/frontend/forum.nim +++ b/src/frontend/forum.nim @@ -1,4 +1,4 @@ -import strformat, times, options, json, tables, sugar +import strformat, times, options, json, tables, sugar, httpcore from dom import window, Location include karax/prelude @@ -46,7 +46,7 @@ proc route(routes: openarray[Route]): VNode = if matched: return route.p(params) - return renderError("Unmatched route: " & path) + return renderError("Unmatched route: " & path, Http500) proc render(): VNode = result = buildHtml(tdiv()): diff --git a/src/frontend/postlist.nim b/src/frontend/postlist.nim index 68f2e85..94be2d2 100644 --- a/src/frontend/postlist.nim +++ b/src/frontend/postlist.nim @@ -308,7 +308,7 @@ when defined(js): proc renderPostList*(threadId: int, postId: Option[int], currentUser: Option[User]): VNode = if state.status != Http200: - return renderError("Couldn't retrieve posts.") + return renderError("Couldn't retrieve posts.", state.status) if state.list.isNone or state.list.get().thread.id != threadId: var params = @[("id", $threadId)] diff --git a/src/frontend/profile.nim b/src/frontend/profile.nim index 56e0d43..e26a56d 100644 --- a/src/frontend/profile.nim +++ b/src/frontend/profile.nim @@ -58,7 +58,7 @@ when defined(js): currentUser: Option[User] ): VNode = if state.status != Http200: - return renderError("Couldn't retrieve profile.") + return renderError("Couldn't retrieve profile.", state.status) if state.profile.isNone or state.profile.get().user.name != username: let uri = makeUri("profile.json", ("username", username)) diff --git a/src/frontend/threadlist.nim b/src/frontend/threadlist.nim index 68391ff..e8b252b 100644 --- a/src/frontend/threadlist.nim +++ b/src/frontend/threadlist.nim @@ -161,7 +161,7 @@ when defined(js): proc genThreadList(currentUser: Option[User]): VNode = if state.status != Http200: - return renderError("Couldn't retrieve threads.") + return renderError("Couldn't retrieve threads.", state.status) if state.list.isNone: if not state.loading: diff --git a/src/frontend/user.nim b/src/frontend/user.nim index 4b2d212..c43288d 100644 --- a/src/frontend/user.nim +++ b/src/frontend/user.nim @@ -20,6 +20,7 @@ type avatarUrl*: string lastOnline*: int64 rank*: Rank + isDeleted*: bool proc isOnline*(user: User): bool = return getTime().toUnix() - user.lastOnline < (60*5) From 0a53392258a9a464ae5122c1f1ea989e8789120d Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 20:27:29 +0100 Subject: [PATCH 174/396] Reset states properly when navigating to new url. --- src/frontend/editbox.nim | 6 +++++- src/frontend/forum.nim | 22 +++++++++++++++++++--- src/frontend/postlist.nim | 6 +++++- src/frontend/profile.nim | 6 +++++- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/frontend/editbox.nim b/src/frontend/editbox.nim index c05c03d..4b10ccb 100644 --- a/src/frontend/editbox.nim +++ b/src/frontend/editbox.nim @@ -58,10 +58,14 @@ when defined(js): (s: int, r: kstring) => onEditPost(s, r, state)) proc render*(state: EditBox, post: Post): VNode = + if state.post.id != post.id: + state.rawContent = none[kstring]() + state.status = Http200 + if state.status != Http200: return renderError("Couldn't retrieve raw post", state.status) - if state.rawContent.isNone() or state.post.id != post.id: + if state.rawContent.isNone(): state.post = post state.rawContent = none[kstring]() var params = @[("id", $post.id)] diff --git a/src/frontend/forum.nim b/src/frontend/forum.nim index 031590b..5c952cc 100644 --- a/src/frontend/forum.nim +++ b/src/frontend/forum.nim @@ -13,9 +13,22 @@ type profile: ProfileState newThread: NewThread +proc copyLocation(loc: Location): Location = + # TODO: It sucks that I had to do this. We need a nice way to deep copy in JS. + Location( + hash: loc.hash, + host: loc.host, + hostname: loc.hostname, + href: loc.href, + pathname: loc.pathname, + port: loc.port, + protocol: loc.protocol, + search: loc.search + ) + proc newState(): State = State( - url: window.location, + url: copyLocation(window.location), profile: newProfileState(), newThread: newNewThread() ) @@ -25,8 +38,11 @@ proc onPopState(event: dom.Event) = # This event is usually only called when the user moves back in their # history. I fire it in karaxutils.anchorCB as well to ensure the URL is # always updated. This should be moved into Karax in the future. - kout(kstring"New URL: ", window.location.href) - state.url = window.location + kout(kstring"New URL: ", window.location.href, " ", state.url.href) + if state.url.href != window.location.href: + state = newState() # Reload the state to remove stale data. + state.url = copyLocation(window.location) + redraw() type Params = Table[string, string] diff --git a/src/frontend/postlist.nim b/src/frontend/postlist.nim index 94be2d2..f568df0 100644 --- a/src/frontend/postlist.nim +++ b/src/frontend/postlist.nim @@ -307,10 +307,14 @@ when defined(js): proc renderPostList*(threadId: int, postId: Option[int], currentUser: Option[User]): VNode = + if state.list.isSome() and state.list.get().thread.id != threadId: + state.list = none[PostList]() + state.status = Http200 + if state.status != Http200: return renderError("Couldn't retrieve posts.", state.status) - if state.list.isNone or state.list.get().thread.id != threadId: + if state.list.isNone: var params = @[("id", $threadId)] if postId.isSome(): params.add(("anchor", $postId.get())) diff --git a/src/frontend/profile.nim b/src/frontend/profile.nim index e26a56d..5bf24c6 100644 --- a/src/frontend/profile.nim +++ b/src/frontend/profile.nim @@ -57,10 +57,14 @@ when defined(js): username: string, currentUser: Option[User] ): VNode = + if state.profile.isSome() and state.profile.get().user.name != username: + state.profile = none[Profile]() + state.status = Http200 + if state.status != Http200: return renderError("Couldn't retrieve profile.", state.status) - if state.profile.isNone or state.profile.get().user.name != username: + if state.profile.isNone: let uri = makeUri("profile.json", ("username", username)) ajaxGet(uri, @[], (s: int, r: kstring) => onProfile(s, r, state)) From c4431ebf2e69df68400215b929dc5d4c67417849 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 20:57:45 +0100 Subject: [PATCH 175/396] Fixes editing box regression. --- src/frontend/editbox.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/editbox.nim b/src/frontend/editbox.nim index 4b10ccb..3f8753e 100644 --- a/src/frontend/editbox.nim +++ b/src/frontend/editbox.nim @@ -58,7 +58,7 @@ when defined(js): (s: int, r: kstring) => onEditPost(s, r, state)) proc render*(state: EditBox, post: Post): VNode = - if state.post.id != post.id: + if (not state.post.isNil) and state.post.id != post.id: state.rawContent = none[kstring]() state.status = Http200 From ff70dce9e8ffc68823a2f63a4cd8ec4ed4d3dff3 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 22:47:42 +0100 Subject: [PATCH 176/396] Attempt to build libsass manually for travis. --- .travis.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index d76eab8..8b3eb96 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,3 @@ -dist: xenial # Needed for libsass-dev :/ - os: - linux @@ -15,7 +13,19 @@ addons: before_install: - sudo apt-get -qq update - - sudo apt-get install -y libsass-dev + - sudo apt-get install autoconf libtool + - git clone -b 3.5.4 https://github.com/sass/libsass.git + - cd libsass + - autoreconf --force --install + - | + ./configure \ + --disable-tests \ + --disable-static \ + --enable-shared \ + --prefix=/usr + - sudo make -j5 install + - cd .. + - wget https://github.com/mozilla/geckodriver/releases/download/v0.20.1/geckodriver-v0.20.1-linux64.tar.gz - mkdir geckodriver - tar -xzf geckodriver-v0.20.1-linux64.tar.gz -C geckodriver From 1592be05037e2f6fba7bb2360a0e18b3992a711b Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sat, 19 May 2018 23:43:10 +0100 Subject: [PATCH 177/396] Implements /about/license --- public/license.rst | 36 +++++++++++++++------------------- src/forum.nim | 14 +++++++++----- src/frontend/about.nim | 44 ++++++++++++++++++++++++++++++++++++++++++ src/frontend/forum.nim | 9 +++++++-- src/setup_nimforum.nim | 4 ++-- src/utils.nim | 27 +++++++++++++------------- 6 files changed, 92 insertions(+), 42 deletions(-) create mode 100644 src/frontend/about.nim diff --git a/public/license.rst b/public/license.rst index 3a15ec1..beebae3 100644 --- a/public/license.rst +++ b/public/license.rst @@ -1,30 +1,30 @@ -Forum content license -===================== +Content license +=============== -All the content contributed to the Nim Forum is `cc-wiki (aka cc-by-sa) +All the content contributed to $hostname is `cc-wiki (aka cc-by-sa) `_ licensed, intended to be -**shared and remixed**. In the future we may even provide all this data as a -convenient data dump. +**shared and remixed**. -But our cc-wiki licensing, while intentionally permissive, does **require -attribution**:: +The cc-wiki licensing, while intentionally permissive, does require +attribution: - **Attribution** — You must attribute the work in the manner specified by - the author or licensor (but not in any way that suggests that they endorse - you or your use of the work). +**Attribution** — You must attribute the work in the manner specified by +the author or licensor (but not in any way that suggests that they endorse +you or your use of the work). -Let us clarify what we mean by attribution. If you republish this content, we -require that you: +This means that if you republish this content, you are +required to: -* **Visually indicate that the content is from the Nim Forum**. It doesn’t +* **Visually indicate that the content is from the $name**. It doesn’t have to be obnoxious; a discreet text blurb is fine. * **Hyperlink directly to the original post** (e.g., - http://forum.nim-lang.org/t/186) + https://$hostname/t/186/1#908) * **Show the author names** for every post. * **Hyperlink each author name** directly back to their user profile page - (e.g., http://forum.nim-lang.org/profile/Araq) + (e.g., http://$hostname/profile/Araq) -By “directly”, we mean each hyperlink must point directly to our domain in +To be more specific, each hyperlink must +point directly to the $hostname domain in standard HTML visible even with JavaScript disabled, and not use a tinyurl or any other form of obfuscation or redirection. Furthermore, the links must not be `nofollowed @@ -36,7 +36,3 @@ time to create that content in the first place! Feel free to remix and reuse to your heart’s content, as long as a good faith effort is made to attribute the content! - -Content previous to the forum license change of -http://forum.nim-lang.org/t/186 remains under the original authors' -copyright, and therefore you cannot reuse it. diff --git a/src/forum.nim b/src/forum.nim index 4dafeca..02f3b34 100644 --- a/src/forum.nim +++ b/src/forum.nim @@ -1848,6 +1848,15 @@ routes: get "/404": resp Http404, readFile("public/karax.html") + get "/about/license.html": + let content = readFile("public/license.rst").multiReplace( + { + "$hostname": config.hostname, + "$name": config.name + } + ) + resp content.rstToHtml() + get re"/(.+)?": resp readFile("public/karax.html") @@ -1992,11 +2001,6 @@ routes: else: resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum") - get "/license": - createTFD() - resp genMain(c, rstToHtml(readFile("static/license.rst")), - "Content license - Nim Forum") - post "/search/?@page?": cond isFTSAvailable createTFD() diff --git a/src/frontend/about.nim b/src/frontend/about.nim new file mode 100644 index 0000000..51c6f81 --- /dev/null +++ b/src/frontend/about.nim @@ -0,0 +1,44 @@ +when defined(js): + import sugar, httpcore, options, json + import dom except Event + + include karax/prelude + import karax / [kajax, kdom] + + import error, replybox, threadlist, post + import karaxutils + + type + About* = ref object + loading: bool + status: HttpCode + content: kstring + page: string + + proc newAbout*(): About = + About( + status: Http200 + ) + + proc onContent(status: int, response: kstring, state: About) = + state.status = status.HttpCode + state.content = response + + proc render*(state: About, page: string): VNode = + if state.status != Http200: + return renderError($state.content, state.status) + + if page != state.page: + if not state.loading: + state.page = page + state.loading = true + state.status = Http200 + let uri = makeUri("/about/" & page & ".html") + ajaxGet(uri, @[], (s: int, r: kstring) => onContent(s, r, state)) + + return buildHtml(tdiv(class="loading")) + + result = buildHtml(): + section(class="container grid-xl"): + tdiv(id="about"): + verbatim(state.content) \ No newline at end of file diff --git a/src/frontend/forum.nim b/src/frontend/forum.nim index 5c952cc..c8210db 100644 --- a/src/frontend/forum.nim +++ b/src/frontend/forum.nim @@ -4,7 +4,7 @@ from dom import window, Location include karax/prelude import jester/patterns -import threadlist, postlist, header, profile, newthread, error +import threadlist, postlist, header, profile, newthread, error, about import karaxutils type @@ -12,6 +12,7 @@ type url: Location profile: ProfileState newThread: NewThread + about: About proc copyLocation(loc: Location): Location = # TODO: It sucks that I had to do this. We need a nice way to deep copy in JS. @@ -30,7 +31,8 @@ proc newState(): State = State( url: copyLocation(window.location), profile: newProfileState(), - newThread: newNewThread() + newThread: newNewThread(), + about: newAbout() ) var state = newState() @@ -87,6 +89,9 @@ proc render(): VNode = ) ) ), + r("/about/?@page?", + (params: Params) => (render(state.about, params["page"])) + ), r("/404", (params: Params) => render404() ), diff --git a/src/setup_nimforum.nim b/src/setup_nimforum.nim index 2cb51f0..fe27bad 100644 --- a/src/setup_nimforum.nim +++ b/src/setup_nimforum.nim @@ -235,7 +235,7 @@ when isMainModule: echo("Initialising nimforum for development...") initialiseConfig( "Development Forum", - "localhost.local", + "localhost", recaptcha=("", ""), smtp=("", "", ""), isDev=true, @@ -251,7 +251,7 @@ when isMainModule: echo("Initialising nimforum for testing...") initialiseConfig( "Test Forum", - "localhost.local", + "localhost", recaptcha=("", ""), smtp=("", "", ""), isDev=true, diff --git a/src/utils.nim b/src/utils.nim index f8cdbbb..918ed32 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -26,6 +26,8 @@ type recaptchaSiteKey*: string isDev*: bool dbPath*: string + hostname*: string + name*: string var docConfig: StringTableRef @@ -36,19 +38,18 @@ docConfig["doc.listing_end"] = "