Compare commits

..

No commits in common. "master" and "fix_tests" have entirely different histories.

50 changed files with 409 additions and 1714 deletions

View file

@ -1,80 +0,0 @@
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the action will run.
on:
# Triggers the workflow on push or pull request events but only for the master branch
push:
branches: [ master ]
pull_request:
branches: [ master ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
test_stable:
runs-on: ubuntu-latest
strategy:
matrix:
firefox: [ '73.0' ]
include:
- nim-version: 'stable'
cache-key: 'stable'
steps:
- uses: actions/checkout@v2
- name: Checkout submodules
run: git submodule update --init --recursive
- name: Setup firefox
uses: browser-actions/setup-firefox@latest
with:
firefox-version: ${{ matrix.firefox }}
- name: Get Date
id: get-date
run: echo "::set-output name=date::$(date "+%Y-%m-%d")"
shell: bash
- name: Cache choosenim
uses: actions/cache@v2
with:
path: ~/.choosenim
key: ${{ runner.os }}-choosenim-${{ matrix.cache-key }}
- name: Cache nimble
uses: actions/cache@v2
with:
path: ~/.nimble
key: ${{ runner.os }}-nimble-${{ hashFiles('*.nimble') }}
- uses: jiro4989/setup-nim-action@v1
with:
nim-version: "${{ matrix.nim-version }}"
- name: Install geckodriver
run: |
sudo apt-get -qq update
sudo apt-get install autoconf libtool libsass-dev
wget https://github.com/mozilla/geckodriver/releases/download/v0.29.1/geckodriver-v0.29.1-linux64.tar.gz
mkdir geckodriver
tar -xzf geckodriver-v0.29.1-linux64.tar.gz -C geckodriver
export PATH=$PATH:$PWD/geckodriver
- name: Install choosenim
run: |
export CHOOSENIM_CHOOSE_VERSION="stable"
curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh
sh init.sh -y
export PATH=$HOME/.nimble/bin:$PATH
nimble refresh -y
- name: Run tests
run: |
export MOZ_HEADLESS=1
nimble -y install
nimble -y test

9
.gitignore vendored
View file

@ -12,12 +12,3 @@ nimcache/
forum forum
createdb createdb
editdb editdb
.vscode
forum.json*
browsertester
setup_nimforum
buildcss
nimforum.css
/src/frontend/forum.js

44
.travis.yml Normal file
View file

@ -0,0 +1,44 @@
os:
- linux
language: c
cache:
directories:
- "$HOME/.nimble"
- "$HOME/.choosenim"
addons:
firefox: "60.0.1"
before_install:
- sudo apt-get -qq update
- 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
- export PATH=$PATH:$PWD/geckodriver
install:
- export CHOOSENIM_CHOOSE_VERSION="#f92d61b1f4e193bd"
- |
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

View file

@ -63,23 +63,6 @@ test Runs tester
fasttest Runs tester without recompiling backend fasttest Runs tester without recompiling backend
``` ```
To get up and running:
```bash
git clone https://github.com/nim-lang/nimforum
cd nimforum
git submodule update --init --recursive
# Setup the db with user: admin, pass: admin and some other users
nimble devdb
# Run this again if frontend code changes
nimble frontend
# Will start a server at localhost:5000
nimble backend
```
Development typically involves running `nimble devdb` which sets up the Development typically involves running `nimble devdb` which sets up the
database for development and testing, then `nimble backend` database for development and testing, then `nimble backend`
which compiles and runs the forum's backend, and `nimble frontend` which compiles and runs the forum's backend, and `nimble frontend`
@ -87,38 +70,6 @@ separately to build the frontend. When making changes to the frontend it
should be enough to simply run `nimble frontend` again to rebuild. This command should be enough to simply run `nimble frontend` again to rebuild. This command
will also build the SASS ``nimforum.scss`` file in the `public/css` directory. will also build the SASS ``nimforum.scss`` file in the `public/css` directory.
### With docker
You can easily launch site on localhost if you have `docker` and `docker-compose`.
You don't have to setup dependencies (libsass, sglite, pcre, etc...) on you host PC.
To get up and running:
```bash
cd docker
docker-compose build
docker-compose up
```
And you can access local NimForum site.
Open http://localhost:5000 .
# Troubleshooting
You might have to run `nimble install karax@#5f21dcd`, if setup fails
with:
```
andinus@circinus ~/projects/forks/nimforum> nimble --verbose devdb
[...]
Installing karax@#5f21dcd
Tip: 24 messages have been suppressed, use --verbose to show them.
Error: No binaries built, did you specify a valid binary name?
[...]
Error: Exception raised during nimble script execution
```
The hash needs to be replaced with the one specified in output.
# Copyright # Copyright

View file

@ -1,14 +0,0 @@
FROM nimlang/nim:1.2.6-ubuntu
RUN apt-get update -yqq \
&& apt-get install -y --no-install-recommends \
libsass-dev \
sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
# install dependencies
RUN nimble install -Y

View file

@ -1,12 +0,0 @@
version: "3.7"
services:
forum:
build:
context: ../
dockerfile: ./docker/Dockerfile
volumes:
- "../:/app"
ports:
- "5000:5000"
entrypoint: "/app/docker/entrypoint.sh"

View file

@ -1,19 +0,0 @@
#!/bin/sh
set -eu
git submodule update --init --recursive
# setup
nimble c -d:release src/setup_nimforum.nim
./src/setup_nimforum --dev
# build frontend
nimble c -r src/buildcss
nimble js -d:release src/frontend/forum.nim
mkdir -p public/js
cp src/frontend/forum.js public/js/forum.js
# build backend
nimble c src/forum.nim
./src/forum

View file

@ -1,5 +1,5 @@
# Package # Package
version = "2.1.0" version = "2.0.1"
author = "Dominik Picheta" author = "Dominik Picheta"
description = "The Nim forum" description = "The Nim forum"
license = "MIT" license = "MIT"
@ -12,16 +12,16 @@ skipExt = @["nim"]
# Dependencies # Dependencies
requires "nim >= 1.0.6" requires "nim >= 0.18.1"
requires "jester#405be2e" requires "jester 0.4.0"
requires "bcrypt#440c5676ff6" requires "bcrypt#head"
requires "hmac#9c61ebe2fd134cf97" requires "hmac#9c61ebe2fd134cf97"
requires "recaptcha#d06488e" requires "recaptcha 1.0.2"
requires "sass#649e0701fa5c" requires "sass#649e0701fa5c"
requires "karax#5f21dcd" requires "karax#d8df257dd"
requires "webdriver#429933a" requires "webdriver#20f3c1b"
# Tasks # Tasks
@ -32,14 +32,11 @@ task backend, "Compiles and runs the forum backend":
task runbackend, "Runs the forum backend": task runbackend, "Runs the forum backend":
exec "./src/forum" exec "./src/forum"
task testbackend, "Runs the forum backend in test mode":
exec "nimble c -r -d:skipRateLimitCheck src/forum.nim"
task frontend, "Builds the necessary JS frontend (with CSS)": task frontend, "Builds the necessary JS frontend (with CSS)":
exec "nimble c -r src/buildcss" exec "nimble c -r src/buildcss"
exec "nimble js -d:release src/frontend/forum.nim" exec "nimble js -d:release src/frontend/forum.nim"
mkDir "public/js" mkDir "public/js"
cpFile "src/frontend/forum.js", "public/js/forum.js" cpFile "src/frontend/nimcache/forum.js", "public/js/forum.js"
task minify, "Minifies the JS using Google's closure compiler": task minify, "Minifies the JS using Google's closure compiler":
exec "closure-compiler public/js/forum.js --js_output_file public/js/forum.js.opt" exec "closure-compiler public/js/forum.js --js_output_file public/js/forum.js.opt"
@ -58,7 +55,7 @@ task blankdb, "Creates a blank DB":
task test, "Runs tester": task test, "Runs tester":
exec "nimble c -y src/forum.nim" exec "nimble c -y src/forum.nim"
exec "nimble c -y -r -d:actionDelayMs=0 tests/browsertester" exec "nimble c -y -r tests/browsertester"
task fasttest, "Runs tester without recompiling backend": task fasttest, "Runs tester without recompiling backend":
exec "nimble c -r -d:actionDelayMs=0 tests/browsertester" exec "nimble c -r tests/browsertester"

View file

@ -22,7 +22,6 @@ table th {
// Custom styles. // Custom styles.
// - Navigation bar. // - Navigation bar.
$navbar-height: 60px; $navbar-height: 60px;
$default-category-color: #a3a3a3;
$logo-height: $navbar-height - 20px; $logo-height: $navbar-height - 20px;
.navbar-button { .navbar-button {
@ -51,7 +50,6 @@ $logo-height: $navbar-height - 20px;
// Unfortunately we must colour the controls in the navbar manually. // Unfortunately we must colour the controls in the navbar manually.
.search-input { .search-input {
@extend .form-input; @extend .form-input;
min-width: 120px;
border-color: $navbar-border-color-dark; border-color: $navbar-border-color-dark;
} }
@ -109,40 +107,6 @@ $logo-height: $navbar-height - 20px;
} }
} }
#category-selection {
.dropdown {
.btn {
margin-right: 0px;
}
}
.plus-btn {
margin-right: 0px;
i {
margin-right: 0px;
}
}
}
.category-description {
opacity: 0.6;
font-size: small;
}
.category-status {
font-size: small;
font-weight: bold;
.topic-count {
margin-left: 5px;
opacity: 0.7;
font-size: small;
}
}
.category {
white-space: nowrap;
}
#new-thread { #new-thread {
.modal-container .modal-body { .modal-container .modal-body {
max-height: none; max-height: none;
@ -184,33 +148,6 @@ $logo-height: $navbar-height - 20px;
} }
} }
.thread-list {
@extend .container;
@extend .grid-xl;
}
.category-list {
@extend .thread-list;
.category-title {
@extend .thread-title;
a, a:hover {
color: lighten($body-font-color, 10%);
text-decoration: none;
}
}
.category-description {
opacity: 0.6;
}
}
#categories-list .category {
border-left: 6px solid;
border-left-color: $default-category-color;
}
$super-popular-color: #f86713; $super-popular-color: #f86713;
$popular-color: darken($super-popular-color, 25%); $popular-color: darken($super-popular-color, 25%);
$threads-meta-color: #545d70; $threads-meta-color: #545d70;
@ -259,12 +196,14 @@ $threads-meta-color: #545d70;
} }
} }
.category-color { .triangle {
// TODO: Abstract this into a "category" class.
width: 0; width: 0;
height: 0; height: 0;
border: 0.25rem solid $default-category-color; border-left: 0.3rem solid transparent;
border-right: 0.3rem solid transparent;
border-bottom: 0.6rem solid #98c766;
display: inline-block; display: inline-block;
margin-right: 5px;
} }
.load-more-separator { .load-more-separator {
@ -301,14 +240,6 @@ $threads-meta-color: #545d70;
} }
} }
.thread-replies, .thread-time, .views-text, .popular-text, .centered-header {
text-align: center;
}
.thread-users {
text-align: left;
}
.thread-time { .thread-time {
color: $threads-meta-color; color: $threads-meta-color;
@ -322,13 +253,6 @@ $threads-meta-color: #545d70;
} }
// Hide all the avatars but the first on small screens.
@media screen and (max-width: 600px) {
#threads-list a:not(:first-child) > .avatar {
display: none;
}
}
.posts, .about { .posts, .about {
@extend .grid-md; @extend .grid-md;
@extend .container; @extend .container;
@ -779,3 +703,18 @@ hr {
margin-top: $control-padding-y*2; margin-top: $control-padding-y*2;
} }
} }
// - Hide features that have not been implemented yet.
#main-buttons > section.navbar-section:nth-child(1) {
display: none;
}
#threads-list.table {
tr > th:nth-child(2), tr > td:nth-child(2) {
display: none;
}
}
.category, div.flag-button {
display: none;
}

View file

@ -100,7 +100,7 @@ You should then create a symlink to this file inside ``/etc/nginx/sites-enabled/
ln -s /etc/nginx/sites-available/<forum.hostname.com> /etc/nginx/sites-enabled/<forum.hostname.com> ln -s /etc/nginx/sites-available/<forum.hostname.com> /etc/nginx/sites-enabled/<forum.hostname.com>
``` ```
Then reload nginx configuration by running ``sudo nginx -s reload``. Then restart nginx by running ``sudo systemctl restart nginx``.
### Supervisor ### Supervisor
@ -168,4 +168,4 @@ You should see something like this:
## Conclusion ## Conclusion
That should be all you need to get started. Your forum should now be accessible That should be all you need to get started. Your forum should now be accessible
via your hostname, assuming that it points to your VPS' IP address. via your hostname, assuming that it points to your VPS' IP address.

View file

@ -71,13 +71,13 @@ when isMainModule:
"test", "test",
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG", "$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
1526908753, 1526908753,
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um" "*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\um"
) )
let ident2 = makeIdentHash( let ident2 = makeIdentHash(
"test", "test",
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG", "$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
1526908753, 1526908753,
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um" "*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\um"
) )
doAssert ident == ident2 doAssert ident == ident2
@ -85,6 +85,6 @@ when isMainModule:
"test", "test",
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG", "$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
1526908754, 1526908754,
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um" "*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\um"
) )
doAssert ident != invalid doAssert ident != invalid

View file

@ -1,4 +1,4 @@
import os import os, strutils
import sass import sass

View file

@ -20,7 +20,7 @@ proc newMailer*(config: Config): Mailer =
proc rateCheck(mailer: Mailer, address: string): bool = proc rateCheck(mailer: Mailer, address: string): bool =
## Returns true if we've emailed the address too much. ## Returns true if we've emailed the address too much.
let diff = getTime() - mailer.lastReset let diff = getTime() - mailer.lastReset
if diff.inHours >= 1: if diff.hours >= 1:
mailer.lastReset = getTime() mailer.lastReset = getTime()
mailer.emailsSent.clear() mailer.emailsSent.clear()
@ -30,6 +30,7 @@ proc rateCheck(mailer: Mailer, address: string): bool =
proc sendMail( proc sendMail(
mailer: Mailer, mailer: Mailer,
subject, message, recipient: string, subject, message, recipient: string,
fromAddr = "forum@nim-lang.org",
otherHeaders:seq[(string, string)] = @[] otherHeaders:seq[(string, string)] = @[]
) {.async.} = ) {.async.} =
# Ensure we aren't emailing this address too much. # Ensure we aren't emailing this address too much.
@ -40,37 +41,21 @@ proc sendMail(
if mailer.config.smtpAddress.len == 0: if mailer.config.smtpAddress.len == 0:
warn("Cannot send mail: no smtp server configured (smtpAddress).") warn("Cannot send mail: no smtp server configured (smtpAddress).")
return return
if mailer.config.smtpFromAddr.len == 0:
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
return
var client: AsyncSmtp
if mailer.config.smtpTls:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
await client.startTls()
elif mailer.config.smtpSsl:
client = newAsyncSmtp(useSsl=true)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
else:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
var client = newAsyncSmtp()
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
if mailer.config.smtpUser.len > 0: if mailer.config.smtpUser.len > 0:
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword) await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
let toList = @[recipient] let toList = @[recipient]
var headers = otherHeaders var headers = otherHeaders
headers.add(("From", mailer.config.smtpFromAddr)) headers.add(("From", fromAddr))
let dateHeader = now().utc().format("ddd, dd MMM yyyy hh:mm:ss") & " +0000"
headers.add(("Date", dateHeader))
let encoded = createMessage(subject, message, let encoded = createMessage(subject, message,
toList, @[], headers) toList, @[], headers)
await client.sendMail(mailer.config.smtpFromAddr, toList, $encoded) await client.sendMail(fromAddr, toList, $encoded)
proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} = proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} =
let message = """Hello $1, let message = """Hello $1,
@ -148,4 +133,4 @@ proc sendSecureEmail*(
if emailSentFut.error of ForumError: if emailSentFut.error of ForumError:
raise emailSentFut.error raise emailSentFut.error
else: else:
raise newForumError("Couldn't send email", @["email"]) raise newForumError("Couldn't send email", @["email"])

View file

@ -8,7 +8,7 @@
import system except Thread import system except Thread
import import
os, strutils, times, md5, strtabs, math, db_sqlite, os, strutils, times, md5, strtabs, math, db_sqlite,
jester, asyncdispatch, asyncnet, sequtils, scgi, jester, asyncdispatch, asyncnet, sequtils,
parseutils, random, rst, recaptcha, json, re, sugar, parseutils, random, rst, recaptcha, json, re, sugar,
strformat, logging strformat, logging
import cgi except setCookie import cgi except setCookie
@ -76,6 +76,7 @@ proc getGravatarUrl(email: string, size = 80): string =
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
template `||`(x: untyped): untyped = (if not isNil(x): x else: "")
proc validateCaptcha(recaptchaResp, ip: string) {.async.} = proc validateCaptcha(recaptchaResp, ip: string) {.async.} =
# captcha validation: # captcha validation:
@ -132,9 +133,9 @@ proc checkLoggedIn(c: TForumData) =
let row = getRow(db, let row = getRow(db,
sql"select name, email, status from person where id = ?", c.userid) sql"select name, email, status from person where id = ?", c.userid)
c.username = row[0] c.username = ||row[0]
c.email = row[1] c.email = ||row[1]
c.rank = parseEnum[Rank](row[2]) c.rank = parseEnum[Rank](||row[2])
# In order to handle the "last visit" line appropriately, i.e. # In order to handle the "last visit" line appropriately, i.e.
# it shouldn't disappear after a refresh, we need to manage a # it shouldn't disappear after a refresh, we need to manage a
@ -151,7 +152,7 @@ proc checkLoggedIn(c: TForumData) =
) )
c.previousVisitAt = personRow[1].parseInt c.previousVisitAt = personRow[1].parseInt
let diff = getTime() - fromUnix(personRow[0].parseInt) let diff = getTime() - fromUnix(personRow[0].parseInt)
if diff.inMinutes > 30: if diff.minutes > 30:
c.previousVisitAt = personRow[0].parseInt c.previousVisitAt = personRow[0].parseInt
db.exec( db.exec(
sql""" sql"""
@ -238,7 +239,7 @@ proc verifyIdentHash(
let newIdent = makeIdentHash(name, row[0], epoch, row[1]) let newIdent = makeIdentHash(name, row[0], epoch, row[1])
# Check that it hasn't expired. # Check that it hasn't expired.
let diff = getTime() - epoch.fromUnix() let diff = getTime() - epoch.fromUnix()
if diff.inHours > 2: if diff.hours > 2:
raise newForumError("Link expired") raise newForumError("Link expired")
if newIdent != ident: if newIdent != ident:
raise newForumError("Invalid ident hash") raise newForumError("Invalid ident hash")
@ -276,26 +277,25 @@ template createTFD() =
new(c) new(c)
init(c) init(c)
c.req = request c.req = request
if cookies(request).len > 0: if request.cookies.len > 0:
checkLoggedIn(c) checkLoggedIn(c)
#[ DB functions. TODO: Move to another module? ]# #[ DB functions. TODO: Move to another module? ]#
proc selectUser(userRow: seq[string], avatarSize: int=80): User = proc selectUser(userRow: seq[string], avatarSize: int=80): User =
result = User( result = User(
id: userRow[0], name: userRow[0],
name: userRow[1], avatarUrl: userRow[1].getGravatarUrl(avatarSize),
avatarUrl: userRow[2].getGravatarUrl(avatarSize), lastOnline: userRow[2].parseInt,
lastOnline: userRow[3].parseInt, previousVisitAt: userRow[3].parseInt,
previousVisitAt: userRow[4].parseInt, rank: parseEnum[Rank](userRow[4]),
rank: parseEnum[Rank](userRow[5]), isDeleted: userRow[5] == "1"
isDeleted: userRow[6] == "1"
) )
# Don't give data about a deleted user. # Don't give data about a deleted user.
if result.isDeleted: if result.isDeleted:
result.name = "DeletedUser" result.name = "DeletedUser"
result.avatarUrl = getGravatarUrl(result.name & userRow[2], avatarSize) result.avatarUrl = getGravatarUrl(result.name & userRow[1], avatarSize)
proc selectPost(postRow: seq[string], skippedPosts: seq[int], proc selectPost(postRow: seq[string], skippedPosts: seq[int],
replyingTo: Option[PostLink], history: seq[PostInfo], replyingTo: Option[PostLink], history: seq[PostInfo],
@ -303,7 +303,7 @@ proc selectPost(postRow: seq[string], skippedPosts: seq[int],
return Post( return Post(
id: postRow[0].parseInt, id: postRow[0].parseInt,
replyingTo: replyingTo, replyingTo: replyingTo,
author: selectUser(postRow[5..11]), author: selectUser(postRow[5..10]),
likes: likes, likes: likes,
seen: false, # TODO: seen: false, # TODO:
history: history, history: history,
@ -319,7 +319,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
const replyingToQuery = sql""" const replyingToQuery = sql"""
select p.id, strftime('%s', p.creation), p.thread, select p.id, strftime('%s', p.creation), p.thread,
u.id, u.name, u.email, strftime('%s', u.lastOnline), u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status, strftime('%s', u.previousVisitAt), u.status,
u.isDeleted, u.isDeleted,
t.name t.name
@ -335,7 +335,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
topic: row[^1], topic: row[^1],
threadId: row[2].parseInt(), threadId: row[2].parseInt(),
postId: row[0].parseInt(), postId: row[0].parseInt(),
author: some(selectUser(row[3..9])) author: some(selectUser(row[3..8]))
)) ))
proc selectHistory(postId: int): seq[PostInfo] = proc selectHistory(postId: int): seq[PostInfo] =
@ -354,7 +354,7 @@ proc selectHistory(postId: int): seq[PostInfo] =
proc selectLikes(postId: int): seq[User] = proc selectLikes(postId: int): seq[User] =
const likeQuery = sql""" const likeQuery = sql"""
select u.id, u.name, u.email, strftime('%s', u.lastOnline), select u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status, strftime('%s', u.previousVisitAt), u.status,
u.isDeleted u.isDeleted
from like h, person u from like h, person u
@ -369,7 +369,7 @@ proc selectLikes(postId: int): seq[User] =
proc selectThreadAuthor(threadId: int): User = proc selectThreadAuthor(threadId: int): User =
const authorQuery = const authorQuery =
sql""" sql"""
select id, name, email, strftime('%s', lastOnline), select name, email, strftime('%s', lastOnline),
strftime('%s', previousVisitAt), status, isDeleted strftime('%s', previousVisitAt), status, isDeleted
from person where id in ( from person where id in (
select author from post select author from post
@ -387,7 +387,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
where thread = ?;""" where thread = ?;"""
const usersListQuery = const usersListQuery =
sql""" sql"""
select u.id, name, email, strftime('%s', lastOnline), select name, email, strftime('%s', lastOnline),
strftime('%s', previousVisitAt), status, u.isDeleted, strftime('%s', previousVisitAt), status, u.isDeleted,
count(*) count(*)
from person u, post p where p.author = u.id and p.thread = ? from person u, post p where p.author = u.id and p.thread = ?
@ -400,10 +400,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
id: threadRow[0].parseInt, id: threadRow[0].parseInt,
topic: threadRow[1], topic: threadRow[1],
category: Category( category: Category(
id: threadRow[6].parseInt, id: threadRow[5].parseInt,
name: threadRow[7], name: threadRow[6],
description: threadRow[8], description: threadRow[7],
color: threadRow[9] color: threadRow[8]
), ),
users: @[], users: @[],
replies: posts[0].parseInt-1, replies: posts[0].parseInt-1,
@ -412,7 +412,6 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
creation: posts[1].parseInt, creation: posts[1].parseInt,
isLocked: threadRow[4] == "1", isLocked: threadRow[4] == "1",
isSolved: false, # TODO: Add a field to `post` to identify the solution. isSolved: false, # TODO: Add a field to `post` to identify the solution.
isPinned: threadRow[5] == "1"
) )
# Gather the users list. # Gather the users list.
@ -436,9 +435,8 @@ proc executeReply(c: TForumData, threadId: int, content: string,
else: else:
raise newForumError("You are not allowed to post") raise newForumError("You are not allowed to post")
when not defined(skipRateLimitCheck): if rateLimitCheck(c):
if rateLimitCheck(c): raise newForumError("You're posting too fast!")
raise newForumError("You're posting too fast!")
if content.strip().len == 0: if content.strip().len == 0:
raise newForumError("Message cannot be empty") raise newForumError("Message cannot be empty")
@ -460,21 +458,13 @@ proc executeReply(c: TForumData, threadId: int, content: string,
if isLocked == "1": if isLocked == "1":
raise newForumError("Cannot reply to a locked thread.") raise newForumError("Cannot reply to a locked thread.")
var retID: int64 let retID = insertID(
db,
if replyingTo.isSome(): crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"),
retID = insertID( c.userId, c.req.ip, content, $threadId,
db, if replyingTo.isSome(): $replyingTo.get()
crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"), else: nil
c.userId, c.req.ip, content, $threadId, $replyingTo.get() )
)
else:
retID = insertID(
db,
crud(crCreate, "post", "author", "ip", "content", "thread"),
c.userId, c.req.ip, content, $threadId
)
discard tryExec( discard tryExec(
db, db,
crud(crCreate, "post_fts", "id", "content"), crud(crCreate, "post_fts", "id", "content"),
@ -500,10 +490,10 @@ proc updatePost(c: TForumData, postId: int, content: string,
# Verify that the current user has permissions to edit the specified post. # Verify that the current user has permissions to edit the specified post.
let creation = fromUnix(postRow[1].parseInt) let creation = fromUnix(postRow[1].parseInt)
let isArchived = (getTime() - creation).inHours >= 2 let isArchived = (getTime() - creation).weeks > 8
let canEdit = c.rank == Admin or c.userid == postRow[0] let canEdit = c.rank == Admin or c.userid == postRow[0]
if isArchived and c.rank < Admin: if isArchived:
raise newForumError("This post is too old and can no longer be edited") raise newForumError("This post is archived and can no longer be edited")
if not canEdit: if not canEdit:
raise newForumError("You cannot edit this post") raise newForumError("You cannot edit this post")
@ -530,20 +520,10 @@ proc updatePost(c: TForumData, postId: int, content: string,
if row[0] == $postId: if row[0] == $postId:
exec(db, crud(crUpdate, "thread", "name"), subject.get(), threadId) exec(db, crud(crUpdate, "thread", "name"), subject.get(), threadId)
proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], queryValues: seq[string]) = proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) =
let threadAuthor = selectThreadAuthor(threadId.parseInt)
# Verify that the current user has permissions to edit the specified thread.
let canEdit = c.rank in {Admin, Moderator} or c.userid == threadAuthor.id
if not canEdit:
raise newForumError("You cannot edit this thread")
exec(db, crud(crUpdate, "thread", queryKeys), queryValues)
proc executeNewThread(c: TForumData, subject, msg, categoryID: string): (int64, int64) =
const const
query = sql""" query = sql"""
insert into thread(name, views, modified, category) values (?, 0, DATETIME('now'), ?) insert into thread(name, views, modified) values (?, 0, DATETIME('now'))
""" """
assert c.loggedIn() assert c.loggedIn()
@ -563,18 +543,13 @@ proc executeNewThread(c: TForumData, subject, msg, categoryID: string): (int64,
if msg.len == 0: if msg.len == 0:
raise newForumError("Message is empty", @["msg"]) raise newForumError("Message is empty", @["msg"])
let catID = getInt(categoryID, -1)
if catID == -1:
raise newForumError("CategoryID is invalid", @["categoryId"])
if not validateRst(c, msg): if not validateRst(c, msg):
raise newForumError("Message needs to be valid RST", @["msg"]) raise newForumError("Message needs to be valid RST", @["msg"])
when not defined(skipRateLimitCheck): if rateLimitCheck(c):
if rateLimitCheck(c): raise newForumError("You're posting too fast!")
raise newForumError("You're posting too fast!")
result[0] = tryInsertID(db, query, subject, categoryID).int result[0] = tryInsertID(db, query, subject).int
if result[0] < 0: if result[0] < 0:
raise newForumError("Subject already exists", @["subject"]) raise newForumError("Subject already exists", @["subject"])
@ -633,7 +608,7 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp,
raise newForumError("Invalid username", @["username"]) raise newForumError("Invalid username", @["username"])
if getValue( if getValue(
db, db,
sql"select name from person where name = ? collate nocase and isDeleted = 0", sql"select name from person where name = ? and isDeleted = 0",
name name
).len > 0: ).len > 0:
raise newForumError("Username already exists", @["username"]) raise newForumError("Username already exists", @["username"])
@ -676,18 +651,6 @@ proc executeLike(c: TForumData, postId: int) =
# Save the like. # Save the like.
exec(db, crud(crCreate, "like", "author", "post"), c.userid, postId) exec(db, crud(crCreate, "like", "author", "post"), c.userid, postId)
proc executeNewCategory(c: TForumData, name, color, description: string): int64 =
let canAdd = c.rank == Admin
if not canAdd:
raise newForumError("You do not have permissions to add a category.")
if name.len == 0:
raise newForumError("Category name must not be empty!", @["name"])
result = insertID(db, crud(crCreate, "category", "name", "color", "description"), name, color, description)
proc executeUnlike(c: TForumData, postId: int) = proc executeUnlike(c: TForumData, postId: int) =
# Verify the post and like exists for the current user. # Verify the post and like exists for the current user.
const likeQuery = sql""" const likeQuery = sql"""
@ -710,25 +673,15 @@ proc executeLockState(c: TForumData, threadId: int, locked: bool) =
# Save the like. # Save the like.
exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId) exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId)
proc executePinState(c: TForumData, threadId: int, pinned: bool) =
if c.rank < Moderator:
raise newForumError("You do not have permission to pin this thread.")
# (Un)pin this thread
exec(db, crud(crUpdate, "thread", "isPinned"), pinned.int, threadId)
proc executeDeletePost(c: TForumData, postId: int) = proc executeDeletePost(c: TForumData, postId: int) =
# Verify that this post belongs to the user. # Verify that this post belongs to the user.
const postQuery = sql""" const postQuery = sql"""
select p.author, p.id from post p select p.id from post p
where p.author = ? and p.id = ? where p.author = ? and p.id = ?
""" """
let let id = getValue(db, postQuery, c.username, postId)
row = getRow(db, postQuery, c.username, postId)
author = row[0]
id = row[1]
if id.len == 0 and not (c.rank == Admin or c.userid == author): if id.len == 0 and c.rank < Admin:
raise newForumError("You cannot delete this post") raise newForumError("You cannot delete this post")
# Set the `isDeleted` flag. # Set the `isDeleted` flag.
@ -783,7 +736,7 @@ proc updateProfile(
raise newForumError("Rank needs a change when setting new email.") raise newForumError("Rank needs a change when setting new email.")
await sendSecureEmail( await sendSecureEmail(
mailer, ActivateEmail, c.req, row[0], row[1], email, row[3] mailer, ActivateEmail, c.req, row[0], row[1], row[2], row[3]
) )
validateEmail(email, checkDuplicated=wasEmailChanged) validateEmail(email, checkDuplicated=wasEmailChanged)
@ -803,65 +756,33 @@ settings:
routes: routes:
get "/categories.json":
# TODO: Limit this query in the case of many many categories
const categoriesQuery =
sql"""
select c.*, count(thread.category)
from category c
left join thread on c.id == thread.category
group by c.id;
"""
var list = CategoryList(categories: @[])
for data in getAllRows(db, categoriesQuery):
let category = Category(
id: data[0].getInt, name: data[1], description: data[2], color: data[3], numTopics: data[4].parseInt
)
list.categories.add(category)
resp $(%list), "application/json"
get "/threads.json": get "/threads.json":
var var
start = getInt(@"start", 0) start = getInt(@"start", 0)
count = getInt(@"count", 30) count = getInt(@"count", 30)
categoryId = getInt(@"categoryId", -1)
var
categorySection = ""
categoryArgs: seq[string] = @[$start, $count]
countQuery = sql"select count(*) from thread;"
countArgs: seq[string] = @[]
if categoryId != -1:
categorySection = "c.id == ? and "
countQuery = sql"select count(*) from thread t, category c where category == c.id and c.id == ?;"
countArgs.add($categoryId)
categoryArgs.insert($categoryId, 0)
const threadsQuery = const threadsQuery =
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
c.id, c.name, c.description, c.color, c.id, c.name, c.description, c.color,
u.id, u.name, u.email, strftime('%s', u.lastOnline), u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status, u.isDeleted strftime('%s', u.previousVisitAt), u.status, u.isDeleted
from thread t, category c, person u from thread t, category c, person u
where t.isDeleted = 0 and category = c.id and $# where t.isDeleted = 0 and category = c.id and
u.status <> 'Spammer' and u.status <> 'Troll' and u.status <> 'Spammer' and u.status <> 'Troll' and
u.id = ( u.id in (
select p.author from post p select u.id from post p, person u
where p.thread = t.id where p.author = u.id and p.thread = t.id
order by p.author order by u.id
limit 1 limit 1
) )
order by isPinned desc, modified desc limit ?, ?;""" order by modified desc limit ?, ?;"""
let thrCount = getValue(db, countQuery, countArgs).parseInt() let thrCount = getValue(db, sql"select count(*) from thread;").parseInt()
let moreCount = max(0, thrCount - (start + count)) let moreCount = max(0, thrCount - (start + count))
var list = ThreadList(threads: @[], moreCount: moreCount) var list = ThreadList(threads: @[], moreCount: moreCount)
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs): for data in getAllRows(db, threadsQuery, start, count):
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1])) let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
list.threads.add(thread) list.threads.add(thread)
resp $(%list), "application/json" resp $(%list), "application/json"
@ -876,24 +797,19 @@ routes:
count = 10 count = 10
const threadsQuery = const threadsQuery =
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
c.id, c.name, c.description, c.color c.id, c.name, c.description, c.color
from thread t, category c from thread t, category c
where t.id = ? and isDeleted = 0 and category = c.id;""" where t.id = ? and isDeleted = 0 and category = c.id;"""
let threadRow = getRow(db, threadsQuery, id) let threadRow = getRow(db, threadsQuery, id)
if threadRow[0].len == 0:
let err = PostError(
message: "Specified thread does not exist"
)
resp Http404, $(%err), "application/json"
let thread = selectThread(threadRow, selectThreadAuthor(id)) let thread = selectThread(threadRow, selectThreadAuthor(id))
let postsQuery = let postsQuery =
sql( sql(
"""select p.id, p.content, strftime('%s', p.creation), p.author, """select p.id, p.content, strftime('%s', p.creation), p.author,
p.replyingTo, p.replyingTo,
u.id, u.name, u.email, strftime('%s', u.lastOnline), u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status, strftime('%s', u.previousVisitAt), u.status,
u.isDeleted u.isDeleted
from post p, person u from post p, person u
@ -932,20 +848,15 @@ routes:
get "/specific_posts.json": get "/specific_posts.json":
createTFD() createTFD()
var ids: JsonNode var
try:
ids = parseJson(@"ids") ids = parseJson(@"ids")
except JsonParsingError:
let err = PostError(
message: "Invalid JSON in the `ids` parameter"
)
resp Http400, $(%err), "application/json"
cond ids.kind == JArray cond ids.kind == JArray
let intIDs = ids.elems.map(x => x.getInt()) let intIDs = ids.elems.map(x => x.getInt())
let postsQuery = sql(""" let postsQuery = sql("""
select p.id, p.content, strftime('%s', p.creation), p.author, select p.id, p.content, strftime('%s', p.creation), p.author,
p.replyingTo, p.replyingTo,
u.id, u.name, u.email, strftime('%s', u.lastOnline), u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status, strftime('%s', u.previousVisitAt), u.status,
u.isDeleted u.isDeleted
from post p, person u from post p, person u
@ -1014,7 +925,7 @@ routes:
""" % postsFrom) """ % postsFrom)
let userQuery = sql(""" let userQuery = sql("""
select id, name, email, strftime('%s', lastOnline), select name, email, strftime('%s', lastOnline),
strftime('%s', previousVisitAt), status, isDeleted, strftime('%s', previousVisitAt), status, isDeleted,
strftime('%s', creation), id strftime('%s', creation), id
from person from person
@ -1040,7 +951,7 @@ routes:
getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt() getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt()
if c.rank >= Admin or c.username == username: if c.rank >= Admin or c.username == username:
profile.email = some(userRow[2]) profile.email = some(userRow[1])
for row in db.getAllRows(postsQuery, username): for row in db.getAllRows(postsQuery, username):
profile.posts.add( profile.posts.add(
@ -1114,21 +1025,6 @@ routes:
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" resp Http400, $(%exc.data), "application/json"
post "/createCategory":
createTFD()
let formData = request.formData
let name = formData["name"].body
let color = formData["color"].body.replace("#", "")
let description = formData["description"].body
try:
let id = executeNewCategory(c, name, color, description)
let category = Category(id: id.int, name: name, color: color, description: description)
resp Http200, $(%category), "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
get "/status.json": get "/status.json":
createTFD() createTFD()
@ -1175,8 +1071,7 @@ routes:
except EParseError: except EParseError:
let err = PostError( let err = PostError(
errorFields: @[], errorFields: @[],
message: "Message needs to be valid RST! Error: " & message: getCurrentExceptionMsg()
getCurrentExceptionMsg()
) )
resp Http400, $(%err), "application/json" resp Http400, $(%err), "application/json"
@ -1240,45 +1135,6 @@ routes:
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" resp Http400, $(%exc.data), "application/json"
post "/updateThread":
# TODO: Add some way of keeping track of modifications for historical
# purposes
createTFD()
if not c.loggedIn():
let err = PostError(
errorFields: @[],
message: "Not logged in."
)
resp Http401, $(%err), "application/json"
let formData = request.formData
cond "threadId" in formData
let threadId = formData["threadId"].body
# TODO: might want to add more properties here under a tighter permissions
# model
let keys = ["name", "category", "solution"]
# optional parameters
var
queryValues: seq[string] = @[]
queryKeys: seq[string] = @[]
for key in keys:
if key in formData:
queryKeys.add(key)
queryValues.add(formData[key].body)
if queryKeys.len() > 0:
queryValues.add(threadId)
try:
updateThread(c, threadId, queryKeys, queryValues)
resp Http200, "{}", "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post "/newthread": post "/newthread":
createTFD() createTFD()
if not c.loggedIn(): if not c.loggedIn():
@ -1291,14 +1147,13 @@ routes:
let formData = request.formData let formData = request.formData
cond "msg" in formData cond "msg" in formData
cond "subject" in formData cond "subject" in formData
cond "categoryId" in formData
let msg = formData["msg"].body let msg = formData["msg"].body
let subject = formData["subject"].body let subject = formData["subject"].body
let categoryID = formData["categoryId"].body # TODO: category
try: try:
let res = executeNewThread(c, subject, msg, categoryID) let res = executeNewThread(c, subject, msg)
resp Http200, $(%[res[0], res[1]]), "application/json" resp Http200, $(%[res[0], res[1]]), "application/json"
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" resp Http400, $(%exc.data), "application/json"
@ -1357,33 +1212,6 @@ routes:
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" resp Http400, $(%exc.data), "application/json"
post re"/(pin|unpin)":
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 threadId = getInt(formData["id"].body, -1)
cond threadId != -1
try:
case request.path
of "/pin":
executePinState(c, threadId, true)
of "/unpin":
executePinState(c, threadId, false)
else:
assert false
resp Http200, "{}", "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post re"/delete(Post|Thread)": post re"/delete(Post|Thread)":
createTFD() createTFD()
if not c.loggedIn(): if not c.loggedIn():
@ -1616,7 +1444,7 @@ routes:
postId: rowFT[2].parseInt(), postId: rowFT[2].parseInt(),
postContent: content, postContent: content,
creation: rowFT[4].parseInt(), creation: rowFT[4].parseInt(),
author: selectUser(rowFT[5 .. 11]), author: selectUser(rowFT[5 .. 10]),
) )
) )

View file

@ -1,11 +1,11 @@
when defined(js): when defined(js):
import sugar, httpcore import sugar, httpcore, options, json
import dom except Event import dom except Event
include karax/prelude include karax/prelude
import karax / [kajax] import karax / [kajax, kdom]
import error import error, replybox, threadlist, post
import karaxutils import karaxutils
type type

View file

@ -5,7 +5,7 @@ when defined(js):
include karax/prelude include karax/prelude
import karax / [kajax, kdom] import karax / [kajax, kdom]
import error import error, replybox, threadlist, post
import karaxutils import karaxutils
type type
@ -13,12 +13,17 @@ when defined(js):
loading: bool loading: bool
status: HttpCode status: HttpCode
error: Option[PostError] error: Option[PostError]
newPassword: kstring
proc newActivateEmail*(): ActivateEmail = proc newActivateEmail*(): ActivateEmail =
ActivateEmail( ActivateEmail(
status: Http200 status: Http200,
newPassword: ""
) )
proc onPassChange(e: Event, n: VNode, state: ActivateEmail) =
state.newPassword = n.value
proc onPost(httpStatus: int, response: kstring, state: ActivateEmail) = proc onPost(httpStatus: int, response: kstring, state: ActivateEmail) =
postFinished: postFinished:
navigateTo(makeUri("/activateEmail/success")) navigateTo(makeUri("/activateEmail/success"))

View file

@ -1,87 +0,0 @@
when defined(js):
import sugar, httpcore, options, json, strutils
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom, vdom]
import error, category
import category, karaxutils
type
AddCategoryModal* = ref object of VComponent
modalShown: bool
loading: bool
error: Option[PostError]
onAddCategory: CategoryEvent
let nullCategory: CategoryEvent = proc (category: Category) = discard
proc newAddCategoryModal*(onAddCategory=nullCategory): AddCategoryModal =
result = AddCategoryModal(
modalShown: false,
loading: false,
onAddCategory: onAddCategory
)
proc onAddCategoryPost(httpStatus: int, response: kstring, state: AddCategoryModal) =
postFinished:
state.modalShown = false
let j = parseJson($response)
let category = j.to(Category)
state.onAddCategory(category)
proc onAddCategoryClick(state: AddCategoryModal) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("createCategory")
let form = dom.document.getElementById("add-category-form")
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onAddCategoryPost(s, r, state))
proc setModalShown*(state: AddCategoryModal, visible: bool) =
state.modalShown = visible
state.markDirty()
proc onModalClose(state: AddCategoryModal, ev: Event, n: VNode) =
state.setModalShown(false)
ev.preventDefault()
proc render*(state: AddCategoryModal): VNode =
result = buildHtml():
tdiv(class=class({"active": state.modalShown}, "modal modal-sm")):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onModalClose(state, ev, n))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
tdiv(class="card-title h5"):
text "Add New Category"
tdiv(class="modal-body"):
form(id="add-category-form"):
genFormField(
state.error, "name", "Name", "text", false,
placeholder="Category Name")
genFormField(
state.error, "color", "Color", "color", false,
placeholder="#XXYYZZ"
)
genFormField(
state.error,
"description",
"Description",
"text",
true,
placeholder="Description"
)
tdiv(class="modal-footer"):
button(
id="add-category-btn",
class="btn btn-primary",
onClick=(ev: Event, n: VNode) =>
state.onAddCategoryClick()):
text "Add"

View file

@ -5,44 +5,24 @@ type
name*: string name*: string
description*: string description*: string
color*: string color*: string
numTopics*: int
CategoryList* = ref object
categories*: seq[Category]
CategoryEvent* = proc (category: Category) {.closure.}
CategoryChangeEvent* = proc (oldCategory: Category, newCategory: Category) {.closure.}
const categoryDescriptionCharLimit = 250
proc cmpNames*(cat1: Category, cat2: Category): int =
cat1.name.cmp(cat2.name)
when defined(js): when defined(js):
include karax/prelude include karax/prelude
import karax / [vstyles] import karax / [vstyles, kajax, kdom]
import karaxutils import karaxutils
proc render*(category: Category, compact=true): VNode = proc render*(category: Category): VNode =
if category.name.len == 0: result = buildHtml():
return buildHtml(): if category.name.len >= 0:
span()
result = buildhtml(tdiv):
tdiv(class="category-status"):
tdiv(class="category", tdiv(class="category",
title=category.description,
"data-color"="#" & category.color): "data-color"="#" & category.color):
tdiv(class="category-color", tdiv(class="triangle",
style=style( style=style(
(StyleAttr.border, (StyleAttr.borderBottom,
kstring"0.25rem solid #" & category.color) kstring"0.6rem solid #" & category.color)
)) ))
span(class="category-name"): text category.name
text category.name else:
if not compact: span()
span(class="topic-count"):
text "× " & $category.numTopics
if not compact:
tdiv(class="category-description"):
text category.description.limit(categoryDescriptionCharLimit)

View file

@ -1,105 +0,0 @@
import options, json, httpcore
import category
when defined(js):
import sugar
include karax/prelude
import karax / [vstyles, kajax]
import karaxutils, error, user, mainbuttons, addcategorymodal
type
State = ref object
list: Option[CategoryList]
loading: bool
mainButtons: MainButtons
status: HttpCode
addCategoryModal: AddCategoryModal
var state: State
proc newState(): State =
State(
list: none[CategoryList](),
loading: false,
mainButtons: newMainButtons(),
status: Http200,
addCategoryModal: newAddCategoryModal(
onAddCategory=
(category: Category) => state.list.get().categories.add(category)
)
)
state = newState()
proc genCategory(category: Category, noBorder = false): VNode =
result = buildHtml():
tr(class=class({"no-border": noBorder})):
td(style=style((StyleAttr.borderLeftColor, kstring("#" & category.color))), class="category"):
h4(class="category-title", id="category-" & category.name.slug):
a(href=makeUri("/c/" & $category.id)):
tdiv():
tdiv(class="category-name"):
text category.name
tdiv(class="category-description"):
text category.description
td(class="topics"):
text $category.numTopics
proc onCategoriesRetrieved(httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = to(parsed, CategoryList)
if state.list.isSome:
state.list.get().categories.add(list.categories)
else:
state.list = some(list)
proc renderCategoryHeader*(currentUser: Option[User]): VNode =
result = buildHtml(tdiv(id="add-category")):
text "Category"
if currentUser.isAdmin():
button(class="plus-btn btn btn-link",
onClick=(ev: Event, n: VNode) => (
state.addCategoryModal.setModalShown(true)
)):
italic(class="fas fa-plus")
render(state.addCategoryModal)
proc renderCategories(currentUser: Option[User]): VNode =
if state.status != Http200:
return renderError("Couldn't retrieve threads.", state.status)
if state.list.isNone:
if not state.loading:
state.loading = true
ajaxGet(makeUri("categories.json"), @[], onCategoriesRetrieved)
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get()
return buildHtml():
section(class="category-list"):
table(id="categories-list", class="table"):
thead():
tr:
th:
renderCategoryHeader(currentUser)
th(text "Topics")
tbody():
for i in 0 ..< list.categories.len:
let category = list.categories[i]
let isLastCategory = i+1 == list.categories.len
genCategory(category, noBorder=isLastCategory)
proc renderCategoryList*(currentUser: Option[User]): VNode =
result = buildHtml(tdiv):
state.mainButtons.render(currentUser)
renderCategories(currentUser)

View file

@ -1,135 +0,0 @@
when defined(js):
import sugar, httpcore, options, json, strutils, algorithm
import dom except Event
include karax/prelude
import karax / [kajax, kdom, vdom]
import error, category, user
import category, karaxutils, addcategorymodal
type
CategoryPicker* = ref object of VComponent
list: Option[CategoryList]
selectedCategoryID*: int
loading: bool
addEnabled: bool
status: HttpCode
error: Option[PostError]
addCategoryModal: AddCategoryModal
onCategoryChange: CategoryChangeEvent
onAddCategory: CategoryEvent
proc onCategoryLoad(state: CategoryPicker): proc (httpStatus: int, response: kstring) =
return
proc (httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = parsed.to(CategoryList)
list.categories.sort(cmpNames)
if state.list.isSome:
state.list.get().categories.add(list.categories)
else:
state.list = some(list)
if state.selectedCategoryID > state.list.get().categories.len():
state.selectedCategoryID = 0
proc loadCategories(state: CategoryPicker) =
if not state.loading:
state.loading = true
ajaxGet(makeUri("categories.json"), @[], onCategoryLoad(state))
proc `[]`*(state: CategoryPicker, id: int): Category =
for cat in state.list.get().categories:
if cat.id == id:
return cat
raise newException(IndexError, "Category at " & $id & " not found!")
let nullAddCategory: CategoryEvent = proc (category: Category) = discard
let nullCategoryChange: CategoryChangeEvent = proc (oldCategory: Category, newCategory: Category) = discard
proc select*(state: CategoryPicker, id: int) =
state.selectedCategoryID = id
state.markDirty()
proc onCategory(state: CategoryPicker): CategoryEvent =
result =
proc (category: Category) =
state.list.get().categories.add(category)
state.list.get().categories.sort(cmpNames)
state.select(category.id)
state.onAddCategory(category)
proc newCategoryPicker*(onCategoryChange=nullCategoryChange, onAddCategory=nullAddCategory): CategoryPicker =
result = CategoryPicker(
list: none[CategoryList](),
selectedCategoryID: 0,
loading: false,
addEnabled: false,
status: Http200,
error: none[PostError](),
onCategoryChange: onCategoryChange,
onAddCategory: onAddCategory
)
let state = result
result.addCategoryModal = newAddCategoryModal(
onAddCategory=onCategory(state)
)
proc setAddEnabled*(state: CategoryPicker, enabled: bool) =
state.addEnabled = enabled
proc onCategoryClick(state: CategoryPicker, category: Category): proc (ev: Event, n: VNode) =
# this is necessary to capture the right value
let cat = category
return
proc (ev: Event, n: VNode) =
let oldCategory = state[state.selectedCategoryID]
state.select(cat.id)
state.onCategoryChange(oldCategory, cat)
proc genAddCategory(state: CategoryPicker): VNode =
result = buildHtml():
tdiv(id="add-category"):
button(class="plus-btn btn btn-link",
onClick=(ev: Event, n: VNode) => (
state.addCategoryModal.setModalShown(true)
)):
italic(class="fas fa-plus")
render(state.addCategoryModal)
proc render*(state: CategoryPicker, currentUser: Option[User], compact=true): VNode =
state.setAddEnabled(currentUser.isAdmin())
if state.status != Http200:
return renderError("Couldn't retrieve categories.", state.status)
if state.list.isNone:
state.loadCategories()
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get().categories
let selectedCategory = state[state.selectedCategoryID]
result = buildHtml():
tdiv(id="category-selection", class="input-group"):
tdiv(class="dropdown"):
a(class="btn btn-link dropdown-toggle", tabindex="0"):
tdiv(class="selected-category d-inline-block"):
render(selectedCategory)
text " "
italic(class="fas fa-caret-down")
ul(class="menu"):
for category in list:
li(class="menu-item"):
a(class="category-" & $category.id & " " & category.name.slug,
onClick=onCategoryClick(state, category)):
render(category, compact)
if state.addEnabled:
genAddCategory(state)

View file

@ -1,7 +1,6 @@
when defined(js): when defined(js):
import sugar, httpcore, options, json import sugar, httpcore, options, json
import dom except Event import dom except Event
import jsffi except `&`
include karax/prelude include karax/prelude
import karax / [kajax, kdom] import karax / [kajax, kdom]
@ -60,7 +59,7 @@ when defined(js):
formData.append("id", $state.post.id) formData.append("id", $state.post.id)
of DeleteThread: of DeleteThread:
formData.append("id", $state.thread.id) formData.append("id", $state.thread.id)
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => onDeletePost(s, r, state)) (s: int, r: kstring) => onDeletePost(s, r, state))
proc onClose(ev: Event, n: VNode, state: DeleteModal) = proc onClose(ev: Event, n: VNode, state: DeleteModal) =

View file

@ -1,6 +1,5 @@
when defined(js): when defined(js):
import httpcore, options, sugar, json import httpcore, options, sugar, json
import jsffi except `&`
include karax/prelude include karax/prelude
import karax/kajax import karax/kajax
@ -55,7 +54,7 @@ when defined(js):
formData.append("postId", $state.post.id) formData.append("postId", $state.post.id)
# TODO: Subject # TODO: Subject
let uri = makeUri("/updatePost") let uri = makeUri("/updatePost")
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => onEditPost(s, r, state)) (s: int, r: kstring) => onEditPost(s, r, state))
proc render*(state: EditBox, post: Post): VNode = proc render*(state: EditBox, post: Post): VNode =

View file

@ -1,12 +1,13 @@
import httpcore import options, httpcore
type type
PostError* = object PostError* = object
errorFields*: seq[string] ## IDs of the fields with an error. errorFields*: seq[string] ## IDs of the fields with an error.
message*: string message*: string
when defined(js): when defined(js):
import json, options import json
include karax/prelude include karax/prelude
import karax / [vstyles, kajax, kdom]
import karaxutils import karaxutils
@ -85,8 +86,8 @@ when defined(js):
state.error = some(error) state.error = some(error)
except: except:
echo getCurrentExceptionMsg() kout(getCurrentExceptionMsg().cstring)
state.error = some(PostError( state.error = some(PostError(
errorFields: @[], errorFields: @[],
message: "Unknown error occurred." message: "Unknown error occurred."
)) ))

View file

@ -1,12 +1,10 @@
import options, tables, sugar, httpcore import strformat, times, options, json, tables, sugar, httpcore, uri
from dom import window, Location, document, decodeURI from dom import window, Location, document, decodeURI
include karax/prelude include karax/prelude
import karax/[kdom]
import jester/[patterns] import jester/[patterns]
import threadlist, postlist, header, profile, newthread, error, about import threadlist, postlist, header, profile, newthread, error, about
import categorylist
import resetpassword, activateemail, search import resetpassword, activateemail, search
import karaxutils import karaxutils
@ -51,7 +49,7 @@ proc onPopState(event: dom.Event) =
# This event is usually only called when the user moves back in their # 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 # 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. # always updated. This should be moved into Karax in the future.
echo "New URL: ", window.location.href, " ", state.url.href kout(kstring"New URL: ", window.location.href, " ", state.url.href)
document.title = state.originalTitle document.title = state.originalTitle
if state.url.href != window.location.href: if state.url.href != window.location.href:
state = newState() # Reload the state to remove stale data. state = newState() # Reload the state to remove stale data.
@ -83,17 +81,9 @@ proc render(): VNode =
result = buildHtml(tdiv()): result = buildHtml(tdiv()):
renderHeader() renderHeader()
route([ route([
r("/categories",
(params: Params) =>
(renderCategoryList(getLoggedInUser()))
),
r("/c/@id",
(params: Params) =>
(renderThreadList(getLoggedInUser(), some(params["id"].parseInt)))
),
r("/newthread", r("/newthread",
(params: Params) => (params: Params) =>
(render(state.newThread, getLoggedInUser())) (render(state.newThread))
), ),
r("/profile/@username", r("/profile/@username",
(params: Params) => (params: Params) =>
@ -159,4 +149,4 @@ proc render(): VNode =
]) ])
window.onPopState = onPopState window.onPopState = onPopState
setRenderer render setRenderer render

View file

@ -1,13 +1,12 @@
import options, httpcore import options, times, httpcore, json, sugar
import user import threadlist, user
type type
UserStatus* = object UserStatus* = object
user*: Option[User] user*: Option[User]
recaptchaSiteKey*: Option[string] recaptchaSiteKey*: Option[string]
when defined(js): when defined(js):
import times, json, sugar
include karax/prelude include karax/prelude
import karax / [kajax, kdom] import karax / [kajax, kdom]
@ -32,7 +31,7 @@ when defined(js):
var var
state = newState() state = newState()
proc getStatus(logout=false) proc getStatus(logout: bool=false)
proc newState(): State = proc newState(): State =
State( State(
data: none[UserStatus](), data: none[UserStatus](),
@ -61,10 +60,10 @@ when defined(js):
state.lastUpdate = getTime() state.lastUpdate = getTime()
proc getStatus(logout=false) = proc getStatus(logout: bool=false) =
if state.loading: return if state.loading: return
let diff = getTime() - state.lastUpdate let diff = getTime() - state.lastUpdate
if diff.inMinutes < 5: if diff.minutes < 5:
return return
state.loading = true state.loading = true
@ -96,8 +95,8 @@ when defined(js):
section(class="navbar-section"): section(class="navbar-section"):
tdiv(class="input-group input-inline"): tdiv(class="input-group input-inline"):
input(class="search-input input-sm", input(class="search-input input-sm",
`type`="search", placeholder="Search", `type`="text", placeholder="search",
id="search-box", required="required", id="search-box",
onKeyDown=onKeyDown) onKeyDown=onKeyDown)
if state.loading: if state.loading:
tdiv(class="loading") tdiv(class="loading")

View file

@ -1,15 +1,4 @@
import strutils, strformat, parseutils, tables import strutils, options, strformat, parseutils, tables
proc limit*(str: string, n: int): string =
## Limit the number of characters in a string. Ends with a elipsis
if str.len > n:
return str[0..<n-3] & "..."
else:
return str
proc slug*(name: string): string =
## Transforms text into a url slug
name.strip().replace(" ", "-").toLowerAscii
proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} = proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} =
## parses `s` into an integer in the range `validRange`. If successful, ## parses `s` into an integer in the range `validRange`. If successful,
@ -36,7 +25,7 @@ proc getInt64*(s: string, default = 0): int64 =
when defined(js): when defined(js):
include karax/prelude include karax/prelude
import karax / [kdom, kajax] import karax / [kdom]
from dom import nil from dom import nil
@ -98,10 +87,16 @@ when defined(js):
navigateTo(url) navigateTo(url)
type
FormData* = ref object
proc newFormData*(): FormData
{.importcpp: "new FormData()", constructor.}
proc newFormData*(form: dom.Element): FormData proc newFormData*(form: dom.Element): FormData
{.importcpp: "new FormData(@)", constructor.} {.importcpp: "new FormData(@)", constructor.}
proc get*(form: FormData, key: cstring): cstring proc get*(form: FormData, key: cstring): cstring
{.importcpp: "#.get(@)".} {.importcpp: "#.get(@)".}
proc append*(form: FormData, key, val: cstring)
{.importcpp: "#.append(@)".}
proc renderProfileUrl*(username: string): string = proc renderProfileUrl*(username: string): string =
makeUri(fmt"/profile/{username}") makeUri(fmt"/profile/{username}")
@ -125,4 +120,4 @@ when defined(js):
inc(i) # Skip = inc(i) # Skip =
i += query.parseUntil(val, '&', i) i += query.parseUntil(val, '&', i)
inc(i) # Skip & inc(i) # Skip &
result[$decodeUri(key)] = $decodeUri(val) result[$decodeUri(key)] = $decodeUri(val)

View file

@ -1,7 +1,6 @@
when defined(js): when defined(js):
import sugar, httpcore, options, json import sugar, httpcore, options, json
import dom except Event, KeyboardEvent import dom except Event
import jsffi except `&`
include karax/prelude include karax/prelude
import karax / [kajax, kdom] import karax / [kajax, kdom]
@ -31,7 +30,7 @@ when defined(js):
let form = dom.document.getElementById("login-form") let form = dom.document.getElementById("login-form")
# TODO: This is a hack, karax should support this. # TODO: This is a hack, karax should support this.
let formData = newFormData(form) let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => onLogInPost(s, r, state)) (s: int, r: kstring) => onLogInPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: LoginModal) = proc onClose(ev: Event, n: VNode, state: LoginModal) =
@ -94,4 +93,4 @@ when defined(js):
(state.onSignUp(); state.shown = false)): (state.onSignUp(); state.shown = false)):
text "Create account" text "Create account"
render(state.resetPasswordModal, recaptchaSiteKey) render(state.resetPasswordModal, recaptchaSiteKey)

View file

@ -1,58 +0,0 @@
import options
import user
when defined(js):
include karax/prelude
import karax / [kdom]
import karaxutils, user, categorypicker, category
let buttons = [
(name: "Latest", url: makeUri("/"), id: "latest-btn"),
(name: "Categories", url: makeUri("/categories"), id: "categories-btn"),
]
proc onSelectedCategoryChanged(oldCategory: Category, newCategory: Category) =
let uri = makeUri("/c/" & $newCategory.id)
navigateTo(uri)
type
MainButtons* = ref object
categoryPicker: CategoryPicker
onCategoryChange*: CategoryChangeEvent
proc newMainButtons*(onCategoryChange: CategoryChangeEvent = onSelectedCategoryChanged): MainButtons =
new result
result.onCategoryChange = onCategoryChange
result.categoryPicker = newCategoryPicker(
onCategoryChange = proc (oldCategory, newCategory: Category) =
onSelectedCategoryChanged(oldCategory, newCategory)
result.onCategoryChange(oldCategory, newCategory)
)
proc render*(state: MainButtons, currentUser: Option[User], categoryId = none(int)): 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" ]#
if categoryId.isSome:
state.categoryPicker.selectedCategoryID = categoryId.get()
render(state.categoryPicker, currentUser, compact=false)
for btn in buttons:
let active = btn.url == window.location.href
a(id=btn.id, href=btn.url):
button(class=class({"btn-primary": active, "btn-link": not active}, "btn")):
text btn.name
section(class="navbar-section"):
if currentUser.isSome():
a(id="new-thread-btn", href=makeUri("/newthread"), onClick=anchorCB):
button(class="btn btn-secondary"):
italic(class="fas fa-plus")
text " New Thread"

View file

@ -1,13 +1,12 @@
when defined(js): when defined(js):
import sugar, httpcore, options, json import sugar, httpcore, options, json
import dom except Event import dom except Event
import jsffi except `&`
include karax/prelude include karax/prelude
import karax / [kajax, kdom] import karax / [kajax, kdom]
import error, replybox, threadlist, post, user import error, replybox, threadlist, post
import karaxutils, categorypicker import karaxutils
type type
NewThread* = ref object NewThread* = ref object
@ -15,13 +14,11 @@ when defined(js):
error: Option[PostError] error: Option[PostError]
replyBox: ReplyBox replyBox: ReplyBox
subject: kstring subject: kstring
categoryPicker: CategoryPicker
proc newNewThread*(): NewThread = proc newNewThread*(): NewThread =
NewThread( NewThread(
replyBox: newReplyBox(nil), replyBox: newReplyBox(nil),
subject: "", subject: ""
categoryPicker: newCategoryPicker()
) )
proc onSubjectChange(e: Event, n: VNode, state: NewThread) = proc onSubjectChange(e: Event, n: VNode, state: NewThread) =
@ -40,16 +37,12 @@ when defined(js):
let uri = makeUri("newthread") let uri = makeUri("newthread")
# TODO: This is a hack, karax should support this. # TODO: This is a hack, karax should support this.
let formData = newFormData() let formData = newFormData()
let categoryID = state.categoryPicker.selectedCategoryID
formData.append("subject", state.subject) formData.append("subject", state.subject)
formData.append("msg", state.replyBox.getText()) formData.append("msg", state.replyBox.getText())
formData.append("categoryId", $categoryID) ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCreatePost(s, r, state)) (s: int, r: kstring) => onCreatePost(s, r, state))
proc render*(state: NewThread, currentUser: Option[User]): VNode = proc render*(state: NewThread): VNode =
result = buildHtml(): result = buildHtml():
section(class="container grid-xl"): section(class="container grid-xl"):
tdiv(id="new-thread"): tdiv(id="new-thread"):
@ -62,10 +55,6 @@ when defined(js):
if state.error.isSome(): if state.error.isSome():
p(class="text-error"): p(class="text-error"):
text state.error.get().message text state.error.get().message
tdiv():
label(class="d-inline-block form-label"):
text "Category"
render(state.categoryPicker, currentUser, compact=false)
renderContent(state.replyBox, none[Thread](), none[Post]()) renderContent(state.replyBox, none[Thread](), none[Post]())
tdiv(class="footer"): tdiv(class="footer"):

View file

@ -1,6 +1,6 @@
import options import strformat, options
import user import user, threadlist
type type
PostInfo* = object PostInfo* = object
@ -58,10 +58,10 @@ type
email*: Option[string] email*: Option[string]
when defined(js): when defined(js):
import karaxutils, threadlist import karaxutils
proc renderPostUrl*(post: Post, thread: Thread): string = proc renderPostUrl*(post: Post, thread: Thread): string =
renderPostUrl(thread.id, post.id) renderPostUrl(thread.id, post.id)
proc renderPostUrl*(link: PostLink): string = proc renderPostUrl*(link: PostLink): string =
renderPostUrl(link.threadId, link.postId) renderPostUrl(link.threadId, link.postId)

View file

@ -7,7 +7,6 @@ import options, httpcore, json, sugar, sequtils, strutils
when defined(js): when defined(js):
include karax/prelude include karax/prelude
import karax/[kajax, kdom] import karax/[kajax, kdom]
import jsffi except `&`
import error, karaxutils, post, user, threadlist import error, karaxutils, post, user, threadlist
@ -117,7 +116,7 @@ when defined(js):
makeUri("/unlike") makeUri("/unlike")
else: else:
makeUri("/like") makeUri("/like")
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => (s: int, r: kstring) =>
onPost(s, r, state, post, currentUser.get())) onPost(s, r, state, post, currentUser.get()))
@ -173,7 +172,7 @@ when defined(js):
makeUri("/unlock") makeUri("/unlock")
else: else:
makeUri("/lock") makeUri("/lock")
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => (s: int, r: kstring) =>
onPost(s, r, state, thread)) onPost(s, r, state, thread))
@ -190,7 +189,7 @@ when defined(js):
else: "" else: ""
result = buildHtml(): result = buildHtml():
button(class="btn btn-secondary", id="lock-btn", button(class="btn btn-secondary",
onClick=(e: Event, n: VNode) => onClick=(e: Event, n: VNode) =>
onLockClick(e, n, state, thread), onLockClick(e, n, state, thread),
"data-tooltip"=tooltip, "data-tooltip"=tooltip,
@ -201,61 +200,4 @@ when defined(js):
text " Unlock Thread" text " Unlock Thread"
else: else:
italic(class="fas fa-lock") italic(class="fas fa-lock")
text " Lock Thread" text " Lock Thread"
type
PinButton* = ref object
error: Option[PostError]
loading: bool
proc newPinButton*(): PinButton =
PinButton()
proc onPost(httpStatus: int, response: kstring, state: PinButton,
thread: var Thread) =
postFinished:
thread.isPinned = not thread.isPinned
proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) =
if state.loading: return
state.loading = true
state.error = none[PostError]()
# Same as LockButton so the following is still a hack and karax should support this.
var formData = newFormData()
formData.append("id", $thread.id)
let uri =
if thread.isPinned:
makeUri("/unpin")
else:
makeUri("/pin")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPost(s, r, state, thread))
ev.preventDefault()
proc render*(state: PinButton, thread: var Thread,
currentUser: Option[User]): VNode =
if currentUser.isNone() or
currentUser.get().rank < Moderator:
return buildHtml(tdiv())
let tooltip =
if state.error.isSome(): state.error.get().message
else: ""
result = buildHtml():
button(class="btn btn-secondary", id="pin-btn",
onClick=(e: Event, n: VNode) =>
onPinClick(e, n, state, thread),
"data-tooltip"=tooltip,
onmouseleave=(e: Event, n: VNode) =>
(state.error = none[PostError]())):
if thread.isPinned:
italic(class="fas fa-thumbtack")
text " Unpin Thread"
else:
italic(class="fas fa-thumbtack")
text " Pin Thread"

View file

@ -1,6 +1,6 @@
import system except Thread import system except Thread
import options, json, times, httpcore, sugar, strutils import options, json, times, httpcore, strformat, sugar, math, strutils
import sequtils import sequtils
import threadlist, category, post, user import threadlist, category, post, user
@ -15,20 +15,17 @@ type
when defined(js): when defined(js):
from dom import document from dom import document
import jsffi except `&`
include karax/prelude include karax/prelude
import karax / [kajax, kdom] import karax / [vstyles, kajax, kdom]
import karaxutils, error, replybox, editbox, postbutton, delete import karaxutils, error, replybox, editbox, postbutton, delete
import categorypicker
type type
State = ref object State = ref object
list: Option[PostList] list: Option[PostList]
loading: bool loading: bool
status: HttpCode status: HttpCode
error: Option[PostError]
replyingTo: Option[Post] replyingTo: Option[Post]
replyBox: ReplyBox replyBox: ReplyBox
editing: Option[Post] ## If in edit mode, this contains the post. editing: Option[Post] ## If in edit mode, this contains the post.
@ -36,11 +33,8 @@ when defined(js):
likeButton: LikeButton likeButton: LikeButton
deleteModal: DeleteModal deleteModal: DeleteModal
lockButton: LockButton lockButton: LockButton
pinButton: PinButton
categoryPicker: CategoryPicker
proc onReplyPosted(id: int) proc onReplyPosted(id: int)
proc onCategoryChanged(oldCategory: Category, newCategory: Category)
proc onEditPosted(id: int, content: string, subject: Option[string]) proc onEditPosted(id: int, content: string, subject: Option[string])
proc onEditCancelled() proc onEditCancelled()
proc onDeletePost(post: Post) proc onDeletePost(post: Post)
@ -50,38 +44,17 @@ when defined(js):
list: none[PostList](), list: none[PostList](),
loading: false, loading: false,
status: Http200, status: Http200,
error: none[PostError](),
replyingTo: none[Post](), replyingTo: none[Post](),
replyBox: newReplyBox(onReplyPosted), replyBox: newReplyBox(onReplyPosted),
editBox: newEditBox(onEditPosted, onEditCancelled), editBox: newEditBox(onEditPosted, onEditCancelled),
likeButton: newLikeButton(), likeButton: newLikeButton(),
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil), deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
lockButton: newLockButton(), lockButton: newLockButton()
pinButton: newPinButton(),
categoryPicker: newCategoryPicker(onCategoryChanged)
) )
var var
state = newState() state = newState()
proc onCategoryPost(httpStatus: int, response: kstring, state: State) =
state.loading = false
postFinished:
discard
# TODO: show success message
proc onCategoryChanged(oldCategory: Category, newCategory: Category) =
let uri = makeUri("/updateThread")
let formData = newFormData()
formData.append("threadId", $state.list.get().thread.id)
formData.append("category", $newCategory.id)
state.loading = true
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCategoryPost(s, r, state))
proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) = proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) =
state.loading = false state.loading = false
state.status = httpStatus.HttpCode state.status = httpStatus.HttpCode
@ -93,7 +66,6 @@ when defined(js):
state.list = some(list) state.list = some(list)
dom.document.title = list.thread.topic & " - " & dom.document.title dom.document.title = list.thread.topic & " - " & dom.document.title
state.categoryPicker.select(list.thread.category.id)
# The anchor should be jumped to once all the posts have been loaded. # The anchor should be jumped to once all the posts have been loaded.
if postId.isSome(): if postId.isSome():
@ -207,20 +179,6 @@ when defined(js):
span(class="more-post-count"): span(class="more-post-count"):
text "(" & $post.moreBefore.len & ")" text "(" & $post.moreBefore.len & ")"
proc genCategories(thread: Thread, currentUser: Option[User]): VNode =
let loggedIn = currentUser.isSome()
let authoredByUser =
loggedIn and currentUser.get().name == thread.author.name
let canChangeCategory =
loggedIn and currentUser.get().rank in {Admin, Moderator}
result = buildHtml():
tdiv():
if authoredByUser or canChangeCategory:
render(state.categoryPicker, currentUser, compact=false)
else:
render(thread.category)
proc genPostButtons(post: Post, currentUser: Option[User]): Vnode = proc genPostButtons(post: Post, currentUser: Option[User]): Vnode =
let loggedIn = currentUser.isSome() let loggedIn = currentUser.isSome()
let authoredByUser = let authoredByUser =
@ -328,12 +286,12 @@ when defined(js):
] ]
var diffStr = tmpl[0] var diffStr = tmpl[0]
let diff = latestTime - prevPost.info.creation.fromUnix() let diff = latestTime - prevPost.info.creation.fromUnix()
if diff.inWeeks > 48: if diff.weeks > 48:
let years = diff.inWeeks div 48 let years = diff.weeks div 48
diffStr = diffStr =
(if years == 1: tmpl[1] else: tmpl[2]) % $years (if years == 1: tmpl[1] else: tmpl[2]) % $years
elif diff.inWeeks > 4: elif diff.weeks > 4:
let months = diff.inWeeks div 4 let months = diff.weeks div 4
diffStr = diffStr =
(if months == 1: tmpl[3] else: tmpl[4]) % $months (if months == 1: tmpl[3] else: tmpl[4]) % $months
else: else:
@ -372,10 +330,7 @@ when defined(js):
result = buildHtml(): result = buildHtml():
section(class="container grid-xl"): section(class="container grid-xl"):
tdiv(id="thread-title", class="title"): tdiv(id="thread-title", class="title"):
if state.error.isSome(): p(): text list.thread.topic
span(class="text-error"):
text state.error.get().message
p(class="title-text"): text list.thread.topic
if list.thread.isLocked: if list.thread.isLocked:
italic(class="fas fa-lock fa-xs", italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to") title="Thread cannot be replied to")
@ -388,7 +343,7 @@ when defined(js):
italic(class="fas fa-check-square fa-xs", italic(class="fas fa-check-square fa-xs",
title="Thread has a solution") title="Thread has a solution")
text "Solved" text "Solved"
genCategories(list.thread, currentUser) render(list.thread.category)
tdiv(class="posts"): tdiv(class="posts"):
var prevPost: Option[Post] = none[Post]() var prevPost: Option[Post] = none[Post]()
for i, post in list.posts: for i, post in list.posts:
@ -413,7 +368,6 @@ when defined(js):
text " Reply" text " Reply"
render(state.lockButton, list.thread, currentUser) render(state.lockButton, list.thread, currentUser)
render(state.pinButton, list.thread, currentUser)
render(state.replyBox, list.thread, state.replyingTo, false) render(state.replyBox, list.thread, state.replyingTo, false)

View file

@ -1,12 +1,12 @@
import options, httpcore, json, sugar, times, strutils import options, httpcore, json, sugar, times, strformat, strutils
import threadlist, post, error, user import threadlist, post, category, error, user
when defined(js): when defined(js):
from dom import document from dom import document
include karax/prelude include karax/prelude
import karax/[kajax, kdom] import karax/[kajax, kdom]
import karaxutils, profilesettings import karaxutils, postbutton, delete, profilesettings
type type
ProfileTab* = enum ProfileTab* = enum

View file

@ -1,11 +1,10 @@
when defined(js): when defined(js):
import httpcore, options, sugar, json, strutils, strformat import httpcore, options, sugar, json, strutils, strformat
import jsffi except `&`
include karax/prelude include karax/prelude
import karax/[kajax, kdom] import karax/[kajax, kdom]
import post, karaxutils, postbutton, error, delete, user import replybox, post, karaxutils, postbutton, error, delete, user
type type
ProfileSettings* = ref object ProfileSettings* = ref object
@ -69,7 +68,7 @@ when defined(js):
formData.append("rank", $state.rank) formData.append("rank", $state.rank)
formData.append("username", $state.profile.user.name) formData.append("username", $state.profile.user.name)
let uri = makeUri("/saveProfile") let uri = makeUri("/saveProfile")
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => onProfilePost(s, r, state)) (s: int, r: kstring) => onProfilePost(s, r, state))
proc needsSave(state: ProfileSettings): bool = proc needsSave(state: ProfileSettings): bool =

View file

@ -1,6 +1,5 @@
when defined(js): when defined(js):
import strformat, options, httpcore, json, sugar import strformat, options, httpcore, json, sugar
import jsffi except `&`
from dom import getElementById, scrollIntoView, setTimeout from dom import getElementById, scrollIntoView, setTimeout
@ -27,7 +26,7 @@ when defined(js):
proc performScroll() = proc performScroll() =
let replyBox = dom.document.getElementById("reply-box") let replyBox = dom.document.getElementById("reply-box")
replyBox.scrollIntoView() replyBox.scrollIntoView(false)
proc show*(state: ReplyBox) = proc show*(state: ReplyBox) =
# Scroll to the reply box. # Scroll to the reply box.
@ -45,7 +44,7 @@ when defined(js):
proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) = proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) =
postFinished: postFinished:
echo response kout(response)
state.rendering = some[kstring](response) state.rendering = some[kstring](response)
proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) = proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) =
@ -57,7 +56,7 @@ when defined(js):
let formData = newFormData() let formData = newFormData()
formData.append("msg", state.text) formData.append("msg", state.text)
let uri = makeUri("/preview") let uri = makeUri("/preview")
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => onPreviewPost(s, r, state)) (s: int, r: kstring) => onPreviewPost(s, r, state))
proc onMessageClick(e: Event, n: VNode, state: ReplyBox) = proc onMessageClick(e: Event, n: VNode, state: ReplyBox) =
@ -81,7 +80,7 @@ when defined(js):
if replyingTo.isSome: if replyingTo.isSome:
formData.append("replyingTo", $replyingTo.get().id) formData.append("replyingTo", $replyingTo.get().id)
let uri = makeUri("/createPost") let uri = makeUri("/createPost")
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => onReplyPost(s, r, state)) (s: int, r: kstring) => onReplyPost(s, r, state))
proc onCancelClick(e: Event, n: VNode, state: ReplyBox) = proc onCancelClick(e: Event, n: VNode, state: ReplyBox) =

View file

@ -1,12 +1,11 @@
when defined(js): when defined(js):
import sugar, httpcore, options, json import sugar, httpcore, options, json
import dom except Event, KeyboardEvent import dom except Event
import jsffi except `&`
include karax/prelude include karax/prelude
import karax / [kajax, kdom] import karax / [kajax, kdom]
import error import error, replybox, threadlist, post
import karaxutils import karaxutils
type type
@ -87,7 +86,7 @@ when defined(js):
let form = dom.document.getElementById("resetpassword-form") let form = dom.document.getElementById("resetpassword-form")
# TODO: This is a hack, karax should support this. # TODO: This is a hack, karax should support this.
let formData = newFormData(form) let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => onPost(s, r, state)) (s: int, r: kstring) => onPost(s, r, state))
ev.preventDefault() ev.preventDefault()
@ -153,4 +152,4 @@ when defined(js):
), ),
`type`="button", `type`="button",
onClick=(ev: Event, n: VNode) => onClick(ev, n, state)): onClick=(ev: Event, n: VNode) => onClick(ev, n, state)):
text "Reset password" text "Reset password"

View file

@ -20,7 +20,7 @@ when defined(js):
from dom import nil from dom import nil
include karax/prelude include karax/prelude
import karax / [kajax] import karax / [vstyles, kajax, kdom]
import karaxutils, error, threadlist, sugar import karaxutils, error, threadlist, sugar

View file

@ -1,7 +1,6 @@
when defined(js): when defined(js):
import sugar, httpcore, options, json import sugar, httpcore, options, json
import dom except Event import dom except Event
import jsffi except `&`
include karax/prelude include karax/prelude
import karax / [kajax, kdom] import karax / [kajax, kdom]
@ -29,7 +28,7 @@ when defined(js):
let form = dom.document.getElementById("signup-form") let form = dom.document.getElementById("signup-form")
# TODO: This is a hack, karax should support this. # TODO: This is a hack, karax should support this.
let formData = newFormData(form) let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring), ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => onSignUpPost(s, r, state)) (s: int, r: kstring) => onSignUpPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: SignupModal) = proc onClose(ev: Event, n: VNode, state: SignupModal) =

View file

@ -15,7 +15,6 @@ type
creation*: int64 ## Unix timestamp creation*: int64 ## Unix timestamp
isLocked*: bool isLocked*: bool
isSolved*: bool isSolved*: bool
isPinned*: bool
ThreadList* = ref object ThreadList* = ref object
threads*: seq[Thread] threads*: seq[Thread]
@ -27,34 +26,26 @@ proc isModerated*(thread: Thread): bool =
thread.author.rank <= Moderated thread.author.rank <= Moderated
when defined(js): when defined(js):
import sugar
include karax/prelude include karax/prelude
import karax / [vstyles, kajax, kdom] import karax / [vstyles, kajax, kdom]
import karaxutils, error, user, mainbuttons import karaxutils, error, user
type type
State = ref object State = ref object
list: Option[ThreadList] list: Option[ThreadList]
refreshList: bool
loading: bool loading: bool
status: HttpCode status: HttpCode
mainButtons: MainButtons
var state: State
proc newState(): State = proc newState(): State =
State( State(
list: none[ThreadList](), list: none[ThreadList](),
loading: false, loading: false,
status: Http200, status: Http200
mainButtons: newMainButtons(
onCategoryChange =
(oldCategory: Category, newCategory: Category) => (state.list = none[ThreadList]())
)
) )
state = newState() var
state = newState()
proc visibleTo*[T](thread: T, user: Option[User]): bool = proc visibleTo*[T](thread: T, user: Option[User]): bool =
## Determines whether the specified thread (or post) should be ## Determines whether the specified thread (or post) should be
@ -69,13 +60,34 @@ when defined(js):
if user.isNone(): return not thread.isModerated if user.isNone(): return not thread.isModerated
let rank = user.get().rank let rank = user.get().rank
if rank < Rank.Moderator and thread.isModerated: if rank < Moderator and thread.isModerated:
return thread.author == user.get() return thread.author == user.get()
return true return true
proc genTopButtons(currentUser: Option[User]): 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"):
if currentUser.isSome():
a(id="new-thread-btn", href=makeUri("/newthread"), onClick=anchorCB):
button(class="btn btn-secondary"):
italic(class="fas fa-plus")
text " New Thread"
proc genUserAvatars(users: seq[User]): VNode = proc genUserAvatars(users: seq[User]): VNode =
result = buildHtml(td(class="thread-users")): result = buildHtml(td):
for user in users: for user in users:
render(user, "avatar avatar-sm", showStatus=true) render(user, "avatar avatar-sm", showStatus=true)
text " " text " "
@ -86,29 +98,26 @@ when defined(js):
let duration = currentTime - activityTime let duration = currentTime - activityTime
if currentTime.local().year != activityTime.local().year: if currentTime.local().year != activityTime.local().year:
return activityTime.local().format("MMM yyyy") return activityTime.local().format("MMM yyyy")
elif duration.inDays > 30 and duration.inDays < 300: elif duration.days > 30 and duration.days < 300:
return activityTime.local().format("MMM dd") return activityTime.local().format("MMM dd")
elif duration.inDays != 0: elif duration.days != 0:
return $duration.inDays & "d" return $duration.days & "d"
elif duration.inHours != 0: elif duration.hours != 0:
return $duration.inHours & "h" return $duration.hours & "h"
elif duration.inMinutes != 0: elif duration.minutes != 0:
return $duration.inMinutes & "m" return $duration.minutes & "m"
else: else:
return $duration.inSeconds & "s" return $duration.seconds & "s"
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode = proc genThread(thread: Thread, isNew: bool, noBorder: bool): VNode =
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2 let isOld = (getTime() - thread.creation.fromUnix).weeks > 2
let isBanned = thread.author.rank.isBanned() let isBanned = thread.author.rank.isBanned()
result = buildHtml(): result = buildHtml():
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})): tr(class=class({"no-border": noBorder, "banned": isBanned})):
td(class="thread-title"): td(class="thread-title"):
if thread.isLocked: if thread.isLocked:
italic(class="fas fa-lock fa-xs", italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to") title="Thread cannot be replied to")
if thread.isPinned:
italic(class="fas fa-thumbtack fa-xs",
title="Pinned post")
if isBanned: if isBanned:
italic(class="fas fa-ban fa-xs", italic(class="fas fa-ban fa-xs",
title="Thread author is banned") title="Thread author is banned")
@ -118,16 +127,14 @@ when defined(js):
if thread.isSolved: if thread.isSolved:
italic(class="fas fa-check-square fa-xs", italic(class="fas fa-check-square fa-xs",
title="Thread has a solution") title="Thread has a solution")
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB): a(href=makeUri("/t/" & $thread.id),
onClick=anchorCB):
text thread.topic text thread.topic
tdiv(class="show-sm" & class({"d-none": not displayCategory})): td():
render(thread.category)
td(class="hide-sm" & class({"d-none": not displayCategory})):
render(thread.category) render(thread.category)
genUserAvatars(thread.users) genUserAvatars(thread.users)
td(class="thread-replies"): text $thread.replies td(): text $thread.replies
td(class="hide-sm" & class({ td(class=class({
"views-text": thread.views < 999, "views-text": thread.views < 999,
"popular-text": thread.views > 999 and thread.views < 5000, "popular-text": thread.views > 999 and thread.views < 5000,
"super-popular-text": thread.views > 5000 "super-popular-text": thread.views > 5000
@ -161,13 +168,10 @@ when defined(js):
else: else:
state.list = some(list) state.list = some(list)
proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) = proc onLoadMore(ev: Event, n: VNode) =
state.loading = true state.loading = true
let start = state.list.get().threads.len let start = state.list.get().threads.len
if categoryId.isSome: ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
ajaxGet(makeUri("threads.json?start=" & $start & "&categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
proc getInfo( proc getInfo(
list: seq[Thread], i: int, currentUser: Option[User] list: seq[Thread], i: int, currentUser: Option[User]
@ -192,34 +196,29 @@ when defined(js):
isNew: thread.creation > previousVisitAt isNew: thread.creation > previousVisitAt
) )
proc genThreadList(currentUser: Option[User], categoryId: Option[int]): VNode = proc genThreadList(currentUser: Option[User]): VNode =
if state.status != Http200: if state.status != Http200:
return renderError("Couldn't retrieve threads.", state.status) return renderError("Couldn't retrieve threads.", state.status)
if state.list.isNone: if state.list.isNone:
if not state.loading: if not state.loading:
state.loading = true state.loading = true
if categoryId.isSome: ajaxGet(makeUri("threads.json"), @[], onThreadList)
ajaxGet(makeUri("threads.json?categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json"), @[], onThreadList)
return buildHtml(tdiv(class="loading loading-lg")) return buildHtml(tdiv(class="loading loading-lg"))
let displayCategory = categoryId.isNone
let list = state.list.get() let list = state.list.get()
result = buildHtml(): result = buildHtml():
section(class="thread-list"): section(class="container grid-xl"): # TODO: Rename to `.thread-list`.
table(class="table", id="threads-list"): table(class="table", id="threads-list"):
thead(): thead():
tr: tr:
th(text "Topic") th(text "Topic")
th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category" th(text "Category")
th(class="thread-users"): text "Users" th(style=style((StyleAttr.width, kstring"8rem"))): text "Users"
th(class="centered-header"): text "Replies" th(text "Replies")
th(class="hide-sm centered-header"): text "Views" th(text "Views")
th(class="centered-header"): text "Activity" th(text "Activity")
tbody(): tbody():
for i in 0 ..< list.threads.len: for i in 0 ..< list.threads.len:
let thread = list.threads[i] let thread = list.threads[i]
@ -227,9 +226,8 @@ when defined(js):
let isLastThread = i+1 == list.threads.len let isLastThread = i+1 == list.threads.len
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser) let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
genThread(i+1, thread, isNew, genThread(thread, isNew,
noBorder=isLastUnseen or isLastThread, noBorder=isLastUnseen or isLastThread)
displayCategory=displayCategory)
if isLastUnseen and (not isLastThread): if isLastUnseen and (not isLastThread):
tr(class="last-visit-separator"): tr(class="last-visit-separator"):
td(colspan="6"): td(colspan="6"):
@ -241,11 +239,10 @@ when defined(js):
td(colspan="6"): td(colspan="6"):
tdiv(class="loading loading-lg") tdiv(class="loading loading-lg")
else: else:
td(colspan="6", td(colspan="6", onClick=onLoadMore):
onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))):
span(text "load more threads") span(text "load more threads")
proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode = proc renderThreadList*(currentUser: Option[User]): VNode =
result = buildHtml(tdiv): result = buildHtml(tdiv):
state.mainButtons.render(currentUser, categoryId=categoryId) genTopButtons(currentUser)
genThreadList(currentUser, categoryId) genThreadList(currentUser)

View file

@ -1,4 +1,4 @@
import times, options import times
type type
# If you add more "Banned" states, be sure to modify forum's threadsQuery too. # If you add more "Banned" states, be sure to modify forum's threadsQuery too.
@ -17,7 +17,6 @@ type
Admin ## Admin: can do everything Admin ## Admin: can do everything
User* = object User* = object
id*: string
name*: string name*: string
avatarUrl*: string avatarUrl*: string
lastOnline*: int64 lastOnline*: int64
@ -28,9 +27,6 @@ type
proc isOnline*(user: User): bool = proc isOnline*(user: User): bool =
return getTime().toUnix() - user.lastOnline < (60*5) return getTime().toUnix() - user.lastOnline < (60*5)
proc isAdmin*(user: Option[User]): bool =
return user.isSome and user.get().rank == Admin
proc `==`*(u1, u2: User): bool = proc `==`*(u1, u2: User): bool =
u1.name == u2.name u1.name == u2.name
@ -76,4 +72,4 @@ when defined(js):
title="User is a moderator") title="User is a moderator")
of Admin: of Admin:
italic(class="fas fa-chess-knight", italic(class="fas fa-chess-knight",
title="User is an admin") title="User is an admin")

View file

@ -7,7 +7,6 @@ SELECT
post_id, post_id,
post_content, post_content,
cdate, cdate,
person.id,
person.name AS author, person.name AS author,
person.email AS email, person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline, strftime('%s', person.lastOnline) AS lastOnline,
@ -47,7 +46,6 @@ SELECT
THEN snippet(post_fts, '**', '**', '...', what, -45) THEN snippet(post_fts, '**', '**', '...', what, -45)
ELSE SUBSTR(post_fts.content, 1, 200) END AS content, ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
cdate, cdate,
person.id,
person.name AS author, person.name AS author,
person.email AS email, person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline, strftime('%s', person.lastOnline) AS lastOnline,

View file

@ -66,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
db.exec(sql""" db.exec(sql"""
insert into category (id, name, description, color) insert into category (id, name, description, color)
values (0, 'Unsorted', 'No category has been chosen yet.', ''); values (0, 'Default', '', '');
""") """)
# -- Thread # -- Thread
@ -81,7 +81,6 @@ proc initialiseDb(admin: tuple[username, password, email: string],
isLocked boolean not null default 0, isLocked boolean not null default 0,
solution integer, solution integer,
isDeleted boolean not null default 0, isDeleted boolean not null default 0,
isPinned boolean not null default 0,
foreign key (category) references category(id), foreign key (category) references category(id),
foreign key (solution) references post(id) foreign key (solution) references post(id)
@ -116,7 +115,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
# Create default user. # Create default user.
db.createUser(admin, Admin) db.createUser(admin, Admin)
# Create some test data for development # Create test users if test or development
if isTest or isDev: if isTest or isDev:
for rank in Spammer..Moderator: for rank in Spammer..Moderator:
let rankLower = toLowerAscii($rank) let rankLower = toLowerAscii($rank)
@ -125,14 +124,6 @@ proc initialiseDb(admin: tuple[username, password, email: string],
email: $rankLower & "@localhost.local") email: $rankLower & "@localhost.local")
db.createUser(user, rank) db.createUser(user, rank)
db.exec(sql"""
insert into category (name, description, color)
values ('Libraries', 'Libraries and library development', '0198E1'),
('Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
('Fun', 'Posts that are just for fun', '00897B'),
('Potential Issues', 'Potential Nim compiler issues', 'E53935');
""")
# -- Post # -- Post
db.exec(sql""" db.exec(sql"""
@ -235,7 +226,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
proc initialiseConfig( proc initialiseConfig(
name, title, hostname: string, name, title, hostname: string,
recaptcha: tuple[siteKey, secretKey: string], recaptcha: tuple[siteKey, secretKey: string],
smtp: tuple[address, user, password, fromAddr: string, tls: bool], smtp: tuple[address, user, password: string],
isDev: bool, isDev: bool,
dbPath: string, dbPath: string,
ga: string="" ga: string=""
@ -251,8 +242,6 @@ proc initialiseConfig(
"smtpAddress": %smtp.address, "smtpAddress": %smtp.address,
"smtpUser": %smtp.user, "smtpUser": %smtp.user,
"smtpPassword": %smtp.password, "smtpPassword": %smtp.password,
"smtpFromAddr": %smtp.fromAddr,
"smtpTls": %smtp.tls,
"isDev": %isDev, "isDev": %isDev,
"dbPath": %dbPath "dbPath": %dbPath
} }
@ -284,7 +273,7 @@ These can be changed later in the generated forum.json file.
echo("") echo("")
echo("The following question are related to recaptcha. \nYou must set up a " & echo("The following question are related to recaptcha. \nYou must set up a " &
"recaptcha v2 for your forum before answering them. \nPlease do so now " & "recaptcha for your forum before answering them. \nPlease do so now " &
"and then answer these questions: https://www.google.com/recaptcha/admin") "and then answer these questions: https://www.google.com/recaptcha/admin")
let recaptchaSiteKey = question("Recaptcha site key: ") let recaptchaSiteKey = question("Recaptcha site key: ")
let recaptchaSecretKey = question("Recaptcha secret key: ") let recaptchaSecretKey = question("Recaptcha secret key: ")
@ -295,8 +284,6 @@ These can be changed later in the generated forum.json file.
let smtpAddress = question("SMTP address (eg: mail.hostname.com): ") let smtpAddress = question("SMTP address (eg: mail.hostname.com): ")
let smtpUser = question("SMTP user: ") let smtpUser = question("SMTP user: ")
let smtpPassword = readPasswordFromStdin("SMTP pass: ") let smtpPassword = readPasswordFromStdin("SMTP pass: ")
let smtpFromAddr = question("SMTP sending email address (eg: mail@mail.hostname.com): ")
let smtpTls = parseBool(question("Enable TLS for SMTP: "))
echo("The following is optional. You can specify your Google Analytics ID " & echo("The following is optional. You can specify your Google Analytics ID " &
"if you wish. Otherwise just leave it blank.") "if you wish. Otherwise just leave it blank.")
@ -306,7 +293,7 @@ These can be changed later in the generated forum.json file.
let dbPath = "nimforum.db" let dbPath = "nimforum.db"
initialiseConfig( initialiseConfig(
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey), name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false, (smtpAddress, smtpUser, smtpPassword), isDev=false,
dbPath, ga dbPath, ga
) )
@ -341,7 +328,7 @@ when isMainModule:
"Development Forum", "Development Forum",
"localhost", "localhost",
recaptcha=("", ""), recaptcha=("", ""),
smtp=("", "", "", "", false), smtp=("", "", ""),
isDev=true, isDev=true,
dbPath dbPath
) )
@ -358,7 +345,7 @@ when isMainModule:
"Test Forum", "Test Forum",
"localhost", "localhost",
recaptcha=("", ""), recaptcha=("", ""),
smtp=("", "", "", "", false), smtp=("", "", ""),
isDev=true, isDev=true,
dbPath dbPath
) )

View file

@ -1,24 +1,26 @@
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs, import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
htmlparser, streams, parseutils, options, logging htmlparser, streams, parseutils, options, logging
from times import getTime, utc, format from times import getTime, getGMTime, format
# Used to be: # Used to be:
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'} # {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
let let
UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this. UsernameIdent* = IdentChars # TODO: Double check that everyone follows this.
import frontend/[karaxutils, error] import frontend/[karaxutils, error]
export parseInt export parseInt
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 type
Config* = object Config* = object
smtpAddress*: string smtpAddress*: string
smtpPort*: int smtpPort*: int
smtpUser*: string smtpUser*: string
smtpPassword*: string smtpPassword*: string
smtpFromAddr*: string
smtpTls*: bool
smtpSsl*: bool
mlistAddress*: string mlistAddress*: string
recaptchaSecretKey*: string recaptchaSecretKey*: string
recaptchaSiteKey*: string recaptchaSiteKey*: string
@ -53,12 +55,9 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
smtpPassword: "", mlistAddress: "") smtpPassword: "", mlistAddress: "")
let root = parseFile(filename) let root = parseFile(filename)
result.smtpAddress = root{"smtpAddress"}.getStr("") result.smtpAddress = root{"smtpAddress"}.getStr("")
result.smtpPort = root{"smtpPort"}.getInt(25) result.smtpPort = root{"smtpPort"}.getNum(25).int
result.smtpUser = root{"smtpUser"}.getStr("") result.smtpUser = root{"smtpUser"}.getStr("")
result.smtpPassword = root{"smtpPassword"}.getStr("") result.smtpPassword = root{"smtpPassword"}.getStr("")
result.smtpFromAddr = root{"smtpFromAddr"}.getStr("")
result.smtpTls = root{"smtpTls"}.getBool(false)
result.smtpSsl = root{"smtpSsl"}.getBool(false)
result.mlistAddress = root{"mlistAddress"}.getStr("") result.mlistAddress = root{"mlistAddress"}.getStr("")
result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("") result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("")
result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("") result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("")
@ -68,7 +67,7 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
result.name = root["name"].getStr() result.name = root["name"].getStr()
result.title = root["title"].getStr() result.title = root["title"].getStr()
result.ga = root{"ga"}.getStr() result.ga = root{"ga"}.getStr()
result.port = root{"port"}.getInt(5000) result.port = root{"port"}.getNum(5000).int
proc processGT(n: XmlNode, tag: string): (int, XmlNode, string) = proc processGT(n: XmlNode, tag: string): (int, XmlNode, string) =
result = (0, newElement(tag), tag) result = (0, newElement(tag), tag)

View file

@ -23,7 +23,7 @@ const baseUrl = "http://localhost:" & $port & "/"
template withBackend(body: untyped): untyped = template withBackend(body: untyped): untyped =
## Starts a new backend instance. ## Starts a new backend instance.
spawn runProcess("nimble -y testbackend") spawn runProcess("nimble -y runbackend")
defer: defer:
discard execCmd("killall " & backend) discard execCmd("killall " & backend)
@ -43,11 +43,9 @@ template withBackend(body: untyped): untyped =
body body
import browsertests/[scenario1, threads, issue181, categories] import browsertests/[scenario1, threads, issue181]
proc main() = when isMainModule:
# Kill any already running instances
discard execCmd("killall geckodriver")
spawn runProcess("geckodriver -p 4444 --log config") spawn runProcess("geckodriver -p 4444 --log config")
defer: defer:
discard execCmd("killall geckodriver") discard execCmd("killall geckodriver")
@ -66,13 +64,9 @@ proc main() =
withBackend: withBackend:
scenario1.test(session, baseUrl) scenario1.test(session, baseUrl)
threads.test(session, baseUrl) threads.test(session, baseUrl)
categories.test(session, baseUrl)
issue181.test(session, baseUrl) issue181.test(session, baseUrl)
session.close() session.close()
except: except:
sleep(10000) # See if we can grab any more output. sleep(10000) # See if we can grab any more output.
raise raise
when isMainModule:
main()

View file

@ -1,2 +1 @@
--threads:on --threads:on
--path:"../src/frontend"

View file

@ -1,214 +0,0 @@
import unittest, common
import webdriver
import karaxutils
proc selectCategory(session: Session, name: string) =
with session:
click "#category-selection .dropdown-toggle"
click "#category-selection ." & name
proc createCategory(session: Session, baseUrl, name, color, description: string) =
with session:
navigate baseUrl
click "#categories-btn"
ensureExists "#add-category"
click "#add-category .plus-btn"
clear "#add-category input[name='name']"
clear "#add-category input[name='description']"
sendKeys "#add-category input[name='name']", name
setColor "#add-category input[name='color']", color
sendKeys "#add-category input[name='description']", description
click "#add-category #add-category-btn"
checkText "#category-" & name.slug(), name
proc categoriesUserTests(session: Session, baseUrl: string) =
let
title = "Category Test"
content = "Choosing category test"
suite "user tests":
with session:
navigate baseUrl
login "user", "user"
setup:
with session:
navigate baseUrl
test "no category add available":
with session:
click "#new-thread-btn"
checkIsNone "#add-category"
test "no category add available category page":
with session:
click "#categories-btn"
checkIsNone "#add-category"
test "can create category thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", title
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .category", "Fun"
navigate baseUrl
ensureExists title, LinkTextSelector
test "can create category thread and change category":
with session:
let newTitle = title & " Selection"
click "#new-thread-btn"
sendKeys "#thread-title", newTitle
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .category", "Fun"
selectCategory "announcements"
checkText "#thread-title .category", "Announcements"
# Make sure there is no error
checkIsNone "#thread-title .text-error"
navigate baseUrl
ensureExists newTitle, LinkTextSelector
test "can navigate to categories page":
with session:
click "#categories-btn"
ensureExists "#categories-list"
test "can view post under category":
with session:
# create a few threads
click "#new-thread-btn"
sendKeys "#thread-title", "Post 1"
selectCategory "fun"
sendKeys "#reply-textarea", "Post 1"
click "#create-thread-btn"
navigate baseUrl
click "#new-thread-btn"
sendKeys "#thread-title", "Post 2"
selectCategory "announcements"
sendKeys "#reply-textarea", "Post 2"
click "#create-thread-btn"
navigate baseUrl
click "#new-thread-btn"
sendKeys "#thread-title", "Post 3"
selectCategory "unsorted"
sendKeys "#reply-textarea", "Post 3"
click "#create-thread-btn"
navigate baseUrl
click "#categories-btn"
ensureExists "#categories-list"
click "#category-unsorted"
checkText "#threads-list .thread-title a", "Post 3"
for element in session.waitForElements("#threads-list .category-name"):
# Have to user "innerText" because elements are hidden on this page
assert element.getProperty("innerText") == "Unsorted"
selectCategory "announcements"
checkText "#threads-list .thread-title a", "Post 2"
for element in session.waitForElements("#threads-list .category-name"):
assert element.getProperty("innerText") == "Announcements"
selectCategory "fun"
checkText "#threads-list .thread-title a", "Post 1"
for element in session.waitForElements("#threads-list .category-name"):
assert element.getProperty("innerText") == "Fun"
session.logout()
proc categoriesAdminTests(session: Session, baseUrl: string) =
suite "admin tests":
with session:
navigate baseUrl
login "admin", "admin"
test "can create category via dropdown":
let
name = "Category Test"
color = "#720904"
description = "This is a description"
with session:
click "#new-thread-btn"
ensureExists "#add-category"
click "#add-category .plus-btn"
clear "#add-category input[name='name']"
clear "#add-category input[name='description']"
sendKeys "#add-category input[name='name']", name
setColor "#add-category input[name='color']", color
sendKeys "#add-category input[name='description']", description
click "#add-category #add-category-btn"
checkText "#category-selection .selected-category", name
test "can create category on category page":
let
name = "Category Test Page"
color = "#70B4D4"
description = "This is a description on category page"
with session:
createCategory baseUrl, name, color, description
test "category adding disabled on admin logout":
with session:
navigate(baseUrl & "c/0")
ensureExists "#add-category"
logout()
checkIsNone "#add-category"
navigate baseUrl
login "admin", "admin"
session.logout()
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
categoriesUserTests(session, baseUrl)
categoriesAdminTests(session, baseUrl)
session.navigate(baseUrl)

View file

@ -2,125 +2,82 @@ import os, options, unittest, strutils
import webdriver import webdriver
import macros import macros
const actionDelayMs {.intdefine.} = 0
## Inserts a delay in milliseconds between automated actions. Useful for debugging tests
macro with*(obj: typed, code: untyped): untyped = macro with*(obj: typed, code: untyped): untyped =
## Execute a set of statements with an object ## Execute a set of statements with an object
expectKind code, nnkStmtList expectKind code, nnkStmtList
result = code
template checkCompiles(res, default) =
when compiles(res):
res
else:
default
result = code.copy
# Simply inject obj into call # Simply inject obj into call
for i in 0 ..< result.len: for i in 0 ..< result.len:
if result[i].kind in {nnkCommand, nnkCall}: if result[i].kind in {nnkCommand, nnkCall}:
result[i].insert(1, obj) result[i].insert(1, obj)
result = getAst(checkCompiles(result, code)) template click*(session: Session, element: string, strategy=CssSelector) =
let el = session.findElement(element, strategy)
proc elementIsSome(element: Option[Element]): bool = check el.isSome()
return element.isSome
proc elementIsNone(element: Option[Element]): bool =
return element.isNone
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50,
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
proc click*(session: Session, element: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy)
el.get().click() el.get().click()
proc sendKeys*(session: Session, element, keys: string) = template sendKeys*(session: Session, element, keys: string) =
let el = session.waitForElement(element) let el = session.findElement(element)
check el.isSome()
el.get().sendKeys(keys) el.get().sendKeys(keys)
proc clear*(session: Session, element: string) = template clear*(session: Session, element: string) =
let el = session.waitForElement(element) let el = session.findElement(element)
check el.isSome()
el.get().clear() el.get().clear()
proc sendKeys*(session: Session, element: string, keys: varargs[Key]) = template sendKeys*(session: Session, element: string, keys: varargs[Key]) =
let el = session.waitForElement(element) let el = session.findElement(element)
check el.isSome()
# focus # focus
el.get().click() el.get().click()
for key in keys: for key in keys:
session.press(key) session.press(key)
proc ensureExists*(session: Session, element: string, strategy=CssSelector) = template ensureExists*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy) let el = session.findElement(element, strategy)
check el.isSome()
template check*(session: Session, element: string, function: untyped) = template check*(session: Session, element: string, function: untyped) =
let el = session.waitForElement(element) let el = session.findElement(element)
check function(el) check function(el)
template check*(session: Session, element: string, template check*(session: Session, element: string,
strategy: LocationStrategy, function: untyped) = strategy: LocationStrategy, function: untyped) =
let el = session.waitForElement(element, strategy) let el = session.findElement(element, strategy)
check function(el) check function(el)
proc setColor*(session: Session, element, color: string, strategy=CssSelector) = template checkIsNone*(session: Session, element: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy) let el = session.findElement(element, strategy)
discard session.execute("arguments[0].setAttribute('value', '" & color & "')", el.get()) check el.isNone()
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
template checkText*(session: Session, element, expectedValue: string) = template checkText*(session: Session, element, expectedValue: string) =
let el = session.waitForElement(element) let el = session.findElement(element)
check el.isSome()
check el.get().getText() == expectedValue check el.get().getText() == expectedValue
proc waitForElement*( proc waitForLoad*(session: Session, timeout=20000) =
session: Session, selector: string, strategy=CssSelector,
timeout=20000, pollTime=50,
waitCondition: proc(element: Option[Element]): bool = elementIsSome
): Option[Element] =
var waitTime = 0 var waitTime = 0
sleep(2000)
when actionDelayMs > 0:
sleep(actionDelayMs)
while true: while true:
try: let loading = session.findElement(".loading")
let loading = session.findElement(selector, strategy) if loading.isNone: return
if waitCondition(loading): sleep(1000)
return loading waitTime += 1000
finally:
discard
sleep(pollTime)
waitTime += pollTime
if waitTime > timeout: if waitTime > timeout:
doAssert false, "Wait for load time exceeded" doAssert false, "Wait for load time exceeded"
proc waitForElements*( proc wait*(session: Session, msTimeout: int = 5000) =
session: Session, selector: string, strategy=CssSelector, session.waitForLoad(msTimeout)
timeout=20000, pollTime=50
): seq[Element] =
var waitTime = 0
when actionDelayMs > 0:
sleep(actionDelayMs)
while true:
let loading = session.findElements(selector, strategy)
if loading.len > 0:
return loading
sleep(pollTime)
waitTime += pollTime
if waitTime > timeout:
doAssert false, "Wait for load time exceeded"
proc setUserRank*(session: Session, baseUrl, user, rank: string) = proc setUserRank*(session: Session, baseUrl, user, rank: string) =
with session: with session:
navigate(baseUrl & "profile/" & user) navigate(baseUrl & "profile/" & user)
wait()
click "#settings-tab" click "#settings-tab"
@ -128,11 +85,13 @@ proc setUserRank*(session: Session, baseUrl, user, rank: string) =
click("#rank-field option#rank-" & rank.toLowerAscii) click("#rank-field option#rank-" & rank.toLowerAscii)
click "#save-btn" click "#save-btn"
wait()
proc logout*(session: Session) = proc logout*(session: Session) =
with session: with session:
click "#profile-btn" click "#profile-btn"
click "#profile-btn #logout-btn" click "#profile-btn #logout-btn"
wait()
# Verify we have logged out by looking for the log in button. # Verify we have logged out by looking for the log in button.
ensureExists "#login-btn" ensureExists "#login-btn"
@ -149,12 +108,14 @@ proc login*(session: Session, user, password: string) =
sendKeys "#login-form input[name='password']", Key.Enter sendKeys "#login-form input[name='password']", Key.Enter
wait()
# Verify that the user menu has been initialised properly. # Verify that the user menu has been initialised properly.
click "#profile-btn" click "#profile-btn"
checkText "#profile-btn #profile-name", user checkText "#profile-btn #profile-name", user
click "#profile-btn" click "#profile-btn"
proc register*(session: Session, user, password: string, verify = true) = proc register*(session: Session, user, password: string) =
with session: with session:
click "#signup-btn" click "#signup-btn"
@ -167,23 +128,24 @@ proc register*(session: Session, user, password: string, verify = true) =
sendKeys "#signup-form input[name='password']", password sendKeys "#signup-form input[name='password']", password
click "#signup-modal .create-account-btn" click "#signup-modal .create-account-btn"
wait()
if verify: # Verify that the user menu has been initialised properly.
with session: click "#profile-btn"
# Verify that the user menu has been initialised properly. checkText "#profile-btn #profile-name", user
click "#profile-btn" # close menu
checkText "#profile-btn #profile-name", user click "#profile-btn"
# close menu
click "#profile-btn"
proc createThread*(session: Session, title, content: string) = proc createThread*(session: Session, title, content: string) =
with session: with session:
click "#new-thread-btn" click "#new-thread-btn"
wait()
sendKeys "#thread-title", title sendKeys "#thread-title", title
sendKeys "#reply-textarea", content sendKeys "#reply-textarea", content
click "#create-thread-btn" click "#create-thread-btn"
wait()
checkText "#thread-title .title-text", title checkText "#thread-title", title
checkText ".original-post div.post-content", content checkText ".original-post div.post-content", content

View file

@ -1,10 +1,12 @@
import unittest, common import unittest, options, os, common
import webdriver import webdriver
proc test*(session: Session, baseUrl: string) = proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl) session.navigate(baseUrl)
waitForLoad(session)
test "can see banned posts": test "can see banned posts":
with session: with session:
register("issue181", "issue181") register("issue181", "issue181")
@ -17,6 +19,7 @@ proc test*(session: Session, baseUrl: string) =
login("issue181", "issue181") login("issue181", "issue181")
navigate(baseUrl) navigate(baseUrl)
wait()
const title = "Testing issue 181." const title = "Testing issue 181."
createThread(title, "Test for issue #181") createThread(title, "Test for issue #181")
@ -30,6 +33,7 @@ proc test*(session: Session, baseUrl: string) =
# Make sure the banned user's thread is still visible. # Make sure the banned user's thread is still visible.
navigate(baseUrl) navigate(baseUrl)
wait()
ensureExists("tr.banned") ensureExists("tr.banned")
checkText("tr.banned .thread-title > a", title) checkText("tr.banned .thread-title > a", title)
logout() logout()

View file

@ -1,10 +1,12 @@
import unittest, common import unittest, options, os, common
import webdriver import webdriver
proc test*(session: Session, baseUrl: string) = proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl) session.navigate(baseUrl)
waitForLoad(session)
# Sanity checks # Sanity checks
test "shows sign up": test "shows sign up":
session.checkText("#signup-btn", "Sign up") session.checkText("#signup-btn", "Sign up")
@ -28,16 +30,5 @@ proc test*(session: Session, baseUrl: string) =
test "can register": test "can register":
with session: with session:
register("test", "test") register("test", "test")
logout()
test "can't register same username with different case": session.logout()
with session:
register "test1", "test1", verify = false
logout()
navigate baseUrl
register "TEst1", "test1", verify = false
ensureExists "#signup-form .has-error"
navigate baseUrl

View file

@ -1,4 +1,5 @@
import unittest, common import unittest, options, os, common
import webdriver import webdriver
let let
@ -14,81 +15,36 @@ proc banUser(session: Session, baseUrl: string) =
setUserRank baseUrl, "user", "banned" setUserRank baseUrl, "user", "banned"
logout() logout()
proc unBanUser(session: Session, baseUrl: string) =
with session:
login "admin", "admin"
setUserRank baseUrl, "user", "user"
logout()
proc userTests(session: Session, baseUrl: string) = proc userTests(session: Session, baseUrl: string) =
suite "user thread tests": suite "user thread tests":
session.login("user", "user") session.login("user", "user")
setup: setup:
session.navigate(baseUrl) session.navigate(baseUrl)
session.wait()
test "can create thread": test "can create thread":
with session: with session:
click "#new-thread-btn" click "#new-thread-btn"
wait()
sendKeys "#thread-title", userTitleStr sendKeys "#thread-title", userTitleStr
sendKeys "#reply-textarea", userContentStr sendKeys "#reply-textarea", userContentStr
click "#create-thread-btn" click "#create-thread-btn"
wait()
checkText "#thread-title .title-text", userTitleStr checkText "#thread-title", userTitleStr
checkText ".original-post div.post-content", userContentStr checkText ".original-post div.post-content", userContentStr
test "can delete thread":
with session:
# create thread to be deleted
click "#new-thread-btn"
sendKeys "#thread-title", "To be deleted"
sendKeys "#reply-textarea", "This thread is to be deleted"
click "#create-thread-btn"
click ".post-buttons .delete-button"
# click delete confirmation
click "#delete-modal .delete-btn"
# Make sure the forum post is gone
checkIsNone "To be deleted", LinkTextSelector
test "cannot (un)pin thread":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Unpinnable"
sendKeys "#reply-textarea", "Cannot (un)pin as an user"
click "#create-thread-btn"
checkIsNone "#pin-btn"
test "cannot lock threads":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Locking"
sendkeys "#reply-textarea", "Cannot lock as an user"
click "#create-thread-btn"
checkIsNone "#lock-btn"
session.logout() session.logout()
proc anonymousTests(session: Session, baseUrl: string) = proc anonymousTests(session: Session, baseUrl: string) =
suite "anonymous user tests": suite "anonymous user tests":
with session: with session:
navigate baseUrl navigate baseUrl
wait()
test "can view banned thread": test "can view banned thread":
with session: with session:
@ -96,21 +52,25 @@ proc anonymousTests(session: Session, baseUrl: string) =
with session: with session:
navigate baseUrl navigate baseUrl
wait()
proc bannedTests(session: Session, baseUrl: string) = proc bannedTests(session: Session, baseUrl: string) =
suite "banned user thread tests": suite "banned user thread tests":
with session: with session:
navigate baseUrl navigate baseUrl
wait()
login "banned", "banned" login "banned", "banned"
test "can't start thread": test "can't start thread":
with session: with session:
click "#new-thread-btn" click "#new-thread-btn"
wait()
sendKeys "#thread-title", "test" sendKeys "#thread-title", "test"
sendKeys "#reply-textarea", "test" sendKeys "#reply-textarea", "test"
click "#create-thread-btn" click "#create-thread-btn"
wait()
ensureExists "#new-thread p.text-error" ensureExists "#new-thread p.text-error"
@ -122,6 +82,7 @@ proc adminTests(session: Session, baseUrl: string) =
setup: setup:
session.navigate(baseUrl) session.navigate(baseUrl)
session.wait()
test "can view banned thread": test "can view banned thread":
with session: with session:
@ -130,18 +91,21 @@ proc adminTests(session: Session, baseUrl: string) =
test "can create thread": test "can create thread":
with session: with session:
click "#new-thread-btn" click "#new-thread-btn"
wait()
sendKeys "#thread-title", adminTitleStr sendKeys "#thread-title", adminTitleStr
sendKeys "#reply-textarea", adminContentStr sendKeys "#reply-textarea", adminContentStr
click "#create-thread-btn" click "#create-thread-btn"
wait()
checkText "#thread-title .title-text", adminTitleStr checkText "#thread-title", adminTitleStr
checkText ".original-post div.post-content", adminContentStr checkText ".original-post div.post-content", adminContentStr
test "try create duplicate thread": test "try create duplicate thread":
with session: with session:
click "#new-thread-btn" click "#new-thread-btn"
wait()
ensureExists "#new-thread" ensureExists "#new-thread"
sendKeys "#thread-title", adminTitleStr sendKeys "#thread-title", adminTitleStr
@ -149,17 +113,22 @@ proc adminTests(session: Session, baseUrl: string) =
click "#create-thread-btn" click "#create-thread-btn"
wait()
ensureExists "#new-thread p.text-error" ensureExists "#new-thread p.text-error"
test "can edit post": test "can edit post":
let modificationText = " and I edited it!" let modificationText = " and I edited it!"
with session: with session:
click adminTitleStr, LinkTextSelector click adminTitleStr, LinkTextSelector
wait()
click ".post-buttons .edit-button" click ".post-buttons .edit-button"
wait()
sendKeys ".original-post #reply-textarea", modificationText sendKeys ".original-post #reply-textarea", modificationText
click ".edit-buttons .save-button" click ".edit-buttons .save-button"
wait()
checkText ".original-post div.post-content", adminContentStr & modificationText checkText ".original-post div.post-content", adminContentStr & modificationText
@ -168,6 +137,7 @@ proc adminTests(session: Session, baseUrl: string) =
with session: with session:
click userTitleStr, LinkTextSelector click userTitleStr, LinkTextSelector
wait()
click ".post-buttons .like-button" click ".post-buttons .like-button"
@ -176,83 +146,23 @@ proc adminTests(session: Session, baseUrl: string) =
test "can delete thread": test "can delete thread":
with session: with session:
click adminTitleStr, LinkTextSelector click adminTitleStr, LinkTextSelector
wait()
click ".post-buttons .delete-button" click ".post-buttons .delete-button"
wait()
# click delete confirmation # click delete confirmation
click "#delete-modal .delete-btn" click "#delete-modal .delete-btn"
wait()
# Make sure the forum post is gone # Make sure the forum post is gone
checkIsNone adminTitleStr, LinkTextSelector checkIsNone adminTitleStr, LinkTextSelector
test "can pin a thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "Pinned post"
sendKeys "#reply-textarea", "A pinned post"
click "#create-thread-btn"
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Normal post"
sendKeys "#reply-textarea", "A normal post"
click "#create-thread-btn"
navigate(baseUrl)
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Unpin Thread"
navigate(baseUrl)
# Make sure pin exists
ensureExists "#threads-list .thread-1 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Pinned post"
checkText "#threads-list .thread-2 .thread-title a", "Normal post"
test "can unpin a thread":
with session:
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Pin Thread"
navigate(baseUrl)
checkIsNone "#threads-list .thread-2 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Normal post"
checkText "#threads-list .thread-2 .thread-title a", "Pinned post"
test "can lock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
ensureExists "#thread-title i.fas.fa-lock.fa-xs"
test "locked thread appears on frontpage":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "A new locked thread"
sendKeys "#reply-textarea", "This thread should appear locked on the frontpage"
click "#create-thread-btn"
click "#lock-btn"
navigate(baseUrl)
ensureExists "#threads-list .thread-1 .thread-title i.fas.fa-lock.fa-xs"
test "can unlock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
checkIsNone "#thread-title i.fas.fa-lock.fa-xs"
session.logout() session.logout()
proc test*(session: Session, baseUrl: string) = proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl) session.navigate(baseUrl)
session.wait()
userTests(session, baseUrl) userTests(session, baseUrl)
@ -262,6 +172,5 @@ proc test*(session: Session, baseUrl: string) =
anonymousTests(session, baseUrl) anonymousTests(session, baseUrl)
adminTests(session, baseUrl) adminTests(session, baseUrl)
unBanUser(session, baseUrl)
session.navigate(baseUrl) session.navigate(baseUrl)
session.wait()