Compare commits
164 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c2011323 | ||
|
|
4a181e9cb1 | ||
|
|
4f00ea5942 | ||
|
|
9994fc7840 | ||
|
|
ceb04561cd | ||
|
|
40d7b1cf02 |
||
|
|
c4684155f5 | ||
|
|
f940f81861 | ||
|
|
a1601b4600 |
||
|
|
35e0de7b91 |
||
|
|
7954a38601 |
||
|
|
48c025ae78 |
||
|
|
8782dff349 | ||
|
|
0055a12fc1 | ||
|
|
8cd5c45cda | ||
|
|
5b7b271627 |
||
|
|
6c6552176a | ||
|
|
3ac9ec3ff6 | ||
|
|
7d8417ff97 | ||
|
|
b2932e9348 |
||
|
|
108aeda5b3 |
||
|
|
3d975e8386 | ||
|
|
4f8a585049 | ||
|
|
b27096ff75 | ||
|
|
ce3de27fb9 | ||
|
|
9739c34abd | ||
|
|
4821746c5d | ||
|
|
dc80ef022e |
||
|
|
77fd9af1cd | ||
|
|
e62ae672b3 | ||
|
|
6e32ec27b4 |
||
|
|
030c52020e |
||
|
|
abb770ebd1 |
||
|
|
16c9daea52 |
||
|
|
8bad518e4b |
||
|
|
5c4d9b36d3 |
||
|
|
14786d3d49 |
||
|
|
48314432e9 |
||
|
|
d808749054 |
||
|
|
03fd4fdc13 |
||
|
|
ebbfa265d5 |
||
|
|
b933a9b2e8 | ||
|
|
474fa63985 | ||
|
|
418bb3fe47 |
||
|
|
d372d9f980 |
||
|
|
2987955e8a |
||
|
|
16abee0596 |
||
|
|
f5e1a71e6e |
||
|
|
f0bfd5516a |
||
|
|
fcbf22a64a | ||
|
|
53ad811bf8 |
||
|
|
55c9476810 | ||
|
|
e749512818 | ||
|
|
a3052efd78 | ||
|
|
6c6ed08ec9 | ||
|
|
3b6e7363a9 | ||
|
|
cd565eabe0 |
||
|
|
f0d9a89167 |
||
|
|
d8661f62c7 |
||
|
|
8984074809 | ||
|
|
3adba32f1f | ||
|
|
f35d6c4a32 | ||
|
|
3db01a1d44 | ||
|
|
dcea4091f4 | ||
|
|
c2cc26ea77 | ||
|
|
b91bdeb450 |
||
|
|
7a7a7145ee | ||
|
|
0d63fef0f7 | ||
|
|
b26085cbd0 | ||
|
|
60ace9c65a | ||
|
|
5f930c7f5a | ||
|
|
3b092ae2d1 | ||
|
|
31d3b2701d | ||
|
|
9127bc4c88 | ||
|
|
95b21874db | ||
|
|
7ec3ff9cac | ||
|
|
5ce99a5a3d | ||
|
|
5fc811e797 | ||
|
|
91746924dc | ||
|
|
5e033d0356 | ||
|
|
1f0f736915 | ||
|
|
433a21aa87 | ||
|
|
fd80f754d2 | ||
|
|
6c70713afb | ||
|
|
f3396777fb | ||
|
|
38a21f34e6 | ||
|
|
70ad45d8f6 | ||
|
|
da7045ecca | ||
|
|
9d19d70558 | ||
|
|
5a4f44b4ee | ||
|
|
616c6eb100 | ||
|
|
8d317ae0e3 | ||
|
|
42985fd2bc |
||
|
|
01d13aa0f3 | ||
|
|
7337bceff3 | ||
|
|
ce9cde4a0d | ||
|
|
918cda96cf | ||
|
|
d9335ee0f0 | ||
|
|
46d6a3b6bb | ||
|
|
de7b391d11 | ||
|
|
fac828b1e9 | ||
|
|
2717496bb5 | ||
|
|
14a0864d86 | ||
|
|
64262978db | ||
|
|
f93bd87316 | ||
|
|
35baa815fd |
||
|
|
f4af965a2e |
||
|
|
41a1a36dbf | ||
|
|
b65fccd416 |
||
|
|
30dc09f453 | ||
|
|
bedaec3540 |
||
|
|
2b88a54f54 | ||
|
|
7321ee6f61 | ||
|
|
f8781ba5f3 | ||
|
|
4e1b906b49 | ||
|
|
416d2601fb | ||
|
|
796d8ee20c | ||
|
|
0af291dc10 | ||
|
|
d5df46823a | ||
|
|
82463ea423 | ||
|
|
f1c5db2ced | ||
|
|
3b681e32f6 | ||
|
|
a05667ef78 | ||
|
|
cb7418f825 | ||
|
|
b0639c4da2 | ||
|
|
2e42ede2ad | ||
|
|
d35f1e90cf | ||
|
|
495ddf5d93 | ||
|
|
3df30386d9 | ||
|
|
5ed17333f9 | ||
|
|
f0bcb9abfd | ||
|
|
7cda14e9fe | ||
|
|
7e42479228 | ||
|
|
ee3324cfb7 |
||
|
|
85f31aaf6b | ||
|
|
7925c4b8b1 | ||
|
|
465ba1e024 | ||
|
|
a6c0fe691c | ||
|
|
8c16a776b6 | ||
|
|
5714ad0c6a | ||
|
|
9f9d16467f | ||
|
|
9ee0ddf176 | ||
|
|
adaae54e7b |
||
|
|
954fe7b05a | ||
|
|
22ec94590c | ||
|
|
93913d9b13 |
||
|
|
b405f63a32 | ||
|
|
80558b6bfb | ||
|
|
c361fda523 | ||
|
|
0050ad42f5 | ||
|
|
b2fc4dfbe0 | ||
|
|
f315be7361 | ||
|
|
3324f37faa | ||
|
|
6fb5cfbfa2 | ||
|
|
0d67eab626 | ||
|
|
3c93224817 | ||
|
|
d7f3a038a9 | ||
|
|
1b55aec5d2 | ||
|
|
cb5923d9f8 | ||
|
|
d422b07394 | ||
|
|
82ff2fb212 |
||
|
|
b650a9f401 | ||
|
|
122e279256 | ||
|
|
8701aa92ac |
51 changed files with 2112 additions and 487 deletions
80
.github/workflows/main.yml
vendored
Normal file
80
.github/workflows/main.yml
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# 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
9
.gitignore
vendored
|
|
@ -12,3 +12,12 @@ nimcache/
|
||||||
forum
|
forum
|
||||||
createdb
|
createdb
|
||||||
editdb
|
editdb
|
||||||
|
|
||||||
|
.vscode
|
||||||
|
forum.json*
|
||||||
|
browsertester
|
||||||
|
setup_nimforum
|
||||||
|
buildcss
|
||||||
|
nimforum.css
|
||||||
|
|
||||||
|
/src/frontend/forum.js
|
||||||
|
|
|
||||||
44
.travis.yml
44
.travis.yml
|
|
@ -1,44 +0,0 @@
|
||||||
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="#987bf13"
|
|
||||||
- |
|
|
||||||
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
|
|
||||||
49
README.md
49
README.md
|
|
@ -63,6 +63,23 @@ 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`
|
||||||
|
|
@ -70,6 +87,38 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
14
docker/Dockerfile
Normal file
14
docker/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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
|
||||||
12
docker/docker-compose.yml
Normal file
12
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
forum:
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: ./docker/Dockerfile
|
||||||
|
volumes:
|
||||||
|
- "../:/app"
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
entrypoint: "/app/docker/entrypoint.sh"
|
||||||
19
docker/entrypoint.sh
Executable file
19
docker/entrypoint.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/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
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Package
|
# Package
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
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 >= 0.14.0"
|
requires "nim >= 1.0.6"
|
||||||
requires "jester#64295c8"
|
requires "jester#405be2e"
|
||||||
requires "bcrypt#head"
|
requires "bcrypt#440c5676ff6"
|
||||||
requires "hmac#9c61ebe2fd134cf97"
|
requires "hmac#9c61ebe2fd134cf97"
|
||||||
requires "recaptcha 1.0.2"
|
requires "recaptcha#d06488e"
|
||||||
requires "sass#649e0701fa5c"
|
requires "sass#649e0701fa5c"
|
||||||
|
|
||||||
requires "karax#d8df257dd"
|
requires "karax#5f21dcd"
|
||||||
|
|
||||||
requires "webdriver#a2be578"
|
requires "webdriver#429933a"
|
||||||
|
|
||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
|
|
@ -32,11 +32,14 @@ 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/nimcache/forum.js", "public/js/forum.js"
|
cpFile "src/frontend/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"
|
||||||
|
|
@ -55,7 +58,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 tests/browsertester"
|
exec "nimble c -y -r -d:actionDelayMs=0 tests/browsertester"
|
||||||
|
|
||||||
task fasttest, "Runs tester without recompiling backend":
|
task fasttest, "Runs tester without recompiling backend":
|
||||||
exec "nimble c -r tests/browsertester"
|
exec "nimble c -r -d:actionDelayMs=0 tests/browsertester"
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ 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 {
|
||||||
|
|
@ -50,6 +51,7 @@ $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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,6 +109,40 @@ $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;
|
||||||
|
|
@ -148,6 +184,33 @@ $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;
|
||||||
|
|
@ -196,14 +259,12 @@ $threads-meta-color: #545d70;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.triangle {
|
.category-color {
|
||||||
// TODO: Abstract this into a "category" class.
|
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-left: 0.3rem solid transparent;
|
border: 0.25rem solid $default-category-color;
|
||||||
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 {
|
||||||
|
|
@ -240,6 +301,14 @@ $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;
|
||||||
|
|
||||||
|
|
@ -253,6 +322,13 @@ $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;
|
||||||
|
|
@ -703,18 +779,3 @@ 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;
|
|
||||||
}
|
|
||||||
2
setup.md
2
setup.md
|
|
@ -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 restart nginx by running ``sudo systemctl restart nginx``.
|
Then reload nginx configuration by running ``sudo nginx -s reload``.
|
||||||
|
|
||||||
### Supervisor
|
### Supervisor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import os, strutils
|
import os
|
||||||
|
|
||||||
import sass
|
import sass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.hours >= 1:
|
if diff.inHours >= 1:
|
||||||
mailer.lastReset = getTime()
|
mailer.lastReset = getTime()
|
||||||
mailer.emailsSent.clear()
|
mailer.emailsSent.clear()
|
||||||
|
|
||||||
|
|
@ -30,7 +30,6 @@ 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.
|
||||||
|
|
@ -41,21 +40,37 @@ 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", fromAddr))
|
headers.add(("From", mailer.config.smtpFromAddr))
|
||||||
|
|
||||||
|
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(fromAddr, toList, $encoded)
|
await client.sendMail(mailer.config.smtpFromAddr, 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,
|
||||||
|
|
|
||||||
336
src/forum.nim
336
src/forum.nim
|
|
@ -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,
|
||||||
scgi, jester, asyncdispatch, asyncnet, sequtils,
|
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,7 +76,6 @@ 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:
|
||||||
|
|
@ -115,27 +114,27 @@ proc sendResetPassword(
|
||||||
)
|
)
|
||||||
|
|
||||||
proc logout(c: TForumData) =
|
proc logout(c: TForumData) =
|
||||||
const query = sql"delete from session where ip = ? and key = ?"
|
const query = sql"delete from session where key = ?"
|
||||||
c.username = ""
|
c.username = ""
|
||||||
c.userpass = ""
|
c.userpass = ""
|
||||||
exec(db, query, c.req.ip, c.req.cookies["sid"])
|
exec(db, query, c.req.cookies["sid"])
|
||||||
|
|
||||||
proc checkLoggedIn(c: TForumData) =
|
proc checkLoggedIn(c: TForumData) =
|
||||||
if not c.req.cookies.hasKey("sid"): return
|
if not c.req.cookies.hasKey("sid"): return
|
||||||
let sid = c.req.cookies["sid"]
|
let sid = c.req.cookies["sid"]
|
||||||
if execAffectedRows(db,
|
if execAffectedRows(db,
|
||||||
sql("update session set lastModified = DATETIME('now') " &
|
sql("update session set lastModified = DATETIME('now') " &
|
||||||
"where ip = ? and key = ?"),
|
"where key = ?"),
|
||||||
c.req.ip, sid) > 0:
|
sid) > 0:
|
||||||
c.userid = getValue(db,
|
c.userid = getValue(db,
|
||||||
sql"select userid from session where ip = ? and key = ?",
|
sql"select userid from session where key = ?",
|
||||||
c.req.ip, sid)
|
sid)
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -152,7 +151,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.minutes > 30:
|
if diff.inMinutes > 30:
|
||||||
c.previousVisitAt = personRow[0].parseInt
|
c.previousVisitAt = personRow[0].parseInt
|
||||||
db.exec(
|
db.exec(
|
||||||
sql"""
|
sql"""
|
||||||
|
|
@ -239,7 +238,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.hours > 2:
|
if diff.inHours > 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")
|
||||||
|
|
@ -277,25 +276,26 @@ template createTFD() =
|
||||||
new(c)
|
new(c)
|
||||||
init(c)
|
init(c)
|
||||||
c.req = request
|
c.req = request
|
||||||
if request.cookies.len > 0:
|
if cookies(request).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(
|
||||||
name: userRow[0],
|
id: userRow[0],
|
||||||
avatarUrl: userRow[1].getGravatarUrl(avatarSize),
|
name: userRow[1],
|
||||||
lastOnline: userRow[2].parseInt,
|
avatarUrl: userRow[2].getGravatarUrl(avatarSize),
|
||||||
previousVisitAt: userRow[3].parseInt,
|
lastOnline: userRow[3].parseInt,
|
||||||
rank: parseEnum[Rank](userRow[4]),
|
previousVisitAt: userRow[4].parseInt,
|
||||||
isDeleted: userRow[5] == "1"
|
rank: parseEnum[Rank](userRow[5]),
|
||||||
|
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[1], avatarSize)
|
result.avatarUrl = getGravatarUrl(result.name & userRow[2], 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..10]),
|
author: selectUser(postRow[5..11]),
|
||||||
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.name, u.email, strftime('%s', u.lastOnline),
|
u.id, 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..8]))
|
author: some(selectUser(row[3..9]))
|
||||||
))
|
))
|
||||||
|
|
||||||
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.name, u.email, strftime('%s', u.lastOnline),
|
select u.id, 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 name, email, strftime('%s', lastOnline),
|
select id, 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 name, email, strftime('%s', lastOnline),
|
select u.id, 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[5].parseInt,
|
id: threadRow[6].parseInt,
|
||||||
name: threadRow[6],
|
name: threadRow[7],
|
||||||
description: threadRow[7],
|
description: threadRow[8],
|
||||||
color: threadRow[8]
|
color: threadRow[9]
|
||||||
),
|
),
|
||||||
users: @[],
|
users: @[],
|
||||||
replies: posts[0].parseInt-1,
|
replies: posts[0].parseInt-1,
|
||||||
|
|
@ -412,6 +412,7 @@ 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.
|
||||||
|
|
@ -435,8 +436,9 @@ 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")
|
||||||
|
|
||||||
if rateLimitCheck(c):
|
when not defined(skipRateLimitCheck):
|
||||||
raise newForumError("You're posting too fast!")
|
if rateLimitCheck(c):
|
||||||
|
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")
|
||||||
|
|
@ -458,13 +460,21 @@ 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.")
|
||||||
|
|
||||||
let retID = insertID(
|
var retID: int64
|
||||||
db,
|
|
||||||
crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"),
|
if replyingTo.isSome():
|
||||||
c.userId, c.req.ip, content, $threadId,
|
retID = insertID(
|
||||||
if replyingTo.isSome(): $replyingTo.get()
|
db,
|
||||||
else: nil
|
crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"),
|
||||||
)
|
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"),
|
||||||
|
|
@ -490,10 +500,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).weeks > 8
|
let isArchived = (getTime() - creation).inHours >= 2
|
||||||
let canEdit = c.rank == Admin or c.userid == postRow[0]
|
let canEdit = c.rank == Admin or c.userid == postRow[0]
|
||||||
if isArchived:
|
if isArchived and c.rank < Admin:
|
||||||
raise newForumError("This post is archived and can no longer be edited")
|
raise newForumError("This post is too old 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")
|
||||||
|
|
||||||
|
|
@ -520,10 +530,20 @@ 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 executeNewThread(c: TForumData, subject, msg: string): (int64, int64) =
|
proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], queryValues: seq[string]) =
|
||||||
|
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) values (?, 0, DATETIME('now'))
|
insert into thread(name, views, modified, category) values (?, 0, DATETIME('now'), ?)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
assert c.loggedIn()
|
assert c.loggedIn()
|
||||||
|
|
@ -543,13 +563,18 @@ proc executeNewThread(c: TForumData, subject, msg: string): (int64, 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"])
|
||||||
|
|
||||||
if rateLimitCheck(c):
|
when not defined(skipRateLimitCheck):
|
||||||
raise newForumError("You're posting too fast!")
|
if rateLimitCheck(c):
|
||||||
|
raise newForumError("You're posting too fast!")
|
||||||
|
|
||||||
result[0] = tryInsertID(db, query, subject).int
|
result[0] = tryInsertID(db, query, subject, categoryID).int
|
||||||
if result[0] < 0:
|
if result[0] < 0:
|
||||||
raise newForumError("Subject already exists", @["subject"])
|
raise newForumError("Subject already exists", @["subject"])
|
||||||
|
|
||||||
|
|
@ -608,7 +633,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 = ? and isDeleted = 0",
|
sql"select name from person where name = ? collate nocase and isDeleted = 0",
|
||||||
name
|
name
|
||||||
).len > 0:
|
).len > 0:
|
||||||
raise newForumError("Username already exists", @["username"])
|
raise newForumError("Username already exists", @["username"])
|
||||||
|
|
@ -651,6 +676,18 @@ 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"""
|
||||||
|
|
@ -673,15 +710,25 @@ 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.id from post p
|
select p.author, p.id from post p
|
||||||
where p.author = ? and p.id = ?
|
where p.author = ? and p.id = ?
|
||||||
"""
|
"""
|
||||||
let id = getValue(db, postQuery, c.username, postId)
|
let
|
||||||
|
row = getRow(db, postQuery, c.username, postId)
|
||||||
|
author = row[0]
|
||||||
|
id = row[1]
|
||||||
|
|
||||||
if id.len == 0 and c.rank < Admin:
|
if id.len == 0 and not (c.rank == Admin or c.userid == author):
|
||||||
raise newForumError("You cannot delete this post")
|
raise newForumError("You cannot delete this post")
|
||||||
|
|
||||||
# Set the `isDeleted` flag.
|
# Set the `isDeleted` flag.
|
||||||
|
|
@ -736,7 +783,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], row[2], row[3]
|
mailer, ActivateEmail, c.req, row[0], row[1], email, row[3]
|
||||||
)
|
)
|
||||||
|
|
||||||
validateEmail(email, checkDuplicated=wasEmailChanged)
|
validateEmail(email, checkDuplicated=wasEmailChanged)
|
||||||
|
|
@ -756,34 +803,65 @@ 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 =
|
||||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||||
c.id, c.name, c.description, c.color,
|
c.id, c.name, c.description, c.color,
|
||||||
u.name, u.email, strftime('%s', u.lastOnline),
|
u.id, 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.status <> 'Banned' 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 modified desc limit ?, ?;"""
|
order by isPinned desc, modified desc limit ?, ?;"""
|
||||||
|
|
||||||
let thrCount = getValue(db, sql"select count(*) from thread;").parseInt()
|
let thrCount = getValue(db, countQuery, countArgs).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, threadsQuery, start, count):
|
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
|
||||||
let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
|
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1]))
|
||||||
list.threads.add(thread)
|
list.threads.add(thread)
|
||||||
|
|
||||||
resp $(%list), "application/json"
|
resp $(%list), "application/json"
|
||||||
|
|
@ -798,19 +876,24 @@ routes:
|
||||||
count = 10
|
count = 10
|
||||||
|
|
||||||
const threadsQuery =
|
const threadsQuery =
|
||||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||||
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.name, u.email, strftime('%s', u.lastOnline),
|
u.id, 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
|
||||||
|
|
@ -849,15 +932,20 @@ routes:
|
||||||
|
|
||||||
get "/specific_posts.json":
|
get "/specific_posts.json":
|
||||||
createTFD()
|
createTFD()
|
||||||
var
|
var ids: JsonNode
|
||||||
|
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.name, u.email, strftime('%s', u.lastOnline),
|
u.id, 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
|
||||||
|
|
@ -926,7 +1014,7 @@ routes:
|
||||||
""" % postsFrom)
|
""" % postsFrom)
|
||||||
|
|
||||||
let userQuery = sql("""
|
let userQuery = sql("""
|
||||||
select name, email, strftime('%s', lastOnline),
|
select id, 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
|
||||||
|
|
@ -952,7 +1040,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[1])
|
profile.email = some(userRow[2])
|
||||||
|
|
||||||
for row in db.getAllRows(postsQuery, username):
|
for row in db.getAllRows(postsQuery, username):
|
||||||
profile.posts.add(
|
profile.posts.add(
|
||||||
|
|
@ -1023,8 +1111,22 @@ routes:
|
||||||
let session = executeLogin(c, username, password)
|
let session = executeLogin(c, username, password)
|
||||||
setCookie("sid", session)
|
setCookie("sid", session)
|
||||||
resp Http200, "{}", "application/json"
|
resp Http200, "{}", "application/json"
|
||||||
except ForumError:
|
except ForumError as exc:
|
||||||
let exc = (ref ForumError)(getCurrentException())
|
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"
|
resp Http400, $(%exc.data), "application/json"
|
||||||
|
|
||||||
get "/status.json":
|
get "/status.json":
|
||||||
|
|
@ -1073,7 +1175,8 @@ routes:
|
||||||
except EParseError:
|
except EParseError:
|
||||||
let err = PostError(
|
let err = PostError(
|
||||||
errorFields: @[],
|
errorFields: @[],
|
||||||
message: getCurrentExceptionMsg()
|
message: "Message needs to be valid RST! Error: " &
|
||||||
|
getCurrentExceptionMsg()
|
||||||
)
|
)
|
||||||
resp Http400, $(%err), "application/json"
|
resp Http400, $(%err), "application/json"
|
||||||
|
|
||||||
|
|
@ -1137,6 +1240,45 @@ 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():
|
||||||
|
|
@ -1149,13 +1291,14 @@ 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
|
||||||
# TODO: category
|
let categoryID = formData["categoryId"].body
|
||||||
|
|
||||||
try:
|
try:
|
||||||
let res = executeNewThread(c, subject, msg)
|
let res = executeNewThread(c, subject, msg, categoryID)
|
||||||
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"
|
||||||
|
|
@ -1214,6 +1357,33 @@ 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():
|
||||||
|
|
@ -1282,8 +1452,7 @@ routes:
|
||||||
try:
|
try:
|
||||||
await updateProfile(c, username, email, rank)
|
await updateProfile(c, username, email, rank)
|
||||||
resp Http200, "{}", "application/json"
|
resp Http200, "{}", "application/json"
|
||||||
except ForumError:
|
except ForumError as exc:
|
||||||
let exc = (ref ForumError)(getCurrentException())
|
|
||||||
resp Http400, $(%exc.data), "application/json"
|
resp Http400, $(%exc.data), "application/json"
|
||||||
|
|
||||||
post "/sendResetPassword":
|
post "/sendResetPassword":
|
||||||
|
|
@ -1311,8 +1480,7 @@ routes:
|
||||||
c, formData["email"].body, recaptcha, request.host
|
c, formData["email"].body, recaptcha, request.host
|
||||||
)
|
)
|
||||||
resp Http200, "{}", "application/json"
|
resp Http200, "{}", "application/json"
|
||||||
except ForumError:
|
except ForumError as exc:
|
||||||
let exc = (ref ForumError)(getCurrentException())
|
|
||||||
resp Http400, $(%exc.data), "application/json"
|
resp Http400, $(%exc.data), "application/json"
|
||||||
|
|
||||||
post "/resetPassword":
|
post "/resetPassword":
|
||||||
|
|
@ -1350,7 +1518,7 @@ routes:
|
||||||
)
|
)
|
||||||
resp Http200, "{}", "application/json"
|
resp Http200, "{}", "application/json"
|
||||||
except ForumError as exc:
|
except ForumError as exc:
|
||||||
resp Http400, $(%exc.data),"application/json"
|
resp Http400, $(%exc.data), "application/json"
|
||||||
|
|
||||||
post "/activateEmail":
|
post "/activateEmail":
|
||||||
createTFD()
|
createTFD()
|
||||||
|
|
@ -1371,7 +1539,7 @@ routes:
|
||||||
)
|
)
|
||||||
resp Http200, "{}", "application/json"
|
resp Http200, "{}", "application/json"
|
||||||
except ForumError as exc:
|
except ForumError as exc:
|
||||||
resp Http400, $(%exc.data),"application/json"
|
resp Http400, $(%exc.data), "application/json"
|
||||||
|
|
||||||
get "/t/@id":
|
get "/t/@id":
|
||||||
cond "id" in request.params
|
cond "id" in request.params
|
||||||
|
|
@ -1448,7 +1616,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 .. 10]),
|
author: selectUser(rowFT[5 .. 11]),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
when defined(js):
|
when defined(js):
|
||||||
import sugar, httpcore, options, json
|
import sugar, httpcore
|
||||||
import dom except Event
|
import dom except Event
|
||||||
|
|
||||||
include karax/prelude
|
include karax/prelude
|
||||||
import karax / [kajax, kdom]
|
import karax / [kajax]
|
||||||
|
|
||||||
import error, replybox, threadlist, post
|
import error
|
||||||
import karaxutils
|
import karaxutils
|
||||||
|
|
||||||
type
|
type
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ when defined(js):
|
||||||
include karax/prelude
|
include karax/prelude
|
||||||
import karax / [kajax, kdom]
|
import karax / [kajax, kdom]
|
||||||
|
|
||||||
import error, replybox, threadlist, post
|
import error
|
||||||
import karaxutils
|
import karaxutils
|
||||||
|
|
||||||
type
|
type
|
||||||
|
|
@ -13,17 +13,12 @@ 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"))
|
||||||
|
|
|
||||||
87
src/frontend/addcategorymodal.nim
Normal file
87
src/frontend/addcategorymodal.nim
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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"
|
||||||
|
|
@ -5,24 +5,44 @@ 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, kajax, kdom]
|
import karax / [vstyles]
|
||||||
|
|
||||||
import karaxutils
|
import karaxutils
|
||||||
|
|
||||||
proc render*(category: Category): VNode =
|
proc render*(category: Category, compact=true): VNode =
|
||||||
result = buildHtml():
|
if category.name.len == 0:
|
||||||
if category.name.len >= 0:
|
return buildHtml():
|
||||||
tdiv(class="category",
|
|
||||||
"data-color"="#" & category.color):
|
|
||||||
tdiv(class="triangle",
|
|
||||||
style=style(
|
|
||||||
(StyleAttr.borderBottom,
|
|
||||||
kstring"0.6rem solid #" & category.color)
|
|
||||||
))
|
|
||||||
text category.name
|
|
||||||
else:
|
|
||||||
span()
|
span()
|
||||||
|
|
||||||
|
result = buildhtml(tdiv):
|
||||||
|
tdiv(class="category-status"):
|
||||||
|
tdiv(class="category",
|
||||||
|
title=category.description,
|
||||||
|
"data-color"="#" & category.color):
|
||||||
|
tdiv(class="category-color",
|
||||||
|
style=style(
|
||||||
|
(StyleAttr.border,
|
||||||
|
kstring"0.25rem solid #" & category.color)
|
||||||
|
))
|
||||||
|
span(class="category-name"):
|
||||||
|
text category.name
|
||||||
|
if not compact:
|
||||||
|
span(class="topic-count"):
|
||||||
|
text "× " & $category.numTopics
|
||||||
|
if not compact:
|
||||||
|
tdiv(class="category-description"):
|
||||||
|
text category.description.limit(categoryDescriptionCharLimit)
|
||||||
105
src/frontend/categorylist.nim
Normal file
105
src/frontend/categorylist.nim
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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)
|
||||||
135
src/frontend/categorypicker.nim
Normal file
135
src/frontend/categorypicker.nim
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
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)
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
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]
|
||||||
|
|
@ -59,7 +60,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, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(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) =
|
||||||
|
|
@ -94,7 +95,7 @@ when defined(js):
|
||||||
proc render*(state: DeleteModal): VNode =
|
proc render*(state: DeleteModal): VNode =
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
|
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
|
||||||
id="login-modal"):
|
id="delete-modal"):
|
||||||
a(href="", class="modal-overlay", "aria-label"="close",
|
a(href="", class="modal-overlay", "aria-label"="close",
|
||||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||||
tdiv(class="modal-container"):
|
tdiv(class="modal-container"):
|
||||||
|
|
@ -122,11 +123,11 @@ when defined(js):
|
||||||
|
|
||||||
button(class=class(
|
button(class=class(
|
||||||
{"loading": state.loading},
|
{"loading": state.loading},
|
||||||
"btn btn-primary"
|
"btn btn-primary delete-btn"
|
||||||
),
|
),
|
||||||
onClick=(ev: Event, n: VNode) => onDelete(ev, n, state)):
|
onClick=(ev: Event, n: VNode) => onDelete(ev, n, state)):
|
||||||
italic(class="fas fa-trash-alt")
|
italic(class="fas fa-trash-alt")
|
||||||
text " Delete"
|
text " Delete"
|
||||||
button(class="btn",
|
button(class="btn cancel-btn",
|
||||||
onClick=(ev: Event, n: VNode) => (state.shown = false)):
|
onClick=(ev: Event, n: VNode) => (state.shown = false)):
|
||||||
text "Cancel"
|
text "Cancel"
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -54,7 +55,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, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(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 =
|
||||||
|
|
@ -87,7 +88,7 @@ when defined(js):
|
||||||
text state.error.get().message
|
text state.error.get().message
|
||||||
|
|
||||||
tdiv(class="edit-buttons"):
|
tdiv(class="edit-buttons"):
|
||||||
tdiv(class="reply-button"):
|
tdiv(class="cancel-button"):
|
||||||
button(class="btn btn-link",
|
button(class="btn btn-link",
|
||||||
onClick=(e: Event, n: VNode) => (state.onEditCancel())):
|
onClick=(e: Event, n: VNode) => (state.onEditCancel())):
|
||||||
text " Cancel"
|
text " Cancel"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import options, httpcore
|
import 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
|
import json, options
|
||||||
include karax/prelude
|
include karax/prelude
|
||||||
import karax / [vstyles, kajax, kdom]
|
|
||||||
|
|
||||||
import karaxutils
|
import karaxutils
|
||||||
|
|
||||||
|
|
@ -86,7 +85,7 @@ when defined(js):
|
||||||
|
|
||||||
state.error = some(error)
|
state.error = some(error)
|
||||||
except:
|
except:
|
||||||
kout(getCurrentExceptionMsg().cstring)
|
echo getCurrentExceptionMsg()
|
||||||
state.error = some(PostError(
|
state.error = some(PostError(
|
||||||
errorFields: @[],
|
errorFields: @[],
|
||||||
message: "Unknown error occurred."
|
message: "Unknown error occurred."
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import strformat, times, options, json, tables, sugar, httpcore, uri
|
import options, tables, sugar, httpcore
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -49,7 +51,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.
|
||||||
kout(kstring"New URL: ", window.location.href, " ", state.url.href)
|
echo "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.
|
||||||
|
|
@ -81,9 +83,17 @@ 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))
|
(render(state.newThread, getLoggedInUser()))
|
||||||
),
|
),
|
||||||
r("/profile/@username",
|
r("/profile/@username",
|
||||||
(params: Params) =>
|
(params: Params) =>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import options, times, httpcore, json, sugar
|
import options, httpcore
|
||||||
|
|
||||||
import threadlist, user
|
import 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]
|
||||||
|
|
||||||
|
|
@ -31,7 +32,7 @@ when defined(js):
|
||||||
var
|
var
|
||||||
state = newState()
|
state = newState()
|
||||||
|
|
||||||
proc getStatus(logout: bool=false)
|
proc getStatus(logout=false)
|
||||||
proc newState(): State =
|
proc newState(): State =
|
||||||
State(
|
State(
|
||||||
data: none[UserStatus](),
|
data: none[UserStatus](),
|
||||||
|
|
@ -60,10 +61,10 @@ when defined(js):
|
||||||
|
|
||||||
state.lastUpdate = getTime()
|
state.lastUpdate = getTime()
|
||||||
|
|
||||||
proc getStatus(logout: bool=false) =
|
proc getStatus(logout=false) =
|
||||||
if state.loading: return
|
if state.loading: return
|
||||||
let diff = getTime() - state.lastUpdate
|
let diff = getTime() - state.lastUpdate
|
||||||
if diff.minutes < 5:
|
if diff.inMinutes < 5:
|
||||||
return
|
return
|
||||||
|
|
||||||
state.loading = true
|
state.loading = true
|
||||||
|
|
@ -95,8 +96,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`="text", placeholder="search",
|
`type`="search", placeholder="Search",
|
||||||
id="search-box",
|
id="search-box", required="required",
|
||||||
onKeyDown=onKeyDown)
|
onKeyDown=onKeyDown)
|
||||||
if state.loading:
|
if state.loading:
|
||||||
tdiv(class="loading")
|
tdiv(class="loading")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,15 @@
|
||||||
import strutils, options, strformat, parseutils, tables
|
import strutils, 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,
|
||||||
|
|
@ -25,7 +36,7 @@ proc getInt64*(s: string, default = 0): int64 =
|
||||||
|
|
||||||
when defined(js):
|
when defined(js):
|
||||||
include karax/prelude
|
include karax/prelude
|
||||||
import karax / [kdom]
|
import karax / [kdom, kajax]
|
||||||
|
|
||||||
from dom import nil
|
from dom import nil
|
||||||
|
|
||||||
|
|
@ -87,16 +98,10 @@ 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}")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
when defined(js):
|
when defined(js):
|
||||||
import sugar, httpcore, options, json
|
import sugar, httpcore, options, json
|
||||||
import dom except Event
|
import dom except Event, KeyboardEvent
|
||||||
|
import jsffi except `&`
|
||||||
|
|
||||||
include karax/prelude
|
include karax/prelude
|
||||||
import karax / [kajax, kdom]
|
import karax / [kajax, kdom]
|
||||||
|
|
@ -30,7 +31,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, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(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) =
|
||||||
|
|
|
||||||
58
src/frontend/mainbuttons.nim
Normal file
58
src/frontend/mainbuttons.nim
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
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"
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
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
|
import error, replybox, threadlist, post, user
|
||||||
import karaxutils
|
import karaxutils, categorypicker
|
||||||
|
|
||||||
type
|
type
|
||||||
NewThread* = ref object
|
NewThread* = ref object
|
||||||
|
|
@ -14,11 +15,13 @@ 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) =
|
||||||
|
|
@ -37,30 +40,39 @@ 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())
|
||||||
ajaxPost(uri, @[], cast[cstring](formData),
|
formData.append("categoryId", $categoryID)
|
||||||
|
|
||||||
|
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): VNode =
|
proc render*(state: NewThread, currentUser: Option[User]): VNode =
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
section(class="container grid-xl"):
|
section(class="container grid-xl"):
|
||||||
tdiv(id="new-thread"):
|
tdiv(id="new-thread"):
|
||||||
tdiv(class="title"):
|
tdiv(class="title"):
|
||||||
p(): text "New Thread"
|
p(): text "New Thread"
|
||||||
tdiv(class="content"):
|
tdiv(class="content"):
|
||||||
input(class="form-input", `type`="text", name="subject",
|
input(id="thread-title", class="form-input", `type`="text", name="subject",
|
||||||
placeholder="Type the title here",
|
placeholder="Type the title here",
|
||||||
oninput=(e: Event, n: VNode) => onSubjectChange(e, n, state))
|
oninput=(e: Event, n: VNode) => onSubjectChange(e, n, state))
|
||||||
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"):
|
||||||
|
|
||||||
button(class=class(
|
button(id="create-thread-btn",
|
||||||
{"loading": state.loading},
|
class=class(
|
||||||
"btn btn-primary"
|
{"loading": state.loading},
|
||||||
|
"btn btn-primary"
|
||||||
),
|
),
|
||||||
onClick=(ev: Event, n: VNode) =>
|
onClick=(ev: Event, n: VNode) =>
|
||||||
(onCreateClick(ev, n, state))):
|
(onCreateClick(ev, n, state))):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import strformat, options
|
import options
|
||||||
|
|
||||||
import user, threadlist
|
import user
|
||||||
|
|
||||||
type
|
type
|
||||||
PostInfo* = object
|
PostInfo* = object
|
||||||
|
|
@ -32,7 +32,8 @@ proc lastEdit*(post: Post): PostInfo =
|
||||||
post.history[^1]
|
post.history[^1]
|
||||||
|
|
||||||
proc isModerated*(post: Post): bool =
|
proc isModerated*(post: Post): bool =
|
||||||
## Determines whether the specified thread is under moderation.
|
## Determines whether the specified post is under moderation
|
||||||
|
## (i.e. whether the post is invisible to ordinary users).
|
||||||
post.author.rank <= Moderated
|
post.author.rank <= Moderated
|
||||||
|
|
||||||
proc isLikedBy*(post: Post, user: Option[User]): bool =
|
proc isLikedBy*(post: Post, user: Option[User]): bool =
|
||||||
|
|
@ -57,7 +58,7 @@ type
|
||||||
email*: Option[string]
|
email*: Option[string]
|
||||||
|
|
||||||
when defined(js):
|
when defined(js):
|
||||||
import karaxutils
|
import karaxutils, threadlist
|
||||||
|
|
||||||
proc renderPostUrl*(post: Post, thread: Thread): string =
|
proc renderPostUrl*(post: Post, thread: Thread): string =
|
||||||
renderPostUrl(thread.id, post.id)
|
renderPostUrl(thread.id, post.id)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -116,7 +117,7 @@ when defined(js):
|
||||||
makeUri("/unlike")
|
makeUri("/unlike")
|
||||||
else:
|
else:
|
||||||
makeUri("/like")
|
makeUri("/like")
|
||||||
ajaxPost(uri, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(s: int, r: kstring) =>
|
(s: int, r: kstring) =>
|
||||||
onPost(s, r, state, post, currentUser.get()))
|
onPost(s, r, state, post, currentUser.get()))
|
||||||
|
|
||||||
|
|
@ -172,7 +173,7 @@ when defined(js):
|
||||||
makeUri("/unlock")
|
makeUri("/unlock")
|
||||||
else:
|
else:
|
||||||
makeUri("/lock")
|
makeUri("/lock")
|
||||||
ajaxPost(uri, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(s: int, r: kstring) =>
|
(s: int, r: kstring) =>
|
||||||
onPost(s, r, state, thread))
|
onPost(s, r, state, thread))
|
||||||
|
|
||||||
|
|
@ -189,7 +190,7 @@ when defined(js):
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
button(class="btn btn-secondary",
|
button(class="btn btn-secondary", id="lock-btn",
|
||||||
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,3 +202,60 @@ when defined(js):
|
||||||
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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
import system except Thread
|
import system except Thread
|
||||||
import options, json, times, httpcore, strformat, sugar, math, strutils
|
import options, json, times, httpcore, sugar, strutils
|
||||||
import sequtils
|
import sequtils
|
||||||
|
|
||||||
import threadlist, category, post, user
|
import threadlist, category, post, user
|
||||||
|
|
@ -15,17 +15,20 @@ 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 / [vstyles, kajax, kdom]
|
import karax / [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.
|
||||||
|
|
@ -33,8 +36,11 @@ 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)
|
||||||
|
|
@ -44,17 +50,38 @@ 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
|
||||||
|
|
@ -66,6 +93,7 @@ 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():
|
||||||
|
|
@ -179,6 +207,20 @@ 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 =
|
||||||
|
|
@ -220,8 +262,11 @@ when defined(js):
|
||||||
): VNode =
|
): VNode =
|
||||||
let postCopy = post # TODO: Another workaround here, closure capture :(
|
let postCopy = post # TODO: Another workaround here, closure capture :(
|
||||||
|
|
||||||
|
let originalPost = thread.author == post.author
|
||||||
|
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
tdiv(class=class({"highlight": highlight}, "post"), id = $post.id):
|
tdiv(class=class({"highlight": highlight, "original-post": originalPost}, "post"),
|
||||||
|
id = $post.id):
|
||||||
tdiv(class="post-icon"):
|
tdiv(class="post-icon"):
|
||||||
render(post.author, "post-avatar")
|
render(post.author, "post-avatar")
|
||||||
tdiv(class="post-main"):
|
tdiv(class="post-main"):
|
||||||
|
|
@ -283,12 +328,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.weeks > 48:
|
if diff.inWeeks > 48:
|
||||||
let years = diff.weeks div 48
|
let years = diff.inWeeks div 48
|
||||||
diffStr =
|
diffStr =
|
||||||
(if years == 1: tmpl[1] else: tmpl[2]) % $years
|
(if years == 1: tmpl[1] else: tmpl[2]) % $years
|
||||||
elif diff.weeks > 4:
|
elif diff.inWeeks > 4:
|
||||||
let months = diff.weeks div 4
|
let months = diff.inWeeks div 4
|
||||||
diffStr =
|
diffStr =
|
||||||
(if months == 1: tmpl[3] else: tmpl[4]) % $months
|
(if months == 1: tmpl[3] else: tmpl[4]) % $months
|
||||||
else:
|
else:
|
||||||
|
|
@ -326,8 +371,11 @@ when defined(js):
|
||||||
let list = state.list.get()
|
let list = state.list.get()
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
section(class="container grid-xl"):
|
section(class="container grid-xl"):
|
||||||
tdiv(class="title"):
|
tdiv(id="thread-title", class="title"):
|
||||||
p(): text list.thread.topic
|
if state.error.isSome():
|
||||||
|
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")
|
||||||
|
|
@ -340,7 +388,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"
|
||||||
render(list.thread.category)
|
genCategories(list.thread, currentUser)
|
||||||
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:
|
||||||
|
|
@ -365,6 +413,7 @@ 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import options, httpcore, json, sugar, times, strformat, strutils
|
import options, httpcore, json, sugar, times, strutils
|
||||||
|
|
||||||
import threadlist, post, category, error, user
|
import threadlist, post, 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, postbutton, delete, profilesettings
|
import karaxutils, profilesettings
|
||||||
|
|
||||||
type
|
type
|
||||||
ProfileTab* = enum
|
ProfileTab* = enum
|
||||||
|
|
@ -119,7 +119,7 @@ when defined(js):
|
||||||
),
|
),
|
||||||
onClick=(e: Event, n: VNode) => (state.currentTab = Overview)
|
onClick=(e: Event, n: VNode) => (state.currentTab = Overview)
|
||||||
):
|
):
|
||||||
a(class="c-hand"):
|
a(id="overview-tab", class="c-hand"):
|
||||||
text "Overview"
|
text "Overview"
|
||||||
li(class=class(
|
li(class=class(
|
||||||
{"active": state.currentTab == Settings},
|
{"active": state.currentTab == Settings},
|
||||||
|
|
@ -127,7 +127,7 @@ when defined(js):
|
||||||
),
|
),
|
||||||
onClick=(e: Event, n: VNode) => (state.currentTab = Settings)
|
onClick=(e: Event, n: VNode) => (state.currentTab = Settings)
|
||||||
):
|
):
|
||||||
a(class="c-hand"):
|
a(id="settings-tab", class="c-hand"):
|
||||||
italic(class="fas fa-cog")
|
italic(class="fas fa-cog")
|
||||||
text " Settings"
|
text " Settings"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
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 replybox, post, karaxutils, postbutton, error, delete, user
|
import post, karaxutils, postbutton, error, delete, user
|
||||||
|
|
||||||
type
|
type
|
||||||
ProfileSettings* = ref object
|
ProfileSettings* = ref object
|
||||||
|
|
@ -68,7 +69,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, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(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 =
|
||||||
|
|
@ -88,7 +89,7 @@ when defined(js):
|
||||||
class="form-select", value = $state.rank,
|
class="form-select", value = $state.rank,
|
||||||
onchange=(e: Event, n: VNode) => onRankChange(e, n, state)):
|
onchange=(e: Event, n: VNode) => onRankChange(e, n, state)):
|
||||||
for r in Rank:
|
for r in Rank:
|
||||||
option(text $r)
|
option(text $r, id="rank-" & toLowerAscii($r))
|
||||||
p(class="form-input-hint text-warning"):
|
p(class="form-input-hint text-warning"):
|
||||||
text "You can modify anyone's rank. Remember: with " &
|
text "You can modify anyone's rank. Remember: with " &
|
||||||
"great power comes great responsibility."
|
"great power comes great responsibility."
|
||||||
|
|
@ -165,7 +166,8 @@ when defined(js):
|
||||||
label(class="form-label"):
|
label(class="form-label"):
|
||||||
text "Account"
|
text "Account"
|
||||||
tdiv(class="col-9 col-sm-12"):
|
tdiv(class="col-9 col-sm-12"):
|
||||||
button(class="btn btn-secondary", `type`="button",
|
button(id="delete-account-btn",
|
||||||
|
class="btn btn-secondary", `type`="button",
|
||||||
onClick=(e: Event, n: VNode) =>
|
onClick=(e: Event, n: VNode) =>
|
||||||
(state.deleteModal.show(state.profile.user))):
|
(state.deleteModal.show(state.profile.user))):
|
||||||
italic(class="fas fa-times")
|
italic(class="fas fa-times")
|
||||||
|
|
@ -176,16 +178,19 @@ when defined(js):
|
||||||
span(class="text-error"):
|
span(class="text-error"):
|
||||||
text state.error.get().message
|
text state.error.get().message
|
||||||
|
|
||||||
button(class=class(
|
button(id="cancel-btn",
|
||||||
|
class=class(
|
||||||
{"disabled": not needsSave(state)}, "btn btn-link"
|
{"disabled": not needsSave(state)}, "btn btn-link"
|
||||||
),
|
),
|
||||||
onClick=(e: Event, n: VNode) => (resetSettings(state))):
|
onClick=(e: Event, n: VNode) => (resetSettings(state))):
|
||||||
text "Cancel"
|
text "Cancel"
|
||||||
|
|
||||||
button(class=class(
|
button(id="save-btn",
|
||||||
|
class=class(
|
||||||
{"disabled": not needsSave(state)}, "btn btn-primary"
|
{"disabled": not needsSave(state)}, "btn btn-primary"
|
||||||
),
|
),
|
||||||
onClick=(e: Event, n: VNode) => save(state)):
|
onClick=(e: Event, n: VNode) => save(state),
|
||||||
|
id="save-btn"):
|
||||||
italic(class="fas fa-save")
|
italic(class="fas fa-save")
|
||||||
text " Save"
|
text " Save"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ when defined(js):
|
||||||
|
|
||||||
proc performScroll() =
|
proc performScroll() =
|
||||||
let replyBox = dom.document.getElementById("reply-box")
|
let replyBox = dom.document.getElementById("reply-box")
|
||||||
replyBox.scrollIntoView(false)
|
replyBox.scrollIntoView()
|
||||||
|
|
||||||
proc show*(state: ReplyBox) =
|
proc show*(state: ReplyBox) =
|
||||||
# Scroll to the reply box.
|
# Scroll to the reply box.
|
||||||
|
|
@ -44,7 +45,7 @@ when defined(js):
|
||||||
|
|
||||||
proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) =
|
proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) =
|
||||||
postFinished:
|
postFinished:
|
||||||
kout(response)
|
echo 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) =
|
||||||
|
|
@ -56,7 +57,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, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(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) =
|
||||||
|
|
@ -80,7 +81,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, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(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) =
|
||||||
|
|
@ -114,7 +115,8 @@ when defined(js):
|
||||||
elif state.rendering.isSome():
|
elif state.rendering.isSome():
|
||||||
verbatim(state.rendering.get())
|
verbatim(state.rendering.get())
|
||||||
else:
|
else:
|
||||||
textarea(class="form-input post-text-area", rows="5",
|
textarea(id="reply-textarea",
|
||||||
|
class="form-input post-text-area", rows="5",
|
||||||
onChange=(e: Event, n: VNode) =>
|
onChange=(e: Event, n: VNode) =>
|
||||||
onChange(e, n, state),
|
onChange(e, n, state),
|
||||||
value=state.text)
|
value=state.text)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +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, KeyboardEvent
|
||||||
|
import jsffi except `&`
|
||||||
|
|
||||||
include karax/prelude
|
include karax/prelude
|
||||||
import karax / [kajax, kdom]
|
import karax / [kajax, kdom]
|
||||||
|
|
||||||
import error, replybox, threadlist, post
|
import error
|
||||||
import karaxutils
|
import karaxutils
|
||||||
|
|
||||||
type
|
type
|
||||||
|
|
@ -86,7 +87,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, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(s: int, r: kstring) => onPost(s, r, state))
|
(s: int, r: kstring) => onPost(s, r, state))
|
||||||
|
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ when defined(js):
|
||||||
from dom import nil
|
from dom import nil
|
||||||
|
|
||||||
include karax/prelude
|
include karax/prelude
|
||||||
import karax / [vstyles, kajax, kdom]
|
import karax / [kajax]
|
||||||
|
|
||||||
import karaxutils, error, threadlist, sugar
|
import karaxutils, error, threadlist, sugar
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
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]
|
||||||
|
|
@ -28,7 +29,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, @[], cast[cstring](formData),
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
(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) =
|
||||||
|
|
@ -78,17 +79,18 @@ when defined(js):
|
||||||
"data-sitekey"=recaptchaSiteKey.get())
|
"data-sitekey"=recaptchaSiteKey.get())
|
||||||
script(src="https://www.google.com/recaptcha/api.js")
|
script(src="https://www.google.com/recaptcha/api.js")
|
||||||
tdiv(class="modal-footer"):
|
tdiv(class="modal-footer"):
|
||||||
button(class=class({"loading": state.loading}, "btn btn-primary"),
|
button(class=class({"loading": state.loading},
|
||||||
onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)):
|
"btn btn-primary create-account-btn"),
|
||||||
|
onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)):
|
||||||
text "Create account"
|
text "Create account"
|
||||||
button(class="btn",
|
button(class="btn login-btn",
|
||||||
onClick=(ev: Event, n: VNode) =>
|
onClick=(ev: Event, n: VNode) =>
|
||||||
(state.onLogIn(); state.shown = false)):
|
(state.onLogIn(); state.shown = false)):
|
||||||
text "Log in"
|
text "Log in"
|
||||||
|
|
||||||
p(class="license-text text-gray"):
|
p(class="license-text text-gray"):
|
||||||
text "By registering, you agree to the "
|
text "By registering, you agree to the "
|
||||||
a(href=makeUri("/about/license"),
|
a(id="license", href=makeUri("/about/license"),
|
||||||
onClick=(ev: Event, n: VNode) =>
|
onClick=(ev: Event, n: VNode) =>
|
||||||
(state.shown = false; anchorCB(ev, n))):
|
(state.shown = false; anchorCB(ev, n))):
|
||||||
text "content license"
|
text "content license"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ 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]
|
||||||
|
|
@ -22,29 +23,38 @@ type
|
||||||
|
|
||||||
proc isModerated*(thread: Thread): bool =
|
proc isModerated*(thread: Thread): bool =
|
||||||
## Determines whether the specified thread is under moderation.
|
## Determines whether the specified thread is under moderation.
|
||||||
|
## (i.e. whether the specified thread is invisible to ordinary users).
|
||||||
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
|
import karaxutils, error, user, mainbuttons
|
||||||
|
|
||||||
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]())
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
var
|
state = newState()
|
||||||
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
|
||||||
|
|
@ -53,38 +63,19 @@ when defined(js):
|
||||||
##
|
##
|
||||||
## The rules for this are determined by the rank of the user, their
|
## The rules for this are determined by the rank of the user, their
|
||||||
## settings (TODO), and whether the thread's creator is moderated or not.
|
## settings (TODO), and whether the thread's creator is moderated or not.
|
||||||
|
##
|
||||||
|
## The ``user`` argument refers to the currently logged in user.
|
||||||
mixin isModerated
|
mixin isModerated
|
||||||
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 < Moderator and thread.isModerated:
|
if rank < 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(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):
|
result = buildHtml(td(class="thread-users")):
|
||||||
for user in users:
|
for user in users:
|
||||||
render(user, "avatar avatar-sm", showStatus=true)
|
render(user, "avatar avatar-sm", showStatus=true)
|
||||||
text " "
|
text " "
|
||||||
|
|
@ -95,44 +86,48 @@ 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.days > 30 and duration.days < 300:
|
elif duration.inDays > 30 and duration.inDays < 300:
|
||||||
return activityTime.local().format("MMM dd")
|
return activityTime.local().format("MMM dd")
|
||||||
elif duration.days != 0:
|
elif duration.inDays != 0:
|
||||||
return $duration.days & "d"
|
return $duration.inDays & "d"
|
||||||
elif duration.hours != 0:
|
elif duration.inHours != 0:
|
||||||
return $duration.hours & "h"
|
return $duration.inHours & "h"
|
||||||
elif duration.minutes != 0:
|
elif duration.inMinutes != 0:
|
||||||
return $duration.minutes & "m"
|
return $duration.inMinutes & "m"
|
||||||
else:
|
else:
|
||||||
return $duration.seconds & "s"
|
return $duration.inSeconds & "s"
|
||||||
|
|
||||||
proc genThread(thread: Thread, isNew: bool, noBorder: bool): VNode =
|
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
|
||||||
let isOld = (getTime() - thread.creation.fromUnix).weeks > 2
|
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
|
||||||
let isBanned = thread.author.rank < Moderated
|
let isBanned = thread.author.rank.isBanned()
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
tr(class=class({"no-border": noBorder, "banned": isBanned})):
|
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
|
||||||
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:
|
||||||
|
italic(class="fas fa-ban fa-xs",
|
||||||
|
title="Thread author is banned")
|
||||||
if thread.isModerated:
|
if thread.isModerated:
|
||||||
if isBanned:
|
italic(class="fas fa-eye-slash fa-xs",
|
||||||
italic(class="fas fa-ban fa-xs",
|
title="Thread is moderated")
|
||||||
title="Thread author is banned")
|
|
||||||
else:
|
|
||||||
italic(class="fas fa-eye-slash fa-xs",
|
|
||||||
title="Thread is moderated")
|
|
||||||
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),
|
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
|
||||||
onClick=anchorCB):
|
|
||||||
text thread.topic
|
text thread.topic
|
||||||
td():
|
tdiv(class="show-sm" & class({"d-none": not displayCategory})):
|
||||||
|
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(): text $thread.replies
|
td(class="thread-replies"): text $thread.replies
|
||||||
td(class=class({
|
td(class="hide-sm" & 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
|
||||||
|
|
@ -166,10 +161,13 @@ when defined(js):
|
||||||
else:
|
else:
|
||||||
state.list = some(list)
|
state.list = some(list)
|
||||||
|
|
||||||
proc onLoadMore(ev: Event, n: VNode) =
|
proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) =
|
||||||
state.loading = true
|
state.loading = true
|
||||||
let start = state.list.get().threads.len
|
let start = state.list.get().threads.len
|
||||||
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
|
if categoryId.isSome:
|
||||||
|
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]
|
||||||
|
|
@ -194,29 +192,34 @@ when defined(js):
|
||||||
isNew: thread.creation > previousVisitAt
|
isNew: thread.creation > previousVisitAt
|
||||||
)
|
)
|
||||||
|
|
||||||
proc genThreadList(currentUser: Option[User]): VNode =
|
proc genThreadList(currentUser: Option[User], categoryId: Option[int]): 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
|
||||||
ajaxGet(makeUri("threads.json"), @[], onThreadList)
|
if categoryId.isSome:
|
||||||
|
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="container grid-xl"): # TODO: Rename to `.thread-list`.
|
section(class="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(text "Category")
|
th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category"
|
||||||
th(style=style((StyleAttr.width, kstring"8rem"))): text "Users"
|
th(class="thread-users"): text "Users"
|
||||||
th(text "Replies")
|
th(class="centered-header"): text "Replies"
|
||||||
th(text "Views")
|
th(class="hide-sm centered-header"): text "Views"
|
||||||
th(text "Activity")
|
th(class="centered-header"): 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]
|
||||||
|
|
@ -224,8 +227,9 @@ 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(thread, isNew,
|
genThread(i+1, 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"):
|
||||||
|
|
@ -237,10 +241,11 @@ when defined(js):
|
||||||
td(colspan="6"):
|
td(colspan="6"):
|
||||||
tdiv(class="loading loading-lg")
|
tdiv(class="loading loading-lg")
|
||||||
else:
|
else:
|
||||||
td(colspan="6", onClick=onLoadMore):
|
td(colspan="6",
|
||||||
|
onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))):
|
||||||
span(text "load more threads")
|
span(text "load more threads")
|
||||||
|
|
||||||
proc renderThreadList*(currentUser: Option[User]): VNode =
|
proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode =
|
||||||
result = buildHtml(tdiv):
|
result = buildHtml(tdiv):
|
||||||
genTopButtons(currentUser)
|
state.mainButtons.render(currentUser, categoryId=categoryId)
|
||||||
genThreadList(currentUser)
|
genThreadList(currentUser, categoryId)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import times
|
import times, options
|
||||||
|
|
||||||
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.
|
||||||
Rank* {.pure.} = enum ## serialized as 'status'
|
Rank* {.pure.} = enum ## serialized as 'status'
|
||||||
Spammer ## spammer: every post is invisible
|
Spammer ## spammer: every post is invisible
|
||||||
Troll ## troll: cannot write new posts
|
|
||||||
Banned ## A non-specific ban
|
|
||||||
Moderated ## new member: posts manually reviewed before everybody
|
Moderated ## new member: posts manually reviewed before everybody
|
||||||
## can see them
|
## can see them
|
||||||
|
Troll ## troll: cannot write new posts
|
||||||
|
Banned ## A non-specific ban
|
||||||
EmailUnconfirmed ## member with unconfirmed email address. Their posts
|
EmailUnconfirmed ## member with unconfirmed email address. Their posts
|
||||||
## are visible, but cannot make new posts. This is so that
|
## are visible, but cannot make new posts. This is so that
|
||||||
## when a user with existing posts changes their email,
|
## when a user with existing posts changes their email,
|
||||||
|
|
@ -17,6 +17,7 @@ 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
|
||||||
|
|
@ -27,6 +28,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -34,6 +38,9 @@ proc canPost*(rank: Rank): bool =
|
||||||
## Determines whether the specified rank can make new posts.
|
## Determines whether the specified rank can make new posts.
|
||||||
rank >= Rank.User or rank == Moderated
|
rank >= Rank.User or rank == Moderated
|
||||||
|
|
||||||
|
proc isBanned*(rank: Rank): bool =
|
||||||
|
rank in {Spammer, Troll, Banned}
|
||||||
|
|
||||||
when defined(js):
|
when defined(js):
|
||||||
include karax/prelude
|
include karax/prelude
|
||||||
import karaxutils
|
import karaxutils
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ when defined(js):
|
||||||
|
|
||||||
proc render*(state: UserMenu, user: User): VNode =
|
proc render*(state: UserMenu, user: User): VNode =
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
tdiv():
|
tdiv(id="profile-btn"):
|
||||||
figure(class="avatar c-hand",
|
figure(class="avatar c-hand",
|
||||||
onClick=(e: Event, n: VNode) => onClick(e, n, state)):
|
onClick=(e: Event, n: VNode) => onClick(e, n, state)):
|
||||||
img(src=user.avatarUrl, title=user.name)
|
img(src=user.avatarUrl, title=user.name)
|
||||||
|
|
@ -52,13 +52,15 @@ when defined(js):
|
||||||
tdiv(class="tile-icon"):
|
tdiv(class="tile-icon"):
|
||||||
img(class="avatar", src=user.avatarUrl,
|
img(class="avatar", src=user.avatarUrl,
|
||||||
title=user.name)
|
title=user.name)
|
||||||
tdiv(class="tile-content"):
|
tdiv(id="profile-name", class="tile-content"):
|
||||||
text user.name
|
text user.name
|
||||||
li(class="divider")
|
li(class="divider")
|
||||||
li(class="menu-item"):
|
li(class="menu-item"):
|
||||||
a(href=makeUri("/profile/" & user.name)):
|
a(id="myprofile-btn",
|
||||||
|
href=makeUri("/profile/" & user.name)):
|
||||||
text "My profile"
|
text "My profile"
|
||||||
li(class="menu-item c-hand"):
|
li(class="menu-item c-hand"):
|
||||||
a(onClick = (e: Event, n: VNode) =>
|
a(id="logout-btn",
|
||||||
|
onClick = (e: Event, n: VNode) =>
|
||||||
(state.shown=false; state.onLogout())):
|
(state.shown=false; state.onLogout())):
|
||||||
text "Logout"
|
text "Logout"
|
||||||
|
|
@ -7,6 +7,7 @@ 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,
|
||||||
|
|
@ -46,6 +47,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,25 @@ proc backup(path: string, contents: Option[string]=none[string]()) =
|
||||||
echo(path, " already exists. Moving to ", backupPath)
|
echo(path, " already exists. Moving to ", backupPath)
|
||||||
moveFile(path, backupPath)
|
moveFile(path, backupPath)
|
||||||
|
|
||||||
|
proc createUser(db: DbConn, user: tuple[username, password, email: string],
|
||||||
|
rank: Rank) =
|
||||||
|
assert user.username.len != 0
|
||||||
|
let salt = makeSalt()
|
||||||
|
let password = makePassword(user.password, salt)
|
||||||
|
|
||||||
|
exec(db, sql"""
|
||||||
|
INSERT INTO person(name, password, email, salt, status, lastOnline)
|
||||||
|
VALUES (?, ?, ?, ?, ?, DATETIME('now'))
|
||||||
|
""", user.username, password, user.email, salt, $rank)
|
||||||
|
|
||||||
proc initialiseDb(admin: tuple[username, password, email: string],
|
proc initialiseDb(admin: tuple[username, password, email: string],
|
||||||
filename="nimforum.db") =
|
filename="nimforum.db") =
|
||||||
let path = getCurrentDir() / filename
|
let
|
||||||
if "-dev" notin filename and "-test" notin filename:
|
path = getCurrentDir() / filename
|
||||||
|
isTest = "-test" in filename
|
||||||
|
isDev = "-dev" in filename
|
||||||
|
|
||||||
|
if not isDev and not isTest:
|
||||||
backup(path)
|
backup(path)
|
||||||
|
|
||||||
removeFile(path)
|
removeFile(path)
|
||||||
|
|
@ -51,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, 'Default', '', '');
|
values (0, 'Unsorted', 'No category has been chosen yet.', '');
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# -- Thread
|
# -- Thread
|
||||||
|
|
@ -66,6 +81,7 @@ 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)
|
||||||
|
|
@ -98,13 +114,24 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
||||||
db.exec sql"create index PersonStatusIdx on person(status);"
|
db.exec sql"create index PersonStatusIdx on person(status);"
|
||||||
|
|
||||||
# Create default user.
|
# Create default user.
|
||||||
if admin.username.len != 0:
|
db.createUser(admin, Admin)
|
||||||
let salt = makeSalt()
|
|
||||||
let password = makePassword(admin.password, salt)
|
# Create some test data for development
|
||||||
|
if isTest or isDev:
|
||||||
|
for rank in Spammer..Moderator:
|
||||||
|
let rankLower = toLowerAscii($rank)
|
||||||
|
let user = (username: $rankLower,
|
||||||
|
password: $rankLower,
|
||||||
|
email: $rankLower & "@localhost.local")
|
||||||
|
db.createUser(user, rank)
|
||||||
|
|
||||||
db.exec(sql"""
|
db.exec(sql"""
|
||||||
insert into person (id, name, password, email, salt, status)
|
insert into category (name, description, color)
|
||||||
values (1, ?, ?, ?, ?, ?);
|
values ('Libraries', 'Libraries and library development', '0198E1'),
|
||||||
""", admin.username, password, admin.email, salt, $Admin)
|
('Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
|
||||||
|
('Fun', 'Posts that are just for fun', '00897B'),
|
||||||
|
('Potential Issues', 'Potential Nim compiler issues', 'E53935');
|
||||||
|
""")
|
||||||
|
|
||||||
# -- Post
|
# -- Post
|
||||||
|
|
||||||
|
|
@ -208,7 +235,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: string],
|
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
|
||||||
isDev: bool,
|
isDev: bool,
|
||||||
dbPath: string,
|
dbPath: string,
|
||||||
ga: string=""
|
ga: string=""
|
||||||
|
|
@ -224,6 +251,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +284,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 for your forum before answering them. \nPlease do so now " &
|
"recaptcha v2 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: ")
|
||||||
|
|
@ -266,6 +295,8 @@ 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.")
|
||||||
|
|
@ -275,7 +306,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), isDev=false,
|
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
|
||||||
dbPath, ga
|
dbPath, ga
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -310,7 +341,7 @@ when isMainModule:
|
||||||
"Development Forum",
|
"Development Forum",
|
||||||
"localhost",
|
"localhost",
|
||||||
recaptcha=("", ""),
|
recaptcha=("", ""),
|
||||||
smtp=("", "", ""),
|
smtp=("", "", "", "", false),
|
||||||
isDev=true,
|
isDev=true,
|
||||||
dbPath
|
dbPath
|
||||||
)
|
)
|
||||||
|
|
@ -327,7 +358,7 @@ when isMainModule:
|
||||||
"Test Forum",
|
"Test Forum",
|
||||||
"localhost",
|
"localhost",
|
||||||
recaptcha=("", ""),
|
recaptcha=("", ""),
|
||||||
smtp=("", "", ""),
|
smtp=("", "", "", "", false),
|
||||||
isDev=true,
|
isDev=true,
|
||||||
dbPath
|
dbPath
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,24 @@
|
||||||
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, getGMTime, format
|
from times import getTime, utc, 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
|
||||||
|
|
@ -55,9 +53,12 @@ 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"}.getNum(25).int
|
result.smtpPort = root{"smtpPort"}.getInt(25)
|
||||||
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("")
|
||||||
|
|
@ -67,7 +68,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"}.getNum(5000).int
|
result.port = root{"port"}.getInt(5000)
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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 runbackend")
|
spawn runProcess("nimble -y testbackend")
|
||||||
defer:
|
defer:
|
||||||
discard execCmd("killall " & backend)
|
discard execCmd("killall " & backend)
|
||||||
|
|
||||||
|
|
@ -43,9 +43,11 @@ template withBackend(body: untyped): untyped =
|
||||||
|
|
||||||
body
|
body
|
||||||
|
|
||||||
import browsertests/scenario1
|
import browsertests/[scenario1, threads, issue181, categories]
|
||||||
|
|
||||||
when isMainModule:
|
proc main() =
|
||||||
|
# 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")
|
||||||
|
|
@ -63,8 +65,14 @@ when isMainModule:
|
||||||
|
|
||||||
withBackend:
|
withBackend:
|
||||||
scenario1.test(session, baseUrl)
|
scenario1.test(session, baseUrl)
|
||||||
|
threads.test(session, baseUrl)
|
||||||
|
categories.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()
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
--threads:on
|
--threads:on
|
||||||
|
--path:"../src/frontend"
|
||||||
214
tests/browsertests/categories.nim
Normal file
214
tests/browsertests/categories.nim
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
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)
|
||||||
189
tests/browsertests/common.nim
Normal file
189
tests/browsertests/common.nim
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import os, options, unittest, strutils
|
||||||
|
import webdriver
|
||||||
|
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 =
|
||||||
|
## Execute a set of statements with an object
|
||||||
|
expectKind code, nnkStmtList
|
||||||
|
|
||||||
|
template checkCompiles(res, default) =
|
||||||
|
when compiles(res):
|
||||||
|
res
|
||||||
|
else:
|
||||||
|
default
|
||||||
|
|
||||||
|
result = code.copy
|
||||||
|
|
||||||
|
# Simply inject obj into call
|
||||||
|
for i in 0 ..< result.len:
|
||||||
|
if result[i].kind in {nnkCommand, nnkCall}:
|
||||||
|
result[i].insert(1, obj)
|
||||||
|
|
||||||
|
result = getAst(checkCompiles(result, code))
|
||||||
|
|
||||||
|
proc elementIsSome(element: Option[Element]): bool =
|
||||||
|
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()
|
||||||
|
|
||||||
|
proc sendKeys*(session: Session, element, keys: string) =
|
||||||
|
let el = session.waitForElement(element)
|
||||||
|
el.get().sendKeys(keys)
|
||||||
|
|
||||||
|
proc clear*(session: Session, element: string) =
|
||||||
|
let el = session.waitForElement(element)
|
||||||
|
el.get().clear()
|
||||||
|
|
||||||
|
proc sendKeys*(session: Session, element: string, keys: varargs[Key]) =
|
||||||
|
let el = session.waitForElement(element)
|
||||||
|
|
||||||
|
# focus
|
||||||
|
el.get().click()
|
||||||
|
for key in keys:
|
||||||
|
session.press(key)
|
||||||
|
|
||||||
|
proc ensureExists*(session: Session, element: string, strategy=CssSelector) =
|
||||||
|
discard session.waitForElement(element, strategy)
|
||||||
|
|
||||||
|
template check*(session: Session, element: string, function: untyped) =
|
||||||
|
let el = session.waitForElement(element)
|
||||||
|
check function(el)
|
||||||
|
|
||||||
|
template check*(session: Session, element: string,
|
||||||
|
strategy: LocationStrategy, function: untyped) =
|
||||||
|
let el = session.waitForElement(element, strategy)
|
||||||
|
check function(el)
|
||||||
|
|
||||||
|
proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
|
||||||
|
let el = session.waitForElement(element, strategy)
|
||||||
|
discard session.execute("arguments[0].setAttribute('value', '" & color & "')", el.get())
|
||||||
|
|
||||||
|
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
|
||||||
|
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
|
||||||
|
|
||||||
|
template checkText*(session: Session, element, expectedValue: string) =
|
||||||
|
let el = session.waitForElement(element)
|
||||||
|
check el.get().getText() == expectedValue
|
||||||
|
|
||||||
|
proc waitForElement*(
|
||||||
|
session: Session, selector: string, strategy=CssSelector,
|
||||||
|
timeout=20000, pollTime=50,
|
||||||
|
waitCondition: proc(element: Option[Element]): bool = elementIsSome
|
||||||
|
): Option[Element] =
|
||||||
|
var waitTime = 0
|
||||||
|
|
||||||
|
when actionDelayMs > 0:
|
||||||
|
sleep(actionDelayMs)
|
||||||
|
|
||||||
|
while true:
|
||||||
|
try:
|
||||||
|
let loading = session.findElement(selector, strategy)
|
||||||
|
if waitCondition(loading):
|
||||||
|
return loading
|
||||||
|
finally:
|
||||||
|
discard
|
||||||
|
sleep(pollTime)
|
||||||
|
waitTime += pollTime
|
||||||
|
|
||||||
|
if waitTime > timeout:
|
||||||
|
doAssert false, "Wait for load time exceeded"
|
||||||
|
|
||||||
|
proc waitForElements*(
|
||||||
|
session: Session, selector: string, strategy=CssSelector,
|
||||||
|
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) =
|
||||||
|
with session:
|
||||||
|
navigate(baseUrl & "profile/" & user)
|
||||||
|
|
||||||
|
click "#settings-tab"
|
||||||
|
|
||||||
|
click "#rank-field"
|
||||||
|
click("#rank-field option#rank-" & rank.toLowerAscii)
|
||||||
|
|
||||||
|
click "#save-btn"
|
||||||
|
|
||||||
|
proc logout*(session: Session) =
|
||||||
|
with session:
|
||||||
|
click "#profile-btn"
|
||||||
|
click "#profile-btn #logout-btn"
|
||||||
|
|
||||||
|
# Verify we have logged out by looking for the log in button.
|
||||||
|
ensureExists "#login-btn"
|
||||||
|
|
||||||
|
proc login*(session: Session, user, password: string) =
|
||||||
|
with session:
|
||||||
|
click "#login-btn"
|
||||||
|
|
||||||
|
clear "#login-form input[name='username']"
|
||||||
|
clear "#login-form input[name='password']"
|
||||||
|
|
||||||
|
sendKeys "#login-form input[name='username']", user
|
||||||
|
sendKeys "#login-form input[name='password']", password
|
||||||
|
|
||||||
|
sendKeys "#login-form input[name='password']", Key.Enter
|
||||||
|
|
||||||
|
# Verify that the user menu has been initialised properly.
|
||||||
|
click "#profile-btn"
|
||||||
|
checkText "#profile-btn #profile-name", user
|
||||||
|
click "#profile-btn"
|
||||||
|
|
||||||
|
proc register*(session: Session, user, password: string, verify = true) =
|
||||||
|
with session:
|
||||||
|
click "#signup-btn"
|
||||||
|
|
||||||
|
clear "#signup-form input[name='email']"
|
||||||
|
clear "#signup-form input[name='username']"
|
||||||
|
clear "#signup-form input[name='password']"
|
||||||
|
|
||||||
|
sendKeys "#signup-form input[name='email']", user & "@" & user & ".com"
|
||||||
|
sendKeys "#signup-form input[name='username']", user
|
||||||
|
sendKeys "#signup-form input[name='password']", password
|
||||||
|
|
||||||
|
click "#signup-modal .create-account-btn"
|
||||||
|
|
||||||
|
if verify:
|
||||||
|
with session:
|
||||||
|
# Verify that the user menu has been initialised properly.
|
||||||
|
click "#profile-btn"
|
||||||
|
checkText "#profile-btn #profile-name", user
|
||||||
|
# close menu
|
||||||
|
click "#profile-btn"
|
||||||
|
|
||||||
|
proc createThread*(session: Session, title, content: string) =
|
||||||
|
with session:
|
||||||
|
click "#new-thread-btn"
|
||||||
|
|
||||||
|
sendKeys "#thread-title", title
|
||||||
|
sendKeys "#reply-textarea", content
|
||||||
|
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
checkText "#thread-title .title-text", title
|
||||||
|
checkText ".original-post div.post-content", content
|
||||||
36
tests/browsertests/issue181.nim
Normal file
36
tests/browsertests/issue181.nim
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import unittest, common
|
||||||
|
|
||||||
|
import webdriver
|
||||||
|
|
||||||
|
proc test*(session: Session, baseUrl: string) =
|
||||||
|
session.navigate(baseUrl)
|
||||||
|
|
||||||
|
test "can see banned posts":
|
||||||
|
with session:
|
||||||
|
register("issue181", "issue181")
|
||||||
|
logout()
|
||||||
|
|
||||||
|
# Change rank to `user` so they can post.
|
||||||
|
login("admin", "admin")
|
||||||
|
setUserRank(baseUrl, "issue181", "user")
|
||||||
|
logout()
|
||||||
|
|
||||||
|
login("issue181", "issue181")
|
||||||
|
navigate(baseUrl)
|
||||||
|
|
||||||
|
const title = "Testing issue 181."
|
||||||
|
createThread(title, "Test for issue #181")
|
||||||
|
|
||||||
|
logout()
|
||||||
|
|
||||||
|
login("admin", "admin")
|
||||||
|
|
||||||
|
# Ban our user.
|
||||||
|
setUserRank(baseUrl, "issue181", "banned")
|
||||||
|
|
||||||
|
# Make sure the banned user's thread is still visible.
|
||||||
|
navigate(baseUrl)
|
||||||
|
ensureExists("tr.banned")
|
||||||
|
checkText("tr.banned .thread-title > a", title)
|
||||||
|
logout()
|
||||||
|
checkText("tr.banned .thread-title > a", title)
|
||||||
|
|
@ -1,115 +1,43 @@
|
||||||
import unittest, options, os
|
import unittest, common
|
||||||
|
|
||||||
import webdriver
|
import webdriver
|
||||||
|
|
||||||
proc waitForLoad(session: Session, timeout=20000) =
|
|
||||||
var waitTime = 0
|
|
||||||
sleep(2000)
|
|
||||||
|
|
||||||
while true:
|
|
||||||
let loading = session.findElement(".loading")
|
|
||||||
if loading.isNone: return
|
|
||||||
sleep(1000)
|
|
||||||
waitTime += 1000
|
|
||||||
|
|
||||||
if waitTime > timeout:
|
|
||||||
doAssert false, "Wait for load time exceeded"
|
|
||||||
|
|
||||||
proc test*(session: Session, baseUrl: string) =
|
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":
|
||||||
let signUp = session.findElement("#signup-btn")
|
session.checkText("#signup-btn", "Sign up")
|
||||||
check signUp.get().getText() == "Sign up"
|
|
||||||
|
|
||||||
test "shows log in":
|
test "shows log in":
|
||||||
let logIn = session.findElement("#login-btn")
|
session.checkText("#login-btn", "Log in")
|
||||||
check logIn.get().getText() == "Log in"
|
|
||||||
|
|
||||||
test "is empty":
|
test "is empty":
|
||||||
let thread = session.findElement("tr > td.thread-title")
|
session.checkIsNone("tr > td.thread-title")
|
||||||
check thread.isNone()
|
|
||||||
|
|
||||||
# Logging in
|
# Logging in
|
||||||
test "can login/logout":
|
test "can login/logout":
|
||||||
let logIn = session.findElement("#login-btn").get()
|
with session:
|
||||||
logIn.click()
|
login("admin", "admin")
|
||||||
|
|
||||||
let usernameField = session.findElement(
|
# Check whether we can log out.
|
||||||
"#login-form input[name='username']"
|
logout()
|
||||||
)
|
# Verify we have logged out by looking for the log in button.
|
||||||
check usernameField.isSome()
|
ensureExists "#login-btn"
|
||||||
let passwordField = session.findElement(
|
|
||||||
"#login-form input[name='password']"
|
|
||||||
)
|
|
||||||
check passwordField.isSome()
|
|
||||||
|
|
||||||
usernameField.get().sendKeys("admin")
|
|
||||||
passwordField.get().sendKeys("admin")
|
|
||||||
passwordField.get().click() # Focus field.
|
|
||||||
session.press(Key.Enter)
|
|
||||||
|
|
||||||
waitForLoad(session, 5000)
|
|
||||||
|
|
||||||
# Verify that the user menu has been initialised properly.
|
|
||||||
let profileButton = session.findElement(
|
|
||||||
"#main-navbar figure.avatar"
|
|
||||||
).get()
|
|
||||||
profileButton.click()
|
|
||||||
|
|
||||||
let profileName = session.findElement(
|
|
||||||
"#main-navbar .menu-right div.tile-content"
|
|
||||||
).get()
|
|
||||||
|
|
||||||
check profileName.getText() == "admin"
|
|
||||||
|
|
||||||
# Check whether we can log out.
|
|
||||||
let logoutLink = session.findElement(
|
|
||||||
"Logout",
|
|
||||||
LinkTextSelector
|
|
||||||
).get()
|
|
||||||
logoutLink.click()
|
|
||||||
|
|
||||||
# Verify we have logged out by looking for the log in button.
|
|
||||||
check session.findElement("#login-btn").isSome()
|
|
||||||
|
|
||||||
test "can register":
|
test "can register":
|
||||||
let signup = session.findElement("#signup-btn").get()
|
with session:
|
||||||
signup.click()
|
register("test", "test")
|
||||||
|
logout()
|
||||||
|
|
||||||
let emailField = session.findElement(
|
test "can't register same username with different case":
|
||||||
"#signup-form input[name='email']"
|
with session:
|
||||||
).get()
|
register "test1", "test1", verify = false
|
||||||
let usernameField = session.findElement(
|
logout()
|
||||||
"#signup-form input[name='username']"
|
|
||||||
).get()
|
|
||||||
let passwordField = session.findElement(
|
|
||||||
"#signup-form input[name='password']"
|
|
||||||
).get()
|
|
||||||
|
|
||||||
emailField.sendKeys("test@test.com")
|
navigate baseUrl
|
||||||
usernameField.sendKeys("test")
|
|
||||||
passwordField.sendKeys("test")
|
|
||||||
|
|
||||||
let createAccount = session.findElement(
|
register "TEst1", "test1", verify = false
|
||||||
"#signup-modal .modal-footer .btn-primary"
|
|
||||||
).get()
|
|
||||||
|
|
||||||
createAccount.click()
|
ensureExists "#signup-form .has-error"
|
||||||
|
navigate baseUrl
|
||||||
waitForLoad(session, 5000)
|
|
||||||
|
|
||||||
# Verify that the user menu has been initialised properly.
|
|
||||||
let profileButton = session.findElement(
|
|
||||||
"#main-navbar figure.avatar"
|
|
||||||
).get()
|
|
||||||
profileButton.click()
|
|
||||||
|
|
||||||
let profileName = session.findElement(
|
|
||||||
"#main-navbar .menu-right div.tile-content"
|
|
||||||
).get()
|
|
||||||
|
|
||||||
check profileName.getText() == "test"
|
|
||||||
|
|
|
||||||
267
tests/browsertests/threads.nim
Normal file
267
tests/browsertests/threads.nim
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import unittest, common
|
||||||
|
import webdriver
|
||||||
|
|
||||||
|
let
|
||||||
|
userTitleStr = "This is a user thread!"
|
||||||
|
userContentStr = "A user has filled this out"
|
||||||
|
|
||||||
|
adminTitleStr = "This is a thread title!"
|
||||||
|
adminContentStr = "This is content"
|
||||||
|
|
||||||
|
proc banUser(session: Session, baseUrl: string) =
|
||||||
|
with session:
|
||||||
|
login "admin", "admin"
|
||||||
|
setUserRank baseUrl, "user", "banned"
|
||||||
|
logout()
|
||||||
|
|
||||||
|
proc unBanUser(session: Session, baseUrl: string) =
|
||||||
|
with session:
|
||||||
|
login "admin", "admin"
|
||||||
|
setUserRank baseUrl, "user", "user"
|
||||||
|
logout()
|
||||||
|
|
||||||
|
proc userTests(session: Session, baseUrl: string) =
|
||||||
|
suite "user thread tests":
|
||||||
|
session.login("user", "user")
|
||||||
|
|
||||||
|
setup:
|
||||||
|
session.navigate(baseUrl)
|
||||||
|
|
||||||
|
test "can create thread":
|
||||||
|
with session:
|
||||||
|
click "#new-thread-btn"
|
||||||
|
|
||||||
|
sendKeys "#thread-title", userTitleStr
|
||||||
|
sendKeys "#reply-textarea", userContentStr
|
||||||
|
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
checkText "#thread-title .title-text", userTitleStr
|
||||||
|
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()
|
||||||
|
|
||||||
|
proc anonymousTests(session: Session, baseUrl: string) =
|
||||||
|
suite "anonymous user tests":
|
||||||
|
with session:
|
||||||
|
navigate baseUrl
|
||||||
|
|
||||||
|
test "can view banned thread":
|
||||||
|
with session:
|
||||||
|
ensureExists userTitleStr, LinkTextSelector
|
||||||
|
|
||||||
|
with session:
|
||||||
|
navigate baseUrl
|
||||||
|
|
||||||
|
proc bannedTests(session: Session, baseUrl: string) =
|
||||||
|
suite "banned user thread tests":
|
||||||
|
with session:
|
||||||
|
navigate baseUrl
|
||||||
|
login "banned", "banned"
|
||||||
|
|
||||||
|
test "can't start thread":
|
||||||
|
with session:
|
||||||
|
click "#new-thread-btn"
|
||||||
|
|
||||||
|
sendKeys "#thread-title", "test"
|
||||||
|
sendKeys "#reply-textarea", "test"
|
||||||
|
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
ensureExists "#new-thread p.text-error"
|
||||||
|
|
||||||
|
session.logout()
|
||||||
|
|
||||||
|
proc adminTests(session: Session, baseUrl: string) =
|
||||||
|
suite "admin thread tests":
|
||||||
|
session.login("admin", "admin")
|
||||||
|
|
||||||
|
setup:
|
||||||
|
session.navigate(baseUrl)
|
||||||
|
|
||||||
|
test "can view banned thread":
|
||||||
|
with session:
|
||||||
|
ensureExists userTitleStr, LinkTextSelector
|
||||||
|
|
||||||
|
test "can create thread":
|
||||||
|
with session:
|
||||||
|
click "#new-thread-btn"
|
||||||
|
|
||||||
|
sendKeys "#thread-title", adminTitleStr
|
||||||
|
sendKeys "#reply-textarea", adminContentStr
|
||||||
|
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
checkText "#thread-title .title-text", adminTitleStr
|
||||||
|
checkText ".original-post div.post-content", adminContentStr
|
||||||
|
|
||||||
|
test "try create duplicate thread":
|
||||||
|
with session:
|
||||||
|
click "#new-thread-btn"
|
||||||
|
ensureExists "#new-thread"
|
||||||
|
|
||||||
|
sendKeys "#thread-title", adminTitleStr
|
||||||
|
sendKeys "#reply-textarea", adminContentStr
|
||||||
|
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
ensureExists "#new-thread p.text-error"
|
||||||
|
|
||||||
|
test "can edit post":
|
||||||
|
let modificationText = " and I edited it!"
|
||||||
|
with session:
|
||||||
|
click adminTitleStr, LinkTextSelector
|
||||||
|
|
||||||
|
click ".post-buttons .edit-button"
|
||||||
|
|
||||||
|
sendKeys ".original-post #reply-textarea", modificationText
|
||||||
|
click ".edit-buttons .save-button"
|
||||||
|
|
||||||
|
checkText ".original-post div.post-content", adminContentStr & modificationText
|
||||||
|
|
||||||
|
test "can like thread":
|
||||||
|
# Try to like the user thread above
|
||||||
|
|
||||||
|
with session:
|
||||||
|
click userTitleStr, LinkTextSelector
|
||||||
|
|
||||||
|
click ".post-buttons .like-button"
|
||||||
|
|
||||||
|
checkText ".post-buttons .like-button .like-count", "1"
|
||||||
|
|
||||||
|
test "can delete thread":
|
||||||
|
with session:
|
||||||
|
click adminTitleStr, LinkTextSelector
|
||||||
|
|
||||||
|
click ".post-buttons .delete-button"
|
||||||
|
|
||||||
|
# click delete confirmation
|
||||||
|
click "#delete-modal .delete-btn"
|
||||||
|
|
||||||
|
# Make sure the forum post is gone
|
||||||
|
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()
|
||||||
|
|
||||||
|
proc test*(session: Session, baseUrl: string) =
|
||||||
|
session.navigate(baseUrl)
|
||||||
|
|
||||||
|
userTests(session, baseUrl)
|
||||||
|
|
||||||
|
banUser(session, baseUrl)
|
||||||
|
|
||||||
|
bannedTests(session, baseUrl)
|
||||||
|
anonymousTests(session, baseUrl)
|
||||||
|
adminTests(session, baseUrl)
|
||||||
|
|
||||||
|
unBanUser(session, baseUrl)
|
||||||
|
|
||||||
|
session.navigate(baseUrl)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue