Compare commits
1 commit
master
...
fix_issue_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b39358dd6 |
38 changed files with 377 additions and 1157 deletions
80
.github/workflows/main.yml
vendored
80
.github/workflows/main.yml
vendored
|
|
@ -1,80 +0,0 @@
|
|||
# This is a basic workflow to help you get started with Actions
|
||||
|
||||
name: CI
|
||||
|
||||
# Controls when the action will run.
|
||||
on:
|
||||
# Triggers the workflow on push or pull request events but only for the master branch
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
||||
jobs:
|
||||
test_stable:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
firefox: [ '73.0' ]
|
||||
include:
|
||||
- nim-version: 'stable'
|
||||
cache-key: 'stable'
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Setup firefox
|
||||
uses: browser-actions/setup-firefox@latest
|
||||
with:
|
||||
firefox-version: ${{ matrix.firefox }}
|
||||
|
||||
- name: Get Date
|
||||
id: get-date
|
||||
run: echo "::set-output name=date::$(date "+%Y-%m-%d")"
|
||||
shell: bash
|
||||
|
||||
- name: Cache choosenim
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.choosenim
|
||||
key: ${{ runner.os }}-choosenim-${{ matrix.cache-key }}
|
||||
|
||||
- name: Cache nimble
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: ${{ runner.os }}-nimble-${{ hashFiles('*.nimble') }}
|
||||
|
||||
- uses: jiro4989/setup-nim-action@v1
|
||||
with:
|
||||
nim-version: "${{ matrix.nim-version }}"
|
||||
|
||||
- name: Install geckodriver
|
||||
run: |
|
||||
sudo apt-get -qq update
|
||||
sudo apt-get install autoconf libtool libsass-dev
|
||||
|
||||
wget https://github.com/mozilla/geckodriver/releases/download/v0.29.1/geckodriver-v0.29.1-linux64.tar.gz
|
||||
mkdir geckodriver
|
||||
tar -xzf geckodriver-v0.29.1-linux64.tar.gz -C geckodriver
|
||||
export PATH=$PATH:$PWD/geckodriver
|
||||
|
||||
- name: Install choosenim
|
||||
run: |
|
||||
export CHOOSENIM_CHOOSE_VERSION="stable"
|
||||
curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh
|
||||
sh init.sh -y
|
||||
export PATH=$HOME/.nimble/bin:$PATH
|
||||
nimble refresh -y
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
export MOZ_HEADLESS=1
|
||||
nimble -y install
|
||||
nimble -y test
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -14,10 +14,3 @@ createdb
|
|||
editdb
|
||||
|
||||
.vscode
|
||||
forum.json*
|
||||
browsertester
|
||||
setup_nimforum
|
||||
buildcss
|
||||
nimforum.css
|
||||
|
||||
/src/frontend/forum.js
|
||||
|
|
|
|||
45
.travis.yml
Normal file
45
.travis.yml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
os:
|
||||
- linux
|
||||
|
||||
language: c
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- "$HOME/.nimble"
|
||||
- "$HOME/.choosenim"
|
||||
|
||||
addons:
|
||||
firefox: "73.0"
|
||||
|
||||
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.26.0/geckodriver-v0.26.0-linux64.tar.gz
|
||||
- mkdir geckodriver
|
||||
- tar -xzf geckodriver-v0.26.0-linux64.tar.gz -C geckodriver
|
||||
- export PATH=$PATH:$PWD/geckodriver
|
||||
|
||||
install:
|
||||
- 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
|
||||
|
||||
script:
|
||||
- export MOZ_HEADLESS=1
|
||||
- nimble -y install
|
||||
- nimble -y test
|
||||
33
README.md
33
README.md
|
|
@ -80,6 +80,7 @@ nimble frontend
|
|||
nimble backend
|
||||
```
|
||||
|
||||
|
||||
Development typically involves running `nimble devdb` which sets up the
|
||||
database for development and testing, then `nimble backend`
|
||||
which compiles and runs the forum's backend, and `nimble frontend`
|
||||
|
|
@ -87,38 +88,6 @@ separately to build the frontend. When making changes to the frontend it
|
|||
should be enough to simply run `nimble frontend` again to rebuild. This command
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
FROM nimlang/nim:1.2.6-ubuntu
|
||||
|
||||
RUN apt-get update -yqq \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libsass-dev \
|
||||
sqlite3 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
|
||||
# install dependencies
|
||||
RUN nimble install -Y
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
version: "3.7"
|
||||
|
||||
services:
|
||||
forum:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./docker/Dockerfile
|
||||
volumes:
|
||||
- "../:/app"
|
||||
ports:
|
||||
- "5000:5000"
|
||||
entrypoint: "/app/docker/entrypoint.sh"
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
git submodule update --init --recursive
|
||||
|
||||
# setup
|
||||
nimble c -d:release src/setup_nimforum.nim
|
||||
./src/setup_nimforum --dev
|
||||
|
||||
# build frontend
|
||||
nimble c -r src/buildcss
|
||||
nimble js -d:release src/frontend/forum.nim
|
||||
mkdir -p public/js
|
||||
cp src/frontend/forum.js public/js/forum.js
|
||||
|
||||
# build backend
|
||||
nimble c src/forum.nim
|
||||
./src/forum
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# Package
|
||||
version = "2.1.0"
|
||||
version = "2.0.2"
|
||||
author = "Dominik Picheta"
|
||||
description = "The Nim forum"
|
||||
license = "MIT"
|
||||
|
|
@ -13,15 +13,15 @@ skipExt = @["nim"]
|
|||
# Dependencies
|
||||
|
||||
requires "nim >= 1.0.6"
|
||||
requires "jester#405be2e"
|
||||
requires "bcrypt#440c5676ff6"
|
||||
requires "jester#d8a03aa"
|
||||
requires "bcrypt#head"
|
||||
requires "hmac#9c61ebe2fd134cf97"
|
||||
requires "recaptcha#d06488e"
|
||||
requires "sass#649e0701fa5c"
|
||||
|
||||
requires "karax#5f21dcd"
|
||||
requires "karax#f6bda9a"
|
||||
|
||||
requires "webdriver#429933a"
|
||||
requires "webdriver#c2fee57"
|
||||
|
||||
# Tasks
|
||||
|
||||
|
|
@ -32,9 +32,6 @@ task backend, "Compiles and runs the forum backend":
|
|||
task runbackend, "Runs the forum backend":
|
||||
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)":
|
||||
exec "nimble c -r src/buildcss"
|
||||
exec "nimble js -d:release src/frontend/forum.nim"
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ table th {
|
|||
// Custom styles.
|
||||
// - Navigation bar.
|
||||
$navbar-height: 60px;
|
||||
$default-category-color: #a3a3a3;
|
||||
$logo-height: $navbar-height - 20px;
|
||||
|
||||
.navbar-button {
|
||||
|
|
@ -51,7 +50,6 @@ $logo-height: $navbar-height - 20px;
|
|||
// Unfortunately we must colour the controls in the navbar manually.
|
||||
.search-input {
|
||||
@extend .form-input;
|
||||
min-width: 120px;
|
||||
border-color: $navbar-border-color-dark;
|
||||
}
|
||||
|
||||
|
|
@ -123,22 +121,6 @@ $logo-height: $navbar-height - 20px;
|
|||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
@ -184,33 +166,6 @@ $logo-height: $navbar-height - 20px;
|
|||
}
|
||||
}
|
||||
|
||||
.thread-list {
|
||||
@extend .container;
|
||||
@extend .grid-xl;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
@extend .thread-list;
|
||||
|
||||
|
||||
.category-title {
|
||||
@extend .thread-title;
|
||||
a, a:hover {
|
||||
color: lighten($body-font-color, 10%);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.category-description {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
#categories-list .category {
|
||||
border-left: 6px solid;
|
||||
border-left-color: $default-category-color;
|
||||
}
|
||||
|
||||
$super-popular-color: #f86713;
|
||||
$popular-color: darken($super-popular-color, 25%);
|
||||
$threads-meta-color: #545d70;
|
||||
|
|
@ -262,7 +217,7 @@ $threads-meta-color: #545d70;
|
|||
.category-color {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0.25rem solid $default-category-color;
|
||||
border: 0.3rem solid #98c766;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
|
@ -301,14 +256,6 @@ $threads-meta-color: #545d70;
|
|||
}
|
||||
}
|
||||
|
||||
.thread-replies, .thread-time, .views-text, .popular-text, .centered-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.thread-users {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.thread-time {
|
||||
color: $threads-meta-color;
|
||||
|
||||
|
|
@ -779,3 +726,8 @@ hr {
|
|||
margin-top: $control-padding-y*2;
|
||||
}
|
||||
}
|
||||
|
||||
// - Hide features that have not been implemented yet.
|
||||
#main-buttons > section.navbar-section:nth-child(1) {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=$ga"></script>
|
||||
<script>
|
||||
window.SERVER_URL = "$server_url";
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import os
|
||||
import os, strutils
|
||||
|
||||
import sass
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ proc newMailer*(config: Config): Mailer =
|
|||
proc rateCheck(mailer: Mailer, address: string): bool =
|
||||
## Returns true if we've emailed the address too much.
|
||||
let diff = getTime() - mailer.lastReset
|
||||
if diff.inHours >= 1:
|
||||
if diff.hours >= 1:
|
||||
mailer.lastReset = getTime()
|
||||
mailer.emailsSent.clear()
|
||||
|
||||
|
|
@ -44,18 +44,8 @@ proc sendMail(
|
|||
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:
|
||||
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
|
||||
|
||||
|
|
@ -64,9 +54,6 @@ proc sendMail(
|
|||
var headers = otherHeaders
|
||||
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,
|
||||
toList, @[], headers)
|
||||
|
||||
|
|
|
|||
182
src/forum.nim
182
src/forum.nim
|
|
@ -151,7 +151,7 @@ proc checkLoggedIn(c: TForumData) =
|
|||
)
|
||||
c.previousVisitAt = personRow[1].parseInt
|
||||
let diff = getTime() - fromUnix(personRow[0].parseInt)
|
||||
if diff.inMinutes > 30:
|
||||
if diff.minutes > 30:
|
||||
c.previousVisitAt = personRow[0].parseInt
|
||||
db.exec(
|
||||
sql"""
|
||||
|
|
@ -238,7 +238,7 @@ proc verifyIdentHash(
|
|||
let newIdent = makeIdentHash(name, row[0], epoch, row[1])
|
||||
# Check that it hasn't expired.
|
||||
let diff = getTime() - epoch.fromUnix()
|
||||
if diff.inHours > 2:
|
||||
if diff.hours > 2:
|
||||
raise newForumError("Link expired")
|
||||
if newIdent != ident:
|
||||
raise newForumError("Invalid ident hash")
|
||||
|
|
@ -267,7 +267,9 @@ proc initialise() =
|
|||
{
|
||||
"title": config.title,
|
||||
"timestamp": encodeUrl(CompileDate & CompileTime),
|
||||
"ga": config.ga
|
||||
"ga": config.ga,
|
||||
# An ugly hack, maybe there's a better way?
|
||||
"server_url": "$server_url"
|
||||
}.newStringTable()
|
||||
|
||||
|
||||
|
|
@ -276,26 +278,25 @@ template createTFD() =
|
|||
new(c)
|
||||
init(c)
|
||||
c.req = request
|
||||
if cookies(request).len > 0:
|
||||
if request.cookies.len > 0:
|
||||
checkLoggedIn(c)
|
||||
|
||||
#[ DB functions. TODO: Move to another module? ]#
|
||||
|
||||
proc selectUser(userRow: seq[string], avatarSize: int=80): User =
|
||||
result = User(
|
||||
id: userRow[0],
|
||||
name: userRow[1],
|
||||
avatarUrl: userRow[2].getGravatarUrl(avatarSize),
|
||||
lastOnline: userRow[3].parseInt,
|
||||
previousVisitAt: userRow[4].parseInt,
|
||||
rank: parseEnum[Rank](userRow[5]),
|
||||
isDeleted: userRow[6] == "1"
|
||||
name: userRow[0],
|
||||
avatarUrl: userRow[1].getGravatarUrl(avatarSize),
|
||||
lastOnline: userRow[2].parseInt,
|
||||
previousVisitAt: userRow[3].parseInt,
|
||||
rank: parseEnum[Rank](userRow[4]),
|
||||
isDeleted: userRow[5] == "1"
|
||||
)
|
||||
|
||||
# Don't give data about a deleted user.
|
||||
if result.isDeleted:
|
||||
result.name = "DeletedUser"
|
||||
result.avatarUrl = getGravatarUrl(result.name & userRow[2], avatarSize)
|
||||
result.avatarUrl = getGravatarUrl(result.name & userRow[1], avatarSize)
|
||||
|
||||
proc selectPost(postRow: seq[string], skippedPosts: seq[int],
|
||||
replyingTo: Option[PostLink], history: seq[PostInfo],
|
||||
|
|
@ -303,7 +304,7 @@ proc selectPost(postRow: seq[string], skippedPosts: seq[int],
|
|||
return Post(
|
||||
id: postRow[0].parseInt,
|
||||
replyingTo: replyingTo,
|
||||
author: selectUser(postRow[5..11]),
|
||||
author: selectUser(postRow[5..10]),
|
||||
likes: likes,
|
||||
seen: false, # TODO:
|
||||
history: history,
|
||||
|
|
@ -319,7 +320,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
|
|||
|
||||
const replyingToQuery = sql"""
|
||||
select p.id, strftime('%s', p.creation), p.thread,
|
||||
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status,
|
||||
u.isDeleted,
|
||||
t.name
|
||||
|
|
@ -335,7 +336,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
|
|||
topic: row[^1],
|
||||
threadId: row[2].parseInt(),
|
||||
postId: row[0].parseInt(),
|
||||
author: some(selectUser(row[3..9]))
|
||||
author: some(selectUser(row[3..8]))
|
||||
))
|
||||
|
||||
proc selectHistory(postId: int): seq[PostInfo] =
|
||||
|
|
@ -354,7 +355,7 @@ proc selectHistory(postId: int): seq[PostInfo] =
|
|||
|
||||
proc selectLikes(postId: int): seq[User] =
|
||||
const likeQuery = sql"""
|
||||
select u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
select u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status,
|
||||
u.isDeleted
|
||||
from like h, person u
|
||||
|
|
@ -369,7 +370,7 @@ proc selectLikes(postId: int): seq[User] =
|
|||
proc selectThreadAuthor(threadId: int): User =
|
||||
const authorQuery =
|
||||
sql"""
|
||||
select id, name, email, strftime('%s', lastOnline),
|
||||
select name, email, strftime('%s', lastOnline),
|
||||
strftime('%s', previousVisitAt), status, isDeleted
|
||||
from person where id in (
|
||||
select author from post
|
||||
|
|
@ -387,7 +388,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
|||
where thread = ?;"""
|
||||
const usersListQuery =
|
||||
sql"""
|
||||
select u.id, name, email, strftime('%s', lastOnline),
|
||||
select name, email, strftime('%s', lastOnline),
|
||||
strftime('%s', previousVisitAt), status, u.isDeleted,
|
||||
count(*)
|
||||
from person u, post p where p.author = u.id and p.thread = ?
|
||||
|
|
@ -400,10 +401,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
|||
id: threadRow[0].parseInt,
|
||||
topic: threadRow[1],
|
||||
category: Category(
|
||||
id: threadRow[6].parseInt,
|
||||
name: threadRow[7],
|
||||
description: threadRow[8],
|
||||
color: threadRow[9]
|
||||
id: threadRow[5].parseInt,
|
||||
name: threadRow[6],
|
||||
description: threadRow[7],
|
||||
color: threadRow[8]
|
||||
),
|
||||
users: @[],
|
||||
replies: posts[0].parseInt-1,
|
||||
|
|
@ -412,7 +413,6 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
|||
creation: posts[1].parseInt,
|
||||
isLocked: threadRow[4] == "1",
|
||||
isSolved: false, # TODO: Add a field to `post` to identify the solution.
|
||||
isPinned: threadRow[5] == "1"
|
||||
)
|
||||
|
||||
# Gather the users list.
|
||||
|
|
@ -436,9 +436,8 @@ proc executeReply(c: TForumData, threadId: int, content: string,
|
|||
else:
|
||||
raise newForumError("You are not allowed to post")
|
||||
|
||||
when not defined(skipRateLimitCheck):
|
||||
if rateLimitCheck(c):
|
||||
raise newForumError("You're posting too fast!")
|
||||
if rateLimitCheck(c):
|
||||
raise newForumError("You're posting too fast!")
|
||||
|
||||
if content.strip().len == 0:
|
||||
raise newForumError("Message cannot be empty")
|
||||
|
|
@ -500,10 +499,10 @@ proc updatePost(c: TForumData, postId: int, content: string,
|
|||
|
||||
# Verify that the current user has permissions to edit the specified post.
|
||||
let creation = fromUnix(postRow[1].parseInt)
|
||||
let isArchived = (getTime() - creation).inHours >= 2
|
||||
let isArchived = (getTime() - creation).weeks > 8
|
||||
let canEdit = c.rank == Admin or c.userid == postRow[0]
|
||||
if isArchived and c.rank < Admin:
|
||||
raise newForumError("This post is too old and can no longer be edited")
|
||||
if isArchived:
|
||||
raise newForumError("This post is archived and can no longer be edited")
|
||||
if not canEdit:
|
||||
raise newForumError("You cannot edit this post")
|
||||
|
||||
|
|
@ -534,7 +533,7 @@ proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], query
|
|||
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
|
||||
let canEdit = c.rank == Admin or c.userid == threadAuthor.name
|
||||
if not canEdit:
|
||||
raise newForumError("You cannot edit this thread")
|
||||
|
||||
|
|
@ -570,9 +569,8 @@ proc executeNewThread(c: TForumData, subject, msg, categoryID: string): (int64,
|
|||
if not validateRst(c, msg):
|
||||
raise newForumError("Message needs to be valid RST", @["msg"])
|
||||
|
||||
when not defined(skipRateLimitCheck):
|
||||
if rateLimitCheck(c):
|
||||
raise newForumError("You're posting too fast!")
|
||||
if rateLimitCheck(c):
|
||||
raise newForumError("You're posting too fast!")
|
||||
|
||||
result[0] = tryInsertID(db, query, subject, categoryID).int
|
||||
if result[0] < 0:
|
||||
|
|
@ -710,25 +708,15 @@ proc executeLockState(c: TForumData, threadId: int, locked: bool) =
|
|||
# Save the like.
|
||||
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) =
|
||||
# Verify that this post belongs to the user.
|
||||
const postQuery = sql"""
|
||||
select p.author, p.id from post p
|
||||
select p.id from post p
|
||||
where p.author = ? and p.id = ?
|
||||
"""
|
||||
let
|
||||
row = getRow(db, postQuery, c.username, postId)
|
||||
author = row[0]
|
||||
id = row[1]
|
||||
let id = getValue(db, postQuery, c.username, postId)
|
||||
|
||||
if id.len == 0 and not (c.rank == Admin or c.userid == author):
|
||||
if id.len == 0 and c.rank < Admin:
|
||||
raise newForumError("You cannot delete this post")
|
||||
|
||||
# Set the `isDeleted` flag.
|
||||
|
|
@ -783,7 +771,7 @@ proc updateProfile(
|
|||
raise newForumError("Rank needs a change when setting new email.")
|
||||
|
||||
await sendSecureEmail(
|
||||
mailer, ActivateEmail, c.req, row[0], row[1], email, row[3]
|
||||
mailer, ActivateEmail, c.req, row[0], row[1], row[2], row[3]
|
||||
)
|
||||
|
||||
validateEmail(email, checkDuplicated=wasEmailChanged)
|
||||
|
|
@ -805,18 +793,12 @@ 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;
|
||||
"""
|
||||
const categoriesQuery = sql"""select * from category;"""
|
||||
|
||||
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
|
||||
id: data[0].getInt, name: data[1], description: data[2], color: data[3]
|
||||
)
|
||||
list.categories.add(category)
|
||||
|
||||
|
|
@ -826,42 +808,29 @@ routes:
|
|||
var
|
||||
start = getInt(@"start", 0)
|
||||
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 =
|
||||
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
||||
c.id, c.name, c.description, c.color,
|
||||
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status, u.isDeleted
|
||||
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.id = (
|
||||
select p.author from post p
|
||||
where p.thread = t.id
|
||||
order by p.author
|
||||
u.id in (
|
||||
select u.id from post p, person u
|
||||
where p.author = u.id and p.thread = t.id
|
||||
order by u.id
|
||||
limit 1
|
||||
)
|
||||
order by isPinned desc, modified desc limit ?, ?;"""
|
||||
order by modified desc limit ?, ?;"""
|
||||
|
||||
let thrCount = getValue(db, countQuery, countArgs).parseInt()
|
||||
let thrCount = getValue(db, sql"select count(*) from thread;").parseInt()
|
||||
let moreCount = max(0, thrCount - (start + count))
|
||||
|
||||
var list = ThreadList(threads: @[], moreCount: moreCount)
|
||||
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
|
||||
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1]))
|
||||
for data in getAllRows(db, threadsQuery, start, count):
|
||||
let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
|
||||
list.threads.add(thread)
|
||||
|
||||
resp $(%list), "application/json"
|
||||
|
|
@ -876,24 +845,19 @@ routes:
|
|||
count = 10
|
||||
|
||||
const threadsQuery =
|
||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
||||
c.id, c.name, c.description, c.color
|
||||
from thread t, category c
|
||||
where t.id = ? and isDeleted = 0 and category = c.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 postsQuery =
|
||||
sql(
|
||||
"""select p.id, p.content, strftime('%s', p.creation), p.author,
|
||||
p.replyingTo,
|
||||
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status,
|
||||
u.isDeleted
|
||||
from post p, person u
|
||||
|
|
@ -932,20 +896,15 @@ routes:
|
|||
|
||||
get "/specific_posts.json":
|
||||
createTFD()
|
||||
var ids: JsonNode
|
||||
try:
|
||||
var
|
||||
ids = parseJson(@"ids")
|
||||
except JsonParsingError:
|
||||
let err = PostError(
|
||||
message: "Invalid JSON in the `ids` parameter"
|
||||
)
|
||||
resp Http400, $(%err), "application/json"
|
||||
|
||||
cond ids.kind == JArray
|
||||
let intIDs = ids.elems.map(x => x.getInt())
|
||||
let postsQuery = sql("""
|
||||
select p.id, p.content, strftime('%s', p.creation), p.author,
|
||||
p.replyingTo,
|
||||
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status,
|
||||
u.isDeleted
|
||||
from post p, person u
|
||||
|
|
@ -1014,7 +973,7 @@ routes:
|
|||
""" % postsFrom)
|
||||
|
||||
let userQuery = sql("""
|
||||
select id, name, email, strftime('%s', lastOnline),
|
||||
select name, email, strftime('%s', lastOnline),
|
||||
strftime('%s', previousVisitAt), status, isDeleted,
|
||||
strftime('%s', creation), id
|
||||
from person
|
||||
|
|
@ -1040,7 +999,7 @@ routes:
|
|||
getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt()
|
||||
|
||||
if c.rank >= Admin or c.username == username:
|
||||
profile.email = some(userRow[2])
|
||||
profile.email = some(userRow[1])
|
||||
|
||||
for row in db.getAllRows(postsQuery, username):
|
||||
profile.posts.add(
|
||||
|
|
@ -1357,33 +1316,6 @@ routes:
|
|||
except ForumError as exc:
|
||||
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)":
|
||||
createTFD()
|
||||
if not c.loggedIn():
|
||||
|
|
@ -1616,7 +1548,7 @@ routes:
|
|||
postId: rowFT[2].parseInt(),
|
||||
postContent: content,
|
||||
creation: rowFT[4].parseInt(),
|
||||
author: selectUser(rowFT[5 .. 11]),
|
||||
author: selectUser(rowFT[5 .. 10]),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1624,4 +1556,4 @@ routes:
|
|||
|
||||
get re"/(.*)":
|
||||
cond request.matches[0].splitFile.ext == ""
|
||||
resp karaxHtml
|
||||
resp karaxHtml % {"server_url": request.url.jsEscape}.newStringTable
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
when defined(js):
|
||||
import sugar, httpcore, options, json, strutils
|
||||
import dom except Event
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom, vdom]
|
||||
|
||||
import error, category
|
||||
import category, karaxutils
|
||||
|
||||
type
|
||||
AddCategoryModal* = ref object of VComponent
|
||||
modalShown: bool
|
||||
loading: bool
|
||||
error: Option[PostError]
|
||||
onAddCategory: CategoryEvent
|
||||
|
||||
let nullCategory: CategoryEvent = proc (category: Category) = discard
|
||||
|
||||
proc newAddCategoryModal*(onAddCategory=nullCategory): AddCategoryModal =
|
||||
result = AddCategoryModal(
|
||||
modalShown: false,
|
||||
loading: false,
|
||||
onAddCategory: onAddCategory
|
||||
)
|
||||
|
||||
proc onAddCategoryPost(httpStatus: int, response: kstring, state: AddCategoryModal) =
|
||||
postFinished:
|
||||
state.modalShown = false
|
||||
let j = parseJson($response)
|
||||
let category = j.to(Category)
|
||||
|
||||
state.onAddCategory(category)
|
||||
|
||||
proc onAddCategoryClick(state: AddCategoryModal) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let uri = makeUri("createCategory")
|
||||
let form = dom.document.getElementById("add-category-form")
|
||||
let formData = newFormData(form)
|
||||
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onAddCategoryPost(s, r, state))
|
||||
|
||||
proc setModalShown*(state: AddCategoryModal, visible: bool) =
|
||||
state.modalShown = visible
|
||||
state.markDirty()
|
||||
|
||||
proc onModalClose(state: AddCategoryModal, ev: Event, n: VNode) =
|
||||
state.setModalShown(false)
|
||||
ev.preventDefault()
|
||||
|
||||
proc render*(state: AddCategoryModal): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(class=class({"active": state.modalShown}, "modal modal-sm")):
|
||||
a(href="", class="modal-overlay", "aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onModalClose(state, ev, n))
|
||||
tdiv(class="modal-container"):
|
||||
tdiv(class="modal-header"):
|
||||
tdiv(class="card-title h5"):
|
||||
text "Add New Category"
|
||||
tdiv(class="modal-body"):
|
||||
form(id="add-category-form"):
|
||||
genFormField(
|
||||
state.error, "name", "Name", "text", false,
|
||||
placeholder="Category Name")
|
||||
genFormField(
|
||||
state.error, "color", "Color", "color", false,
|
||||
placeholder="#XXYYZZ"
|
||||
)
|
||||
genFormField(
|
||||
state.error,
|
||||
"description",
|
||||
"Description",
|
||||
"text",
|
||||
true,
|
||||
placeholder="Description"
|
||||
)
|
||||
tdiv(class="modal-footer"):
|
||||
button(
|
||||
id="add-category-btn",
|
||||
class="btn btn-primary",
|
||||
onClick=(ev: Event, n: VNode) =>
|
||||
state.onAddCategoryClick()):
|
||||
text "Add"
|
||||
|
|
@ -5,44 +5,28 @@ type
|
|||
name*: string
|
||||
description*: 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):
|
||||
include karax/prelude
|
||||
import karax / [vstyles]
|
||||
import karaxutils
|
||||
|
||||
proc render*(category: Category, compact=true): VNode =
|
||||
if category.name.len == 0:
|
||||
return buildHtml():
|
||||
span()
|
||||
|
||||
result = buildhtml(tdiv):
|
||||
tdiv(class="category-status"):
|
||||
proc render*(category: Category): VNode =
|
||||
result = buildHtml():
|
||||
if category.name.len >= 0:
|
||||
tdiv(class="category",
|
||||
title=category.description,
|
||||
"data-color"="#" & category.color):
|
||||
tdiv(class="category-color",
|
||||
style=style(
|
||||
(StyleAttr.border,
|
||||
kstring"0.25rem solid #" & category.color)
|
||||
kstring"0.3rem 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)
|
||||
text category.name
|
||||
else:
|
||||
span()
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
import options, json, httpcore
|
||||
|
||||
import category
|
||||
|
||||
when defined(js):
|
||||
import sugar
|
||||
include karax/prelude
|
||||
import karax / [vstyles, kajax]
|
||||
|
||||
import karaxutils, error, user, mainbuttons, addcategorymodal
|
||||
|
||||
type
|
||||
State = ref object
|
||||
list: Option[CategoryList]
|
||||
loading: bool
|
||||
mainButtons: MainButtons
|
||||
status: HttpCode
|
||||
addCategoryModal: AddCategoryModal
|
||||
|
||||
var state: State
|
||||
|
||||
proc newState(): State =
|
||||
State(
|
||||
list: none[CategoryList](),
|
||||
loading: false,
|
||||
mainButtons: newMainButtons(),
|
||||
status: Http200,
|
||||
addCategoryModal: newAddCategoryModal(
|
||||
onAddCategory=
|
||||
(category: Category) => state.list.get().categories.add(category)
|
||||
)
|
||||
)
|
||||
|
||||
state = newState()
|
||||
|
||||
proc genCategory(category: Category, noBorder = false): VNode =
|
||||
result = buildHtml():
|
||||
tr(class=class({"no-border": noBorder})):
|
||||
td(style=style((StyleAttr.borderLeftColor, kstring("#" & category.color))), class="category"):
|
||||
h4(class="category-title", id="category-" & category.name.slug):
|
||||
a(href=makeUri("/c/" & $category.id)):
|
||||
tdiv():
|
||||
tdiv(class="category-name"):
|
||||
text category.name
|
||||
tdiv(class="category-description"):
|
||||
text category.description
|
||||
td(class="topics"):
|
||||
text $category.numTopics
|
||||
|
||||
proc onCategoriesRetrieved(httpStatus: int, response: kstring) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
let list = to(parsed, CategoryList)
|
||||
|
||||
if state.list.isSome:
|
||||
state.list.get().categories.add(list.categories)
|
||||
else:
|
||||
state.list = some(list)
|
||||
|
||||
proc renderCategoryHeader*(currentUser: Option[User]): VNode =
|
||||
result = buildHtml(tdiv(id="add-category")):
|
||||
text "Category"
|
||||
if currentUser.isAdmin():
|
||||
button(class="plus-btn btn btn-link",
|
||||
onClick=(ev: Event, n: VNode) => (
|
||||
state.addCategoryModal.setModalShown(true)
|
||||
)):
|
||||
italic(class="fas fa-plus")
|
||||
render(state.addCategoryModal)
|
||||
|
||||
proc renderCategories(currentUser: Option[User]): VNode =
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve threads.", state.status)
|
||||
|
||||
if state.list.isNone:
|
||||
if not state.loading:
|
||||
state.loading = true
|
||||
ajaxGet(makeUri("categories.json"), @[], onCategoriesRetrieved)
|
||||
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let list = state.list.get()
|
||||
|
||||
return buildHtml():
|
||||
section(class="category-list"):
|
||||
table(id="categories-list", class="table"):
|
||||
thead():
|
||||
tr:
|
||||
th:
|
||||
renderCategoryHeader(currentUser)
|
||||
th(text "Topics")
|
||||
tbody():
|
||||
for i in 0 ..< list.categories.len:
|
||||
let category = list.categories[i]
|
||||
|
||||
let isLastCategory = i+1 == list.categories.len
|
||||
genCategory(category, noBorder=isLastCategory)
|
||||
|
||||
proc renderCategoryList*(currentUser: Option[User]): VNode =
|
||||
result = buildHtml(tdiv):
|
||||
state.mainButtons.render(currentUser)
|
||||
renderCategories(currentUser)
|
||||
|
|
@ -1,24 +1,28 @@
|
|||
when defined(js):
|
||||
import sugar, httpcore, options, json, strutils, algorithm
|
||||
import dom except Event
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom, vdom]
|
||||
|
||||
import error, category, user
|
||||
import category, karaxutils, addcategorymodal
|
||||
import category, karaxutils
|
||||
|
||||
type
|
||||
CategoryPicker* = ref object of VComponent
|
||||
list: Option[CategoryList]
|
||||
selectedCategoryID*: int
|
||||
loading: bool
|
||||
modalShown: bool
|
||||
addEnabled: bool
|
||||
status: HttpCode
|
||||
error: Option[PostError]
|
||||
addCategoryModal: AddCategoryModal
|
||||
onCategoryChange: CategoryChangeEvent
|
||||
onAddCategory: CategoryEvent
|
||||
onCategoryChange: proc (oldCategory: Category, newCategory: Category)
|
||||
onAddCategory: proc (category: Category)
|
||||
|
||||
proc slug(name: string): string =
|
||||
name.strip().replace(" ", "-").toLowerAscii
|
||||
|
||||
proc onCategoryLoad(state: CategoryPicker): proc (httpStatus: int, response: kstring) =
|
||||
return
|
||||
|
|
@ -50,26 +54,18 @@ when defined(js):
|
|||
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 nullAddCategory(category: Category) = discard
|
||||
proc nullCategoryChange(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 =
|
||||
proc newCategoryPicker*(
|
||||
onCategoryChange: proc(oldCategory: Category, newCategory: Category) = nullCategoryChange,
|
||||
onAddCategory: proc(category: Category) = nullAddCategory
|
||||
): CategoryPicker =
|
||||
result = CategoryPicker(
|
||||
list: none[CategoryList](),
|
||||
selectedCategoryID: 0,
|
||||
loading: false,
|
||||
modalShown: false,
|
||||
addEnabled: false,
|
||||
status: Http200,
|
||||
error: none[PostError](),
|
||||
|
|
@ -77,14 +73,13 @@ when defined(js):
|
|||
onAddCategory: onAddCategory
|
||||
)
|
||||
|
||||
let state = result
|
||||
result.addCategoryModal = newAddCategoryModal(
|
||||
onAddCategory=onCategory(state)
|
||||
)
|
||||
|
||||
proc setAddEnabled*(state: CategoryPicker, enabled: bool) =
|
||||
state.addEnabled = enabled
|
||||
|
||||
proc select*(state: CategoryPicker, id: int) =
|
||||
state.selectedCategoryID = id
|
||||
state.markDirty()
|
||||
|
||||
proc onCategoryClick(state: CategoryPicker, category: Category): proc (ev: Event, n: VNode) =
|
||||
# this is necessary to capture the right value
|
||||
let cat = category
|
||||
|
|
@ -94,18 +89,82 @@ when defined(js):
|
|||
state.select(cat.id)
|
||||
state.onCategoryChange(oldCategory, cat)
|
||||
|
||||
proc onAddCategoryPost(httpStatus: int, response: kstring, state: CategoryPicker) =
|
||||
postFinished:
|
||||
state.modalShown = false
|
||||
let j = parseJson($response)
|
||||
let category = j.to(Category)
|
||||
|
||||
state.list.get().categories.add(category)
|
||||
state.list.get().categories.sort(cmpNames)
|
||||
state.select(category.id)
|
||||
|
||||
state.onAddCategory(category)
|
||||
|
||||
proc onAddCategoryClick(state: CategoryPicker) =
|
||||
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 onClose(ev: Event, n: VNode, state: CategoryPicker) =
|
||||
state.modalShown = false
|
||||
state.markDirty()
|
||||
ev.preventDefault()
|
||||
|
||||
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)
|
||||
state.modalShown = true;
|
||||
state.markDirty()
|
||||
)):
|
||||
italic(class="fas fa-plus")
|
||||
render(state.addCategoryModal)
|
||||
tdiv(class=class({"active": state.modalShown}, "modal modal-sm")):
|
||||
a(href="", class="modal-overlay", "aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||
tdiv(class="modal-container"):
|
||||
tdiv(class="modal-header"):
|
||||
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"
|
||||
|
||||
proc render*(state: CategoryPicker, currentUser: Option[User], compact=true): VNode =
|
||||
state.setAddEnabled(currentUser.isAdmin())
|
||||
proc render*(state: CategoryPicker, currentUser: Option[User]): VNode =
|
||||
let loggedIn = currentUser.isSome()
|
||||
let currentAdmin =
|
||||
loggedIn and currentUser.get().rank == Admin
|
||||
|
||||
if currentAdmin:
|
||||
state.setAddEnabled(true)
|
||||
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve categories.", state.status)
|
||||
|
|
@ -130,6 +189,6 @@ when defined(js):
|
|||
li(class="menu-item"):
|
||||
a(class="category-" & $category.id & " " & category.name.slug,
|
||||
onClick=onCategoryClick(state, category)):
|
||||
render(category, compact)
|
||||
render(category)
|
||||
if state.addEnabled:
|
||||
genAddCategory(state)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import httpcore
|
||||
import options, httpcore
|
||||
type
|
||||
PostError* = object
|
||||
errorFields*: seq[string] ## IDs of the fields with an error.
|
||||
message*: string
|
||||
|
||||
when defined(js):
|
||||
import json, options
|
||||
import json
|
||||
include karax/prelude
|
||||
|
||||
import karaxutils
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import options, tables, sugar, httpcore
|
||||
import options, tables, sugar, httpcore, uri
|
||||
from dom import window, Location, document, decodeURI
|
||||
|
||||
include karax/prelude
|
||||
|
|
@ -6,7 +6,6 @@ import karax/[kdom]
|
|||
import jester/[patterns]
|
||||
|
||||
import threadlist, postlist, header, profile, newthread, error, about
|
||||
import categorylist
|
||||
import resetpassword, activateemail, search
|
||||
import karaxutils
|
||||
|
||||
|
|
@ -14,6 +13,7 @@ type
|
|||
State = ref object
|
||||
originalTitle: cstring
|
||||
url: Location
|
||||
serverUri: Uri
|
||||
profile: ProfileState
|
||||
newThread: NewThread
|
||||
about: About
|
||||
|
|
@ -38,6 +38,7 @@ proc newState(): State =
|
|||
State(
|
||||
originalTitle: document.title,
|
||||
url: copyLocation(window.location),
|
||||
serverUri: SERVER_URI,
|
||||
profile: newProfileState(),
|
||||
newThread: newNewThread(),
|
||||
about: newAbout(),
|
||||
|
|
@ -58,6 +59,7 @@ proc onPopState(event: dom.Event) =
|
|||
state.url = copyLocation(window.location)
|
||||
|
||||
redraw()
|
||||
window.location.reload()
|
||||
|
||||
type Params = Table[string, string]
|
||||
type
|
||||
|
|
@ -68,12 +70,12 @@ type
|
|||
proc r(n: string, p: proc (params: Params): VNode): Route = Route(n: n, p: p)
|
||||
proc route(routes: openarray[Route]): VNode =
|
||||
let path =
|
||||
if state.url.pathname.len == 0: "/" else: $state.url.pathname
|
||||
if state.serverUri.path.len == 0: "/" else: state.serverUri.path
|
||||
let prefix = if appName == "/": "" else: appName
|
||||
for route in routes:
|
||||
let pattern = (prefix & route.n).parsePattern()
|
||||
var (matched, params) = pattern.match(path)
|
||||
parseUrlQuery($state.url.search, params)
|
||||
parseUrlQuery($state.serverUri.query, params)
|
||||
if matched:
|
||||
return route.p(params)
|
||||
|
||||
|
|
@ -83,14 +85,6 @@ proc render(): VNode =
|
|||
result = buildHtml(tdiv()):
|
||||
renderHeader()
|
||||
route([
|
||||
r("/categories",
|
||||
(params: Params) =>
|
||||
(renderCategoryList(getLoggedInUser()))
|
||||
),
|
||||
r("/c/@id",
|
||||
(params: Params) =>
|
||||
(renderThreadList(getLoggedInUser(), some(params["id"].parseInt)))
|
||||
),
|
||||
r("/newthread",
|
||||
(params: Params) =>
|
||||
(render(state.newThread, getLoggedInUser()))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import options, httpcore
|
||||
import options, times, httpcore, json, sugar
|
||||
|
||||
import user
|
||||
type
|
||||
|
|
@ -7,7 +7,6 @@ type
|
|||
recaptchaSiteKey*: Option[string]
|
||||
|
||||
when defined(js):
|
||||
import times, json, sugar
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
|
|
@ -32,7 +31,7 @@ when defined(js):
|
|||
var
|
||||
state = newState()
|
||||
|
||||
proc getStatus(logout=false)
|
||||
proc getStatus(logout: bool=false)
|
||||
proc newState(): State =
|
||||
State(
|
||||
data: none[UserStatus](),
|
||||
|
|
@ -61,7 +60,7 @@ when defined(js):
|
|||
|
||||
state.lastUpdate = getTime()
|
||||
|
||||
proc getStatus(logout=false) =
|
||||
proc getStatus(logout: bool=false) =
|
||||
if state.loading: return
|
||||
let diff = getTime() - state.lastUpdate
|
||||
if diff.inMinutes < 5:
|
||||
|
|
@ -96,8 +95,8 @@ when defined(js):
|
|||
section(class="navbar-section"):
|
||||
tdiv(class="input-group input-inline"):
|
||||
input(class="search-input input-sm",
|
||||
`type`="search", placeholder="Search",
|
||||
id="search-box", required="required",
|
||||
`type`="text", placeholder="search",
|
||||
id="search-box",
|
||||
onKeyDown=onKeyDown)
|
||||
if state.loading:
|
||||
tdiv(class="loading")
|
||||
|
|
|
|||
|
|
@ -1,15 +1,4 @@
|
|||
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
|
||||
import strutils, strformat, parseutils, tables, uri
|
||||
|
||||
proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} =
|
||||
## parses `s` into an integer in the range `validRange`. If successful,
|
||||
|
|
@ -42,6 +31,10 @@ when defined(js):
|
|||
|
||||
const appName* = "/"
|
||||
|
||||
# Get the server URL that we set in the backend
|
||||
var SERVER_URL* {.importc: "SERVER_URL".}: cstring
|
||||
let SERVER_URI* = parseUri($SERVER_URL)
|
||||
|
||||
proc class*(classes: varargs[tuple[name: string, present: bool]],
|
||||
defaultClasses: string = ""): string =
|
||||
result = defaultClasses & " "
|
||||
|
|
@ -53,11 +46,13 @@ when defined(js):
|
|||
## Concatenates ``relative`` to the current URL in a way that is
|
||||
## (possibly) sane.
|
||||
var relative = relative
|
||||
assert appName in $window.location.pathname
|
||||
assert appName in SERVER_URI.path
|
||||
if relative[0] == '/': relative = relative[1..^1]
|
||||
|
||||
return $window.location.protocol & "//" &
|
||||
$window.location.host &
|
||||
let port = if SERVER_URI.port.len > 0: ":" & SERVER_URI.port else: ""
|
||||
|
||||
return SERVER_URI.scheme & "://" &
|
||||
SERVER_URI.hostname & port &
|
||||
appName &
|
||||
relative &
|
||||
search &
|
||||
|
|
@ -73,7 +68,7 @@ when defined(js):
|
|||
query.add(param[0] & "=" & param[1])
|
||||
|
||||
if query.len > 0:
|
||||
var search = if reuseSearch: $window.location.search else: ""
|
||||
var search = if reuseSearch: SERVER_URI.query else: ""
|
||||
if search.len != 0: search.add("&")
|
||||
search.add(query)
|
||||
if search[0] != '?': search = "?" & search
|
||||
|
|
@ -97,6 +92,7 @@ when defined(js):
|
|||
let url = n.getAttr("href")
|
||||
|
||||
navigateTo(url)
|
||||
window.location.href = url
|
||||
|
||||
proc newFormData*(form: dom.Element): FormData
|
||||
{.importcpp: "new FormData(@)", constructor.}
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import options
|
||||
import user
|
||||
|
||||
when defined(js):
|
||||
include karax/prelude
|
||||
import karax / [kdom]
|
||||
|
||||
import karaxutils, user, categorypicker, category
|
||||
|
||||
let buttons = [
|
||||
(name: "Latest", url: makeUri("/"), id: "latest-btn"),
|
||||
(name: "Categories", url: makeUri("/categories"), id: "categories-btn"),
|
||||
]
|
||||
|
||||
proc onSelectedCategoryChanged(oldCategory: Category, newCategory: Category) =
|
||||
let uri = makeUri("/c/" & $newCategory.id)
|
||||
navigateTo(uri)
|
||||
|
||||
type
|
||||
MainButtons* = ref object
|
||||
categoryPicker: CategoryPicker
|
||||
onCategoryChange*: CategoryChangeEvent
|
||||
|
||||
proc newMainButtons*(onCategoryChange: CategoryChangeEvent = onSelectedCategoryChanged): MainButtons =
|
||||
new result
|
||||
result.onCategoryChange = onCategoryChange
|
||||
result.categoryPicker = newCategoryPicker(
|
||||
onCategoryChange = proc (oldCategory, newCategory: Category) =
|
||||
onSelectedCategoryChanged(oldCategory, newCategory)
|
||||
result.onCategoryChange(oldCategory, newCategory)
|
||||
)
|
||||
|
||||
proc render*(state: MainButtons, currentUser: Option[User], categoryId = none(int)): VNode =
|
||||
result = buildHtml():
|
||||
section(class="navbar container grid-xl", id="main-buttons"):
|
||||
section(class="navbar-section"):
|
||||
#[tdiv(class="dropdown"):
|
||||
a(href="#", class="btn dropdown-toggle"):
|
||||
text "Filter "
|
||||
italic(class="fas fa-caret-down")
|
||||
ul(class="menu"):
|
||||
li: text "community"
|
||||
li: text "dev" ]#
|
||||
if categoryId.isSome:
|
||||
state.categoryPicker.selectedCategoryID = categoryId.get()
|
||||
render(state.categoryPicker, currentUser, compact=false)
|
||||
|
||||
for btn in buttons:
|
||||
let active = btn.url == window.location.href
|
||||
a(id=btn.id, href=btn.url):
|
||||
button(class=class({"btn-primary": active, "btn-link": not active}, "btn")):
|
||||
text btn.name
|
||||
section(class="navbar-section"):
|
||||
if currentUser.isSome():
|
||||
a(id="new-thread-btn", href=makeUri("/newthread"), onClick=anchorCB):
|
||||
button(class="btn btn-secondary"):
|
||||
italic(class="fas fa-plus")
|
||||
text " New Thread"
|
||||
|
|
@ -65,7 +65,7 @@ when defined(js):
|
|||
tdiv():
|
||||
label(class="d-inline-block form-label"):
|
||||
text "Category"
|
||||
render(state.categoryPicker, currentUser, compact=false)
|
||||
render(state.categoryPicker, currentUser)
|
||||
renderContent(state.replyBox, none[Thread](), none[Post]())
|
||||
tdiv(class="footer"):
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import options
|
||||
|
||||
import user
|
||||
import user, threadlist
|
||||
|
||||
type
|
||||
PostInfo* = object
|
||||
|
|
@ -58,10 +58,10 @@ type
|
|||
email*: Option[string]
|
||||
|
||||
when defined(js):
|
||||
import karaxutils, threadlist
|
||||
import karaxutils
|
||||
|
||||
proc renderPostUrl*(post: Post, thread: Thread): string =
|
||||
renderPostUrl(thread.id, post.id)
|
||||
|
||||
proc renderPostUrl*(link: PostLink): string =
|
||||
renderPostUrl(link.threadId, link.postId)
|
||||
renderPostUrl(link.threadId, link.postId)
|
||||
|
|
@ -190,7 +190,7 @@ when defined(js):
|
|||
else: ""
|
||||
|
||||
result = buildHtml():
|
||||
button(class="btn btn-secondary", id="lock-btn",
|
||||
button(class="btn btn-secondary",
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onLockClick(e, n, state, thread),
|
||||
"data-tooltip"=tooltip,
|
||||
|
|
@ -201,61 +201,4 @@ when defined(js):
|
|||
text " Unlock Thread"
|
||||
else:
|
||||
italic(class="fas fa-lock")
|
||||
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"
|
||||
|
||||
text " Lock Thread"
|
||||
|
|
@ -36,7 +36,6 @@ when defined(js):
|
|||
likeButton: LikeButton
|
||||
deleteModal: DeleteModal
|
||||
lockButton: LockButton
|
||||
pinButton: PinButton
|
||||
categoryPicker: CategoryPicker
|
||||
|
||||
proc onReplyPosted(id: int)
|
||||
|
|
@ -57,7 +56,6 @@ when defined(js):
|
|||
likeButton: newLikeButton(),
|
||||
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
|
||||
lockButton: newLockButton(),
|
||||
pinButton: newPinButton(),
|
||||
categoryPicker: newCategoryPicker(onCategoryChanged)
|
||||
)
|
||||
|
||||
|
|
@ -211,13 +209,13 @@ when defined(js):
|
|||
let loggedIn = currentUser.isSome()
|
||||
let authoredByUser =
|
||||
loggedIn and currentUser.get().name == thread.author.name
|
||||
let canChangeCategory =
|
||||
loggedIn and currentUser.get().rank in {Admin, Moderator}
|
||||
let currentAdmin =
|
||||
currentUser.isSome() and currentUser.get().rank == Admin
|
||||
|
||||
result = buildHtml():
|
||||
tdiv():
|
||||
if authoredByUser or canChangeCategory:
|
||||
render(state.categoryPicker, currentUser, compact=false)
|
||||
if authoredByUser or currentAdmin:
|
||||
render(state.categoryPicker, currentUser)
|
||||
else:
|
||||
render(thread.category)
|
||||
|
||||
|
|
@ -413,7 +411,6 @@ when defined(js):
|
|||
text " Reply"
|
||||
|
||||
render(state.lockButton, list.thread, currentUser)
|
||||
render(state.pinButton, list.thread, currentUser)
|
||||
|
||||
render(state.replyBox, list.thread, state.replyingTo, false)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import strformat, times, options, json, httpcore, sugar
|
||||
import strformat, times, options, json, httpcore
|
||||
|
||||
import category, user
|
||||
|
||||
|
|
@ -15,7 +15,6 @@ type
|
|||
creation*: int64 ## Unix timestamp
|
||||
isLocked*: bool
|
||||
isSolved*: bool
|
||||
isPinned*: bool
|
||||
|
||||
ThreadList* = ref object
|
||||
threads*: seq[Thread]
|
||||
|
|
@ -27,34 +26,26 @@ proc isModerated*(thread: Thread): bool =
|
|||
thread.author.rank <= Moderated
|
||||
|
||||
when defined(js):
|
||||
import sugar
|
||||
include karax/prelude
|
||||
import karax / [vstyles, kajax, kdom]
|
||||
|
||||
import karaxutils, error, user, mainbuttons
|
||||
import karaxutils, error, user
|
||||
|
||||
type
|
||||
State = ref object
|
||||
list: Option[ThreadList]
|
||||
refreshList: bool
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
mainButtons: MainButtons
|
||||
|
||||
var state: State
|
||||
|
||||
proc newState(): State =
|
||||
State(
|
||||
list: none[ThreadList](),
|
||||
loading: false,
|
||||
status: Http200,
|
||||
mainButtons: newMainButtons(
|
||||
onCategoryChange =
|
||||
(oldCategory: Category, newCategory: Category) => (state.list = none[ThreadList]())
|
||||
)
|
||||
status: Http200
|
||||
)
|
||||
|
||||
state = newState()
|
||||
var
|
||||
state = newState()
|
||||
|
||||
proc visibleTo*[T](thread: T, user: Option[User]): bool =
|
||||
## Determines whether the specified thread (or post) should be
|
||||
|
|
@ -74,8 +65,29 @@ when defined(js):
|
|||
|
||||
return true
|
||||
|
||||
proc genTopButtons(currentUser: Option[User]): VNode =
|
||||
result = buildHtml():
|
||||
section(class="navbar container grid-xl", id="main-buttons"):
|
||||
section(class="navbar-section"):
|
||||
tdiv(class="dropdown"):
|
||||
a(href="#", class="btn dropdown-toggle"):
|
||||
text "Filter "
|
||||
italic(class="fas fa-caret-down")
|
||||
ul(class="menu"):
|
||||
li: text "community"
|
||||
li: text "dev"
|
||||
button(class="btn btn-primary"): text "Latest"
|
||||
button(class="btn btn-link"): text "Most Active"
|
||||
button(class="btn btn-link"): text "Categories"
|
||||
section(class="navbar-section"):
|
||||
if currentUser.isSome():
|
||||
a(id="new-thread-btn", href=makeUri("/newthread"), onClick=anchorCB):
|
||||
button(class="btn btn-secondary"):
|
||||
italic(class="fas fa-plus")
|
||||
text " New Thread"
|
||||
|
||||
proc genUserAvatars(users: seq[User]): VNode =
|
||||
result = buildHtml(td(class="thread-users")):
|
||||
result = buildHtml(td):
|
||||
for user in users:
|
||||
render(user, "avatar avatar-sm", showStatus=true)
|
||||
text " "
|
||||
|
|
@ -97,18 +109,15 @@ when defined(js):
|
|||
else:
|
||||
return $duration.inSeconds & "s"
|
||||
|
||||
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
|
||||
proc genThread(thread: Thread, isNew: bool, noBorder: bool): VNode =
|
||||
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
|
||||
let isBanned = thread.author.rank.isBanned()
|
||||
result = buildHtml():
|
||||
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
|
||||
tr(class=class({"no-border": noBorder, "banned": isBanned})):
|
||||
td(class="thread-title"):
|
||||
if thread.isLocked:
|
||||
italic(class="fas fa-lock fa-xs",
|
||||
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")
|
||||
|
|
@ -118,16 +127,14 @@ when defined(js):
|
|||
if thread.isSolved:
|
||||
italic(class="fas fa-check-square fa-xs",
|
||||
title="Thread has a solution")
|
||||
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
|
||||
a(href=makeUri("/t/" & $thread.id),
|
||||
onClick=anchorCB):
|
||||
text thread.topic
|
||||
tdiv(class="show-sm" & class({"d-none": not displayCategory})):
|
||||
render(thread.category)
|
||||
|
||||
td(class="hide-sm" & class({"d-none": not displayCategory})):
|
||||
td():
|
||||
render(thread.category)
|
||||
genUserAvatars(thread.users)
|
||||
td(class="thread-replies"): text $thread.replies
|
||||
td(class="hide-sm" & class({
|
||||
td(): text $thread.replies
|
||||
td(class=class({
|
||||
"views-text": thread.views < 999,
|
||||
"popular-text": thread.views > 999 and thread.views < 5000,
|
||||
"super-popular-text": thread.views > 5000
|
||||
|
|
@ -161,13 +168,10 @@ when defined(js):
|
|||
else:
|
||||
state.list = some(list)
|
||||
|
||||
proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) =
|
||||
proc onLoadMore(ev: Event, n: VNode) =
|
||||
state.loading = true
|
||||
let start = state.list.get().threads.len
|
||||
if categoryId.isSome:
|
||||
ajaxGet(makeUri("threads.json?start=" & $start & "&categoryId=" & $categoryId.get()), @[], onThreadList)
|
||||
else:
|
||||
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
|
||||
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
|
||||
|
||||
proc getInfo(
|
||||
list: seq[Thread], i: int, currentUser: Option[User]
|
||||
|
|
@ -192,34 +196,29 @@ when defined(js):
|
|||
isNew: thread.creation > previousVisitAt
|
||||
)
|
||||
|
||||
proc genThreadList(currentUser: Option[User], categoryId: Option[int]): VNode =
|
||||
proc genThreadList(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
|
||||
if categoryId.isSome:
|
||||
ajaxGet(makeUri("threads.json?categoryId=" & $categoryId.get()), @[], onThreadList)
|
||||
else:
|
||||
ajaxGet(makeUri("threads.json"), @[], onThreadList)
|
||||
ajaxGet(makeUri("threads.json"), @[], onThreadList)
|
||||
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let displayCategory = categoryId.isNone
|
||||
|
||||
let list = state.list.get()
|
||||
result = buildHtml():
|
||||
section(class="thread-list"):
|
||||
section(class="container grid-xl"): # TODO: Rename to `.thread-list`.
|
||||
table(class="table", id="threads-list"):
|
||||
thead():
|
||||
tr:
|
||||
th(text "Topic")
|
||||
th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category"
|
||||
th(class="thread-users"): text "Users"
|
||||
th(class="centered-header"): text "Replies"
|
||||
th(class="hide-sm centered-header"): text "Views"
|
||||
th(class="centered-header"): text "Activity"
|
||||
th(text "Category")
|
||||
th(style=style((StyleAttr.width, kstring"8rem"))): text "Users"
|
||||
th(text "Replies")
|
||||
th(text "Views")
|
||||
th(text "Activity")
|
||||
tbody():
|
||||
for i in 0 ..< list.threads.len:
|
||||
let thread = list.threads[i]
|
||||
|
|
@ -227,9 +226,8 @@ when defined(js):
|
|||
|
||||
let isLastThread = i+1 == list.threads.len
|
||||
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
|
||||
genThread(i+1, thread, isNew,
|
||||
noBorder=isLastUnseen or isLastThread,
|
||||
displayCategory=displayCategory)
|
||||
genThread(thread, isNew,
|
||||
noBorder=isLastUnseen or isLastThread)
|
||||
if isLastUnseen and (not isLastThread):
|
||||
tr(class="last-visit-separator"):
|
||||
td(colspan="6"):
|
||||
|
|
@ -241,11 +239,10 @@ when defined(js):
|
|||
td(colspan="6"):
|
||||
tdiv(class="loading loading-lg")
|
||||
else:
|
||||
td(colspan="6",
|
||||
onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))):
|
||||
td(colspan="6", onClick=onLoadMore):
|
||||
span(text "load more threads")
|
||||
|
||||
proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode =
|
||||
proc renderThreadList*(currentUser: Option[User]): VNode =
|
||||
result = buildHtml(tdiv):
|
||||
state.mainButtons.render(currentUser, categoryId=categoryId)
|
||||
genThreadList(currentUser, categoryId)
|
||||
genTopButtons(currentUser)
|
||||
genThreadList(currentUser)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import times, options
|
||||
import times
|
||||
|
||||
type
|
||||
# If you add more "Banned" states, be sure to modify forum's threadsQuery too.
|
||||
|
|
@ -17,7 +17,6 @@ type
|
|||
Admin ## Admin: can do everything
|
||||
|
||||
User* = object
|
||||
id*: string
|
||||
name*: string
|
||||
avatarUrl*: string
|
||||
lastOnline*: int64
|
||||
|
|
@ -28,9 +27,6 @@ type
|
|||
proc isOnline*(user: User): bool =
|
||||
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 =
|
||||
u1.name == u2.name
|
||||
|
||||
|
|
@ -76,4 +72,4 @@ when defined(js):
|
|||
title="User is a moderator")
|
||||
of Admin:
|
||||
italic(class="fas fa-chess-knight",
|
||||
title="User is an admin")
|
||||
title="User is an admin")
|
||||
|
|
@ -7,7 +7,6 @@ SELECT
|
|||
post_id,
|
||||
post_content,
|
||||
cdate,
|
||||
person.id,
|
||||
person.name AS author,
|
||||
person.email AS email,
|
||||
strftime('%s', person.lastOnline) AS lastOnline,
|
||||
|
|
@ -47,7 +46,6 @@ SELECT
|
|||
THEN snippet(post_fts, '**', '**', '...', what, -45)
|
||||
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
|
||||
cdate,
|
||||
person.id,
|
||||
person.name AS author,
|
||||
person.email AS email,
|
||||
strftime('%s', person.lastOnline) AS lastOnline,
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
|||
|
||||
db.exec(sql"""
|
||||
insert into category (id, name, description, color)
|
||||
values (0, 'Unsorted', 'No category has been chosen yet.', '');
|
||||
values (0, 'Default', 'The default category', '');
|
||||
""")
|
||||
|
||||
# -- Thread
|
||||
|
|
@ -81,7 +81,6 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
|||
isLocked boolean not null default 0,
|
||||
solution integer,
|
||||
isDeleted boolean not null default 0,
|
||||
isPinned boolean not null default 0,
|
||||
|
||||
foreign key (category) references category(id),
|
||||
foreign key (solution) references post(id)
|
||||
|
|
@ -235,7 +234,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
|||
proc initialiseConfig(
|
||||
name, title, hostname: string,
|
||||
recaptcha: tuple[siteKey, secretKey: string],
|
||||
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
|
||||
smtp: tuple[address, user, password, fromAddr: string],
|
||||
isDev: bool,
|
||||
dbPath: string,
|
||||
ga: string=""
|
||||
|
|
@ -252,7 +251,6 @@ proc initialiseConfig(
|
|||
"smtpUser": %smtp.user,
|
||||
"smtpPassword": %smtp.password,
|
||||
"smtpFromAddr": %smtp.fromAddr,
|
||||
"smtpTls": %smtp.tls,
|
||||
"isDev": %isDev,
|
||||
"dbPath": %dbPath
|
||||
}
|
||||
|
|
@ -284,7 +282,7 @@ These can be changed later in the generated forum.json file.
|
|||
|
||||
echo("")
|
||||
echo("The following question are related to recaptcha. \nYou must set up a " &
|
||||
"recaptcha v2 for your forum before answering them. \nPlease do so now " &
|
||||
"recaptcha for your forum before answering them. \nPlease do so now " &
|
||||
"and then answer these questions: https://www.google.com/recaptcha/admin")
|
||||
let recaptchaSiteKey = question("Recaptcha site key: ")
|
||||
let recaptchaSecretKey = question("Recaptcha secret key: ")
|
||||
|
|
@ -296,7 +294,6 @@ These can be changed later in the generated forum.json file.
|
|||
let smtpUser = question("SMTP user: ")
|
||||
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 " &
|
||||
"if you wish. Otherwise just leave it blank.")
|
||||
|
|
@ -306,7 +303,7 @@ These can be changed later in the generated forum.json file.
|
|||
let dbPath = "nimforum.db"
|
||||
initialiseConfig(
|
||||
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
|
||||
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
|
||||
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr), isDev=false,
|
||||
dbPath, ga
|
||||
)
|
||||
|
||||
|
|
@ -341,7 +338,7 @@ when isMainModule:
|
|||
"Development Forum",
|
||||
"localhost",
|
||||
recaptcha=("", ""),
|
||||
smtp=("", "", "", "", false),
|
||||
smtp=("", "", "", ""),
|
||||
isDev=true,
|
||||
dbPath
|
||||
)
|
||||
|
|
@ -358,7 +355,7 @@ when isMainModule:
|
|||
"Test Forum",
|
||||
"localhost",
|
||||
recaptcha=("", ""),
|
||||
smtp=("", "", "", "", false),
|
||||
smtp=("", "", "", ""),
|
||||
isDev=true,
|
||||
dbPath
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
|
||||
htmlparser, streams, parseutils, options, logging
|
||||
from times import getTime, utc, format
|
||||
import asyncdispatch, strutils, json, os, rst, rstgen, xmltree, strtabs,
|
||||
htmlparser, streams, parseutils, logging, uri, options, nre, strformat
|
||||
from times import getTime, getGMTime, format
|
||||
|
||||
import jester
|
||||
import jester/private/utils as jesterutils
|
||||
|
||||
when useHttpBeast:
|
||||
import httpbeast except Settings, Request
|
||||
else:
|
||||
import asynchttpserver
|
||||
|
||||
# Used to be:
|
||||
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
|
||||
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/[error]
|
||||
export parseInt
|
||||
|
||||
type
|
||||
|
|
@ -17,8 +25,6 @@ type
|
|||
smtpUser*: string
|
||||
smtpPassword*: string
|
||||
smtpFromAddr*: string
|
||||
smtpTls*: bool
|
||||
smtpSsl*: bool
|
||||
mlistAddress*: string
|
||||
recaptchaSecretKey*: string
|
||||
recaptchaSiteKey*: string
|
||||
|
|
@ -57,8 +63,6 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
|
|||
result.smtpUser = root{"smtpUser"}.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.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("")
|
||||
result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("")
|
||||
|
|
@ -185,3 +189,24 @@ proc rstToHtml*(content: string): string =
|
|||
add(result, node, indWidth=0, addNewLines=false)
|
||||
except:
|
||||
warn("Could not parse rst html.")
|
||||
|
||||
proc jsEscape*(str: string): string =
|
||||
## Escapes a string to be stored in a JS variable. This prevents
|
||||
## XSS attacks. Taken from here: https://portswigger.net/web-security/cross-site-scripting/preventing
|
||||
str.replace(
|
||||
re"[^\w. ]",
|
||||
proc(match: string): string =
|
||||
let code = match[0].ord
|
||||
fmt"\u{code:04x}"
|
||||
)
|
||||
|
||||
proc url*(request: Request): string =
|
||||
## Get the full URL of the request, including the query params
|
||||
let nativeRequest = request.getNativeReq
|
||||
when useHttpBeast:
|
||||
let query = nativeRequest.path.get("").parseUri().query
|
||||
else:
|
||||
let query = nativeRequest.url.query
|
||||
|
||||
let proto = if request.secure: "https" else: "http"
|
||||
result = proto & "://" & request.host & request.path & query
|
||||
|
|
@ -23,7 +23,7 @@ const baseUrl = "http://localhost:" & $port & "/"
|
|||
template withBackend(body: untyped): untyped =
|
||||
## Starts a new backend instance.
|
||||
|
||||
spawn runProcess("nimble -y testbackend")
|
||||
spawn runProcess("nimble -y runbackend")
|
||||
defer:
|
||||
discard execCmd("killall " & backend)
|
||||
|
||||
|
|
@ -46,8 +46,6 @@ template withBackend(body: untyped): untyped =
|
|||
import browsertests/[scenario1, threads, issue181, categories]
|
||||
|
||||
proc main() =
|
||||
# Kill any already running instances
|
||||
discard execCmd("killall geckodriver")
|
||||
spawn runProcess("geckodriver -p 4444 --log config")
|
||||
defer:
|
||||
discard execCmd("killall geckodriver")
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
--threads:on
|
||||
--path:"../src/frontend"
|
||||
--threads:on
|
||||
|
|
@ -1,32 +1,13 @@
|
|||
import unittest, common
|
||||
import webdriver
|
||||
import unittest, options, os, common
|
||||
|
||||
import karaxutils
|
||||
import webdriver
|
||||
|
||||
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
|
||||
|
|
@ -49,122 +30,38 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
|
|||
|
||||
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) =
|
||||
let
|
||||
name = "Category Test"
|
||||
color = "Creating category test"
|
||||
description = "This is a description"
|
||||
|
||||
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"
|
||||
|
||||
test "can create category":
|
||||
with session:
|
||||
click "#new-thread-btn"
|
||||
|
||||
|
|
@ -173,36 +70,18 @@ proc categoriesAdminTests(session: Session, baseUrl: string) =
|
|||
click "#add-category .plus-btn"
|
||||
|
||||
clear "#add-category input[name='name']"
|
||||
clear "#add-category input[name='color']"
|
||||
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='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) =
|
||||
|
|
@ -211,4 +90,4 @@ proc test*(session: Session, baseUrl: string) =
|
|||
categoriesUserTests(session, baseUrl)
|
||||
categoriesAdminTests(session, baseUrl)
|
||||
|
||||
session.navigate(baseUrl)
|
||||
session.navigate(baseUrl)
|
||||
|
|
@ -8,44 +8,34 @@ const actionDelayMs {.intdefine.} = 0
|
|||
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
|
||||
result = code
|
||||
|
||||
# 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 waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=elementIsSome): Option[Element]
|
||||
|
||||
proc click*(session: Session, element: string, strategy=CssSelector) =
|
||||
template click*(session: Session, element: string, strategy=CssSelector) =
|
||||
let el = session.waitForElement(element, strategy)
|
||||
el.get().click()
|
||||
|
||||
proc sendKeys*(session: Session, element, keys: string) =
|
||||
template sendKeys*(session: Session, element, keys: string) =
|
||||
let el = session.waitForElement(element)
|
||||
el.get().sendKeys(keys)
|
||||
|
||||
proc clear*(session: Session, element: string) =
|
||||
template clear*(session: Session, element: string) =
|
||||
let el = session.waitForElement(element)
|
||||
el.get().clear()
|
||||
|
||||
proc sendKeys*(session: Session, element: string, keys: varargs[Key]) =
|
||||
template sendKeys*(session: Session, element: string, keys: varargs[Key]) =
|
||||
let el = session.waitForElement(element)
|
||||
|
||||
# focus
|
||||
|
|
@ -53,7 +43,7 @@ proc sendKeys*(session: Session, element: string, keys: varargs[Key]) =
|
|||
for key in keys:
|
||||
session.press(key)
|
||||
|
||||
proc ensureExists*(session: Session, element: string, strategy=CssSelector) =
|
||||
template ensureExists*(session: Session, element: string, strategy=CssSelector) =
|
||||
discard session.waitForElement(element, strategy)
|
||||
|
||||
template check*(session: Session, element: string, function: untyped) =
|
||||
|
|
@ -65,11 +55,7 @@ template check*(session: Session, element: string,
|
|||
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) =
|
||||
template checkIsNone*(session: Session, element: string, strategy=CssSelector) =
|
||||
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
|
||||
|
||||
template checkText*(session: Session, element, expectedValue: string) =
|
||||
|
|
@ -79,7 +65,7 @@ template checkText*(session: Session, element, expectedValue: string) =
|
|||
proc waitForElement*(
|
||||
session: Session, selector: string, strategy=CssSelector,
|
||||
timeout=20000, pollTime=50,
|
||||
waitCondition: proc(element: Option[Element]): bool = elementIsSome
|
||||
waitCondition=elementIsSome
|
||||
): Option[Element] =
|
||||
var waitTime = 0
|
||||
|
||||
|
|
@ -87,30 +73,8 @@ proc waitForElement*(
|
|||
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:
|
||||
let loading = session.findElement(selector, strategy)
|
||||
if waitCondition(loading):
|
||||
return loading
|
||||
sleep(pollTime)
|
||||
waitTime += pollTime
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import unittest, common
|
||||
import unittest, options, os, common
|
||||
|
||||
import webdriver
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import unittest, common
|
||||
import unittest, options, os, common
|
||||
|
||||
import webdriver
|
||||
|
||||
|
|
@ -40,4 +40,4 @@ proc test*(session: Session, baseUrl: string) =
|
|||
register "TEst1", "test1", verify = false
|
||||
|
||||
ensureExists "#signup-form .has-error"
|
||||
navigate baseUrl
|
||||
navigate baseUrl
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import unittest, common
|
||||
import unittest, options, common
|
||||
|
||||
import webdriver
|
||||
|
||||
let
|
||||
|
|
@ -39,53 +40,10 @@ proc userTests(session: Session, baseUrl: string) =
|
|||
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
|
||||
|
|
@ -185,70 +143,6 @@ proc adminTests(session: Session, baseUrl: string) =
|
|||
# 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) =
|
||||
|
|
@ -264,4 +158,4 @@ proc test*(session: Session, baseUrl: string) =
|
|||
|
||||
unBanUser(session, baseUrl)
|
||||
|
||||
session.navigate(baseUrl)
|
||||
session.navigate(baseUrl)
|
||||
Loading…
Add table
Add a link
Reference in a new issue