Compare commits
No commits in common. "master" and "jester" have entirely different histories.
77 changed files with 1704 additions and 8932 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
|
|
||||||
|
|
||||||
23
.gitignore
vendored
23
.gitignore
vendored
|
|
@ -1,23 +0,0 @@
|
||||||
# Wildcard patterns.
|
|
||||||
*.swp
|
|
||||||
nimcache/
|
|
||||||
*.db*
|
|
||||||
|
|
||||||
# Specific paths
|
|
||||||
/createdb
|
|
||||||
/forum
|
|
||||||
/nimforum.db
|
|
||||||
|
|
||||||
# Binaries
|
|
||||||
forum
|
|
||||||
createdb
|
|
||||||
editdb
|
|
||||||
|
|
||||||
.vscode
|
|
||||||
forum.json*
|
|
||||||
browsertester
|
|
||||||
setup_nimforum
|
|
||||||
buildcss
|
|
||||||
nimforum.css
|
|
||||||
|
|
||||||
/src/frontend/forum.js
|
|
||||||
6
.gitmodules
vendored
6
.gitmodules
vendored
|
|
@ -1,6 +0,0 @@
|
||||||
[submodule "frontend/spectre"]
|
|
||||||
path = frontend/spectre
|
|
||||||
url = https://github.com/picturepan2/spectre
|
|
||||||
[submodule "public/css/spectre"]
|
|
||||||
path = public/css/spectre
|
|
||||||
url = https://github.com/picturepan2/spectre
|
|
||||||
130
README.md
130
README.md
|
|
@ -1,131 +1,11 @@
|
||||||
# nimforum
|
nimforum
|
||||||
|
========
|
||||||
|
|
||||||
NimForum is a light-weight forum implementation
|
This is Nimrod's forum. The code is not nice and depends on the RST parser of
|
||||||
with many similarities to Discourse. It is implemented in
|
the Nimrod compiler.
|
||||||
the [Nim](https://nim-lang.org) programming
|
|
||||||
language and uses SQLite for its database.
|
|
||||||
|
|
||||||
## Examples in the wild
|
|
||||||
|
|
||||||
[](https://forum.nim-lang.org)
|
|
||||||
|
|
||||||
<p align="center" margin="0"><a href="https://forum.nim-lang.org"><b>forum.nim-lang.org</b></a></p>
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
* Efficient, type safe and clean **single-page application** developed using the
|
|
||||||
[Karax](https://github.com/pragmagic/karax) and
|
|
||||||
[Jester](https://github.com/dom96/jester) frameworks.
|
|
||||||
* **Utilizes SQLite** making set up much easier.
|
|
||||||
* Endlessly **customizable** using SASS.
|
|
||||||
* Spam blocking via new user sandboxing with great tools for moderators.
|
|
||||||
* reStructuredText enriched by Markdown to make formatting your posts a breeze.
|
|
||||||
* Search powered by SQLite's full-text search.
|
|
||||||
* Context-aware replies.
|
|
||||||
* Last visit tracking.
|
|
||||||
* Gravatar support.
|
|
||||||
* And much more!
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
[See this document.](https://github.com/nim-lang/nimforum/blob/master/setup.md)
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
The following lists the dependencies which you may need to install manually
|
|
||||||
in order to get NimForum running, compiled*, or tested†.
|
|
||||||
|
|
||||||
* libsass
|
|
||||||
* SQLite
|
|
||||||
* pcre
|
|
||||||
* Nim (and the Nimble package manager)*
|
|
||||||
* [geckodriver](https://github.com/mozilla/geckodriver)†
|
|
||||||
* Firefox†
|
|
||||||
|
|
||||||
[*] Build time dependencies
|
|
||||||
|
|
||||||
[†] Test time dependencies
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
Check out the tasks defined by this project's ``nimforum.nimble`` file by
|
|
||||||
running ``nimble tasks``, as of writing they are:
|
|
||||||
|
|
||||||
```
|
|
||||||
backend Compiles and runs the forum backend
|
|
||||||
runbackend Runs the forum backend
|
|
||||||
frontend Builds the necessary JS frontend (with CSS)
|
|
||||||
minify Minifies the JS using Google's closure compiler
|
|
||||||
testdb Creates a test DB (with admin account!)
|
|
||||||
devdb Creates a test DB (with admin account!)
|
|
||||||
blankdb Creates a blank DB
|
|
||||||
test Runs tester
|
|
||||||
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
|
|
||||||
database for development and testing, then `nimble backend`
|
|
||||||
which compiles and runs the forum's backend, and `nimble frontend`
|
|
||||||
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
|
|
||||||
|
|
||||||
Copyright (c) 2012-2018 Andreas Rumpf, Dominik Picheta.
|
|
||||||
|
|
||||||
|
Copyright (c) 2012 Andreas Rumpf.
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
# License
|
|
||||||
|
|
||||||
NimForum is licensed under the MIT license.
|
|
||||||
|
|
|
||||||
39
captchas.nim
Normal file
39
captchas.nim
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# The Nimrod Forum
|
||||||
|
# (c) Copyright 2012 Andreas Rumpf
|
||||||
|
#
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
|
||||||
|
import cairo, os, strutils, jester
|
||||||
|
|
||||||
|
proc getCaptchaFilename*(i: int): string {.inline.} =
|
||||||
|
result = "public/captchas/capture_" & $i & ".png"
|
||||||
|
|
||||||
|
proc getCaptchaUrl*(req: var TRequest, i: int): string =
|
||||||
|
result = req.makeUri("/captchas/capture_" & $i & ".png", absolute = false)
|
||||||
|
|
||||||
|
proc createCaptcha*(file, text: string) =
|
||||||
|
var surface = imageSurfaceCreate(FORMAT_ARGB32, 10*text.len, 10)
|
||||||
|
var cr = create(surface)
|
||||||
|
|
||||||
|
selectFontFace(cr, "serif", FONT_SLANT_NORMAL, FONT_WEIGHT_BOLD)
|
||||||
|
setFontSize(cr, 12.0)
|
||||||
|
|
||||||
|
setSourceRgb(cr, 1.0, 0.5, 0.0)
|
||||||
|
moveTo(cr, 0.0, 10.0)
|
||||||
|
showText(cr, repeatChar(text.len, 'O'))
|
||||||
|
|
||||||
|
setSourceRgb(cr, 0.0, 0.0, 1.0)
|
||||||
|
moveTo(cr, 0.0, 10.0)
|
||||||
|
showText(cr, text)
|
||||||
|
|
||||||
|
destroy(cr)
|
||||||
|
discard writeToPng(surface, file)
|
||||||
|
destroy(surface)
|
||||||
|
|
||||||
|
when isMainModule:
|
||||||
|
createCapture("test.png", "1+33")
|
||||||
|
|
||||||
|
|
||||||
92
createdb.nim
Normal file
92
createdb.nim
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# Nimrod Forum
|
||||||
|
# (c) Copyright 2012 Andreas Rumpf
|
||||||
|
#
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
|
||||||
|
import strutils, db_sqlite
|
||||||
|
|
||||||
|
var db = Open(connection="nimforum.db", user="postgres", password="",
|
||||||
|
database="nimforum")
|
||||||
|
|
||||||
|
const
|
||||||
|
TUserName = "varchar(20)"
|
||||||
|
TPassword = "varchar(32)"
|
||||||
|
TEmail = "varchar(30)"
|
||||||
|
|
||||||
|
db.Exec(sql"""
|
||||||
|
create table if not exists thread(
|
||||||
|
id integer primary key,
|
||||||
|
name varchar(100) not null,
|
||||||
|
views integer not null,
|
||||||
|
modified timestamp not null default (DATETIME('now'))
|
||||||
|
);""", [])
|
||||||
|
|
||||||
|
db.Exec(sql"""
|
||||||
|
create unique index if not exists ThreadNameIx on thread (name);
|
||||||
|
""", [])
|
||||||
|
|
||||||
|
db.Exec(sql("""
|
||||||
|
create table if not exists person(
|
||||||
|
id integer primary key,
|
||||||
|
name $# not null,
|
||||||
|
password $# not null,
|
||||||
|
email $# not null,
|
||||||
|
creation timestamp not null default (DATETIME('now')),
|
||||||
|
salt varbin(128) not null,
|
||||||
|
status integer not null,
|
||||||
|
admin bool default false
|
||||||
|
);""" % [TUserName, TPassword, TEmail]), [])
|
||||||
|
# echo "person table already exists"
|
||||||
|
|
||||||
|
db.Exec(sql"""
|
||||||
|
create unique index if not exists UserNameIx on person (name);
|
||||||
|
""", [])
|
||||||
|
|
||||||
|
# ----------------------- Forum ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
if not db.TryExec(sql"""
|
||||||
|
create table if not exists post(
|
||||||
|
id integer primary key,
|
||||||
|
author integer not null,
|
||||||
|
ip inet not null,
|
||||||
|
header varchar(100) not null,
|
||||||
|
content varchar(1000) not null,
|
||||||
|
thread integer not null,
|
||||||
|
creation timestamp not null default (DATETIME('now')),
|
||||||
|
|
||||||
|
foreign key (thread) references thread(id),
|
||||||
|
foreign key (author) references person(id)
|
||||||
|
);""", []):
|
||||||
|
echo "post table already exists"
|
||||||
|
|
||||||
|
# -------------------- Session -------------------------------------------------
|
||||||
|
|
||||||
|
if not db.TryExec(sql("""
|
||||||
|
create table if not exists session(
|
||||||
|
id integer primary key,
|
||||||
|
ip inet not null,
|
||||||
|
password $# not null,
|
||||||
|
userid integer not null,
|
||||||
|
lastModified timestamp not null default (DATETIME('now')),
|
||||||
|
foreign key (userid) references person(id)
|
||||||
|
);""" % [TPassword]), []):
|
||||||
|
echo "session table already exists"
|
||||||
|
|
||||||
|
if not db.TryExec(sql"""
|
||||||
|
create table if not exists antibot(
|
||||||
|
id integer primary key,
|
||||||
|
ip inet not null,
|
||||||
|
answer varchar(30) not null,
|
||||||
|
created timestamp not null default (DATETIME('now'))
|
||||||
|
);""", []):
|
||||||
|
echo "antibot table already exists"
|
||||||
|
|
||||||
|
#discard stdin.readline()
|
||||||
|
|
||||||
|
Close(db)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
|
||||||
11
editdb.nim
Normal file
11
editdb.nim
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
|
||||||
|
import strutils, db_sqlite
|
||||||
|
|
||||||
|
var db = Open(connection="nimforum.db", user="postgres", password="",
|
||||||
|
database="nimforum")
|
||||||
|
|
||||||
|
db.exec(sql"""ALTER TABLE person add column
|
||||||
|
lastOnline timestamp
|
||||||
|
""", [])
|
||||||
|
|
||||||
|
close(db)
|
||||||
218
forms.tmpl
Normal file
218
forms.tmpl
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
#! stdtmpl
|
||||||
|
#
|
||||||
|
#template `%`(idx: expr): expr {.immediate.} =
|
||||||
|
# row[idx]
|
||||||
|
#end template
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#proc genThreadsList(c: var TForumData): string =
|
||||||
|
# const query = sql"select id, name, views, modified from thread order by modified desc"
|
||||||
|
# const threadId = 0
|
||||||
|
# const name = 1
|
||||||
|
# const views = 2
|
||||||
|
#
|
||||||
|
# result = ""
|
||||||
|
<table id="threads">
|
||||||
|
<tr>
|
||||||
|
<th>Topics</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Posts</th>
|
||||||
|
<th>Views</th>
|
||||||
|
<th>Last reply</th>
|
||||||
|
</tr>
|
||||||
|
# for row in Rows(db, query):
|
||||||
|
<tr>
|
||||||
|
<td class="topic">${UrlButton(c, XMLencode(%name), c.genThreadUrl(threadid = %threadid))}</td>
|
||||||
|
#let authorName = getValue(db, sql("select name from person where id = " &
|
||||||
|
# "(select author from post where id = " &
|
||||||
|
# "(select min(id) from post where thread = ?))"), %threadId)
|
||||||
|
<td class="author">${authorName}</td>
|
||||||
|
# let posts = GetValue(db, sql"select count(*) from post where thread = ?", %threadId)
|
||||||
|
<td class="posts">$posts</td>
|
||||||
|
<td class="views">${XMLencode(%views)}</td>
|
||||||
|
#let latestReplyAuthor = getValue(db, sql("select name from person where id = " &
|
||||||
|
# "(select author from post where id = " &
|
||||||
|
# "(select max(id) from post where thread = ?))"), %threadId)
|
||||||
|
#let latestReplyDate = getValue(db, sql("SELECT strftime('%s', " &
|
||||||
|
# "(select creation from post where id = (select max(id) from post where thread = ?)))"), %threadId)
|
||||||
|
<td class="lastreply">
|
||||||
|
<span>${formatTimestamp(latestReplyDate.parseInt())}</span><br/>
|
||||||
|
<span>${latestReplyAuthor}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
# end for
|
||||||
|
</table>
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#proc genPostPreview(c: var TForumData,
|
||||||
|
# title, content, author, date: string): string =
|
||||||
|
# result = ""
|
||||||
|
<table class="post">
|
||||||
|
<tr>
|
||||||
|
<th colspan="2">
|
||||||
|
<span>${XMLEncode(title)}</span>
|
||||||
|
<span style="float:right;">${XMLencode(date)}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="left">
|
||||||
|
<span>${XMLencode(author)}</span>
|
||||||
|
</td>
|
||||||
|
<td class="content">
|
||||||
|
#try:
|
||||||
|
${content.rstToHtml}
|
||||||
|
#except EParseError:
|
||||||
|
# c.errorMsg = getCurrentExceptionMsg()
|
||||||
|
#end
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#proc genPostsList(c: var TForumData, threadId: string): string =
|
||||||
|
# const query = sql"select p.id, u.name, p.header, p.content, p.creation, p.author, u.email from post p, person u where u.id = p.author and p.thread = ? order by p.id"
|
||||||
|
# const postId = 0
|
||||||
|
# const userName = 1
|
||||||
|
# const postHeader = 2
|
||||||
|
# const postContent = 3
|
||||||
|
# const postCreation = 4
|
||||||
|
# const postAuthor = 5
|
||||||
|
# const userEmail = 6
|
||||||
|
# result = ""
|
||||||
|
# for row in FastRows(db, query, threadId):
|
||||||
|
<table class="post">
|
||||||
|
<tr>
|
||||||
|
<th colspan="2">
|
||||||
|
<span>${XMLencode(%postHeader)}</span>
|
||||||
|
<span style="float:right;">${XMLencode(%postCreation)}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="left">
|
||||||
|
<span>${XMLencode(%userName)}</span>
|
||||||
|
<hr/>
|
||||||
|
${genGravatar(%userEmail)}
|
||||||
|
#if c.userId == %postAuthor and c.currentPost.subject.len == 0:
|
||||||
|
<hr/>${UrlButton(c, "Edit post", c.genThreadUrl(%postId, "edit"))}
|
||||||
|
#elif c.isAdmin and c.currentPost.subject.len == 0:
|
||||||
|
<hr/><span style="color:red">
|
||||||
|
${UrlButton(c, "Edit post", c.genThreadUrl(%postId, "edit"))}</span>
|
||||||
|
#end if
|
||||||
|
</td>
|
||||||
|
<td class="content">
|
||||||
|
#try:
|
||||||
|
${(%postContent).rstToHtml}
|
||||||
|
#except EParseError:
|
||||||
|
# c.errorMsg = getCurrentExceptionMsg()
|
||||||
|
#end
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
# end for
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#proc genFormPost(c: var TForumData, action: string,
|
||||||
|
# topText, title, content: string, isEdit: bool): string =
|
||||||
|
# result = ""
|
||||||
|
<br />
|
||||||
|
<a name="reply"></a>
|
||||||
|
<div id="replywrapper">
|
||||||
|
<div id="replytop">
|
||||||
|
<span>${topText}</span>
|
||||||
|
</div>
|
||||||
|
<form action="${c.req.makeUri(action, false)}" method="POST">
|
||||||
|
${FieldValid(c, "subject", "Subject:")}
|
||||||
|
${TextWidget(c, "subject", title, maxlength=100)}
|
||||||
|
<br />
|
||||||
|
${FieldValid(c, "content", "Content:")}<br />
|
||||||
|
${TextAreaWidget(c, "content", content, width=100, height=20)}<br />
|
||||||
|
${FormSession(c, action)}
|
||||||
|
|
||||||
|
# if isEdit:
|
||||||
|
<input type="checkbox" name="delete" value="Delete">Delete Post<br />
|
||||||
|
# end if
|
||||||
|
<br/>
|
||||||
|
<input type="submit" name="previewBtn" value="Preview" />
|
||||||
|
<input type="submit" name="postBtn" value="Submit" />
|
||||||
|
|
||||||
|
<a href="http://nimrod-code.org/rst.html">Syntax Cheatsheet</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#proc genFormRegister(c: var TForumData): string =
|
||||||
|
# result = ""
|
||||||
|
<form action="${c.req.makeUri("/doregister", false)}" method="POST">
|
||||||
|
<b>Register</b><br />
|
||||||
|
<table border="0">
|
||||||
|
<tr>
|
||||||
|
<td>${FieldValid(c, "name", "Username:")}</td>
|
||||||
|
<td>${TextWidget(c, "name", reuseText, maxlength=20)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>${FieldValid(c, "new_password", "Password:")}</td>
|
||||||
|
<td><input type="password" name="new_password" maxlength="20" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>${FieldValid(c, "email", "E-Mail:")}</td>
|
||||||
|
<td>${TextWidget(c, "email", reuseText, maxlength=30)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>${FieldValid(c, "antibot", "What is " & antibot(c) & "?")}</td>
|
||||||
|
<td>${TextWidget(c, "antibot", "", maxlength=4)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<input type="submit" value="Register">
|
||||||
|
</form>
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#proc genFormLogin(c: var TForumData): string =
|
||||||
|
# result = ""
|
||||||
|
# if not c.loggedIn:
|
||||||
|
<form action="${c.req.makeUri("/dologin", false)}" method="POST">
|
||||||
|
<table border="0">
|
||||||
|
<tr><td>Username:</td><td>
|
||||||
|
<input type="text" name="name" maxlength="20"></td></tr>
|
||||||
|
<tr><td>Password:</td><td>
|
||||||
|
<input type="password" name="password" maxlength="20"></td></tr>
|
||||||
|
</table>
|
||||||
|
<input type="submit" value="Login">
|
||||||
|
</form>
|
||||||
|
<span style="color:red">$c.loginErrorMsg</span>
|
||||||
|
# else:
|
||||||
|
<span style="color:red">You're already logged in!</span>
|
||||||
|
# end if
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#proc genListOnline(c: var TForumData): string =
|
||||||
|
# result = ""
|
||||||
|
# let stats = c.getStats()
|
||||||
|
<div id="whoisonline">
|
||||||
|
<div class="wioHeader">
|
||||||
|
<span>Who is online?<span>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<span>Out of ${stats.totalUsers} users ${stats.activeUsers.len} are online${if stats.activeUsers.len == 0: "." else: ":"}
|
||||||
|
#for index, usr in stats.activeUsers:
|
||||||
|
# if usr.isAdmin:
|
||||||
|
#if index != 0: result.add ','
|
||||||
|
#end if
|
||||||
|
#result.add("""<span class="user admin"> """ & usr.nick & """</span>""")
|
||||||
|
# else:
|
||||||
|
#if index != 0: result.add ','
|
||||||
|
#end if
|
||||||
|
#result.add("""<span class="user"> """ & usr.nick & """</span>""")
|
||||||
|
# end if
|
||||||
|
#end for
|
||||||
|
</span>
|
||||||
|
<hr/>
|
||||||
|
<span>Total threads: ${stats.totalThreads} | Total posts: ${stats.totalPosts} | Newest member: ${stats.newestMember.nick}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
#end proc
|
||||||
580
forum.nim
Normal file
580
forum.nim
Normal file
|
|
@ -0,0 +1,580 @@
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# The Nimrod Forum
|
||||||
|
# (c) Copyright 2012 Andreas Rumpf
|
||||||
|
#
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
|
||||||
|
import
|
||||||
|
os, strutils, times, md5, strtabs, cgi, math, db_sqlite, matchers,
|
||||||
|
rst, rstgen, captchas, sockets, scgi, jester
|
||||||
|
|
||||||
|
const
|
||||||
|
unselectedThread = -1
|
||||||
|
transientThread = 0
|
||||||
|
|
||||||
|
type
|
||||||
|
TCrud = enum crCreate, crRead, crUpdate, crDelete
|
||||||
|
|
||||||
|
TSession = object of TObject
|
||||||
|
threadid: int
|
||||||
|
postid: int
|
||||||
|
userName, userPass, email: string
|
||||||
|
isAdmin: bool
|
||||||
|
|
||||||
|
TPost = tuple[subject, content: string]
|
||||||
|
|
||||||
|
TForumData = object of TSession
|
||||||
|
req: TRequest
|
||||||
|
userid: string
|
||||||
|
actionContent: string
|
||||||
|
errorMsg, loginErrorMsg: string
|
||||||
|
invalidField: string
|
||||||
|
currentPost: TPost
|
||||||
|
startTime: float
|
||||||
|
|
||||||
|
TStyledButton = tuple[text: string, link: string]
|
||||||
|
|
||||||
|
TForumStats = object
|
||||||
|
totalUsers: int
|
||||||
|
totalPosts: int
|
||||||
|
totalThreads: int
|
||||||
|
newestMember: tuple[nick: string, id: int, isAdmin: bool]
|
||||||
|
activeUsers: seq[tuple[nick: string, id: int, isAdmin: bool]]
|
||||||
|
|
||||||
|
var
|
||||||
|
db: TDbConn
|
||||||
|
docConfig: PStringTable
|
||||||
|
|
||||||
|
proc init(c: var TForumData) =
|
||||||
|
c.userPass = ""
|
||||||
|
c.userName = ""
|
||||||
|
c.threadId = unselectedThread
|
||||||
|
c.postId = -1
|
||||||
|
|
||||||
|
c.userid = ""
|
||||||
|
c.actionContent = ""
|
||||||
|
c.errorMsg = ""
|
||||||
|
c.loginErrorMsg = ""
|
||||||
|
c.invalidField = ""
|
||||||
|
c.currentPost = (subject: "", content: "")
|
||||||
|
|
||||||
|
proc loggedIn(c: TForumData): bool =
|
||||||
|
result = c.userName.len > 0
|
||||||
|
|
||||||
|
# --------------- HTML widgets ------------------------------------------------
|
||||||
|
|
||||||
|
# for widgets "" means the empty string as usual; should the old value be
|
||||||
|
# used again, pass `reuseText` instead:
|
||||||
|
const
|
||||||
|
reuseText = "\1"
|
||||||
|
|
||||||
|
proc TextWidget(c: TForumData, name, defaultText: string,
|
||||||
|
maxlength = 30, size = -1): string =
|
||||||
|
let x = if defaultText != reuseText: defaultText
|
||||||
|
else: XMLencode(c.req.params[name])
|
||||||
|
return """<input type="text" name="$1" maxlength="$2" value="$3" $4/>""" % [
|
||||||
|
name, $maxlength, x, if size != -1: "size=\"" & $size & "\"" else: ""]
|
||||||
|
|
||||||
|
proc TextAreaWidget(c: TForumData, name, defaultText: string,
|
||||||
|
width = 80, height = 20): string =
|
||||||
|
let x = if defaultText != reuseText: defaultText
|
||||||
|
else: XMLencode(c.req.params[name])
|
||||||
|
return """<textarea name="$1" cols="$2" rows="$3">$4</textarea>""" % [
|
||||||
|
name, $width, $height, x]
|
||||||
|
|
||||||
|
proc FieldValid(c: TForumData, name, text: string): string =
|
||||||
|
if name == c.invalidField:
|
||||||
|
result = """<span style="color:red">$1</span>""" % text
|
||||||
|
else:
|
||||||
|
result = text
|
||||||
|
|
||||||
|
proc genThreadUrl(c: TForumData, postId = "", action = "", threadid = ""): string =
|
||||||
|
result = "/t/" & (if threadid == "": $c.threadId else: threadid)
|
||||||
|
if action != "":
|
||||||
|
result.add("?action=" & action)
|
||||||
|
if postId != "":
|
||||||
|
result.add("&postid=" & postid)
|
||||||
|
result = c.req.makeUri(result, absolute = false)
|
||||||
|
|
||||||
|
proc FormSession(c: var TForumData, nextAction: string): string =
|
||||||
|
return """<input type="hidden" name="threadid" value="$1" />
|
||||||
|
<input type="hidden" name="postid" value="$2" />""" % [
|
||||||
|
$c.threadId, $c.postid]
|
||||||
|
|
||||||
|
proc UrlButton(c: var TForumData, text, url: string): string =
|
||||||
|
return ("""<a class="url_button" href="$1">$2</a>""") % [
|
||||||
|
url, text]
|
||||||
|
|
||||||
|
proc genButtons(c: var TForumData, btns: seq[TStyledButton]): string =
|
||||||
|
if btns.len == 1:
|
||||||
|
var anchor = ""
|
||||||
|
|
||||||
|
result = ("""<a class="active button" href="$1$3">$2</a>""") % [
|
||||||
|
btns[0].link, btns[0].text, anchor]
|
||||||
|
else:
|
||||||
|
result = ""
|
||||||
|
for i, btn in pairs(btns):
|
||||||
|
var anchor = ""
|
||||||
|
|
||||||
|
var class = ""
|
||||||
|
if i == 0: class = "left "
|
||||||
|
elif i == btns.len()-1: class = "right "
|
||||||
|
else: class = "middle "
|
||||||
|
result.add(("""<a class="$3active button" href="$1$4">$2</a>""") % [
|
||||||
|
btns[i].link, btns[i].text, class, anchor])
|
||||||
|
|
||||||
|
proc formatTimestamp(t: int): string =
|
||||||
|
let t2 = getGMTime(TTime(t))
|
||||||
|
return t2.format("ddd',' d MMM yyyy HH':'mm 'UTC'")
|
||||||
|
|
||||||
|
proc genGravatar(email: string, size: int = 80): string =
|
||||||
|
let emailMD5 = email.toLower.toMD5
|
||||||
|
result = "<img src=\"$1\" />" %
|
||||||
|
("http://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size &
|
||||||
|
"&d=identicon")
|
||||||
|
|
||||||
|
proc randomSalt(): string =
|
||||||
|
result = ""
|
||||||
|
for i in 0..127:
|
||||||
|
var r = random(225)
|
||||||
|
if r >= 32 and r <= 126:
|
||||||
|
result.add(chr(random(225)))
|
||||||
|
|
||||||
|
proc devRandomSalt(): string =
|
||||||
|
when defined(posix):
|
||||||
|
result = ""
|
||||||
|
var f = open("/dev/urandom")
|
||||||
|
var randomBytes: array[0..127, char]
|
||||||
|
discard f.readBuffer(addr(randomBytes), 128)
|
||||||
|
for i in 0..127:
|
||||||
|
if ord(randomBytes[i]) >= 32 and ord(randomBytes[i]) <= 126:
|
||||||
|
result.add(randomBytes[i])
|
||||||
|
f.close()
|
||||||
|
else:
|
||||||
|
result = randomSalt()
|
||||||
|
|
||||||
|
proc makeSalt(): string =
|
||||||
|
## Creates a salt using a cryptographically secure random number generator.
|
||||||
|
try:
|
||||||
|
result = devRandomSalt()
|
||||||
|
except EIO:
|
||||||
|
result = randomSalt()
|
||||||
|
|
||||||
|
proc makePassword(password, salt: string): string =
|
||||||
|
## Creates an MD5 hash by combining password and salt.
|
||||||
|
result = getMD5(salt & getMD5(password))
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
template `||`(x: expr): expr = (if not isNil(x): x else: "")
|
||||||
|
|
||||||
|
proc validThreadId(c: TForumData): bool =
|
||||||
|
result = GetValue(db, sql"select id from thread where id = ?",
|
||||||
|
$c.threadId).len > 0
|
||||||
|
|
||||||
|
proc antibot(c: var TForumData): string =
|
||||||
|
let a = math.random(10)+1
|
||||||
|
let b = math.random(1000)+1
|
||||||
|
let answer = $(a+b)
|
||||||
|
|
||||||
|
Exec(db, sql"delete from antibot where ip = ?", c.req.ip)
|
||||||
|
let CaptchaId = TryInsertID(db,
|
||||||
|
sql"insert into antibot(ip, answer) values (?, ?)", c.req.ip,
|
||||||
|
answer).int mod 10_000
|
||||||
|
let CaptchaFile = getCaptchaFilename(CaptchaId)
|
||||||
|
createCaptcha(CaptchaFile, $a & "+" & $b)
|
||||||
|
result = """<img src="$1" />""" % c.req.getCaptchaUrl(captchaId)
|
||||||
|
|
||||||
|
const
|
||||||
|
SecureChars = {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
|
||||||
|
|
||||||
|
proc setError(c: var TForumData, field, msg: string): bool {.inline.} =
|
||||||
|
c.invalidField = field
|
||||||
|
c.errorMsg = "Error: " & msg
|
||||||
|
return false
|
||||||
|
|
||||||
|
proc register(c: var TForumData, name, pass, antibot, email: string): bool =
|
||||||
|
# Username validation:
|
||||||
|
if name.len == 0 or not allCharsInSet(name, SecureChars):
|
||||||
|
return setError(c, "name", "Invalid username!")
|
||||||
|
if GetValue(db, sql"select name from person where name = ?", name).len > 0:
|
||||||
|
return setError(c, "name", "Username already exists!")
|
||||||
|
|
||||||
|
# Password validation:
|
||||||
|
if pass.len < 4:
|
||||||
|
return setError(c, "new_password", "Invalid password!")
|
||||||
|
|
||||||
|
# antibot validation:
|
||||||
|
let correctRes = GetValue(db,
|
||||||
|
sql"select answer from antibot where ip = ?", c.req.ip)
|
||||||
|
if antibot != correctRes:
|
||||||
|
return setError(c, "antibot", "You seem to be a bot!")
|
||||||
|
|
||||||
|
# email validation
|
||||||
|
if not validEmailAddress(email):
|
||||||
|
return setError(c, "email", "Invalid email address")
|
||||||
|
|
||||||
|
# perform registration:
|
||||||
|
var salt = makeSalt()
|
||||||
|
Exec(db, sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " &
|
||||||
|
"VALUES (?, ?, ?, ?, 'user', DATETIME('now'))"), name,
|
||||||
|
makePassword(pass, salt), email, salt)
|
||||||
|
# return setError(c, "", "Could not create your account!")
|
||||||
|
return true
|
||||||
|
|
||||||
|
proc checkLoggedIn(c: var TForumData) =
|
||||||
|
let pass = c.req.cookies["sid"]
|
||||||
|
if pass.len == 0: return
|
||||||
|
if ExecAffectedRows(db,
|
||||||
|
sql("update session set lastModified = DATETIME('now') " &
|
||||||
|
"where ip = ? and password = ?"),
|
||||||
|
c.req.ip, pass) > 0:
|
||||||
|
c.userpass = pass
|
||||||
|
c.userid = GetValue(db,
|
||||||
|
sql"select userid from session where ip = ? and password = ?",
|
||||||
|
c.req.ip, pass)
|
||||||
|
|
||||||
|
let row = getRow(db,
|
||||||
|
sql"select name, email, admin from person where id = ?", c.userid)
|
||||||
|
c.username = ||row[0]
|
||||||
|
c.email = ||row[1]
|
||||||
|
c.isAdmin = parseBool(||row[2])
|
||||||
|
# Update lastOnline
|
||||||
|
db.exec(sql"update person set lastOnline = DATETIME('now') where id = ?",
|
||||||
|
c.userid)
|
||||||
|
|
||||||
|
else:
|
||||||
|
echo("SID not found in sessions. Assuming logged out.")
|
||||||
|
|
||||||
|
proc logout(c: var TForumData) =
|
||||||
|
const query = sql"delete from session where ip = ? and password = ?"
|
||||||
|
c.username = ""
|
||||||
|
c.userpass = ""
|
||||||
|
Exec(db, query, c.req.ip, c.req.cookies["sid"])
|
||||||
|
|
||||||
|
proc incrementViews(c: var TForumData) =
|
||||||
|
const query = sql"update thread set views = views + 1 where id = ?"
|
||||||
|
Exec(db, query, $c.threadId)
|
||||||
|
|
||||||
|
proc isPreview(c: TForumData): bool =
|
||||||
|
result = c.req.params["previewBtn"].len > 0 # TODO: Could be wrong?
|
||||||
|
|
||||||
|
proc isDelete(c: TForumData): bool =
|
||||||
|
result = c.req.params["delete"].len > 0
|
||||||
|
|
||||||
|
proc rstToHtml(content: string): string =
|
||||||
|
result = rstgen.rstToHtml(content, {roSupportSmilies, roSupportMarkdown},
|
||||||
|
docConfig)
|
||||||
|
|
||||||
|
proc validateRst(c: var TForumData, content: string): bool =
|
||||||
|
result = true
|
||||||
|
try:
|
||||||
|
discard rstToHtml(content)
|
||||||
|
except EParseError:
|
||||||
|
result = setError(c, "", getCurrentExceptionMsg())
|
||||||
|
|
||||||
|
proc crud(c: TCrud, table: string, data: openArray[string]): TSqlQuery =
|
||||||
|
case c
|
||||||
|
of crCreate:
|
||||||
|
var fields = "insert into " & table & "("
|
||||||
|
var vals = ""
|
||||||
|
for i, d in data:
|
||||||
|
if i > 0:
|
||||||
|
fields.add(", ")
|
||||||
|
vals.add(", ")
|
||||||
|
fields.add(d)
|
||||||
|
vals.add('?')
|
||||||
|
result = sql(fields & ") values (" & vals & ")")
|
||||||
|
of crRead:
|
||||||
|
var res = "select "
|
||||||
|
for i, d in data:
|
||||||
|
if i > 0: res.add(", ")
|
||||||
|
res.add(d)
|
||||||
|
result = sql(res & " from " & table)
|
||||||
|
of crUpdate:
|
||||||
|
var res = "update " & table & " set "
|
||||||
|
for i, d in data:
|
||||||
|
if i > 0: res.add(", ")
|
||||||
|
res.add(d)
|
||||||
|
res.add(" = ?")
|
||||||
|
result = sql(res & " where id = ?")
|
||||||
|
of crDelete:
|
||||||
|
result = sql("delete from " & table & " where id = ?")
|
||||||
|
|
||||||
|
template retrSubject(c: expr) =
|
||||||
|
let subject = c.req.params["subject"]
|
||||||
|
if subject.len < 3: return setError(c, "subject", "Subject not long enough")
|
||||||
|
|
||||||
|
template retrContent(c: expr) =
|
||||||
|
let content = c.req.params["content"]
|
||||||
|
if not validateRst(c, content): return false
|
||||||
|
|
||||||
|
template retrPost(c: expr) =
|
||||||
|
retrSubject(c)
|
||||||
|
retrContent(c)
|
||||||
|
|
||||||
|
template checkLogin(c: expr) =
|
||||||
|
if not loggedIn(c): return setError(c, "", "User is not logged in")
|
||||||
|
|
||||||
|
template checkOwnership(c, postId: expr) =
|
||||||
|
if not c.isAdmin:
|
||||||
|
let x = getValue(db, sql"select author from post where id = ?",
|
||||||
|
postId)
|
||||||
|
if x != c.userId:
|
||||||
|
return setError(c, "", "You are not the owner of this post")
|
||||||
|
|
||||||
|
template setPreviewData(c: expr) =
|
||||||
|
c.currentPost.subject = subject
|
||||||
|
c.currentPost.content = content
|
||||||
|
|
||||||
|
template writeToDb(c, cr, postId: expr) =
|
||||||
|
exec(db, crud(cr, "post", "author", "ip", "header", "content", "thread"),
|
||||||
|
c.userId, c.req.ip, subject, content, $c.threadId, postId)
|
||||||
|
|
||||||
|
proc edit(c: var TForumData, postId: int): bool =
|
||||||
|
checkLogin(c)
|
||||||
|
if c.isPreview:
|
||||||
|
retrPost(c)
|
||||||
|
setPreviewData(c)
|
||||||
|
elif c.isDelete:
|
||||||
|
checkOwnership(c, $postId)
|
||||||
|
if not TryExec(db, crud(crDelete, "post"), $postId):
|
||||||
|
return setError(c, "", "database error")
|
||||||
|
# delete corresponding thread:
|
||||||
|
if ExecAffectedRows(db,
|
||||||
|
sql"delete from thread where id not in (select thread from post)") > 0:
|
||||||
|
# whole thread has been deleted, so:
|
||||||
|
c.threadId = unselectedThread
|
||||||
|
result = true
|
||||||
|
else:
|
||||||
|
checkOwnership(c, $postId)
|
||||||
|
retrPost(c)
|
||||||
|
exec(db, crud(crUpdate, "post", "header", "content"),
|
||||||
|
subject, content, $postId)
|
||||||
|
result = true
|
||||||
|
|
||||||
|
proc reply(c: var TForumData): bool =
|
||||||
|
checkLogin(c)
|
||||||
|
retrPost(c)
|
||||||
|
if c.isPreview:
|
||||||
|
setPreviewData(c)
|
||||||
|
else:
|
||||||
|
writeToDb(c, crCreate, "")
|
||||||
|
exec(db, sql"update thread set modified = DATETIME('now') where id = ?",
|
||||||
|
$c.threadId)
|
||||||
|
result = true
|
||||||
|
|
||||||
|
proc newThread(c: var TForumData): bool =
|
||||||
|
const query = sql"insert into thread(name, views, modified) values (?, 0, DATETIME('now'))"
|
||||||
|
checkLogin(c)
|
||||||
|
retrPost(c)
|
||||||
|
if c.isPreview:
|
||||||
|
setPreviewData(c)
|
||||||
|
c.threadID = transientThread
|
||||||
|
else:
|
||||||
|
c.threadID = TryInsertID(db, query, c.req.params["subject"]).int
|
||||||
|
if c.threadID < 0: return setError(c, "subject", "Subject already exists")
|
||||||
|
writeToDb(c, crCreate, "")
|
||||||
|
result = true
|
||||||
|
|
||||||
|
proc login(c: var TForumData, name, pass: string): bool =
|
||||||
|
# get form data:
|
||||||
|
const query =
|
||||||
|
sql"select id, name, password, email, salt, admin from person where name = ?"
|
||||||
|
if name.len == 0:
|
||||||
|
return c.setError("name", "Username cannot be nil.")
|
||||||
|
var success = false
|
||||||
|
for row in FastRows(db, query, name):
|
||||||
|
if row[2] == makePassword(pass, row[4]):
|
||||||
|
c.userid = row[0]
|
||||||
|
c.username = row[1]
|
||||||
|
c.userpass = row[2]
|
||||||
|
c.email = row[3]
|
||||||
|
c.isAdmin = row[5].parseBool
|
||||||
|
success = true
|
||||||
|
break
|
||||||
|
if success:
|
||||||
|
# create session:
|
||||||
|
Exec(db,
|
||||||
|
sql"insert into session (ip, password, userid) values (?, ?, ?)",
|
||||||
|
c.req.ip, c.userpass, c.userid)
|
||||||
|
return true
|
||||||
|
else:
|
||||||
|
return c.setError("password", "Login failed!")
|
||||||
|
|
||||||
|
proc genActionMenu(c: var TForumData): string =
|
||||||
|
result = ""
|
||||||
|
var btns: seq[TStyledButton] = @[]
|
||||||
|
# TODO: Make this detection better?
|
||||||
|
if c.req.pathInfo notin ["/", "/login", "/register", "/dologin", "/doregister"]:
|
||||||
|
btns.add(("Thread List", c.req.makeUri("/", false)))
|
||||||
|
if c.loggedIn:
|
||||||
|
let hasReplyBtn = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply"
|
||||||
|
if c.threadId >= 0 and hasReplyBtn:
|
||||||
|
let replyUrl = c.genThreadUrl("", "reply") & "#reply"
|
||||||
|
btns.add(("Reply", replyUrl))
|
||||||
|
btns.add(("New Thread", c.req.makeUri("/newthread", false)))
|
||||||
|
result = c.genButtons(btns)
|
||||||
|
|
||||||
|
proc getStats(c: var TForumData): TForumStats =
|
||||||
|
const totalUsersQuery =
|
||||||
|
sql"select count(*) from person"
|
||||||
|
result.totalUsers = getValue(db, totalUsersQuery).parseInt
|
||||||
|
const totalPostsQuery =
|
||||||
|
sql"select count(*) from post"
|
||||||
|
result.totalPosts = getValue(db, totalPostsQuery).parseInt
|
||||||
|
const totalThreadsQuery =
|
||||||
|
sql"select count(*) from thread"
|
||||||
|
result.totalThreads = getValue(db, totalThreadsQuery).parseInt
|
||||||
|
|
||||||
|
var newestMemberCreation = 0
|
||||||
|
result.activeUsers = @[]
|
||||||
|
const getUsersQuery =
|
||||||
|
sql"select id, name, admin, strftime('%s', lastOnline), strftime('%s', creation) from person"
|
||||||
|
for row in fastRows(db, getUsersQuery):
|
||||||
|
let secs = if row[3] == "": 0 else: row[3].parseint
|
||||||
|
let lastOnlineSeconds = getTime() - TTime(secs)
|
||||||
|
if lastOnlineSeconds < (60 * 5): # 5 minutes
|
||||||
|
result.activeUsers.add((row[1], row[0].parseInt, row[2].parseBool))
|
||||||
|
if row[4].parseInt > newestMemberCreation:
|
||||||
|
result.newestMember = (row[1], row[0].parseInt, row[2].parseBool)
|
||||||
|
newestMemberCreation = row[4].parseInt
|
||||||
|
|
||||||
|
include "forms.tmpl"
|
||||||
|
include "main.tmpl"
|
||||||
|
|
||||||
|
proc prependRe(s: string): string =
|
||||||
|
result = if s.len == 0:
|
||||||
|
""
|
||||||
|
elif s.startswith("Re:"): s
|
||||||
|
else: "Re: " & s
|
||||||
|
|
||||||
|
template createTFD(): stmt =
|
||||||
|
var c: TForumData
|
||||||
|
init(c)
|
||||||
|
c.req = request
|
||||||
|
c.startTime = epochTime()
|
||||||
|
if request.cookies.len > 0:
|
||||||
|
checkLoggedIn(c)
|
||||||
|
|
||||||
|
get "/":
|
||||||
|
createTFD()
|
||||||
|
resp genMain(c, genThreadsList(c), true)
|
||||||
|
|
||||||
|
get "/t/@threadid/?":
|
||||||
|
createTFD()
|
||||||
|
parseInt(@"threadid", c.threadId, -1..1000_000)
|
||||||
|
if (@"postid").len > 0:
|
||||||
|
parseInt(@"postid", c.postId, -1..1000_000)
|
||||||
|
|
||||||
|
if (@"action").len > 0:
|
||||||
|
case @"action"
|
||||||
|
of "reply":
|
||||||
|
let subject = GetValue(db,
|
||||||
|
sql"select header from post where id = (select max(id) from post where thread = ?)",
|
||||||
|
$c.threadId).prependRe
|
||||||
|
body = genPostsList(c, $c.threadId)
|
||||||
|
echo(c.threadId)
|
||||||
|
body.add genFormPost(c, "doreply", "Reply", subject, "", false)
|
||||||
|
of "edit":
|
||||||
|
cond c.postId != -1
|
||||||
|
const query = sql"select header, content from post where id = ?"
|
||||||
|
let row = getRow(db, query, $c.postId)
|
||||||
|
let header = ||row[0]
|
||||||
|
let content = ||row[1]
|
||||||
|
body = genFormPost(c, "doedit", "Edit", header, content, true)
|
||||||
|
resp c.genMain(body)
|
||||||
|
else:
|
||||||
|
cond validThreadId(c)
|
||||||
|
incrementViews(c)
|
||||||
|
resp genMain(c, genPostsList(c, $c.threadId))
|
||||||
|
|
||||||
|
get "/login/?":
|
||||||
|
createTFD()
|
||||||
|
resp genMain(c, genFormLogin(c))
|
||||||
|
|
||||||
|
get "/logout/?":
|
||||||
|
createTFD()
|
||||||
|
logout(c)
|
||||||
|
redirect(uri("/"))
|
||||||
|
|
||||||
|
get "/register/?":
|
||||||
|
createTFD()
|
||||||
|
resp genMain(c, genFormRegister(c))
|
||||||
|
|
||||||
|
template readIDs(): stmt =
|
||||||
|
# Retrieve the threadid and postid
|
||||||
|
if (@"threadid").len > 0:
|
||||||
|
parseInt(@"threadid", c.threadId, -1..1000_000)
|
||||||
|
if (@"postid").len > 0:
|
||||||
|
parseInt(@"postid", c.postId, -1..1000_000)
|
||||||
|
|
||||||
|
template finishLogin(): stmt =
|
||||||
|
setCookie("sid", c.userpass, daysForward(7))
|
||||||
|
redirect(uri("/"))
|
||||||
|
|
||||||
|
template handleError(action: string, topText: string, isEdit: bool): stmt =
|
||||||
|
if c.isPreview:
|
||||||
|
body.add genPostPreview(c, @"subject", @"content",
|
||||||
|
c.userName, $getGMTime(getTime()))
|
||||||
|
body.add genFormPost(c, action, topText, reuseText, reuseText, isEdit)
|
||||||
|
resp genMain(c, body)
|
||||||
|
|
||||||
|
post "/dologin":
|
||||||
|
createTFD()
|
||||||
|
if login(c, @"name", @"password"):
|
||||||
|
finishLogin()
|
||||||
|
else:
|
||||||
|
resp c.genMain(genFormLogin(c))
|
||||||
|
|
||||||
|
post "/doregister":
|
||||||
|
createTFD()
|
||||||
|
if c.register(@"name", @"new_password", @"antibot", @"email"):
|
||||||
|
discard c.login(@"name", @"new_password")
|
||||||
|
finishLogin()
|
||||||
|
else:
|
||||||
|
resp c.genMain(genFormRegister(c))
|
||||||
|
|
||||||
|
post "/donewthread":
|
||||||
|
createTFD()
|
||||||
|
if newThread(c):
|
||||||
|
redirect(uri("/"))
|
||||||
|
else:
|
||||||
|
body = ""
|
||||||
|
handleError("donewthread", "New thread", false)
|
||||||
|
|
||||||
|
post "/doreply":
|
||||||
|
createTFD()
|
||||||
|
readIDs()
|
||||||
|
if reply(c):
|
||||||
|
redirect(c.genThreadUrl())
|
||||||
|
else:
|
||||||
|
body = genPostsList(c, $c.threadId)
|
||||||
|
handleError("doreply", "Reply", false)
|
||||||
|
|
||||||
|
post "/doedit":
|
||||||
|
createTFD()
|
||||||
|
readIDs()
|
||||||
|
if edit(c, c.postId):
|
||||||
|
redirect(c.genThreadUrl())
|
||||||
|
else:
|
||||||
|
body = ""
|
||||||
|
handleError("doedit", "Edit", true)
|
||||||
|
|
||||||
|
get "/newthread/?":
|
||||||
|
createTFD()
|
||||||
|
resp genMain(c, genFormPost(c, "donewthread", "New thread", "", "", false))
|
||||||
|
|
||||||
|
when isMainModule:
|
||||||
|
docConfig = rstgen.defaultConfig()
|
||||||
|
math.randomize()
|
||||||
|
db = Open(connection="nimforum.db", user="postgres", password="",
|
||||||
|
database="nimforum")
|
||||||
|
var http = true
|
||||||
|
if paramCount() > 0:
|
||||||
|
if paramStr(1) == "scgi":
|
||||||
|
http = false
|
||||||
|
run("", port = TPort(9000), http = http)
|
||||||
|
db.close()
|
||||||
|
|
||||||
18
license.txt
18
license.txt
|
|
@ -1,18 +0,0 @@
|
||||||
Copyright (C) 2018 Andreas Rumpf, Dominik Picheta
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
||||||
of the Software, and to permit persons to whom the Software is furnished to do
|
|
||||||
so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// Use this to customise the styles of your forum.
|
|
||||||
$primary-color: #6577ac;
|
|
||||||
$body-font-color: #292929;
|
|
||||||
$dark-color: #505050;
|
|
||||||
$label-color: #7cd2ff;
|
|
||||||
$secondary-btn-color: #f1f1f1;
|
|
||||||
|
|
||||||
// Define nav bar colours.
|
|
||||||
$body-bg: #ffffff;
|
|
||||||
$navbar-color: $body-bg;
|
|
||||||
$navbar-border-color-dark: $body-bg;
|
|
||||||
$navbar-primary-color: #e80080;
|
|
||||||
|
|
||||||
#main-navbar input#search-box {
|
|
||||||
border: 1px solid #e6e6e6;
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
53
main.tmpl
Normal file
53
main.tmpl
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#! stdtmpl
|
||||||
|
#proc genMain(c: var TForumData, content: string, mainPage = false): string =
|
||||||
|
# result = ""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Nimrod Forum</title>
|
||||||
|
<link rel="stylesheet" href="${c.req.makeUri("css/normalize.css", absolute = false)}">
|
||||||
|
<link rel="stylesheet" href="${c.req.makeUri("css/style.css", absolute = false)}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="wrapper">
|
||||||
|
<div id="nimbtn">
|
||||||
|
<a href="http://nimrod-code.org/">Homepage</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
#let frontQuery = c.req.makeUri("/")
|
||||||
|
<span><a href="${frontQuery}">Nimrod's Forum</a></span>
|
||||||
|
#if c.loggedIn:
|
||||||
|
<a href="${frontQuery}logout" class="right">Logout</a>
|
||||||
|
<span id="welcome">$c.username</span>
|
||||||
|
${genGravatar(c.email, 26)}
|
||||||
|
#else:
|
||||||
|
<a href="${frontQuery}register" class="right">Register</a>
|
||||||
|
<a href="${frontQuery}login" class="right">Login</a>
|
||||||
|
#end if
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="topbar">
|
||||||
|
${c.genActionMenu}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content">
|
||||||
|
$content
|
||||||
|
<span style="color:red">$c.errorMsg</span>
|
||||||
|
</div>
|
||||||
|
<div id="topbar">
|
||||||
|
${c.genActionMenu}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
#if mainPage:
|
||||||
|
${c.genListOnline}
|
||||||
|
#end if
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div id="footer">
|
||||||
|
<span>Written in <a href="http://nimrod-code.org/">Nimrod</a> using <a href="https://github.com/dom96/jester">Jester</a></span>
|
||||||
|
<span> | <a href="https://github.com/nimrod-code/nimforum">Fork on Github</a></span>
|
||||||
|
<span style="float:right;">Generated in ${int((epochTime()-c.startTime)*1000.0)}ms</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
|
|
||||||
<title>The Nim programming language forum</title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="nimforum.css">
|
|
||||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
|
|
||||||
<link rel="icon" href="images/favicon.png">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header id="main-navbar">
|
|
||||||
<div class="navbar container grid-xl">
|
|
||||||
<section class="navbar-section">
|
|
||||||
<a href="#">
|
|
||||||
<img src="images/crown.png"
|
|
||||||
id="img-logo"/>
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
<section class="navbar-section">
|
|
||||||
<div class="input-group input-inline">
|
|
||||||
<input class="form-input input-sm" type="text" placeholder="search">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm"><i class="fas fa-user-plus"></i> Sign up</button>
|
|
||||||
<button class="btn btn-primary btn-sm"><i class="fas fa-sign-in-alt"></i> Log in</button>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="navbar container grid-xl" id="main-buttons">
|
|
||||||
<section class="navbar-section">
|
|
||||||
<div class="dropdown">
|
|
||||||
<a href="#" class="btn dropdown-toggle" tabindex="0">
|
|
||||||
Filter <i class="fas fa-caret-down"></i>
|
|
||||||
</a>
|
|
||||||
<ul class="menu">
|
|
||||||
<li>community</li>
|
|
||||||
<li>dev</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary">Latest</button>
|
|
||||||
<button class="btn btn-link">Most Active</button>
|
|
||||||
<button class="btn btn-link">Categories</button>
|
|
||||||
</section>
|
|
||||||
<section class="navbar-section">
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="container grid-xl">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Topic</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Users</th>
|
|
||||||
<th>Replies</th>
|
|
||||||
<th>Views</th>
|
|
||||||
<th>Activity</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><i class="fas fa-lock fa-xs" style="vertical-align: 0.05rem;"></i> Few mixed up questions</td>
|
|
||||||
<td><div class="triangle" style="border-bottom: 0.6rem solid #fc7c14;"></div> help</td>
|
|
||||||
<td>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/4831aedc3469e317e581e9e7348519a1?s=40&d=identicon" title="DTxplorer">
|
|
||||||
</figure>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/ed45a09ddafbe85bd1f3917f6ee57593?s=40&d=identicon" title="mashingan">
|
|
||||||
</figure>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/b3ed6848f575cc54c49f4916d15b65fd?s=40&d=identicon" title="mratsim"></figure>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/bd68fd5a3c41111e89cc9c71d25d5a06?s=40&d=identicon" title="Hlaaftana"></figure>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/aa81bb8117f158b6d9e6f3f174092573?s=40&d=identicon" title="ErikCampobadal"><i class="avatar-presence online"></i></figure>
|
|
||||||
</td>
|
|
||||||
<td>5</td>
|
|
||||||
<td class="views-text">547</td>
|
|
||||||
<td class="text-success">45m</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><i class="fas fa-check-square fa-xs" style="vertical-align: 0.05rem;"></i> Lexers and parsers in Nim</td>
|
|
||||||
<td><div class="triangle"></div> community</td>
|
|
||||||
<td>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/aa81bb8117f158b6d9e6f3f174092573?s=40&d=identicon" title="ErikCampobadal"><i class="avatar-presence online"></i></figure>
|
|
||||||
</td>
|
|
||||||
<td>0</td>
|
|
||||||
<td class="views-text">14</td>
|
|
||||||
<td class="text-success">44m</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="no-border">
|
|
||||||
<td>I need help <span class="label label-custom">2</span></td>
|
|
||||||
<td><div class="triangle" style="border-bottom: 0.6rem solid #fc7c14;"></div> help</td>
|
|
||||||
<td>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/4831aedc3469e317e581e9e7348519a1?s=40&d=identicon" title="DTxplorer">
|
|
||||||
</figure>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/ed45a09ddafbe85bd1f3917f6ee57593?s=40&d=identicon" title="mashingan">
|
|
||||||
</figure>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/b3ed6848f575cc54c49f4916d15b65fd?s=40&d=identicon" title="mratsim"></figure>
|
|
||||||
</td>
|
|
||||||
<td>4</td>
|
|
||||||
<td class="popular-text">1.4k</td>
|
|
||||||
<td>1d</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="last-visit-separator">
|
|
||||||
<td colspan="6">
|
|
||||||
<span>
|
|
||||||
last visit
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="no-border">
|
|
||||||
<td>Nim v1.0 is here!</td>
|
|
||||||
<td><div class="triangle" style="border-bottom: 0.6rem solid #1f93f3;"></div> announcement</td>
|
|
||||||
<td>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/4831aedc3469e317e581e9e7348519a1?s=40&d=identicon" title="DTxplorer">
|
|
||||||
</figure>
|
|
||||||
<figure class="avatar avatar-sm">
|
|
||||||
<img src="https://www.gravatar.com/avatar/ed45a09ddafbe85bd1f3917f6ee57593?s=40&d=identicon" title="mashingan">
|
|
||||||
</figure>
|
|
||||||
</td>
|
|
||||||
<td class="text-error">4</td>
|
|
||||||
<td class="super-popular-text">24.2k</td>
|
|
||||||
<td class="text-gray">4d</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="load-more-separator">
|
|
||||||
<td colspan="6">
|
|
||||||
<span>
|
|
||||||
load more threads
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
|
|
||||||
<title>The Nim programming language forum</title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="nimforum.css">
|
|
||||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
|
|
||||||
<link rel="icon" href="images/favicon.png">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header id="main-navbar">
|
|
||||||
<div class="navbar container grid-xl">
|
|
||||||
<section class="navbar-section">
|
|
||||||
<a href="#">
|
|
||||||
<img src="images/crown.png"
|
|
||||||
id="img-logo"/>
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
<section class="navbar-section">
|
|
||||||
<div class="input-group input-inline">
|
|
||||||
<input class="form-input input-sm" type="text" placeholder="search">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm"><i class="fas fa-user-plus"></i> Sign up</button>
|
|
||||||
<button class="btn btn-primary btn-sm"><i class="fas fa-sign-in-alt"></i> Log in</button>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="container grid-xl">
|
|
||||||
<div class="title">
|
|
||||||
<p>Lexers and parsers in nim</p>
|
|
||||||
<div class="triangle"></div> community
|
|
||||||
</div>
|
|
||||||
<div class="posts">
|
|
||||||
<div class="post">
|
|
||||||
<div class="post-icon">
|
|
||||||
<figure class="post-avatar">
|
|
||||||
<img src="https://www.gravatar.com/avatar/aa81bb8117f158b6d9e6f3f174092573?s=80&d=identicon" alt="Avatar">
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
<div class="post-main">
|
|
||||||
<div class="post-title">
|
|
||||||
<div class="post-username">
|
|
||||||
ErikCampobadal
|
|
||||||
</div>
|
|
||||||
<div class="post-time">Jan 2015</div>
|
|
||||||
</div>
|
|
||||||
<div class="post-content">
|
|
||||||
<p>Hey! I'm willing to create a programming language using nim.</p>
|
|
||||||
|
|
||||||
<p>It's an educational project. Been reading about compilers for weeks now and I started using tools like flex and bison for lexer and parser. I know nim have a parsing library but nowhere near that level.</p>
|
|
||||||
|
|
||||||
<p>There is an old post (2014) with a similar question so I'm bringing that back a few years later. Is there anything anyone know that could speed up the process of developing a programing language using nim? (I can have c code if needed ofc)</p>
|
|
||||||
</div>
|
|
||||||
<div class="post-buttons">
|
|
||||||
<div class="like-button">
|
|
||||||
<button class="btn">
|
|
||||||
<span class="like-count">2</span>
|
|
||||||
<i class="fas fa-heart"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flag-button">
|
|
||||||
<button class="btn">
|
|
||||||
<i class="far fa-flag"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="reply-button">
|
|
||||||
<button class="btn">
|
|
||||||
<i class="fas fa-reply"></i>
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="post">
|
|
||||||
<div class="post-icon">
|
|
||||||
<figure class="post-avatar">
|
|
||||||
<img src="https://www.gravatar.com/avatar/bfa46cf3ac91fcf26792ff824983e07e?s=80&d=identicon" alt="Avatar">
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
<div class="post-main">
|
|
||||||
<div class="post-title">
|
|
||||||
<div class="post-username">
|
|
||||||
twetzel59
|
|
||||||
</div>
|
|
||||||
<div class="post-time">Jan 2015</div>
|
|
||||||
</div>
|
|
||||||
<div class="post-content">
|
|
||||||
<p>Wow, I was just reading about the compilation pipeline today!</p>
|
|
||||||
|
|
||||||
<p>I suppose you could use at least the lexing part from a generator like <code>flex</code>, not so sure about using AST generators <b>easily</b> (it's possible).</p>
|
|
||||||
|
|
||||||
<p>Is your language complicated enough to warrant a parser generator or could you just use a custom parser?</p>
|
|
||||||
</div>
|
|
||||||
<div class="post-buttons">
|
|
||||||
<div class="like-button">
|
|
||||||
<button class="btn">
|
|
||||||
<i class="far fa-heart"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flag-button">
|
|
||||||
<button class="btn">
|
|
||||||
<i class="far fa-flag"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="reply-button">
|
|
||||||
<button class="btn">
|
|
||||||
<i class="fas fa-reply"></i>
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="information time-passed">
|
|
||||||
<div class="information-icon">
|
|
||||||
<i class="fas fa-clock"></i>
|
|
||||||
</div>
|
|
||||||
<div class="information-main">
|
|
||||||
<div class="information-title">
|
|
||||||
3 years later
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="post">
|
|
||||||
<div class="post-icon">
|
|
||||||
<figure class="post-avatar">
|
|
||||||
<img src="https://www.gravatar.com/avatar/1d180d9e0368472144840049b3d79448?s=80&d=identicon" alt="Avatar">
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
<div class="post-main">
|
|
||||||
<div class="post-title">
|
|
||||||
<div class="post-username">
|
|
||||||
dom96 <i class="fas fa-shield-alt"></i> <i class="fas fa-chess-king"></i><!-- Random idea, allow to specify any icon class -->
|
|
||||||
</div>
|
|
||||||
<div class="post-time">32m</div>
|
|
||||||
</div>
|
|
||||||
<div class="post-content">
|
|
||||||
<p>Let us test this new design a bit, <i>shall we?</i></p>
|
|
||||||
<pre class="code" data-lang="Nim"><code>proc hello(x: int) =
|
|
||||||
echo("Hello ", x)
|
|
||||||
|
|
||||||
42.hello()</code><div class="code-buttons"><button class="btn btn-primary btn-sm">Run</button></div></pre><pre class="execution-result execution-success"><button class="btn btn-clear float-right"></button><h6>Output</h6>Hello 42</pre>
|
|
||||||
|
|
||||||
<p>The greatest function ever written is <code>hello</code>.</p>
|
|
||||||
<blockquote>
|
|
||||||
<p>Designing websites is often a pain.</p>
|
|
||||||
<blockquote>Multi-level baby!</blockquote></blockquote>
|
|
||||||
<p>True that.</p>
|
|
||||||
<p>I also want to be able to support more detailed quoting:</p>
|
|
||||||
<blockquote>
|
|
||||||
<div class="detail">
|
|
||||||
<figure class="quote-avatar">
|
|
||||||
<img src="https://www.gravatar.com/avatar/ad1ada3bea74a6afab83d2e40da1dcf3?s=30&d=identicon" alt="Avatar">
|
|
||||||
</figure>
|
|
||||||
<span class="quote-username">Araq:</span>
|
|
||||||
<span class="quote-link"><i class="fas fa-arrow-up"></i></span>
|
|
||||||
</div>
|
|
||||||
Unix is a cancer.
|
|
||||||
</blockquote>
|
|
||||||
<p>We also want to be able to highlight user mentions:</p>
|
|
||||||
<p>Please let
|
|
||||||
<span class="user-mention">
|
|
||||||
@Araq
|
|
||||||
</span>
|
|
||||||
know that this forum is awesome.</p>
|
|
||||||
</div>
|
|
||||||
<div class="post-buttons">
|
|
||||||
<div class="like-button">
|
|
||||||
<button class="btn">
|
|
||||||
<i class="far fa-heart"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flag-button">
|
|
||||||
<button class="btn">
|
|
||||||
<i class="far fa-flag"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="reply-button">
|
|
||||||
<button class="btn">
|
|
||||||
<i class="fas fa-reply"></i>
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="information load-more-posts">
|
|
||||||
<div class="information-icon">
|
|
||||||
<i class="fas fa-comment-dots"></i>
|
|
||||||
</div>
|
|
||||||
<div class="information-main">
|
|
||||||
<div class="information-title">
|
|
||||||
Load more posts
|
|
||||||
</div>
|
|
||||||
<div class="information-content">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="information no-border">
|
|
||||||
<div class="information-icon">
|
|
||||||
<i class="fas fa-reply"></i>
|
|
||||||
</div>
|
|
||||||
<div class="information-main">
|
|
||||||
<div class="information-title">
|
|
||||||
Replying to "Lexers and parsers in nim"
|
|
||||||
</div>
|
|
||||||
<div class="information-content">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
# Package
|
|
||||||
version = "2.1.0"
|
|
||||||
author = "Dominik Picheta"
|
|
||||||
description = "The Nim forum"
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
srcDir = "src"
|
|
||||||
|
|
||||||
bin = @["forum"]
|
|
||||||
|
|
||||||
skipExt = @["nim"]
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
|
|
||||||
requires "nim >= 1.0.6"
|
|
||||||
requires "jester#405be2e"
|
|
||||||
requires "bcrypt#440c5676ff6"
|
|
||||||
requires "hmac#9c61ebe2fd134cf97"
|
|
||||||
requires "recaptcha#d06488e"
|
|
||||||
requires "sass#649e0701fa5c"
|
|
||||||
|
|
||||||
requires "karax#5f21dcd"
|
|
||||||
|
|
||||||
requires "webdriver#429933a"
|
|
||||||
|
|
||||||
# Tasks
|
|
||||||
|
|
||||||
task backend, "Compiles and runs the forum backend":
|
|
||||||
exec "nimble c src/forum.nim"
|
|
||||||
exec "./src/forum"
|
|
||||||
|
|
||||||
task runbackend, "Runs the forum backend":
|
|
||||||
exec "./src/forum"
|
|
||||||
|
|
||||||
task 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"
|
|
||||||
mkDir "public/js"
|
|
||||||
cpFile "src/frontend/forum.js", "public/js/forum.js"
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
task testdb, "Creates a test DB (with admin account!)":
|
|
||||||
exec "nimble c src/setup_nimforum"
|
|
||||||
exec "./src/setup_nimforum --test"
|
|
||||||
|
|
||||||
task devdb, "Creates a test DB (with admin account!)":
|
|
||||||
exec "nimble c src/setup_nimforum"
|
|
||||||
exec "./src/setup_nimforum --dev"
|
|
||||||
|
|
||||||
task blankdb, "Creates a blank DB":
|
|
||||||
exec "nimble c src/setup_nimforum"
|
|
||||||
exec "./src/setup_nimforum --blank"
|
|
||||||
|
|
||||||
task test, "Runs tester":
|
|
||||||
exec "nimble c -y src/forum.nim"
|
|
||||||
exec "nimble c -y -r -d:actionDelayMs=0 tests/browsertester"
|
|
||||||
|
|
||||||
task fasttest, "Runs tester without recompiling backend":
|
|
||||||
exec "nimble c -r -d:actionDelayMs=0 tests/browsertester"
|
|
||||||
6
nimrod.cfg
Normal file
6
nimrod.cfg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
# we need the documentation generator of the compiler:
|
||||||
|
--path:"$nimrod/packages/docutils"
|
||||||
|
|
||||||
|
--path:"$nimrod"
|
||||||
|
--path:"/home/dominik/code/nimrod/jester"
|
||||||
2
public/captchas/.gitignore
vendored
Normal file
2
public/captchas/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
|
@ -1,781 +0,0 @@
|
||||||
@import "custom-style";
|
|
||||||
|
|
||||||
// Import full Spectre source code
|
|
||||||
@import "spectre/src/spectre";
|
|
||||||
|
|
||||||
// Global styles.
|
|
||||||
// - TODO: Make these non-global.
|
|
||||||
.btn, .form-input {
|
|
||||||
margin-right: $control-padding-x;
|
|
||||||
}
|
|
||||||
|
|
||||||
table th {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spectre fixes.
|
|
||||||
// - Weird avatar outline.
|
|
||||||
.avatar {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom styles.
|
|
||||||
// - Navigation bar.
|
|
||||||
$navbar-height: 60px;
|
|
||||||
$default-category-color: #a3a3a3;
|
|
||||||
$logo-height: $navbar-height - 20px;
|
|
||||||
|
|
||||||
.navbar-button {
|
|
||||||
border-color: $navbar-border-color-dark;
|
|
||||||
background-color: $navbar-primary-color;
|
|
||||||
color: $navbar-color;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: darken($navbar-primary-color, 20%);
|
|
||||||
color: $navbar-color;
|
|
||||||
border-color: $navbar-border-color-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-navbar {
|
|
||||||
background-color: $navbar-color;
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
height: $navbar-height;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unfortunately we must colour the controls in the navbar manually.
|
|
||||||
.search-input {
|
|
||||||
@extend .form-input;
|
|
||||||
min-width: 120px;
|
|
||||||
border-color: $navbar-border-color-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
border-color: $navbar-border-color-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
@extend .navbar-button;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#img-logo {
|
|
||||||
vertical-align: middle;
|
|
||||||
height: $logo-height;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-right {
|
|
||||||
// To make sure the user menu doesn't move off the screen.
|
|
||||||
@media (max-width: 1600px) {
|
|
||||||
left: auto;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
// - Main buttons
|
|
||||||
.btn-secondary {
|
|
||||||
background: $secondary-btn-color;
|
|
||||||
border-color: darken($secondary-btn-color, 5%);
|
|
||||||
color: invert($secondary-btn-color);
|
|
||||||
|
|
||||||
margin-right: $control-padding-x*2;
|
|
||||||
|
|
||||||
&:hover, &:focus {
|
|
||||||
background: darken($secondary-btn-color, 5%);
|
|
||||||
border-color: darken($secondary-btn-color, 10%);
|
|
||||||
|
|
||||||
color: invert($secondary-btn-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
@include control-shadow(darken($secondary-btn-color, 40%));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-buttons {
|
|
||||||
margin-top: $control-padding-y*2;
|
|
||||||
margin-bottom: $control-padding-y*2;
|
|
||||||
|
|
||||||
.dropdown > .btn {
|
|
||||||
@extend .btn-secondary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#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 {
|
|
||||||
.modal-container .modal-body {
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-body {
|
|
||||||
padding-top: $control-padding-y*2;
|
|
||||||
padding-bottom: $control-padding-y*2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input[name='subject'] {
|
|
||||||
margin-bottom: $control-padding-y*2;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.form-input, .panel-body > div {
|
|
||||||
min-height: 40vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
float: right;
|
|
||||||
margin-top: $control-padding-y*2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// - Thread table
|
|
||||||
.thread-title {
|
|
||||||
a, a:hover {
|
|
||||||
color: $body-font-color;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.visited, a:visited {
|
|
||||||
color: lighten($body-font-color, 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
// Icon
|
|
||||||
margin-right: $control-padding-x-sm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
|
|
||||||
.super-popular-text {
|
|
||||||
color: $super-popular-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popular-text {
|
|
||||||
color: $popular-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.views-text {
|
|
||||||
color: $threads-meta-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-custom {
|
|
||||||
color: white;
|
|
||||||
background-color: $label-color;
|
|
||||||
|
|
||||||
font-size: 0.6rem;
|
|
||||||
padding-left: 0.3rem;
|
|
||||||
padding-right: 0.3rem;
|
|
||||||
border-radius: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-visit-separator {
|
|
||||||
td {
|
|
||||||
border-bottom: 1px solid $super-popular-color;
|
|
||||||
line-height: 0.1rem;
|
|
||||||
padding: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: $super-popular-color;
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
background-color: $body-bg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-border {
|
|
||||||
td {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-color {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border: 0.25rem solid $default-category-color;
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more-separator {
|
|
||||||
text-align: center;
|
|
||||||
color: darken($label-color, 35%);
|
|
||||||
background-color: lighten($label-color, 15%);
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 80%;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
td {
|
|
||||||
border: none;
|
|
||||||
padding: $control-padding-x $control-padding-y/2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// - Thread view
|
|
||||||
.title {
|
|
||||||
margin-top: $control-padding-y*2;
|
|
||||||
margin-bottom: $control-padding-y*2;
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
color: darken($dark-color, 20%);
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
i.fas {
|
|
||||||
margin-right: $control-padding-x-sm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-replies, .thread-time, .views-text, .popular-text, .centered-header {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-users {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-time {
|
|
||||||
color: $threads-meta-color;
|
|
||||||
|
|
||||||
&.is-new {
|
|
||||||
@extend .text-success;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-old {
|
|
||||||
@extend .text-gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
@extend .grid-md;
|
|
||||||
@extend .container;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
margin-bottom: 10rem; // Just some empty space at the bottom.
|
|
||||||
}
|
|
||||||
|
|
||||||
.post {
|
|
||||||
@extend .tile;
|
|
||||||
border-top: 1px solid $border-color;
|
|
||||||
padding-top: $control-padding-y-lg;
|
|
||||||
|
|
||||||
&:target .post-main, &.highlight .post-main {
|
|
||||||
animation: highlight 2000ms ease-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes highlight {
|
|
||||||
0% {
|
|
||||||
background-color: lighten($primary-color, 20%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-icon {
|
|
||||||
@extend .tile-icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-avatar {
|
|
||||||
@extend .avatar;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
width: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-main {
|
|
||||||
@extend .tile-content;
|
|
||||||
|
|
||||||
margin-bottom: $control-padding-y-lg*2;
|
|
||||||
// https://stackoverflow.com/a/41675912/492186
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-title {
|
|
||||||
margin-bottom: $control-padding-y*2;
|
|
||||||
|
|
||||||
&, a, a:visited, a:hover {
|
|
||||||
color: lighten($body-font-color, 20%);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.thread-title {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
a > div {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-username {
|
|
||||||
font-weight: bold;
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
i {
|
|
||||||
margin-left: $control-padding-x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-metadata {
|
|
||||||
float: right;
|
|
||||||
|
|
||||||
.post-replyingTo {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: $control-padding-x;
|
|
||||||
|
|
||||||
i.fa-reply {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-history {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: $control-padding-x;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-count {
|
|
||||||
margin-right: $control-padding-x-sm/2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content, .about {
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-buttons {
|
|
||||||
float: right;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
background: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: darken($secondary-btn-color, 40%);
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
margin-left: $control-padding-y-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: $secondary-btn-color;
|
|
||||||
border-color: darken($secondary-btn-color, 5%);
|
|
||||||
color: invert($secondary-btn-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:focus {
|
|
||||||
@include control-shadow(darken($secondary-btn-color, 50%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
box-shadow: inset 0 0 .4rem .01rem darken($secondary-btn-color, 80%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.like-button i:hover, .like-button i.fas {
|
|
||||||
color: #f783ac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.like-count {
|
|
||||||
margin-right: $control-padding-x-sm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#thread-buttons {
|
|
||||||
border-top: 1px solid $border-color;
|
|
||||||
width: 100%;
|
|
||||||
padding-top: $control-padding-y;
|
|
||||||
padding-bottom: $control-padding-y;
|
|
||||||
@extend .clearfix;
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
float: right;
|
|
||||||
margin-right: 0;
|
|
||||||
margin-left: $control-padding-x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 0.2rem solid darken($bg-color, 10%);
|
|
||||||
background-color: $bg-color;
|
|
||||||
|
|
||||||
.detail {
|
|
||||||
margin-bottom: $control-padding-y;
|
|
||||||
color: lighten($body-font-color, 20%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-avatar {
|
|
||||||
@extend .avatar;
|
|
||||||
@extend .avatar-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-link {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-mention {
|
|
||||||
@extend .chip;
|
|
||||||
vertical-align: initial;
|
|
||||||
font-weight: bold;
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 85%;
|
|
||||||
height: inherit;
|
|
||||||
padding: 0.08rem 0.4rem;
|
|
||||||
background-color: darken($bg-color-dark, 5%);
|
|
||||||
|
|
||||||
img {
|
|
||||||
@extend .avatar;
|
|
||||||
@extend .avatar-sm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-buttons {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
margin-bottom: $control-padding-y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.execution-result {
|
|
||||||
@extend .toast;
|
|
||||||
|
|
||||||
h6 {
|
|
||||||
font-family: $base-font-family;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.execution-success {
|
|
||||||
@extend .toast-success;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code {
|
|
||||||
// Don't show the "none".
|
|
||||||
&[data-lang="none"]::before {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// &:not([data-lang="Nim"]) > .code-buttons {
|
|
||||||
// display: none;
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
.code-buttons {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content {
|
|
||||||
pre:not(.code) {
|
|
||||||
overflow: scroll;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.information {
|
|
||||||
@extend .tile;
|
|
||||||
border-top: 1px solid $border-color;
|
|
||||||
padding-top: $control-padding-y-lg*2;
|
|
||||||
padding-bottom: $control-padding-y-lg*2;
|
|
||||||
color: lighten($body-font-color, 20%);
|
|
||||||
.information-title {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.no-border {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.information-icon {
|
|
||||||
@extend .tile-icon;
|
|
||||||
|
|
||||||
i {
|
|
||||||
width: $unit-16;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-passed {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more-posts {
|
|
||||||
text-align: center;
|
|
||||||
color: darken($label-color, 35%);
|
|
||||||
background-color: lighten($label-color, 15%);
|
|
||||||
border: none;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.information-main {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more-post-count {
|
|
||||||
color: rgba(darken($label-color, 35%), 0.5);
|
|
||||||
margin-right: $control-padding-x*2;
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input.post-text-area {
|
|
||||||
margin-top: $control-padding-y*2;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
#reply-box {
|
|
||||||
.panel {
|
|
||||||
margin-top: $control-padding-y*2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
color: $body-font-color;
|
|
||||||
background-color: $bg-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
tt {
|
|
||||||
@extend code;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
background: $border-color;
|
|
||||||
height: $border-width;
|
|
||||||
margin: $unit-2 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-box {
|
|
||||||
.edit-buttons {
|
|
||||||
margin-top: $control-padding-y*2;
|
|
||||||
|
|
||||||
float: right;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-error {
|
|
||||||
margin-top: $control-padding-y*3;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input.post-text-area {
|
|
||||||
margin-bottom: $control-padding-y*2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@import "syntax.scss";
|
|
||||||
|
|
||||||
// - Profile view
|
|
||||||
|
|
||||||
.profile {
|
|
||||||
@extend .tile;
|
|
||||||
margin-top: $control-padding-y*5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-icon {
|
|
||||||
@extend .tile-icon;
|
|
||||||
margin-right: $control-padding-x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar {
|
|
||||||
@extend .avatar;
|
|
||||||
@extend .avatar-xl;
|
|
||||||
|
|
||||||
height: 6.2rem;
|
|
||||||
width: 6.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-content {
|
|
||||||
@extend .tile-content;
|
|
||||||
padding: $control-padding-x $control-padding-y;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-title {
|
|
||||||
@extend .tile-title;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-stats {
|
|
||||||
dl {
|
|
||||||
border-top: 1px solid $border-color;
|
|
||||||
border-bottom: 1px solid $border-color;
|
|
||||||
padding: $control-padding-x $control-padding-y;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
font-weight: normal;
|
|
||||||
color: lighten($dark-color, 15%);
|
|
||||||
}
|
|
||||||
|
|
||||||
dt, dd {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0;
|
|
||||||
margin-right: $control-padding-x;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin-right: $control-padding-x-lg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-tabs {
|
|
||||||
margin-bottom: $control-padding-y-lg*2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-post {
|
|
||||||
@extend .post;
|
|
||||||
|
|
||||||
.profile-post-main {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-post-time {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler {
|
|
||||||
text-shadow: gray 0px 0px 15px;
|
|
||||||
color: transparent;
|
|
||||||
-moz-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
cursor: normal;
|
|
||||||
|
|
||||||
&:hover, &:focus {
|
|
||||||
text-shadow: $body-font-color 0px 0px 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-post-title {
|
|
||||||
@extend .thread-title;
|
|
||||||
}
|
|
||||||
|
|
||||||
// - Sign up modal
|
|
||||||
|
|
||||||
#signup-modal {
|
|
||||||
.modal-container .modal-body {
|
|
||||||
max-height: 60vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.license-text {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// - Reset password
|
|
||||||
#resetpassword {
|
|
||||||
@extend .grid-sm;
|
|
||||||
@extend .container;
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
display: inline-block;
|
|
||||||
width: 15rem;
|
|
||||||
margin-bottom: $control-padding-y*2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
margin-top: $control-padding-y*2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
284
public/css/normalize.css
vendored
Normal file
284
public/css/normalize.css
vendored
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
/*
|
||||||
|
* HTML5 Boilerplate
|
||||||
|
*
|
||||||
|
* What follows is the result of much research on cross-browser styling.
|
||||||
|
* Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal,
|
||||||
|
* Kroc Camen, and the H5BP dev community and team.
|
||||||
|
*
|
||||||
|
* Detailed information about this CSS: h5bp.com/css
|
||||||
|
*
|
||||||
|
* ==|== normalize ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
HTML5 display definitions
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
article, aside, details, figcaption, figure, footer, header, hgroup, nav, section { display: block; }
|
||||||
|
audio, canvas, video { display: inline-block; *display: inline; *zoom: 1; }
|
||||||
|
audio:not([controls]) { display: none; }
|
||||||
|
[hidden] { display: none; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Base
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 1. Correct text resizing oddly in IE6/7 when body font-size is set using em units
|
||||||
|
* 2. Prevent iOS text size adjust on device orientation change, without disabling user zoom: h5bp.com/g
|
||||||
|
*/
|
||||||
|
|
||||||
|
html { font-size: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||||
|
|
||||||
|
html, button, input, select, textarea { font-family: sans-serif; color: #222; }
|
||||||
|
|
||||||
|
body { margin: 0; font-size: 1em; line-height: 1.4; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Links
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
a { color: #00e; }
|
||||||
|
a:visited { color: #551a8b; }
|
||||||
|
a:hover { color: #06e; }
|
||||||
|
a:focus { outline: thin dotted; }
|
||||||
|
|
||||||
|
/* Improve readability when focused and hovered in all browsers: h5bp.com/h */
|
||||||
|
a:hover, a:active { outline: 0; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Typography
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
abbr[title] { border-bottom: 1px dotted; }
|
||||||
|
|
||||||
|
b, strong { font-weight: bold; }
|
||||||
|
|
||||||
|
blockquote { margin: 1em 40px; }
|
||||||
|
|
||||||
|
dfn { font-style: italic; }
|
||||||
|
|
||||||
|
hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; }
|
||||||
|
|
||||||
|
ins { background: #ff9; color: #000; text-decoration: none; }
|
||||||
|
|
||||||
|
mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; }
|
||||||
|
|
||||||
|
/* Redeclare monospace font family: h5bp.com/j */
|
||||||
|
pre, code, kbd, samp { font-family: monospace, serif; _font-family: 'courier new', monospace; font-size: 1em; }
|
||||||
|
|
||||||
|
/* Improve readability of pre-formatted text in all browsers */
|
||||||
|
pre { white-space: pre; white-space: pre-wrap; word-wrap: break-word; }
|
||||||
|
|
||||||
|
q { quotes: none; }
|
||||||
|
q:before, q:after { content: ""; content: none; }
|
||||||
|
|
||||||
|
small { font-size: 85%; }
|
||||||
|
|
||||||
|
/* Position subscript and superscript content without affecting line-height: h5bp.com/k */
|
||||||
|
sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; }
|
||||||
|
sup { top: -0.5em; }
|
||||||
|
sub { bottom: -0.25em; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Lists
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
ul, ol { margin: 1em 0; padding: 0 0 0 40px; }
|
||||||
|
dd { margin: 0 0 0 40px; }
|
||||||
|
nav ul, nav ol { list-style: none; list-style-image: none; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Embedded content
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 1. Improve image quality when scaled in IE7: h5bp.com/d
|
||||||
|
* 2. Remove the gap between images and borders on image containers: h5bp.com/i/440
|
||||||
|
*/
|
||||||
|
|
||||||
|
img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Correct overflow not hidden in IE9
|
||||||
|
*/
|
||||||
|
|
||||||
|
svg:not(:root) { overflow: hidden; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Figures
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
figure { margin: 0; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Forms
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
form { margin: 0; }
|
||||||
|
fieldset { border: 0; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
/* Indicate that 'label' will shift focus to the associated form element */
|
||||||
|
label { cursor: pointer; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 1. Correct color not inheriting in IE6/7/8/9
|
||||||
|
* 2. Correct alignment displayed oddly in IE6/7
|
||||||
|
*/
|
||||||
|
|
||||||
|
legend { border: 0; *margin-left: -7px; padding: 0; white-space: normal; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 1. Correct font-size not inheriting in all browsers
|
||||||
|
* 2. Remove margins in FF3/4 S5 Chrome
|
||||||
|
* 3. Define consistent vertical alignment display in all browsers
|
||||||
|
*/
|
||||||
|
|
||||||
|
button, input, select, textarea { font-size: 100%; margin: 0; vertical-align: baseline; *vertical-align: middle; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 1. Define line-height as normal to match FF3/4 (set using !important in the UA stylesheet)
|
||||||
|
*/
|
||||||
|
|
||||||
|
button, input { line-height: normal; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 1. Display hand cursor for clickable form elements
|
||||||
|
* 2. Allow styling of clickable form elements in iOS
|
||||||
|
* 3. Correct inner spacing displayed oddly in IE7 (doesn't effect IE6)
|
||||||
|
*/
|
||||||
|
|
||||||
|
button, input[type="button"], input[type="reset"], input[type="submit"] { cursor: pointer; -webkit-appearance: button; *overflow: visible; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Re-set default cursor for disabled elements
|
||||||
|
*/
|
||||||
|
|
||||||
|
button[disabled], input[disabled] { cursor: default; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Consistent box sizing and appearance
|
||||||
|
*/
|
||||||
|
|
||||||
|
input[type="checkbox"], input[type="radio"] { box-sizing: border-box; padding: 0; *width: 13px; *height: 13px; }
|
||||||
|
input[type="search"] { -webkit-appearance: textfield; -moz-box-sizing: content-box; -webkit-box-sizing: content-box; box-sizing: content-box; }
|
||||||
|
input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remove inner padding and border in FF3/4: h5bp.com/l
|
||||||
|
*/
|
||||||
|
|
||||||
|
button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 1. Remove default vertical scrollbar in IE6/7/8/9
|
||||||
|
* 2. Allow only vertical resizing
|
||||||
|
*/
|
||||||
|
|
||||||
|
textarea { overflow: auto; vertical-align: top; resize: vertical; }
|
||||||
|
|
||||||
|
/* Colors for form validity */
|
||||||
|
input:valid, textarea:valid { }
|
||||||
|
input:invalid, textarea:invalid { background-color: #f0dddd; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Tables
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
table { border-collapse: collapse; border-spacing: 0; }
|
||||||
|
td { vertical-align: top; }
|
||||||
|
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Chrome Frame Prompt
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.chromeframe { margin: 0.2em 0; background: #ccc; color: black; padding: 0.2em 0; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ==|== primary styles =====================================================
|
||||||
|
Author:
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ==|== media queries ======================================================
|
||||||
|
EXAMPLE Media Query for Responsive Design.
|
||||||
|
This example overrides the primary ('mobile first') styles
|
||||||
|
Modify as content requires.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media only screen and (min-width: 35em) {
|
||||||
|
/* Style adjustments for viewports that meet the condition */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ==|== non-semantic helper classes ========================================
|
||||||
|
Please define your styles before this section.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* For image replacement */
|
||||||
|
.ir { display: block; border: 0; text-indent: -999em; overflow: hidden; background-color: transparent; background-repeat: no-repeat; text-align: left; direction: ltr; *line-height: 0; }
|
||||||
|
.ir br { display: none; }
|
||||||
|
|
||||||
|
/* Hide from both screenreaders and browsers: h5bp.com/u */
|
||||||
|
.hidden { display: none !important; visibility: hidden; }
|
||||||
|
|
||||||
|
/* Hide only visually, but have it available for screenreaders: h5bp.com/v */
|
||||||
|
.visuallyhidden { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; }
|
||||||
|
|
||||||
|
/* Extends the .visuallyhidden class to allow the element to be focusable when navigated to via the keyboard: h5bp.com/p */
|
||||||
|
.visuallyhidden.focusable:active, .visuallyhidden.focusable:focus { clip: auto; height: auto; margin: 0; overflow: visible; position: static; width: auto; }
|
||||||
|
|
||||||
|
/* Hide visually and from screenreaders, but maintain layout */
|
||||||
|
.invisible { visibility: hidden; }
|
||||||
|
|
||||||
|
/* Contain floats: h5bp.com/q */
|
||||||
|
.clearfix:before, .clearfix:after { content: ""; display: table; }
|
||||||
|
.clearfix:after { clear: both; }
|
||||||
|
.clearfix { *zoom: 1; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ==|== print styles =======================================================
|
||||||
|
Print styles.
|
||||||
|
Inlined to avoid required HTTP connection: h5bp.com/r
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
* { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } /* Black prints faster: h5bp.com/s */
|
||||||
|
a, a:visited { text-decoration: underline; }
|
||||||
|
a[href]:after { content: " (" attr(href) ")"; }
|
||||||
|
abbr[title]:after { content: " (" attr(title) ")"; }
|
||||||
|
.ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; } /* Don't show links for images, or javascript/internal links */
|
||||||
|
pre, blockquote { border: 1px solid #999; page-break-inside: avoid; }
|
||||||
|
thead { display: table-header-group; } /* h5bp.com/t */
|
||||||
|
tr, img { page-break-inside: avoid; }
|
||||||
|
img { max-width: 100% !important; }
|
||||||
|
@page { margin: 0.5cm; }
|
||||||
|
p, h2, h3 { orphans: 3; widows: 3; }
|
||||||
|
h2, h3 { page-break-after: avoid; }
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 7a6af53bca72b549b2cbf1948763348bd5b30dcd
|
|
||||||
308
public/css/style.css
Normal file
308
public/css/style.css
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#wrapper {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#header {
|
||||||
|
background-color: #3d3d3d;
|
||||||
|
color: #ffffff;
|
||||||
|
padding-top: 3pt;
|
||||||
|
padding-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#header span#welcome {
|
||||||
|
float: right;
|
||||||
|
padding: 0;
|
||||||
|
padding-right: 7pt;
|
||||||
|
}
|
||||||
|
div#header img {
|
||||||
|
float: right;
|
||||||
|
margin-top: -2pt;
|
||||||
|
padding-right: 7pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#header span, #nimbtn span {
|
||||||
|
margin: 0;
|
||||||
|
padding: 5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#header a.right, #nimbtn a {
|
||||||
|
float: right;
|
||||||
|
|
||||||
|
color: #ffffff;
|
||||||
|
margin-right: 6pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#header a {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#header a:visited, #nimbtn a:visited {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#header a:hover, #nimbtn a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nimbtn a {
|
||||||
|
margin-left: 6pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nimbtn {
|
||||||
|
float: left;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
color: #ffffff;
|
||||||
|
padding-top: 3pt;
|
||||||
|
padding-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
margin: 5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content table#threads {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
text-align: center;
|
||||||
|
border: #ffffff solid 1px;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content table#threads th {
|
||||||
|
background-color: #5D5D5D;
|
||||||
|
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
|
||||||
|
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom: #2d2d2d solid 2px;
|
||||||
|
border-right: #2d2d2d solid 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content table#threads tr:nth-child(even) {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content table#threads td {
|
||||||
|
vertical-align: middle;
|
||||||
|
border-right: #9d9d9d solid 1px;
|
||||||
|
border-bottom: #9d9d9d solid 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content table#threads>tbody>tr>td:first-child {
|
||||||
|
border-left: #9d9d9d solid 1px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#content table#threads>tbody>tr>td:last-child {
|
||||||
|
border-right: #9d9d9d solid 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content table#threads td:hover {
|
||||||
|
border-right-color: #9d9d9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content table#threads td.topic {
|
||||||
|
text-align: left;
|
||||||
|
padding: 5pt;
|
||||||
|
}
|
||||||
|
#content table#threads td.author {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
#content table#threads td.posts {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
#content table#threads td.views {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
#content table#threads td.lastreply {
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoisonline {
|
||||||
|
margin: 5pt;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoisonline .wioHeader {
|
||||||
|
background-color: #5D5D5D;
|
||||||
|
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
|
||||||
|
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom: #2d2d2d solid 1px;
|
||||||
|
padding: 3px;
|
||||||
|
padding-left: 5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoisonline .content {
|
||||||
|
border: #9d9d9d solid 1px;
|
||||||
|
border-top: #2D2D2D solid 1px;
|
||||||
|
padding: 5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whoisonline .content hr {
|
||||||
|
margin-top: 5pt;
|
||||||
|
margin-bottom: 5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer {
|
||||||
|
background-color: #5D5D5D;
|
||||||
|
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
|
||||||
|
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer a:link, #footer a:visited {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar {
|
||||||
|
margin: 5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content .post {
|
||||||
|
border: #4d4d4d solid 2px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content .post th {
|
||||||
|
background-color: #5D5D5D;
|
||||||
|
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
|
||||||
|
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
color: #ffffff;
|
||||||
|
padding-left: 5pt;
|
||||||
|
padding-right: 5pt;
|
||||||
|
padding-top: 3pt;
|
||||||
|
padding-bottom: 3pt;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content .post .left {
|
||||||
|
border-left: #4d4d4d solid 2px;
|
||||||
|
background-color: #eee;
|
||||||
|
padding: 7pt;
|
||||||
|
width: 15%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content .post .left hr {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 5pt;
|
||||||
|
margin-top: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content .post .content {
|
||||||
|
padding: 6pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#replywrapper {
|
||||||
|
width: 70%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
border: #4d4d4d solid 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#replywrapper div#replytop {
|
||||||
|
background-color: #5D5D5D;
|
||||||
|
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
|
||||||
|
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 5pt;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
div#replywrapper form textarea {
|
||||||
|
width: 99%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#replywrapper form > input:first-child {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#replywrapper form {
|
||||||
|
padding: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For RST nimrod syntax highlighter */
|
||||||
|
span.DecNumber {color: blue}
|
||||||
|
span.BinNumber {color: blue}
|
||||||
|
span.HexNumber {color: blue}
|
||||||
|
span.OctNumber {color: blue}
|
||||||
|
span.FloatNumber {color: blue}
|
||||||
|
span.Identifier {color: black}
|
||||||
|
span.Keyword {font-weight: bold}
|
||||||
|
span.StringLit {color: blue}
|
||||||
|
span.LongStringLit {color: blue}
|
||||||
|
span.CharLit {color: blue}
|
||||||
|
span.EscapeSequence {color: black}
|
||||||
|
span.Operator {color: black}
|
||||||
|
span.Punctation {color: black}
|
||||||
|
span.Comment, span.LongComment {font-style:italic; color: green}
|
||||||
|
span.RegularExpression {color: DarkViolet}
|
||||||
|
span.TagStart {color: DarkViolet}
|
||||||
|
span.TagEnd {color: DarkViolet}
|
||||||
|
span.Key {color: blue}
|
||||||
|
span.Value {color: black}
|
||||||
|
span.RawData {color: blue}
|
||||||
|
span.Assembler {color: blue}
|
||||||
|
span.Preprocessor {color: DarkViolet}
|
||||||
|
span.Directive {color: DarkViolet}
|
||||||
|
span.Command, span.Rule, span.Hyperlink, span.Label, span.Reference,
|
||||||
|
span.Other {color: black}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
a.button {
|
||||||
|
border-radius: 2px 2px 2px 2px;
|
||||||
|
background: -moz-linear-gradient(top, #f7f7f7, #ebebeb);
|
||||||
|
background: -webkit-linear-gradient(top, #f7f7f7, #ebebeb);
|
||||||
|
background: -o-linear-gradient(top, #f7f7f7, #ebebeb);
|
||||||
|
text-decoration: none;
|
||||||
|
color: #3d3d3d;
|
||||||
|
padding: 5px;
|
||||||
|
border: solid 1px #9d9d9d;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button.left {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button.middle {
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button.right {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button:hover {
|
||||||
|
background: -moz-linear-gradient(top, #0099c7, #0294C1);
|
||||||
|
background: -webkit-linear-gradient(top, #0099c7, #0294C1);
|
||||||
|
background: -o-linear-gradient(top, #0099c7, #0294C1);
|
||||||
|
border: solid 1px #077A9C;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
pre .Comment { color:#618f0b; font-style:italic; }
|
|
||||||
pre .Keyword { color:rgb(39, 141, 182); font-weight:bold; }
|
|
||||||
pre .Type { color:#128B7D; font-weight:bold; }
|
|
||||||
pre .Operator { font-weight: bold; }
|
|
||||||
pre .atr { color:#128B7D; font-weight:bold; font-style:italic; }
|
|
||||||
pre .def { color:#CAD6E4; font-weight:bold; font-style:italic; }
|
|
||||||
pre .StringLit { color:rgb(190, 15, 15); font-weight:bold; }
|
|
||||||
pre .DecNumber, pre .FloatNumber { color:#8AB647; }
|
|
||||||
pre .tab { border-left:1px dotted rgba(67,168,207,0.4); }
|
|
||||||
pre .EscapeSequence
|
|
||||||
{
|
|
||||||
color: #C08D12;
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
|
|
||||||
<title>$title</title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/css/nimforum.css">
|
|
||||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
|
|
||||||
<link rel="icon" href="/images/favicon.png">
|
|
||||||
|
|
||||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=$ga"></script>
|
|
||||||
<script>
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag(){dataLayer.push(arguments);}
|
|
||||||
gtag('js', new Date());
|
|
||||||
|
|
||||||
gtag('config', '$ga');
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="ROOT"></div>
|
|
||||||
|
|
||||||
<script type="text/javascript" src="/js/forum.js?t=$timestamp"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
Content license
|
|
||||||
===============
|
|
||||||
|
|
||||||
All the content contributed to $hostname is `cc-wiki (aka cc-by-sa)
|
|
||||||
<http://creativecommons.org/licenses/by-sa/3.0/>`_ licensed, intended to be
|
|
||||||
**shared and remixed**.
|
|
||||||
|
|
||||||
The cc-wiki licensing, while intentionally permissive, does require
|
|
||||||
attribution:
|
|
||||||
|
|
||||||
**Attribution** — You must attribute the work in the manner specified by
|
|
||||||
the author or licensor (but not in any way that suggests that they endorse
|
|
||||||
you or your use of the work).
|
|
||||||
|
|
||||||
This means that if you republish this content, you are
|
|
||||||
required to:
|
|
||||||
|
|
||||||
* **Visually indicate that the content is from the $name**. It doesn’t
|
|
||||||
have to be obnoxious; a discreet text blurb is fine.
|
|
||||||
* **Hyperlink directly to the original post** (e.g.,
|
|
||||||
https://$hostname/t/186/1#908)
|
|
||||||
* **Show the author names** for every post.
|
|
||||||
* **Hyperlink each author name** directly back to their user profile page
|
|
||||||
(e.g., http://$hostname/profile/Araq)
|
|
||||||
|
|
||||||
To be more specific, each hyperlink must
|
|
||||||
point directly to the $hostname domain in
|
|
||||||
standard HTML visible even with JavaScript disabled, and not use a tinyurl or
|
|
||||||
any other form of obfuscation or redirection. Furthermore, the links must not
|
|
||||||
be `nofollowed
|
|
||||||
<http://googleblog.blogspot.com.es/2005/01/preventing-comment-spam.html>`_.
|
|
||||||
|
|
||||||
This is about the spirit of fair **attribution**. Attribution to the website,
|
|
||||||
and more importantly, to the individuals who so generously contributed their
|
|
||||||
time to create that content in the first place!
|
|
||||||
|
|
||||||
Feel free to remix and reuse to your heart’s content, as long as a good faith
|
|
||||||
effort is made to attribute the content!
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
Full-text search for Nim forum
|
|
||||||
==============================
|
|
||||||
|
|
||||||
Syntax (using *SQLite* dll compiled without *Enhanced Query Syntax* support):
|
|
||||||
-----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
- Only alphanumeric characters are searched.
|
|
||||||
- Only full words and words beginnings (e.g. ``Nim*`` for both ``Nimrod`` and ``Nim``) are searched
|
|
||||||
- All words are joined with implicit **AND** operator; there's no explicit one
|
|
||||||
- There's explicit **OR** operator (upper-case) and it has higher priority
|
|
||||||
- Words can be prepended with **-** to be excluded from search
|
|
||||||
- No parentheses support
|
|
||||||
- Quotes for phrases search, e.g. ``"programming language"``
|
|
||||||
- Distances between words/phrases can be specified putting ``NEAR`` or ``NEAR/some_number`` between them
|
|
||||||
|
|
||||||
Syntax - differences in *Enhanced Query Syntax* (should be enabled in *SQLite* dll):
|
|
||||||
------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
- **AND** and **NOT** logical operators available
|
|
||||||
- Precedence of operators is, from highest to lowest: **NOT**, **AND**, **OR**
|
|
||||||
- Parentheses for grouping are supported
|
|
||||||
|
|
||||||
Where search is performed:
|
|
||||||
--------------------------
|
|
||||||
|
|
||||||
- **Threads' titles** - these results are outputed first
|
|
||||||
- **Posts' titles** - middle precedence
|
|
||||||
- **Posts' contents** - the latest
|
|
||||||
|
|
||||||
How results are shown:
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
- All results are ordered by date (posts' edits don't affect)
|
|
||||||
- Matched tokens in text are marked (bold or dotted underline)
|
|
||||||
- Threads title is the link to the thread and posts title is the link to the post
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
Markdown and RST supported by this forum
|
===========================================================================
|
||||||
========================================
|
reStructuredText cheat sheet
|
||||||
|
===========================================================================
|
||||||
|
|
||||||
This is a cheat sheet for the *reStructuredText* dialect as implemented by
|
This is a cheat sheet for the *reStructuredText* dialect as implemented by
|
||||||
Nim's documentation generator which has been reused for this forum.
|
Nimrod's documentation generator which has been reused for this forum. :-)
|
||||||
|
|
||||||
See also the
|
See also the
|
||||||
`official RST cheat sheet <http://docutils.sourceforge.net/docs/user/rst/cheatsheet.txt>`_
|
`official RST cheat sheet <http://docutils.sourceforge.net/docs/user/rst/cheatsheet.txt>`_
|
||||||
|
|
@ -11,8 +12,9 @@ for further information.
|
||||||
|
|
||||||
Elements of **markdown** are also supported.
|
Elements of **markdown** are also supported.
|
||||||
|
|
||||||
|
|
||||||
Inline elements
|
Inline elements
|
||||||
---------------
|
===============
|
||||||
|
|
||||||
Ordinary text may contain *inline elements*:
|
Ordinary text may contain *inline elements*:
|
||||||
|
|
||||||
|
|
@ -27,71 +29,68 @@ Plain text Result
|
||||||
``\\escape`` \\escape
|
``\\escape`` \\escape
|
||||||
=============================== ============================================
|
=============================== ============================================
|
||||||
|
|
||||||
Quoting other users can be done by prefixing their message with ``>``::
|
|
||||||
|
|
||||||
> Hello World
|
|
||||||
|
|
||||||
Hi!
|
|
||||||
|
|
||||||
Which will result in:
|
|
||||||
|
|
||||||
> Hello World
|
|
||||||
|
|
||||||
Hi!
|
|
||||||
|
|
||||||
Links
|
Links
|
||||||
-----
|
=====
|
||||||
|
|
||||||
Links are either direct URLs like ``https://nim-lang.org`` or written like
|
Links are either direct URLs like ``http://nimrod-code.org`` or written like
|
||||||
this::
|
this::
|
||||||
|
|
||||||
`Nim <https://nim-lang.org>`_
|
`Nimrod <http://nimrod-code.org>`_
|
||||||
|
|
||||||
Or like this::
|
Or like this::
|
||||||
|
|
||||||
`<https://nim-lang.org>`_
|
`<http://nimrod-code.org>`_
|
||||||
|
|
||||||
|
|
||||||
Code blocks
|
Code blocks
|
||||||
-----------
|
===========
|
||||||
|
|
||||||
The code blocks can be written in the same style as most common Markdown
|
are done this way::
|
||||||
flavours::
|
|
||||||
|
|
||||||
```nim
|
.. code-block:: nimrod
|
||||||
if x == "abc":
|
|
||||||
echo "xyz"
|
|
||||||
```
|
|
||||||
|
|
||||||
or using RST syntax::
|
|
||||||
|
|
||||||
.. code-block:: nim
|
|
||||||
|
|
||||||
if x == "abc":
|
if x == "abc":
|
||||||
echo "xyz"
|
echo "xyz"
|
||||||
|
|
||||||
Both are rendered as:
|
|
||||||
|
|
||||||
.. code-block:: nim
|
Is rendered as:
|
||||||
|
|
||||||
|
.. code-block:: nimrod
|
||||||
|
|
||||||
|
if x == "abc":
|
||||||
|
echo "xyz"
|
||||||
|
|
||||||
|
|
||||||
|
Except Nimrod, the programming languages C, C++, Java and C# have highlighting
|
||||||
|
support.
|
||||||
|
|
||||||
|
An alternative github-like syntax is also supported. This has the advantage
|
||||||
|
that no excessive indentation is needed::
|
||||||
|
|
||||||
|
```nimrod
|
||||||
|
if x == "abc":
|
||||||
|
echo "xyz"```
|
||||||
|
|
||||||
|
Is rendered as:
|
||||||
|
|
||||||
|
.. code-block:: nimrod
|
||||||
|
|
||||||
if x == "abc":
|
if x == "abc":
|
||||||
echo "xyz"
|
echo "xyz"
|
||||||
|
|
||||||
|
|
||||||
Apart from Nim, the programming languages C, C++, Java and C# also
|
|
||||||
have highlighting support.
|
|
||||||
|
|
||||||
Literal blocks
|
Literal blocks
|
||||||
--------------
|
==============
|
||||||
|
|
||||||
These are introduced by '::' and a newline. The block is indicated by indentation:
|
Are introduced by '::' and a newline. The block is indicated by indentation:
|
||||||
|
|
||||||
::
|
::
|
||||||
::
|
::
|
||||||
if x == "abc":
|
if x == "abc":
|
||||||
echo "xyz"
|
echo "xyz"
|
||||||
|
|
||||||
The above is rendered as::
|
Is rendered as::
|
||||||
|
|
||||||
if x == "abc":
|
if x == "abc":
|
||||||
echo "xyz"
|
echo "xyz"
|
||||||
|
|
@ -99,9 +98,9 @@ The above is rendered as::
|
||||||
|
|
||||||
|
|
||||||
Bullet lists
|
Bullet lists
|
||||||
------------
|
============
|
||||||
|
|
||||||
Bullet lists look like this::
|
look like this::
|
||||||
|
|
||||||
* Item 1
|
* Item 1
|
||||||
* Item 2 that
|
* Item 2 that
|
||||||
|
|
@ -112,7 +111,7 @@ Bullet lists look like this::
|
||||||
- item 3b
|
- item 3b
|
||||||
- valid bullet characters are ``+``, ``*`` and ``-``
|
- valid bullet characters are ``+``, ``*`` and ``-``
|
||||||
|
|
||||||
The above rendered as:
|
Is rendered as:
|
||||||
* Item 1
|
* Item 1
|
||||||
* Item 2 that
|
* Item 2 that
|
||||||
spans over multiple lines
|
spans over multiple lines
|
||||||
|
|
@ -124,9 +123,9 @@ The above rendered as:
|
||||||
|
|
||||||
|
|
||||||
Enumerated lists
|
Enumerated lists
|
||||||
----------------
|
================
|
||||||
|
|
||||||
Enumerated lists are written like this::
|
are written like this::
|
||||||
|
|
||||||
1. This is the first item
|
1. This is the first item
|
||||||
2. This is the second item
|
2. This is the second item
|
||||||
|
|
@ -134,17 +133,64 @@ Enumerated lists are written like this::
|
||||||
single letters, or roman numerals
|
single letters, or roman numerals
|
||||||
#. This item is auto-enumerated
|
#. This item is auto-enumerated
|
||||||
|
|
||||||
They are rendered as:
|
Is rendered as:
|
||||||
|
|
||||||
1. This is the first item
|
1. This is the first item
|
||||||
2. This is the second item
|
2. This is the second item
|
||||||
3. Enumerators are arabic numbers,
|
3. Enumerators are arabic numbers,
|
||||||
single letters, or roman numerals
|
single letters, or roman numerals
|
||||||
#. This item is auto-enumerated
|
#. This item is auto-enumerated
|
||||||
|
|
||||||
|
|
||||||
|
Quoting someone
|
||||||
|
===============
|
||||||
|
|
||||||
|
quotes are just::
|
||||||
|
|
||||||
|
**Someone said**: Indented paragraphs,
|
||||||
|
|
||||||
|
and they may nest.
|
||||||
|
|
||||||
|
Is rendered as:
|
||||||
|
|
||||||
|
**Someone said**: Indented paragraphs,
|
||||||
|
|
||||||
|
and they may nest.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Definition lists
|
||||||
|
================
|
||||||
|
|
||||||
|
are written like this::
|
||||||
|
|
||||||
|
what
|
||||||
|
Definition lists associate a term with
|
||||||
|
a definition.
|
||||||
|
|
||||||
|
how
|
||||||
|
The term is a one-line phrase, and the
|
||||||
|
definition is one or more paragraphs or
|
||||||
|
body elements, indented relative to the
|
||||||
|
term. Blank lines are not allowed
|
||||||
|
between term and definition.
|
||||||
|
|
||||||
|
and look like:
|
||||||
|
|
||||||
|
what
|
||||||
|
Definition lists associate a term with
|
||||||
|
a definition.
|
||||||
|
|
||||||
|
how
|
||||||
|
The term is a one-line phrase, and the
|
||||||
|
definition is one or more paragraphs or
|
||||||
|
body elements, indented relative to the
|
||||||
|
term. Blank lines are not allowed
|
||||||
|
between term and definition.
|
||||||
|
|
||||||
|
|
||||||
Tables
|
Tables
|
||||||
------
|
======
|
||||||
|
|
||||||
Only *simple tables* are supported. They are of the form::
|
Only *simple tables* are supported. They are of the form::
|
||||||
|
|
||||||
|
|
@ -172,39 +218,3 @@ Cell 4 Cell 5; any Cell 6
|
||||||
multiple lines
|
multiple lines
|
||||||
Cell 7 Cell 8 Cell 9
|
Cell 7 Cell 8 Cell 9
|
||||||
================== =============== ===================
|
================== =============== ===================
|
||||||
|
|
||||||
Images
|
|
||||||
------
|
|
||||||
|
|
||||||
Image embedding is supported. This includes GIFs as well as mp4 (for which a
|
|
||||||
<video> tag will be automatically generated).
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```
|
|
||||||
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
|
|
||||||
```
|
|
||||||
|
|
||||||
Will render as:
|
|
||||||
|
|
||||||
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
|
|
||||||
|
|
||||||
And a GIF example:
|
|
||||||
|
|
||||||
```
|
|
||||||
.. image:: https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif
|
|
||||||
```
|
|
||||||
|
|
||||||
Will render as:
|
|
||||||
|
|
||||||
.. image:: https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif
|
|
||||||
|
|
||||||
You can also specify the size of the image:
|
|
||||||
|
|
||||||
```
|
|
||||||
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
|
|
||||||
:width: 40%
|
|
||||||
```
|
|
||||||
|
|
||||||
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
|
|
||||||
:width: 40%
|
|
||||||
171
setup.md
171
setup.md
|
|
@ -1,171 +0,0 @@
|
||||||
# NimForum setup
|
|
||||||
|
|
||||||
This document describes the steps needed to setup a working NimForum instance.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
* Ubuntu 16.04+
|
|
||||||
* Some Linux knowledge
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Begin by downloading the latest NimForum release from
|
|
||||||
[here](https://github.com/nim-lang/nimforum/releases). The macOS releases are
|
|
||||||
mainly provided for testing.
|
|
||||||
|
|
||||||
Extract the downloaded tarball on your server. These steps can be done using
|
|
||||||
the following commands:
|
|
||||||
|
|
||||||
```
|
|
||||||
wget https://github.com/nim-lang/nimforum/releases/download/v2.0.0/nimforum_2.0.0_linux.tar.xz
|
|
||||||
tar -xf nimforum_2.0.0_linux.tar.xz
|
|
||||||
```
|
|
||||||
|
|
||||||
Then ``cd`` into the forum's directory:
|
|
||||||
|
|
||||||
```
|
|
||||||
cd nimforum_2.0.0_linux
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
The following may need to be installed on your server:
|
|
||||||
|
|
||||||
```
|
|
||||||
sudo apt install libsass-dev sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration and DB creation
|
|
||||||
|
|
||||||
The NimForum release comes with a handy ``setup_nimforum`` program. Run
|
|
||||||
it to begin the setup process:
|
|
||||||
|
|
||||||
```
|
|
||||||
./setup_nimforum --setup
|
|
||||||
```
|
|
||||||
|
|
||||||
The program will ask you multiple questions which will require some
|
|
||||||
additional setup, including mail server info and recaptcha keys. You can
|
|
||||||
just specify dummy values if you want to play around with the forum as
|
|
||||||
quickly as possible and set these up later.
|
|
||||||
|
|
||||||
This program will create a ``nimforum.db`` file, this contains your forum's
|
|
||||||
database. It will also create a ``forum.json`` file, you can modify this
|
|
||||||
file after running the ``setup_nimforum`` script if you've made any mistakes
|
|
||||||
or just want to change things.
|
|
||||||
|
|
||||||
## Running the forum
|
|
||||||
|
|
||||||
Executing the forum is simple, just run the ``forum`` binary:
|
|
||||||
|
|
||||||
```
|
|
||||||
./forum
|
|
||||||
```
|
|
||||||
|
|
||||||
The forum will start listening to HTTP requests on port 5000 (by default, this
|
|
||||||
can be changed in ``forum.json``).
|
|
||||||
|
|
||||||
On your server you should set up a separate HTTP server. The recommended choice
|
|
||||||
is nginx. You can then use it as a reverse proxy for NimForum.
|
|
||||||
|
|
||||||
### HTTP server
|
|
||||||
|
|
||||||
#### nginx
|
|
||||||
|
|
||||||
Once you have nginx installed on your server, you will need to configure it.
|
|
||||||
Create a ``forum.hostname.com`` file (replace the hostname with your forum's
|
|
||||||
hostname) inside ``/etc/nginx/sites-available/``.
|
|
||||||
|
|
||||||
Place the following inside it:
|
|
||||||
|
|
||||||
```
|
|
||||||
server {
|
|
||||||
server_name forum.hostname.com;
|
|
||||||
autoindex off;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:5000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real_IP $remote_addr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Again, be sure to replace ``forum.hostname.com`` with your forum's
|
|
||||||
hostname.
|
|
||||||
|
|
||||||
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>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then reload nginx configuration by running ``sudo nginx -s reload``.
|
|
||||||
|
|
||||||
### Supervisor
|
|
||||||
|
|
||||||
#### systemd
|
|
||||||
|
|
||||||
In order to ensure the forum is always running, even after a crash or a server
|
|
||||||
reboot, you should create a systemd service file.
|
|
||||||
|
|
||||||
Create a new file called ``nimforum.service`` inside ``/lib/systemd/system/nimforum.service``.
|
|
||||||
|
|
||||||
Place the following inside it:
|
|
||||||
|
|
||||||
```
|
|
||||||
[Unit]
|
|
||||||
Description=nimforum
|
|
||||||
After=network.target httpd.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
WorkingDirectory=/home/<user>/nimforum-2.0.0/ # MODIFY THIS
|
|
||||||
ExecStart=/usr/bin/stdbuf -oL /home/<user>/nimforum-2.0.0/forum # MODIFY THIS
|
|
||||||
# Restart when crashes.
|
|
||||||
Restart=always
|
|
||||||
RestartSec=1
|
|
||||||
|
|
||||||
User=dom
|
|
||||||
|
|
||||||
StandardOutput=syslog+console
|
|
||||||
StandardError=syslog+console
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
**Be sure to specify the correct ``WorkingDirectory`` and ``ExecStart``!**
|
|
||||||
|
|
||||||
You can then enable and start the service by running the following:
|
|
||||||
|
|
||||||
```
|
|
||||||
sudo systemctl enable nimforum
|
|
||||||
sudo systemctl start nimforum
|
|
||||||
```
|
|
||||||
|
|
||||||
To check that everything is in order, run this:
|
|
||||||
|
|
||||||
```
|
|
||||||
systemctl status nimforum
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see something like this:
|
|
||||||
|
|
||||||
```
|
|
||||||
● nimforum.service - nimforum
|
|
||||||
Loaded: loaded (/lib/systemd/system/nimforum.service; enabled; vendor preset: enabled)
|
|
||||||
Active: active (running) since Fri 2018-05-25 22:09:59 UTC; 1 day 22h ago
|
|
||||||
Main PID: 21474 (forum)
|
|
||||||
Tasks: 1
|
|
||||||
Memory: 55.2M
|
|
||||||
CPU: 1h 15min 31.905s
|
|
||||||
CGroup: /system.slice/nimforum.service
|
|
||||||
└─21474 /home/dom/nimforum/src/forum
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
That should be all you need to get started. Your forum should now be accessible
|
|
||||||
via your hostname, assuming that it points to your VPS' IP address.
|
|
||||||
90
src/auth.nim
90
src/auth.nim
|
|
@ -1,90 +0,0 @@
|
||||||
import random, md5
|
|
||||||
|
|
||||||
import bcrypt, hmac
|
|
||||||
|
|
||||||
proc randomSalt(): string =
|
|
||||||
result = ""
|
|
||||||
for i in 0..127:
|
|
||||||
var r = rand(225)
|
|
||||||
if r >= 32 and r <= 126:
|
|
||||||
result.add(chr(rand(225)))
|
|
||||||
|
|
||||||
proc devRandomSalt(): string =
|
|
||||||
when defined(posix):
|
|
||||||
result = ""
|
|
||||||
var f = open("/dev/urandom")
|
|
||||||
var randomBytes: array[0..127, char]
|
|
||||||
discard f.readBuffer(addr(randomBytes), 128)
|
|
||||||
for i in 0..127:
|
|
||||||
if ord(randomBytes[i]) >= 32 and ord(randomBytes[i]) <= 126:
|
|
||||||
result.add(randomBytes[i])
|
|
||||||
f.close()
|
|
||||||
else:
|
|
||||||
result = randomSalt()
|
|
||||||
|
|
||||||
proc makeSalt*(): string =
|
|
||||||
## Creates a salt using a cryptographically secure random number generator.
|
|
||||||
##
|
|
||||||
## Ensures that the resulting salt contains no ``\0``.
|
|
||||||
try:
|
|
||||||
result = devRandomSalt()
|
|
||||||
except IOError:
|
|
||||||
result = randomSalt()
|
|
||||||
|
|
||||||
var newResult = ""
|
|
||||||
for i in 0 ..< result.len:
|
|
||||||
if result[i] != '\0':
|
|
||||||
newResult.add result[i]
|
|
||||||
return newResult
|
|
||||||
|
|
||||||
proc makeSessionKey*(): string =
|
|
||||||
## Creates a random key to be used to authorize a session.
|
|
||||||
let random = makeSalt()
|
|
||||||
return bcrypt.hash(random, genSalt(8))
|
|
||||||
|
|
||||||
proc makePassword*(password, salt: string, comparingTo = ""): string =
|
|
||||||
## Creates an MD5 hash by combining password and salt.
|
|
||||||
let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8)
|
|
||||||
result = hash(getMD5(salt & getMD5(password)), bcryptSalt)
|
|
||||||
|
|
||||||
proc makeIdentHash*(user, password: string, epoch: int64,
|
|
||||||
secret: string): string =
|
|
||||||
## Creates a hash verifying the identity of a user. Used for password reset
|
|
||||||
## links and email activation links.
|
|
||||||
## The ``epoch`` determines the creation time of this hash, it will be checked
|
|
||||||
## during verification to ensure the hash hasn't expired.
|
|
||||||
## The ``secret`` is the 'salt' field in the ``person`` table.
|
|
||||||
result = hmac_sha256(secret, user & password & $epoch).toHex()
|
|
||||||
|
|
||||||
|
|
||||||
when isMainModule:
|
|
||||||
block:
|
|
||||||
let ident = makeIdentHash("test", "pass", 1526908753, "randomtext")
|
|
||||||
let ident2 = makeIdentHash("test", "pass", 1526908753, "randomtext")
|
|
||||||
doAssert ident == ident2
|
|
||||||
|
|
||||||
let invalid = makeIdentHash("test", "pass", 1526908754, "randomtext")
|
|
||||||
doAssert ident != invalid
|
|
||||||
|
|
||||||
block:
|
|
||||||
let ident = makeIdentHash(
|
|
||||||
"test",
|
|
||||||
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
|
|
||||||
1526908753,
|
|
||||||
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um"
|
|
||||||
)
|
|
||||||
let ident2 = makeIdentHash(
|
|
||||||
"test",
|
|
||||||
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
|
|
||||||
1526908753,
|
|
||||||
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um"
|
|
||||||
)
|
|
||||||
doAssert ident == ident2
|
|
||||||
|
|
||||||
let invalid = makeIdentHash(
|
|
||||||
"test",
|
|
||||||
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
|
|
||||||
1526908754,
|
|
||||||
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um"
|
|
||||||
)
|
|
||||||
doAssert ident != invalid
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import os
|
|
||||||
|
|
||||||
import sass
|
|
||||||
|
|
||||||
import utils
|
|
||||||
|
|
||||||
proc buildCSS*(config: Config) =
|
|
||||||
let publicLoc = "public"
|
|
||||||
var includePaths: seq[string] = @[]
|
|
||||||
# Check for a styles override.
|
|
||||||
var hostname = config.hostname
|
|
||||||
if not existsDir(hostname):
|
|
||||||
hostname = "localhost.local"
|
|
||||||
|
|
||||||
let dir = getCurrentDir() / hostname / "public"
|
|
||||||
includePaths.add(dir / "css")
|
|
||||||
createDir(publicLoc / "images")
|
|
||||||
let logo = publicLoc / "images" / "logo.png"
|
|
||||||
removeFile(logo)
|
|
||||||
createSymlink(
|
|
||||||
dir / "images" / "logo.png",
|
|
||||||
logo
|
|
||||||
)
|
|
||||||
|
|
||||||
let cssLoc = publicLoc / "css"
|
|
||||||
sass.compileFile(
|
|
||||||
cssLoc / "nimforum.scss",
|
|
||||||
cssLoc / "nimforum.css",
|
|
||||||
includePaths=includePaths
|
|
||||||
)
|
|
||||||
|
|
||||||
when isMainModule:
|
|
||||||
let config = loadConfig()
|
|
||||||
buildCSS(config)
|
|
||||||
echo("CSS Built successfully")
|
|
||||||
151
src/email.nim
151
src/email.nim
|
|
@ -1,151 +0,0 @@
|
||||||
import asyncdispatch, smtp, strutils, times, cgi, tables, logging
|
|
||||||
|
|
||||||
from jester import Request, makeUri
|
|
||||||
|
|
||||||
import utils, auth
|
|
||||||
|
|
||||||
type
|
|
||||||
Mailer* = ref object
|
|
||||||
config: Config
|
|
||||||
lastReset: Time
|
|
||||||
emailsSent: CountTable[string]
|
|
||||||
|
|
||||||
proc newMailer*(config: Config): Mailer =
|
|
||||||
Mailer(
|
|
||||||
config: config,
|
|
||||||
lastReset: getTime(),
|
|
||||||
emailsSent: initCountTable[string]()
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
|
||||||
mailer.lastReset = getTime()
|
|
||||||
mailer.emailsSent.clear()
|
|
||||||
|
|
||||||
result = address in mailer.emailsSent and mailer.emailsSent[address] >= 2
|
|
||||||
mailer.emailsSent.inc(address)
|
|
||||||
|
|
||||||
proc sendMail(
|
|
||||||
mailer: Mailer,
|
|
||||||
subject, message, recipient: string,
|
|
||||||
otherHeaders:seq[(string, string)] = @[]
|
|
||||||
) {.async.} =
|
|
||||||
# Ensure we aren't emailing this address too much.
|
|
||||||
if rateCheck(mailer, recipient):
|
|
||||||
let msg = "Too many messages have been sent to this email address recently."
|
|
||||||
raise newForumError(msg)
|
|
||||||
|
|
||||||
if mailer.config.smtpAddress.len == 0:
|
|
||||||
warn("Cannot send mail: no smtp server configured (smtpAddress).")
|
|
||||||
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))
|
|
||||||
|
|
||||||
if mailer.config.smtpUser.len > 0:
|
|
||||||
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
|
|
||||||
|
|
||||||
let toList = @[recipient]
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
await client.sendMail(mailer.config.smtpFromAddr, toList, $encoded)
|
|
||||||
|
|
||||||
proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} =
|
|
||||||
let message = """Hello $1,
|
|
||||||
A password reset has been requested for your account on the $3.
|
|
||||||
|
|
||||||
If you did not make this request, you can safely ignore this email.
|
|
||||||
A password reset request can be made by anyone, and it does not indicate
|
|
||||||
that your account is in any danger of being accessed by someone else.
|
|
||||||
|
|
||||||
If you do actually want to reset your password, visit this link:
|
|
||||||
|
|
||||||
$2
|
|
||||||
|
|
||||||
Thank you for being a part of our community!
|
|
||||||
""" % [user, resetUrl, mailer.config.name]
|
|
||||||
|
|
||||||
let subject = mailer.config.name & " Password Recovery"
|
|
||||||
await sendMail(mailer, subject, message, email)
|
|
||||||
|
|
||||||
proc sendEmailActivation(
|
|
||||||
mailer: Mailer,
|
|
||||||
email, user, activateUrl: string
|
|
||||||
) {.async.} =
|
|
||||||
let message = """Hello $1,
|
|
||||||
You have recently registered an account on the $3.
|
|
||||||
|
|
||||||
As the final step in your registration, we require that you confirm your email
|
|
||||||
via the following link:
|
|
||||||
|
|
||||||
$2
|
|
||||||
|
|
||||||
Thank you for registering and becoming a part of our community!
|
|
||||||
""" % [user, activateUrl, mailer.config.name]
|
|
||||||
let subject = mailer.config.name & " Account Email Confirmation"
|
|
||||||
await sendMail(mailer, subject, message, email)
|
|
||||||
|
|
||||||
type
|
|
||||||
SecureEmailKind* = enum
|
|
||||||
ActivateEmail, ResetPassword
|
|
||||||
|
|
||||||
proc sendSecureEmail*(
|
|
||||||
mailer: Mailer,
|
|
||||||
kind: SecureEmailKind, req: Request,
|
|
||||||
name, password, email, salt: string
|
|
||||||
) {.async.} =
|
|
||||||
let epoch = int(epochTime())
|
|
||||||
|
|
||||||
let path =
|
|
||||||
case kind
|
|
||||||
of ActivateEmail:
|
|
||||||
"activateEmail"
|
|
||||||
of ResetPassword:
|
|
||||||
"resetPassword"
|
|
||||||
let url = req.makeUri(
|
|
||||||
"/$#?nick=$#&epoch=$#&ident=$#" %
|
|
||||||
[
|
|
||||||
path,
|
|
||||||
encodeUrl(name),
|
|
||||||
encodeUrl($epoch),
|
|
||||||
encodeUrl(makeIdentHash(name, password, epoch, salt))
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
debug(url)
|
|
||||||
|
|
||||||
let emailSentFut =
|
|
||||||
case kind
|
|
||||||
of ActivateEmail:
|
|
||||||
sendEmailActivation(mailer, email, name, url)
|
|
||||||
of ResetPassword:
|
|
||||||
sendPassReset(mailer, email, name, url)
|
|
||||||
yield emailSentFut
|
|
||||||
if emailSentFut.failed:
|
|
||||||
warn("Couldn't send email: ", emailSentFut.error.msg)
|
|
||||||
if emailSentFut.error of ForumError:
|
|
||||||
raise emailSentFut.error
|
|
||||||
else:
|
|
||||||
raise newForumError("Couldn't send email", @["email"])
|
|
||||||
1627
src/forum.nim
1627
src/forum.nim
File diff suppressed because it is too large
Load diff
|
|
@ -1,44 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import sugar, httpcore
|
|
||||||
import dom except Event
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax]
|
|
||||||
|
|
||||||
import error
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
type
|
|
||||||
About* = ref object
|
|
||||||
loading: bool
|
|
||||||
status: HttpCode
|
|
||||||
content: kstring
|
|
||||||
page: string
|
|
||||||
|
|
||||||
proc newAbout*(): About =
|
|
||||||
About(
|
|
||||||
status: Http200
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onContent(status: int, response: kstring, state: About) =
|
|
||||||
state.status = status.HttpCode
|
|
||||||
state.content = response
|
|
||||||
|
|
||||||
proc render*(state: About, page: string): VNode =
|
|
||||||
if state.status != Http200:
|
|
||||||
return renderError($state.content, state.status)
|
|
||||||
|
|
||||||
if page != state.page:
|
|
||||||
if not state.loading:
|
|
||||||
state.page = page
|
|
||||||
state.loading = true
|
|
||||||
state.status = Http200
|
|
||||||
let uri = makeUri("/about/" & page & ".html")
|
|
||||||
ajaxGet(uri, @[], (s: int, r: kstring) => onContent(s, r, state))
|
|
||||||
|
|
||||||
return buildHtml(tdiv(class="loading"))
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
section(class="container grid-xl"):
|
|
||||||
tdiv(class="about"):
|
|
||||||
verbatim(state.content)
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import sugar, httpcore, options, json
|
|
||||||
import dom except Event
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax, kdom]
|
|
||||||
|
|
||||||
import error
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
type
|
|
||||||
ActivateEmail* = ref object
|
|
||||||
loading: bool
|
|
||||||
status: HttpCode
|
|
||||||
error: Option[PostError]
|
|
||||||
|
|
||||||
proc newActivateEmail*(): ActivateEmail =
|
|
||||||
ActivateEmail(
|
|
||||||
status: Http200
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onPost(httpStatus: int, response: kstring, state: ActivateEmail) =
|
|
||||||
postFinished:
|
|
||||||
navigateTo(makeUri("/activateEmail/success"))
|
|
||||||
|
|
||||||
proc onSetClick(
|
|
||||||
ev: Event, n: VNode,
|
|
||||||
state: ActivateEmail
|
|
||||||
) =
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let uri = makeUri("activateEmail", search = $kdom.window.location.search)
|
|
||||||
ajaxPost(uri, @[], "",
|
|
||||||
(s: int, r: kstring) => onPost(s, r, state))
|
|
||||||
|
|
||||||
proc render*(state: ActivateEmail): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
section(class="container grid-xl"):
|
|
||||||
tdiv(id="activateemail"):
|
|
||||||
tdiv(class="title"):
|
|
||||||
p(): text "Activate Email"
|
|
||||||
tdiv(class="content"):
|
|
||||||
button(class=class(
|
|
||||||
{"loading": state.loading},
|
|
||||||
"btn btn-primary"
|
|
||||||
),
|
|
||||||
onClick=(ev: Event, n: VNode) =>
|
|
||||||
(onSetClick(ev, n, state))):
|
|
||||||
text "Activate"
|
|
||||||
if state.error.isSome():
|
|
||||||
p(class="text-error"):
|
|
||||||
text state.error.get().message
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
|
|
||||||
type
|
|
||||||
Category* = object
|
|
||||||
id*: int
|
|
||||||
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"):
|
|
||||||
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)
|
|
||||||
|
|
@ -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,135 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import sugar, httpcore, options, json, strutils, algorithm
|
|
||||||
import dom except Event
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax, kdom, vdom]
|
|
||||||
|
|
||||||
import error, category, user
|
|
||||||
import category, karaxutils, addcategorymodal
|
|
||||||
|
|
||||||
type
|
|
||||||
CategoryPicker* = ref object of VComponent
|
|
||||||
list: Option[CategoryList]
|
|
||||||
selectedCategoryID*: int
|
|
||||||
loading: bool
|
|
||||||
addEnabled: bool
|
|
||||||
status: HttpCode
|
|
||||||
error: Option[PostError]
|
|
||||||
addCategoryModal: AddCategoryModal
|
|
||||||
onCategoryChange: CategoryChangeEvent
|
|
||||||
onAddCategory: CategoryEvent
|
|
||||||
|
|
||||||
proc onCategoryLoad(state: CategoryPicker): proc (httpStatus: int, response: kstring) =
|
|
||||||
return
|
|
||||||
proc (httpStatus: int, response: kstring) =
|
|
||||||
state.loading = false
|
|
||||||
state.status = httpStatus.HttpCode
|
|
||||||
if state.status != Http200: return
|
|
||||||
|
|
||||||
let parsed = parseJson($response)
|
|
||||||
let list = parsed.to(CategoryList)
|
|
||||||
list.categories.sort(cmpNames)
|
|
||||||
|
|
||||||
if state.list.isSome:
|
|
||||||
state.list.get().categories.add(list.categories)
|
|
||||||
else:
|
|
||||||
state.list = some(list)
|
|
||||||
|
|
||||||
if state.selectedCategoryID > state.list.get().categories.len():
|
|
||||||
state.selectedCategoryID = 0
|
|
||||||
|
|
||||||
proc loadCategories(state: CategoryPicker) =
|
|
||||||
if not state.loading:
|
|
||||||
state.loading = true
|
|
||||||
ajaxGet(makeUri("categories.json"), @[], onCategoryLoad(state))
|
|
||||||
|
|
||||||
proc `[]`*(state: CategoryPicker, id: int): Category =
|
|
||||||
for cat in state.list.get().categories:
|
|
||||||
if cat.id == id:
|
|
||||||
return cat
|
|
||||||
raise newException(IndexError, "Category at " & $id & " not found!")
|
|
||||||
|
|
||||||
let nullAddCategory: CategoryEvent = proc (category: Category) = discard
|
|
||||||
let nullCategoryChange: CategoryChangeEvent = proc (oldCategory: Category, newCategory: Category) = discard
|
|
||||||
|
|
||||||
proc select*(state: CategoryPicker, id: int) =
|
|
||||||
state.selectedCategoryID = id
|
|
||||||
state.markDirty()
|
|
||||||
|
|
||||||
proc onCategory(state: CategoryPicker): CategoryEvent =
|
|
||||||
result =
|
|
||||||
proc (category: Category) =
|
|
||||||
state.list.get().categories.add(category)
|
|
||||||
state.list.get().categories.sort(cmpNames)
|
|
||||||
state.select(category.id)
|
|
||||||
state.onAddCategory(category)
|
|
||||||
|
|
||||||
proc newCategoryPicker*(onCategoryChange=nullCategoryChange, onAddCategory=nullAddCategory): CategoryPicker =
|
|
||||||
result = CategoryPicker(
|
|
||||||
list: none[CategoryList](),
|
|
||||||
selectedCategoryID: 0,
|
|
||||||
loading: false,
|
|
||||||
addEnabled: false,
|
|
||||||
status: Http200,
|
|
||||||
error: none[PostError](),
|
|
||||||
onCategoryChange: onCategoryChange,
|
|
||||||
onAddCategory: onAddCategory
|
|
||||||
)
|
|
||||||
|
|
||||||
let state = result
|
|
||||||
result.addCategoryModal = newAddCategoryModal(
|
|
||||||
onAddCategory=onCategory(state)
|
|
||||||
)
|
|
||||||
|
|
||||||
proc setAddEnabled*(state: CategoryPicker, enabled: bool) =
|
|
||||||
state.addEnabled = enabled
|
|
||||||
|
|
||||||
proc onCategoryClick(state: CategoryPicker, category: Category): proc (ev: Event, n: VNode) =
|
|
||||||
# this is necessary to capture the right value
|
|
||||||
let cat = category
|
|
||||||
return
|
|
||||||
proc (ev: Event, n: VNode) =
|
|
||||||
let oldCategory = state[state.selectedCategoryID]
|
|
||||||
state.select(cat.id)
|
|
||||||
state.onCategoryChange(oldCategory, cat)
|
|
||||||
|
|
||||||
proc genAddCategory(state: CategoryPicker): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(id="add-category"):
|
|
||||||
button(class="plus-btn btn btn-link",
|
|
||||||
onClick=(ev: Event, n: VNode) => (
|
|
||||||
state.addCategoryModal.setModalShown(true)
|
|
||||||
)):
|
|
||||||
italic(class="fas fa-plus")
|
|
||||||
render(state.addCategoryModal)
|
|
||||||
|
|
||||||
proc render*(state: CategoryPicker, currentUser: Option[User], compact=true): VNode =
|
|
||||||
state.setAddEnabled(currentUser.isAdmin())
|
|
||||||
|
|
||||||
if state.status != Http200:
|
|
||||||
return renderError("Couldn't retrieve categories.", state.status)
|
|
||||||
|
|
||||||
if state.list.isNone:
|
|
||||||
state.loadCategories()
|
|
||||||
return buildHtml(tdiv(class="loading loading-lg"))
|
|
||||||
|
|
||||||
let list = state.list.get().categories
|
|
||||||
let selectedCategory = state[state.selectedCategoryID]
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(id="category-selection", class="input-group"):
|
|
||||||
tdiv(class="dropdown"):
|
|
||||||
a(class="btn btn-link dropdown-toggle", tabindex="0"):
|
|
||||||
tdiv(class="selected-category d-inline-block"):
|
|
||||||
render(selectedCategory)
|
|
||||||
text " "
|
|
||||||
italic(class="fas fa-caret-down")
|
|
||||||
ul(class="menu"):
|
|
||||||
for category in list:
|
|
||||||
li(class="menu-item"):
|
|
||||||
a(class="category-" & $category.id & " " & category.name.slug,
|
|
||||||
onClick=onCategoryClick(state, category)):
|
|
||||||
render(category, compact)
|
|
||||||
if state.addEnabled:
|
|
||||||
genAddCategory(state)
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import sugar, httpcore, options, json
|
|
||||||
import dom except Event
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax, kdom]
|
|
||||||
|
|
||||||
import error, post, threadlist, user
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
type
|
|
||||||
DeleteKind* = enum
|
|
||||||
DeleteUser, DeletePost, DeleteThread
|
|
||||||
|
|
||||||
DeleteModal* = ref object
|
|
||||||
shown: bool
|
|
||||||
loading: bool
|
|
||||||
onDeletePost: proc (post: Post)
|
|
||||||
onDeleteThread: proc (thread: Thread)
|
|
||||||
onDeleteUser: proc (user: User)
|
|
||||||
error: Option[PostError]
|
|
||||||
case kind: DeleteKind
|
|
||||||
of DeleteUser:
|
|
||||||
user: User
|
|
||||||
of DeletePost:
|
|
||||||
post: Post
|
|
||||||
of DeleteThread:
|
|
||||||
thread: Thread
|
|
||||||
|
|
||||||
proc onDeletePost(httpStatus: int, response: kstring, state: DeleteModal) =
|
|
||||||
postFinished:
|
|
||||||
state.shown = false
|
|
||||||
case state.kind
|
|
||||||
of DeleteUser:
|
|
||||||
state.onDeleteUser(state.user)
|
|
||||||
of DeletePost:
|
|
||||||
state.onDeletePost(state.post)
|
|
||||||
of DeleteThread:
|
|
||||||
state.onDeleteThread(state.thread)
|
|
||||||
|
|
||||||
proc onDelete(ev: Event, n: VNode, state: DeleteModal) =
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let uri =
|
|
||||||
case state.kind
|
|
||||||
of DeleteUser:
|
|
||||||
makeUri("/deleteUser")
|
|
||||||
of DeleteThread:
|
|
||||||
makeUri("/deleteThread")
|
|
||||||
of DeletePost:
|
|
||||||
makeUri("/deletePost")
|
|
||||||
# TODO: This is a hack, karax should support this.
|
|
||||||
let formData = newFormData()
|
|
||||||
case state.kind
|
|
||||||
of DeleteUser:
|
|
||||||
formData.append("username", state.user.name)
|
|
||||||
of DeletePost:
|
|
||||||
formData.append("id", $state.post.id)
|
|
||||||
of DeleteThread:
|
|
||||||
formData.append("id", $state.thread.id)
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) => onDeletePost(s, r, state))
|
|
||||||
|
|
||||||
proc onClose(ev: Event, n: VNode, state: DeleteModal) =
|
|
||||||
state.shown = false
|
|
||||||
ev.preventDefault()
|
|
||||||
|
|
||||||
proc newDeleteModal*(
|
|
||||||
onDeletePost: proc (post: Post),
|
|
||||||
onDeleteThread: proc (thread: Thread),
|
|
||||||
onDeleteUser: proc (user: User),
|
|
||||||
): DeleteModal =
|
|
||||||
DeleteModal(
|
|
||||||
shown: false,
|
|
||||||
onDeletePost: onDeletePost,
|
|
||||||
onDeleteThread: onDeleteThread,
|
|
||||||
onDeleteUser: onDeleteUser,
|
|
||||||
)
|
|
||||||
|
|
||||||
proc show*(state: DeleteModal, thing: User | Post | Thread) =
|
|
||||||
state.shown = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
when thing is User:
|
|
||||||
state.kind = DeleteUser
|
|
||||||
state.user = thing
|
|
||||||
when thing is Post:
|
|
||||||
state.kind = DeletePost
|
|
||||||
state.post = thing
|
|
||||||
when thing is Thread:
|
|
||||||
state.kind = DeleteThread
|
|
||||||
state.thread = thing
|
|
||||||
|
|
||||||
proc render*(state: DeleteModal): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
|
|
||||||
id="delete-modal"):
|
|
||||||
a(href="", class="modal-overlay", "aria-label"="close",
|
|
||||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
|
||||||
tdiv(class="modal-container"):
|
|
||||||
tdiv(class="modal-header"):
|
|
||||||
a(href="", class="btn btn-clear float-right",
|
|
||||||
"aria-label"="close",
|
|
||||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
|
||||||
tdiv(class="modal-title h5"):
|
|
||||||
text "Delete"
|
|
||||||
tdiv(class="modal-body"):
|
|
||||||
tdiv(class="content"):
|
|
||||||
p():
|
|
||||||
text "Are you sure you want to delete this "
|
|
||||||
case state.kind
|
|
||||||
of DeleteUser:
|
|
||||||
text "user account?"
|
|
||||||
of DeleteThread:
|
|
||||||
text "thread?"
|
|
||||||
of DeletePost:
|
|
||||||
text "post?"
|
|
||||||
tdiv(class="modal-footer"):
|
|
||||||
if state.error.isSome():
|
|
||||||
p(class="text-error"):
|
|
||||||
text state.error.get().message
|
|
||||||
|
|
||||||
button(class=class(
|
|
||||||
{"loading": state.loading},
|
|
||||||
"btn btn-primary delete-btn"
|
|
||||||
),
|
|
||||||
onClick=(ev: Event, n: VNode) => onDelete(ev, n, state)):
|
|
||||||
italic(class="fas fa-trash-alt")
|
|
||||||
text " Delete"
|
|
||||||
button(class="btn cancel-btn",
|
|
||||||
onClick=(ev: Event, n: VNode) => (state.shown = false)):
|
|
||||||
text "Cancel"
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import httpcore, options, sugar, json
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax/kajax
|
|
||||||
|
|
||||||
import replybox, post, karaxutils, threadlist, error
|
|
||||||
|
|
||||||
type
|
|
||||||
OnEditPosted* = proc (id: int, content: string, subject: Option[string])
|
|
||||||
|
|
||||||
EditBox* = ref object
|
|
||||||
box: ReplyBox
|
|
||||||
post: Post
|
|
||||||
rawContent: Option[kstring] ## The raw rst for a post (needs to be loaded)
|
|
||||||
loading: bool
|
|
||||||
status: HttpCode
|
|
||||||
error: Option[PostError]
|
|
||||||
onEditPosted: OnEditPosted
|
|
||||||
onEditCancel: proc ()
|
|
||||||
|
|
||||||
proc newEditBox*(onEditPosted: OnEditPosted, onEditCancel: proc ()): EditBox =
|
|
||||||
EditBox(
|
|
||||||
box: newReplyBox(nil),
|
|
||||||
onEditPosted: onEditPosted,
|
|
||||||
onEditCancel: onEditCancel,
|
|
||||||
status: Http200
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onRawContent(httpStatus: int, response: kstring, state: EditBox) =
|
|
||||||
state.status = httpStatus.HttpCode
|
|
||||||
if state.status != Http200: return
|
|
||||||
|
|
||||||
state.rawContent = some(response)
|
|
||||||
state.box.setText(state.rawContent.get())
|
|
||||||
|
|
||||||
proc onEditPost(httpStatus: int, response: kstring, state: EditBox) =
|
|
||||||
postFinished:
|
|
||||||
state.onEditPosted(
|
|
||||||
state.post.id,
|
|
||||||
$response,
|
|
||||||
none[string]()
|
|
||||||
)
|
|
||||||
|
|
||||||
proc save(state: EditBox) =
|
|
||||||
if state.loading:
|
|
||||||
# TODO: Weird behaviour: onClick handler gets called 80+ times.
|
|
||||||
return
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let formData = newFormData()
|
|
||||||
formData.append("msg", state.box.getText())
|
|
||||||
formData.append("postId", $state.post.id)
|
|
||||||
# TODO: Subject
|
|
||||||
let uri = makeUri("/updatePost")
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) => onEditPost(s, r, state))
|
|
||||||
|
|
||||||
proc render*(state: EditBox, post: Post): VNode =
|
|
||||||
if (not state.post.isNil) and state.post.id != post.id:
|
|
||||||
state.rawContent = none[kstring]()
|
|
||||||
state.status = Http200
|
|
||||||
|
|
||||||
if state.status != Http200:
|
|
||||||
return renderError("Couldn't retrieve raw post", state.status)
|
|
||||||
|
|
||||||
if state.rawContent.isNone():
|
|
||||||
state.post = post
|
|
||||||
state.rawContent = none[kstring]()
|
|
||||||
var params = @[("id", $post.id)]
|
|
||||||
let uri = makeUri("post.rst", params)
|
|
||||||
ajaxGet(uri, @[], (s: int, r: kstring) => onRawContent(s, r, state))
|
|
||||||
|
|
||||||
return buildHtml(tdiv(class="loading"))
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="edit-box"):
|
|
||||||
renderContent(
|
|
||||||
state.box,
|
|
||||||
none[Thread](),
|
|
||||||
none[Post]()
|
|
||||||
)
|
|
||||||
|
|
||||||
if state.error.isSome():
|
|
||||||
span(class="text-error"):
|
|
||||||
text state.error.get().message
|
|
||||||
|
|
||||||
tdiv(class="edit-buttons"):
|
|
||||||
tdiv(class="cancel-button"):
|
|
||||||
button(class="btn btn-link",
|
|
||||||
onClick=(e: Event, n: VNode) => (state.onEditCancel())):
|
|
||||||
text " Cancel"
|
|
||||||
tdiv(class="save-button"):
|
|
||||||
button(class=class({"loading": state.loading}, "btn btn-primary"),
|
|
||||||
onClick=(e: Event, n: VNode) => state.save()):
|
|
||||||
italic(class="fas fa-check")
|
|
||||||
text " Save"
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import httpcore
|
|
||||||
type
|
|
||||||
PostError* = object
|
|
||||||
errorFields*: seq[string] ## IDs of the fields with an error.
|
|
||||||
message*: string
|
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
import json, options
|
|
||||||
include karax/prelude
|
|
||||||
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
proc render404*(): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="empty error"):
|
|
||||||
tdiv(class="empty icon"):
|
|
||||||
italic(class="fas fa-bug fa-5x")
|
|
||||||
p(class="empty-title h5"):
|
|
||||||
text "404 Not Found"
|
|
||||||
p(class="empty-subtitle"):
|
|
||||||
text "Cannot find what you are looking for, it might have been " &
|
|
||||||
"deleted. Sorry!"
|
|
||||||
tdiv(class="empty-action"):
|
|
||||||
a(href="/", onClick=anchorCB):
|
|
||||||
button(class="btn btn-primary"):
|
|
||||||
text "Go back home"
|
|
||||||
|
|
||||||
proc renderError*(message: string, status: HttpCode): VNode =
|
|
||||||
if status == Http404:
|
|
||||||
return render404()
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="empty error"):
|
|
||||||
tdiv(class="empty icon"):
|
|
||||||
italic(class="fas fa-bug fa-5x")
|
|
||||||
p(class="empty-title h5"):
|
|
||||||
text message
|
|
||||||
p(class="empty-subtitle"):
|
|
||||||
text "Please report this issue to us so we can fix it!"
|
|
||||||
tdiv(class="empty-action"):
|
|
||||||
a(href="https://github.com/nim-lang/nimforum/issues", target="_blank"):
|
|
||||||
button(class="btn btn-primary"):
|
|
||||||
text "Report issue"
|
|
||||||
|
|
||||||
proc renderMessage*(message, submessage, icon: string): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="empty error"):
|
|
||||||
tdiv(class="empty icon"):
|
|
||||||
italic(class="fas " & icon & " fa-5x")
|
|
||||||
p(class="empty-title h5"):
|
|
||||||
text message
|
|
||||||
p(class="empty-subtitle"):
|
|
||||||
text submessage
|
|
||||||
|
|
||||||
proc genFormField*(error: Option[PostError], name, label, typ: string,
|
|
||||||
isLast: bool, placeholder=""): VNode =
|
|
||||||
let hasError =
|
|
||||||
not error.isNone and (
|
|
||||||
name in error.get().errorFields or
|
|
||||||
error.get().errorFields.len == 0)
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class=class({"has-error": hasError}, "form-group")):
|
|
||||||
label(class="form-label", `for`=name):
|
|
||||||
text label
|
|
||||||
input(class="form-input", `type`=typ, name=name,
|
|
||||||
placeholder=placeholder)
|
|
||||||
|
|
||||||
if not error.isNone:
|
|
||||||
let e = error.get()
|
|
||||||
if (e.errorFields.len == 1 and e.errorFields[0] == name) or
|
|
||||||
(isLast and e.errorFields.len == 0):
|
|
||||||
span(class="form-input-hint"):
|
|
||||||
text e.message
|
|
||||||
|
|
||||||
template postFinished*(onSuccess: untyped): untyped =
|
|
||||||
state.loading = false
|
|
||||||
let status = httpStatus.HttpCode
|
|
||||||
if status == Http200:
|
|
||||||
onSuccess
|
|
||||||
else:
|
|
||||||
# TODO: Karax should pass the content-type...
|
|
||||||
try:
|
|
||||||
let parsed = parseJson($response)
|
|
||||||
let error = to(parsed, PostError)
|
|
||||||
|
|
||||||
state.error = some(error)
|
|
||||||
except:
|
|
||||||
echo getCurrentExceptionMsg()
|
|
||||||
state.error = some(PostError(
|
|
||||||
errorFields: @[],
|
|
||||||
message: "Unknown error occurred."
|
|
||||||
))
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
import options, tables, sugar, httpcore
|
|
||||||
from dom import window, Location, document, decodeURI
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax/[kdom]
|
|
||||||
import jester/[patterns]
|
|
||||||
|
|
||||||
import threadlist, postlist, header, profile, newthread, error, about
|
|
||||||
import categorylist
|
|
||||||
import resetpassword, activateemail, search
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
type
|
|
||||||
State = ref object
|
|
||||||
originalTitle: cstring
|
|
||||||
url: Location
|
|
||||||
profile: ProfileState
|
|
||||||
newThread: NewThread
|
|
||||||
about: About
|
|
||||||
resetPassword: ResetPassword
|
|
||||||
activateEmail: ActivateEmail
|
|
||||||
search: Search
|
|
||||||
|
|
||||||
proc copyLocation(loc: Location): Location =
|
|
||||||
# TODO: It sucks that I had to do this. We need a nice way to deep copy in JS.
|
|
||||||
Location(
|
|
||||||
hash: loc.hash,
|
|
||||||
host: loc.host,
|
|
||||||
hostname: loc.hostname,
|
|
||||||
href: loc.href,
|
|
||||||
pathname: loc.pathname,
|
|
||||||
port: loc.port,
|
|
||||||
protocol: loc.protocol,
|
|
||||||
search: loc.search
|
|
||||||
)
|
|
||||||
|
|
||||||
proc newState(): State =
|
|
||||||
State(
|
|
||||||
originalTitle: document.title,
|
|
||||||
url: copyLocation(window.location),
|
|
||||||
profile: newProfileState(),
|
|
||||||
newThread: newNewThread(),
|
|
||||||
about: newAbout(),
|
|
||||||
resetPassword: newResetPassword(),
|
|
||||||
activateEmail: newActivateEmail(),
|
|
||||||
search: newSearch()
|
|
||||||
)
|
|
||||||
|
|
||||||
var state = newState()
|
|
||||||
proc onPopState(event: dom.Event) =
|
|
||||||
# This event is usually only called when the user moves back in their
|
|
||||||
# history. I fire it in karaxutils.anchorCB as well to ensure the URL is
|
|
||||||
# always updated. This should be moved into Karax in the future.
|
|
||||||
echo "New URL: ", window.location.href, " ", state.url.href
|
|
||||||
document.title = state.originalTitle
|
|
||||||
if state.url.href != window.location.href:
|
|
||||||
state = newState() # Reload the state to remove stale data.
|
|
||||||
state.url = copyLocation(window.location)
|
|
||||||
|
|
||||||
redraw()
|
|
||||||
|
|
||||||
type Params = Table[string, string]
|
|
||||||
type
|
|
||||||
Route = object
|
|
||||||
n: string
|
|
||||||
p: proc (params: Params): VNode
|
|
||||||
|
|
||||||
proc r(n: string, p: proc (params: Params): VNode): Route = Route(n: n, p: p)
|
|
||||||
proc route(routes: openarray[Route]): VNode =
|
|
||||||
let path =
|
|
||||||
if state.url.pathname.len == 0: "/" else: $state.url.pathname
|
|
||||||
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)
|
|
||||||
if matched:
|
|
||||||
return route.p(params)
|
|
||||||
|
|
||||||
return renderError("Unmatched route: " & path, Http500)
|
|
||||||
|
|
||||||
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()))
|
|
||||||
),
|
|
||||||
r("/profile/@username",
|
|
||||||
(params: Params) =>
|
|
||||||
(
|
|
||||||
render(
|
|
||||||
state.profile,
|
|
||||||
decodeURI(params["username"]),
|
|
||||||
getLoggedInUser()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
r("/t/@id",
|
|
||||||
(params: Params) =>
|
|
||||||
(
|
|
||||||
let postId = getInt(($state.url.hash).substr(1), 0);
|
|
||||||
renderPostList(
|
|
||||||
params["id"].parseInt(),
|
|
||||||
if postId == 0: none[int]() else: some[int](postId),
|
|
||||||
getLoggedInUser()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
r("/about/?@page?",
|
|
||||||
(params: Params) => (render(state.about, params["page"]))
|
|
||||||
),
|
|
||||||
r("/activateEmail/success",
|
|
||||||
(params: Params) => (
|
|
||||||
renderMessage(
|
|
||||||
"Email activated",
|
|
||||||
"You can now create new posts!",
|
|
||||||
"fa-check"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
r("/activateEmail",
|
|
||||||
(params: Params) => (
|
|
||||||
render(state.activateEmail)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
r("/resetPassword/success",
|
|
||||||
(params: Params) => (
|
|
||||||
renderMessage(
|
|
||||||
"Password changed",
|
|
||||||
"You can now login using your new password!",
|
|
||||||
"fa-check"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
r("/resetPassword",
|
|
||||||
(params: Params) => (
|
|
||||||
render(state.resetPassword)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
r("/search",
|
|
||||||
(params: Params) => (
|
|
||||||
render(state.search, params["q"], getLoggedInUser())
|
|
||||||
)
|
|
||||||
),
|
|
||||||
r("/404",
|
|
||||||
(params: Params) => render404()
|
|
||||||
),
|
|
||||||
r("/", (params: Params) => renderThreadList(getLoggedInUser()))
|
|
||||||
])
|
|
||||||
|
|
||||||
window.onPopState = onPopState
|
|
||||||
setRenderer render
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
-d:js
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import options, httpcore
|
|
||||||
|
|
||||||
import user
|
|
||||||
type
|
|
||||||
UserStatus* = object
|
|
||||||
user*: Option[User]
|
|
||||||
recaptchaSiteKey*: Option[string]
|
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
import times, json, sugar
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax, kdom]
|
|
||||||
|
|
||||||
import login, signup, usermenu
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
from dom import
|
|
||||||
setTimeout, window, document, getElementById, focus
|
|
||||||
|
|
||||||
|
|
||||||
type
|
|
||||||
State = ref object
|
|
||||||
data: Option[UserStatus]
|
|
||||||
loading: bool
|
|
||||||
status: HttpCode
|
|
||||||
lastUpdate: Time
|
|
||||||
loginModal: LoginModal
|
|
||||||
signupModal: SignupModal
|
|
||||||
userMenu: UserMenu
|
|
||||||
|
|
||||||
proc newState(): State
|
|
||||||
var
|
|
||||||
state = newState()
|
|
||||||
|
|
||||||
proc getStatus(logout=false)
|
|
||||||
proc newState(): State =
|
|
||||||
State(
|
|
||||||
data: none[UserStatus](),
|
|
||||||
loading: false,
|
|
||||||
status: Http200,
|
|
||||||
loginModal: newLoginModal(
|
|
||||||
() => (state.lastUpdate = fromUnix(0); getStatus()),
|
|
||||||
() => state.signupModal.show()
|
|
||||||
),
|
|
||||||
signupModal: newSignupModal(
|
|
||||||
() => (state.lastUpdate = fromUnix(0); getStatus()),
|
|
||||||
() => state.loginModal.show()
|
|
||||||
),
|
|
||||||
userMenu: newUserMenu(
|
|
||||||
() => (state.lastUpdate = fromUnix(0); getStatus(logout=true))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onStatus(httpStatus: int, response: kstring) =
|
|
||||||
state.loading = false
|
|
||||||
state.status = httpStatus.HttpCode
|
|
||||||
if state.status != Http200: return
|
|
||||||
|
|
||||||
let parsed = parseJson($response)
|
|
||||||
state.data = some(to(parsed, UserStatus))
|
|
||||||
|
|
||||||
state.lastUpdate = getTime()
|
|
||||||
|
|
||||||
proc getStatus(logout=false) =
|
|
||||||
if state.loading: return
|
|
||||||
let diff = getTime() - state.lastUpdate
|
|
||||||
if diff.inMinutes < 5:
|
|
||||||
return
|
|
||||||
|
|
||||||
state.loading = true
|
|
||||||
let uri = makeUri("status.json", [("logout", $logout)])
|
|
||||||
ajaxGet(uri, @[], onStatus)
|
|
||||||
|
|
||||||
proc getLoggedInUser*(): Option[User] =
|
|
||||||
state.data.map(x => x.user).flatten
|
|
||||||
|
|
||||||
proc isLoggedIn*(): bool =
|
|
||||||
not getLoggedInUser().isNone
|
|
||||||
|
|
||||||
proc onKeyDown(e: Event, n: VNode) =
|
|
||||||
let event = cast[KeyboardEvent](e)
|
|
||||||
if event.key == "Enter":
|
|
||||||
navigateTo(makeUri("/search", ("q", $n.value), reuseSearch=false))
|
|
||||||
|
|
||||||
proc renderHeader*(): VNode =
|
|
||||||
if state.data.isNone and state.status == Http200:
|
|
||||||
getStatus()
|
|
||||||
|
|
||||||
let user = state.data.map(x => x.user).flatten
|
|
||||||
result = buildHtml(tdiv()):
|
|
||||||
header(id="main-navbar"):
|
|
||||||
tdiv(class="navbar container grid-xl"):
|
|
||||||
section(class="navbar-section"):
|
|
||||||
a(href=makeUri("/")):
|
|
||||||
img(src="/images/logo.png", id="img-logo")
|
|
||||||
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",
|
|
||||||
onKeyDown=onKeyDown)
|
|
||||||
if state.loading:
|
|
||||||
tdiv(class="loading")
|
|
||||||
elif user.isNone:
|
|
||||||
button(id="signup-btn", class="btn btn-primary btn-sm",
|
|
||||||
onClick=(e: Event, n: VNode) => state.signupModal.show()):
|
|
||||||
italic(class="fas fa-user-plus")
|
|
||||||
text " Sign up"
|
|
||||||
button(id="login-btn", class="btn btn-primary btn-sm",
|
|
||||||
onClick=(e: Event, n: VNode) => state.loginModal.show()):
|
|
||||||
italic(class="fas fa-sign-in-alt")
|
|
||||||
text " Log in"
|
|
||||||
else:
|
|
||||||
render(state.userMenu, user.get())
|
|
||||||
|
|
||||||
# Modals
|
|
||||||
if state.data.isSome():
|
|
||||||
render(state.loginModal, state.data.get().recaptchaSiteKey)
|
|
||||||
render(state.signupModal, state.data.get().recaptchaSiteKey)
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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.} =
|
|
||||||
## parses `s` into an integer in the range `validRange`. If successful,
|
|
||||||
## `value` is modified to contain the result. Otherwise no exception is
|
|
||||||
## raised and `value` is not touched; this way a reasonable default value
|
|
||||||
## won't be overwritten.
|
|
||||||
try:
|
|
||||||
discard parseutils.parseInt(s, value, 0)
|
|
||||||
except OverflowError:
|
|
||||||
discard
|
|
||||||
|
|
||||||
proc getInt*(s: string, default = 0): int =
|
|
||||||
## Safely parses an int and returns it.
|
|
||||||
result = default
|
|
||||||
parseIntSafe(s, result)
|
|
||||||
|
|
||||||
proc getInt64*(s: string, default = 0): int64 =
|
|
||||||
## Safely parses an int and returns it.
|
|
||||||
result = default
|
|
||||||
try:
|
|
||||||
discard parseutils.parseBiggestInt(s, result, 0)
|
|
||||||
except OverflowError:
|
|
||||||
discard
|
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kdom, kajax]
|
|
||||||
|
|
||||||
from dom import nil
|
|
||||||
|
|
||||||
const appName* = "/"
|
|
||||||
|
|
||||||
proc class*(classes: varargs[tuple[name: string, present: bool]],
|
|
||||||
defaultClasses: string = ""): string =
|
|
||||||
result = defaultClasses & " "
|
|
||||||
for class in classes:
|
|
||||||
if class.present: result.add(class.name & " ")
|
|
||||||
|
|
||||||
proc makeUri*(relative: string, appName=appName, includeHash=false,
|
|
||||||
search: string=""): string =
|
|
||||||
## Concatenates ``relative`` to the current URL in a way that is
|
|
||||||
## (possibly) sane.
|
|
||||||
var relative = relative
|
|
||||||
assert appName in $window.location.pathname
|
|
||||||
if relative[0] == '/': relative = relative[1..^1]
|
|
||||||
|
|
||||||
return $window.location.protocol & "//" &
|
|
||||||
$window.location.host &
|
|
||||||
appName &
|
|
||||||
relative &
|
|
||||||
search &
|
|
||||||
(if includeHash: $window.location.hash else: "")
|
|
||||||
|
|
||||||
proc makeUri*(relative: string, params: varargs[(string, string)],
|
|
||||||
appName=appName, includeHash=false,
|
|
||||||
reuseSearch=true): string =
|
|
||||||
var query = ""
|
|
||||||
for i in 0 ..< params.len:
|
|
||||||
let param = params[i]
|
|
||||||
if i != 0: query.add("&")
|
|
||||||
query.add(param[0] & "=" & param[1])
|
|
||||||
|
|
||||||
if query.len > 0:
|
|
||||||
var search = if reuseSearch: $window.location.search else: ""
|
|
||||||
if search.len != 0: search.add("&")
|
|
||||||
search.add(query)
|
|
||||||
if search[0] != '?': search = "?" & search
|
|
||||||
makeUri(relative, appName, search=search)
|
|
||||||
else:
|
|
||||||
makeUri(relative, appName)
|
|
||||||
|
|
||||||
proc navigateTo*(uri: cstring) =
|
|
||||||
# TODO: This was annoying. Karax also shouldn't have its own `window`.
|
|
||||||
dom.pushState(dom.window.history, 0, cstring"", uri)
|
|
||||||
|
|
||||||
# Fire the popState event.
|
|
||||||
dom.dispatchEvent(dom.window, dom.newEvent("popstate"))
|
|
||||||
|
|
||||||
proc anchorCB*(e: Event, n: VNode) =
|
|
||||||
let mE = e.MouseEvent
|
|
||||||
if not (mE.metaKey or mE.ctrlKey):
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
# TODO: Why does Karax have it's own Node type? That's just silly.
|
|
||||||
let url = n.getAttr("href")
|
|
||||||
|
|
||||||
navigateTo(url)
|
|
||||||
|
|
||||||
proc newFormData*(form: dom.Element): FormData
|
|
||||||
{.importcpp: "new FormData(@)", constructor.}
|
|
||||||
proc get*(form: FormData, key: cstring): cstring
|
|
||||||
{.importcpp: "#.get(@)".}
|
|
||||||
|
|
||||||
proc renderProfileUrl*(username: string): string =
|
|
||||||
makeUri(fmt"/profile/{username}")
|
|
||||||
|
|
||||||
proc renderPostUrl*(threadId, postId: int): string =
|
|
||||||
makeUri(fmt"/t/{threadId}#{postId}")
|
|
||||||
|
|
||||||
proc parseUrlQuery*(query: string, result: var Table[string, string])
|
|
||||||
{.deprecated: "use stdlib".} =
|
|
||||||
## Based on copy from Jester. Use stdlib when
|
|
||||||
## https://github.com/nim-lang/Nim/pull/7761 is merged.
|
|
||||||
var i = 0
|
|
||||||
i = query.skip("?")
|
|
||||||
while i < query.len()-1:
|
|
||||||
var key = ""
|
|
||||||
var val = ""
|
|
||||||
i += query.parseUntil(key, '=', i)
|
|
||||||
if query[i] != '=':
|
|
||||||
raise newException(ValueError, "Expected '=' at " & $i &
|
|
||||||
" but got: " & $query[i])
|
|
||||||
inc(i) # Skip =
|
|
||||||
i += query.parseUntil(val, '&', i)
|
|
||||||
inc(i) # Skip &
|
|
||||||
result[$decodeUri(key)] = $decodeUri(val)
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import sugar, httpcore, options, json
|
|
||||||
import dom except Event, KeyboardEvent
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax, kdom]
|
|
||||||
|
|
||||||
import error, resetpassword
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
type
|
|
||||||
LoginModal* = ref object
|
|
||||||
shown: bool
|
|
||||||
loading: bool
|
|
||||||
onLogIn: proc ()
|
|
||||||
onSignUp: proc ()
|
|
||||||
error: Option[PostError]
|
|
||||||
resetPasswordModal: ResetPasswordModal
|
|
||||||
|
|
||||||
proc onLogInPost(httpStatus: int, response: kstring, state: LoginModal) =
|
|
||||||
postFinished:
|
|
||||||
state.shown = false
|
|
||||||
state.onLogIn()
|
|
||||||
|
|
||||||
proc onLogInClick(ev: Event, n: VNode, state: LoginModal) =
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let uri = makeUri("login")
|
|
||||||
let form = dom.document.getElementById("login-form")
|
|
||||||
# TODO: This is a hack, karax should support this.
|
|
||||||
let formData = newFormData(form)
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) => onLogInPost(s, r, state))
|
|
||||||
|
|
||||||
proc onClose(ev: Event, n: VNode, state: LoginModal) =
|
|
||||||
state.shown = false
|
|
||||||
ev.preventDefault()
|
|
||||||
|
|
||||||
proc newLoginModal*(onLogIn, onSignUp: proc ()): LoginModal =
|
|
||||||
LoginModal(
|
|
||||||
shown: false,
|
|
||||||
onLogIn: onLogIn,
|
|
||||||
onSignUp: onSignUp,
|
|
||||||
resetPasswordModal: newResetPasswordModal()
|
|
||||||
)
|
|
||||||
|
|
||||||
proc show*(state: LoginModal) =
|
|
||||||
state.shown = true
|
|
||||||
|
|
||||||
proc onKeyDown(e: Event, n: VNode, state: LoginModal) =
|
|
||||||
let event = cast[KeyboardEvent](e)
|
|
||||||
if event.key == "Enter":
|
|
||||||
onLogInClick(e, n, state)
|
|
||||||
|
|
||||||
proc render*(state: LoginModal, recaptchaSiteKey: Option[string]): VNode =
|
|
||||||
result = buildHtml(tdiv()):
|
|
||||||
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
|
|
||||||
id="login-modal"):
|
|
||||||
a(href="", class="modal-overlay", "aria-label"="close",
|
|
||||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
|
||||||
tdiv(class="modal-container"):
|
|
||||||
tdiv(class="modal-header"):
|
|
||||||
a(href="", class="btn btn-clear float-right",
|
|
||||||
"aria-label"="close",
|
|
||||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
|
||||||
tdiv(class="modal-title h5"):
|
|
||||||
text "Log in"
|
|
||||||
tdiv(class="modal-body"):
|
|
||||||
tdiv(class="content"):
|
|
||||||
form(id="login-form",
|
|
||||||
onKeyDown=(ev: Event, n: VNode) => onKeyDown(ev, n, state)):
|
|
||||||
genFormField(state.error, "username", "Username", "text", false)
|
|
||||||
genFormField(
|
|
||||||
state.error,
|
|
||||||
"password",
|
|
||||||
"Password",
|
|
||||||
"password",
|
|
||||||
true
|
|
||||||
)
|
|
||||||
a(href="", onClick=(e: Event, n: VNode) =>
|
|
||||||
(state.resetPasswordModal.show(); e.preventDefault())):
|
|
||||||
text "Reset your password"
|
|
||||||
tdiv(class="modal-footer"):
|
|
||||||
button(class=class(
|
|
||||||
{"loading": state.loading},
|
|
||||||
"btn btn-primary"
|
|
||||||
),
|
|
||||||
onClick=(ev: Event, n: VNode) => onLogInClick(ev, n, state)):
|
|
||||||
text "Log in"
|
|
||||||
button(class="btn",
|
|
||||||
onClick=(ev: Event, n: VNode) =>
|
|
||||||
(state.onSignUp(); state.shown = false)):
|
|
||||||
text "Create account"
|
|
||||||
|
|
||||||
render(state.resetPasswordModal, recaptchaSiteKey)
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import sugar, httpcore, options, json
|
|
||||||
import dom except Event
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax, kdom]
|
|
||||||
|
|
||||||
import error, replybox, threadlist, post, user
|
|
||||||
import karaxutils, categorypicker
|
|
||||||
|
|
||||||
type
|
|
||||||
NewThread* = ref object
|
|
||||||
loading: bool
|
|
||||||
error: Option[PostError]
|
|
||||||
replyBox: ReplyBox
|
|
||||||
subject: kstring
|
|
||||||
categoryPicker: CategoryPicker
|
|
||||||
|
|
||||||
proc newNewThread*(): NewThread =
|
|
||||||
NewThread(
|
|
||||||
replyBox: newReplyBox(nil),
|
|
||||||
subject: "",
|
|
||||||
categoryPicker: newCategoryPicker()
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onSubjectChange(e: Event, n: VNode, state: NewThread) =
|
|
||||||
state.subject = n.value
|
|
||||||
|
|
||||||
proc onCreatePost(httpStatus: int, response: kstring, state: NewThread) =
|
|
||||||
postFinished:
|
|
||||||
let j = parseJson($response)
|
|
||||||
let response = to(j, array[2, int])
|
|
||||||
navigateTo(renderPostUrl(response[0], response[1]))
|
|
||||||
|
|
||||||
proc onCreateClick(ev: Event, n: VNode, state: NewThread) =
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let uri = makeUri("newthread")
|
|
||||||
# TODO: This is a hack, karax should support this.
|
|
||||||
let formData = newFormData()
|
|
||||||
let categoryID = state.categoryPicker.selectedCategoryID
|
|
||||||
|
|
||||||
formData.append("subject", state.subject)
|
|
||||||
formData.append("msg", state.replyBox.getText())
|
|
||||||
formData.append("categoryId", $categoryID)
|
|
||||||
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) => onCreatePost(s, r, state))
|
|
||||||
|
|
||||||
proc render*(state: NewThread, currentUser: Option[User]): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
section(class="container grid-xl"):
|
|
||||||
tdiv(id="new-thread"):
|
|
||||||
tdiv(class="title"):
|
|
||||||
p(): text "New Thread"
|
|
||||||
tdiv(class="content"):
|
|
||||||
input(id="thread-title", class="form-input", `type`="text", name="subject",
|
|
||||||
placeholder="Type the title here",
|
|
||||||
oninput=(e: Event, n: VNode) => onSubjectChange(e, n, state))
|
|
||||||
if state.error.isSome():
|
|
||||||
p(class="text-error"):
|
|
||||||
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]())
|
|
||||||
tdiv(class="footer"):
|
|
||||||
|
|
||||||
button(id="create-thread-btn",
|
|
||||||
class=class(
|
|
||||||
{"loading": state.loading},
|
|
||||||
"btn btn-primary"
|
|
||||||
),
|
|
||||||
onClick=(ev: Event, n: VNode) =>
|
|
||||||
(onCreateClick(ev, n, state))):
|
|
||||||
text "Create thread"
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import options
|
|
||||||
|
|
||||||
import user
|
|
||||||
|
|
||||||
type
|
|
||||||
PostInfo* = object
|
|
||||||
creation*: int64
|
|
||||||
content*: string
|
|
||||||
|
|
||||||
Post* = ref object
|
|
||||||
id*: int
|
|
||||||
author*: User
|
|
||||||
likes*: seq[User] ## Users that liked this post.
|
|
||||||
seen*: bool ## Determines whether the current user saw this post.
|
|
||||||
## I considered using a simple timestamp for each thread,
|
|
||||||
## but that wouldn't work when a user navigates to the last
|
|
||||||
## post in a thread for example.
|
|
||||||
history*: seq[PostInfo] ## If the post was edited this will contain the
|
|
||||||
## older versions of the post.
|
|
||||||
info*: PostInfo
|
|
||||||
moreBefore*: seq[int]
|
|
||||||
replyingTo*: Option[PostLink]
|
|
||||||
|
|
||||||
PostLink* = object ## Used by profile
|
|
||||||
creation*: int64
|
|
||||||
topic*: string
|
|
||||||
threadId*: int
|
|
||||||
postId*: int
|
|
||||||
author*: Option[User] ## Only used for `replyingTo`.
|
|
||||||
|
|
||||||
proc lastEdit*(post: Post): PostInfo =
|
|
||||||
post.history[^1]
|
|
||||||
|
|
||||||
proc isModerated*(post: Post): bool =
|
|
||||||
## Determines whether the specified post is under moderation
|
|
||||||
## (i.e. whether the post is invisible to ordinary users).
|
|
||||||
post.author.rank <= Moderated
|
|
||||||
|
|
||||||
proc isLikedBy*(post: Post, user: Option[User]): bool =
|
|
||||||
## Determines whether the specified user has liked the post.
|
|
||||||
if user.isNone(): return false
|
|
||||||
|
|
||||||
for u in post.likes:
|
|
||||||
if u.name == user.get().name:
|
|
||||||
return true
|
|
||||||
|
|
||||||
return false
|
|
||||||
|
|
||||||
type
|
|
||||||
Profile* = object
|
|
||||||
user*: User
|
|
||||||
joinTime*: int64
|
|
||||||
threads*: seq[PostLink]
|
|
||||||
posts*: seq[PostLink]
|
|
||||||
postCount*: int
|
|
||||||
threadCount*: int
|
|
||||||
# Information that only admins should see.
|
|
||||||
email*: Option[string]
|
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
import karaxutils, threadlist
|
|
||||||
|
|
||||||
proc renderPostUrl*(post: Post, thread: Thread): string =
|
|
||||||
renderPostUrl(thread.id, post.id)
|
|
||||||
|
|
||||||
proc renderPostUrl*(link: PostLink): string =
|
|
||||||
renderPostUrl(link.threadId, link.postId)
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
## Simple generic button that can be clicked to make a post request.
|
|
||||||
## The button will show a loading indicator and a tick on success.
|
|
||||||
##
|
|
||||||
## Used for password reset emails.
|
|
||||||
|
|
||||||
import options, httpcore, json, sugar, sequtils, strutils
|
|
||||||
when defined(js):
|
|
||||||
include karax/prelude
|
|
||||||
import karax/[kajax, kdom]
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
import error, karaxutils, post, user, threadlist
|
|
||||||
|
|
||||||
type
|
|
||||||
PostButton* = ref object
|
|
||||||
uri, title, icon: string
|
|
||||||
formData: FormData
|
|
||||||
error: Option[PostError]
|
|
||||||
loading: bool
|
|
||||||
posted: bool
|
|
||||||
|
|
||||||
proc newPostButton*(uri: string, formData: FormData,
|
|
||||||
title: string, icon: string): PostButton =
|
|
||||||
PostButton(
|
|
||||||
uri: uri,
|
|
||||||
formData: formData,
|
|
||||||
title: title,
|
|
||||||
icon: icon
|
|
||||||
)
|
|
||||||
|
|
||||||
proc newResetPasswordButton*(username: string): PostButton =
|
|
||||||
var formData = newFormData()
|
|
||||||
formData.append("email", username)
|
|
||||||
result = newPostButton(
|
|
||||||
makeUri("/sendResetPassword"),
|
|
||||||
formData,
|
|
||||||
"Send password reset email",
|
|
||||||
"fas fa-envelope",
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onPost(httpStatus: int, response: kstring, state: PostButton) =
|
|
||||||
postFinished:
|
|
||||||
discard
|
|
||||||
|
|
||||||
proc onClick(ev: Event, n: VNode, state: PostButton) =
|
|
||||||
if state.loading or state.posted: return
|
|
||||||
|
|
||||||
state.loading = true
|
|
||||||
state.posted = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
# TODO: This is a hack, karax should support this.
|
|
||||||
ajaxPost(state.uri, @[], cast[cstring](state.formData),
|
|
||||||
(s: int, r: kstring) => onPost(s, r, state))
|
|
||||||
|
|
||||||
ev.preventDefault()
|
|
||||||
|
|
||||||
proc render*(state: PostButton, disabled: bool): VNode =
|
|
||||||
result = buildHtml(tdiv()):
|
|
||||||
button(class=class({
|
|
||||||
"loading": state.loading,
|
|
||||||
"disabled": disabled
|
|
||||||
},
|
|
||||||
"btn btn-secondary"
|
|
||||||
),
|
|
||||||
`type`="button",
|
|
||||||
onClick=(e: Event, n: VNode) => (onClick(e, n, state))):
|
|
||||||
if state.posted:
|
|
||||||
if state.error.isNone():
|
|
||||||
italic(class="fas fa-check")
|
|
||||||
else:
|
|
||||||
italic(class="fas fa-times")
|
|
||||||
else:
|
|
||||||
italic(class=state.icon)
|
|
||||||
text " " & state.title
|
|
||||||
|
|
||||||
if state.error.isSome():
|
|
||||||
p(class="text-error"):
|
|
||||||
text state.error.get().message
|
|
||||||
|
|
||||||
|
|
||||||
type
|
|
||||||
LikeButton* = ref object
|
|
||||||
error: Option[PostError]
|
|
||||||
loading: bool
|
|
||||||
|
|
||||||
proc newLikeButton*(): LikeButton =
|
|
||||||
LikeButton()
|
|
||||||
|
|
||||||
proc onPost(httpStatus: int, response: kstring, state: LikeButton,
|
|
||||||
post: Post, user: User) =
|
|
||||||
postFinished:
|
|
||||||
if post.isLikedBy(some(user)):
|
|
||||||
var newLikes: seq[User] = @[]
|
|
||||||
for like in post.likes:
|
|
||||||
if like.name != user.name:
|
|
||||||
newLikes.add(like)
|
|
||||||
post.likes = newLikes
|
|
||||||
else:
|
|
||||||
post.likes.add(user)
|
|
||||||
|
|
||||||
proc onClick(ev: Event, n: VNode, state: LikeButton, post: Post,
|
|
||||||
currentUser: Option[User]) =
|
|
||||||
if state.loading: return
|
|
||||||
if currentUser.isNone():
|
|
||||||
state.error = some[PostError](PostError(message: "Not logged in."))
|
|
||||||
return
|
|
||||||
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
# TODO: This is a hack, karax should support this.
|
|
||||||
var formData = newFormData()
|
|
||||||
formData.append("id", $post.id)
|
|
||||||
let uri =
|
|
||||||
if post.isLikedBy(currentUser):
|
|
||||||
makeUri("/unlike")
|
|
||||||
else:
|
|
||||||
makeUri("/like")
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) =>
|
|
||||||
onPost(s, r, state, post, currentUser.get()))
|
|
||||||
|
|
||||||
ev.preventDefault()
|
|
||||||
|
|
||||||
proc render*(state: LikeButton, post: Post,
|
|
||||||
currentUser: Option[User]): VNode =
|
|
||||||
|
|
||||||
let liked = isLikedBy(post, currentUser)
|
|
||||||
let tooltip =
|
|
||||||
if state.error.isSome(): state.error.get().message
|
|
||||||
else: ""
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="like-button"):
|
|
||||||
button(class=class({"tooltip": state.error.isSome()}, "btn"),
|
|
||||||
onClick=(e: Event, n: VNode) =>
|
|
||||||
(onClick(e, n, state, post, currentUser)),
|
|
||||||
"data-tooltip"=tooltip,
|
|
||||||
onmouseleave=(e: Event, n: VNode) =>
|
|
||||||
(state.error = none[PostError]())):
|
|
||||||
if post.likes.len > 0:
|
|
||||||
let names = post.likes.map(x => x.name).join(", ")
|
|
||||||
span(class="like-count tooltip", "data-tooltip"=names):
|
|
||||||
text $post.likes.len
|
|
||||||
|
|
||||||
italic(class=class({"far": not liked, "fas": liked}, "fa-heart"))
|
|
||||||
|
|
||||||
type
|
|
||||||
LockButton* = ref object
|
|
||||||
error: Option[PostError]
|
|
||||||
loading: bool
|
|
||||||
|
|
||||||
proc newLockButton*(): LockButton =
|
|
||||||
LockButton()
|
|
||||||
|
|
||||||
proc onPost(httpStatus: int, response: kstring, state: LockButton,
|
|
||||||
thread: var Thread) =
|
|
||||||
postFinished:
|
|
||||||
thread.isLocked = not thread.isLocked
|
|
||||||
|
|
||||||
proc onLockClick(ev: Event, n: VNode, state: LockButton, thread: var Thread) =
|
|
||||||
if state.loading: return
|
|
||||||
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
# TODO: This is a hack, karax should support this.
|
|
||||||
var formData = newFormData()
|
|
||||||
formData.append("id", $thread.id)
|
|
||||||
let uri =
|
|
||||||
if thread.isLocked:
|
|
||||||
makeUri("/unlock")
|
|
||||||
else:
|
|
||||||
makeUri("/lock")
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) =>
|
|
||||||
onPost(s, r, state, thread))
|
|
||||||
|
|
||||||
ev.preventDefault()
|
|
||||||
|
|
||||||
proc render*(state: LockButton, 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="lock-btn",
|
|
||||||
onClick=(e: Event, n: VNode) =>
|
|
||||||
onLockClick(e, n, state, thread),
|
|
||||||
"data-tooltip"=tooltip,
|
|
||||||
onmouseleave=(e: Event, n: VNode) =>
|
|
||||||
(state.error = none[PostError]())):
|
|
||||||
if thread.isLocked:
|
|
||||||
italic(class="fas fa-unlock-alt")
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
@ -1,420 +0,0 @@
|
||||||
|
|
||||||
import system except Thread
|
|
||||||
import options, json, times, httpcore, sugar, strutils
|
|
||||||
import sequtils
|
|
||||||
|
|
||||||
import threadlist, category, post, user
|
|
||||||
type
|
|
||||||
|
|
||||||
PostList* = ref object
|
|
||||||
thread*: Thread
|
|
||||||
history*: seq[Thread] ## If the thread was edited this will contain the
|
|
||||||
## older versions of the thread (title/category
|
|
||||||
## changes). TODO
|
|
||||||
posts*: seq[Post]
|
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
from dom import document
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax, kdom]
|
|
||||||
|
|
||||||
import karaxutils, error, replybox, editbox, postbutton, delete
|
|
||||||
import categorypicker
|
|
||||||
|
|
||||||
type
|
|
||||||
State = ref object
|
|
||||||
list: Option[PostList]
|
|
||||||
loading: bool
|
|
||||||
status: HttpCode
|
|
||||||
error: Option[PostError]
|
|
||||||
replyingTo: Option[Post]
|
|
||||||
replyBox: ReplyBox
|
|
||||||
editing: Option[Post] ## If in edit mode, this contains the post.
|
|
||||||
editBox: EditBox
|
|
||||||
likeButton: LikeButton
|
|
||||||
deleteModal: DeleteModal
|
|
||||||
lockButton: LockButton
|
|
||||||
pinButton: PinButton
|
|
||||||
categoryPicker: CategoryPicker
|
|
||||||
|
|
||||||
proc onReplyPosted(id: int)
|
|
||||||
proc onCategoryChanged(oldCategory: Category, newCategory: Category)
|
|
||||||
proc onEditPosted(id: int, content: string, subject: Option[string])
|
|
||||||
proc onEditCancelled()
|
|
||||||
proc onDeletePost(post: Post)
|
|
||||||
proc onDeleteThread(thread: Thread)
|
|
||||||
proc newState(): State =
|
|
||||||
State(
|
|
||||||
list: none[PostList](),
|
|
||||||
loading: false,
|
|
||||||
status: Http200,
|
|
||||||
error: none[PostError](),
|
|
||||||
replyingTo: none[Post](),
|
|
||||||
replyBox: newReplyBox(onReplyPosted),
|
|
||||||
editBox: newEditBox(onEditPosted, onEditCancelled),
|
|
||||||
likeButton: newLikeButton(),
|
|
||||||
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
|
|
||||||
lockButton: newLockButton(),
|
|
||||||
pinButton: newPinButton(),
|
|
||||||
categoryPicker: newCategoryPicker(onCategoryChanged)
|
|
||||||
)
|
|
||||||
|
|
||||||
var
|
|
||||||
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]) =
|
|
||||||
state.loading = false
|
|
||||||
state.status = httpStatus.HttpCode
|
|
||||||
if state.status != Http200: return
|
|
||||||
|
|
||||||
let parsed = parseJson($response)
|
|
||||||
let list = to(parsed, PostList)
|
|
||||||
|
|
||||||
state.list = some(list)
|
|
||||||
|
|
||||||
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.
|
|
||||||
if postId.isSome():
|
|
||||||
discard setTimeout(
|
|
||||||
() => (
|
|
||||||
# Would have used scrollIntoView but then the `:target` selector
|
|
||||||
# isn't activated.
|
|
||||||
getVNodeById($postId.get()).dom.scrollIntoView()
|
|
||||||
),
|
|
||||||
100
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onMorePosts(httpStatus: int, response: kstring, start: int) =
|
|
||||||
state.loading = false
|
|
||||||
state.status = httpStatus.HttpCode
|
|
||||||
if state.status != Http200: return
|
|
||||||
|
|
||||||
let parsed = parseJson($response)
|
|
||||||
var list = to(parsed, seq[Post])
|
|
||||||
|
|
||||||
var idsLoaded: seq[int] = @[]
|
|
||||||
for i in 0..<list.len:
|
|
||||||
state.list.get().posts.insert(list[i], i+start)
|
|
||||||
idsLoaded.add(list[i].id)
|
|
||||||
|
|
||||||
# Save a list of the IDs which have not yet been loaded into the top-most
|
|
||||||
# post.
|
|
||||||
let postIndex = start+list.len
|
|
||||||
# The following check is necessary because we reuse this proc to load
|
|
||||||
# a newly created post.
|
|
||||||
if postIndex < state.list.get().posts.len:
|
|
||||||
let post = state.list.get().posts[postIndex]
|
|
||||||
var newPostIds: seq[int] = @[]
|
|
||||||
for id in post.moreBefore:
|
|
||||||
if id notin idsLoaded:
|
|
||||||
newPostIds.add(id)
|
|
||||||
post.moreBefore = newPostIds
|
|
||||||
|
|
||||||
proc loadMore(start: int, ids: seq[int]) =
|
|
||||||
if state.loading: return
|
|
||||||
|
|
||||||
state.loading = true
|
|
||||||
let uri = makeUri(
|
|
||||||
"specific_posts.json",
|
|
||||||
[("ids", $(%ids))]
|
|
||||||
)
|
|
||||||
ajaxGet(
|
|
||||||
uri,
|
|
||||||
@[],
|
|
||||||
(s: int, r: kstring) => onMorePosts(s, r, start)
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onReplyPosted(id: int) =
|
|
||||||
## Executed when a reply has been successfully posted.
|
|
||||||
loadMore(state.list.get().posts.len, @[id])
|
|
||||||
|
|
||||||
proc onEditCancelled() = state.editing = none[Post]()
|
|
||||||
|
|
||||||
proc onEditPosted(id: int, content: string, subject: Option[string]) =
|
|
||||||
## Executed when an edit has been successfully posted.
|
|
||||||
state.editing = none[Post]()
|
|
||||||
let list = state.list.get()
|
|
||||||
for i in 0 ..< list.posts.len:
|
|
||||||
if list.posts[i].id == id:
|
|
||||||
list.posts[i].history.add(PostInfo(
|
|
||||||
creation: getTime().toUnix(),
|
|
||||||
content: content
|
|
||||||
))
|
|
||||||
break
|
|
||||||
|
|
||||||
proc onReplyClick(e: Event, n: VNode, p: Option[Post]) =
|
|
||||||
state.replyingTo = p
|
|
||||||
state.replyBox.show()
|
|
||||||
|
|
||||||
proc onEditClick(e: Event, n: VNode, p: Post) =
|
|
||||||
state.editing = some(p)
|
|
||||||
|
|
||||||
# TODO: Ensure the edit box is as big as its content. Auto resize the
|
|
||||||
# text area.
|
|
||||||
|
|
||||||
proc onDeletePost(post: Post) =
|
|
||||||
state.list.get().posts.keepIf(
|
|
||||||
x => x.id != post.id
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onDeleteThread(thread: Thread) =
|
|
||||||
window.location.href = makeUri("/")
|
|
||||||
|
|
||||||
proc onDeleteClick(e: Event, n: VNode, p: Post) =
|
|
||||||
let list = state.list.get()
|
|
||||||
if list.posts[0].id == p.id:
|
|
||||||
state.deleteModal.show(list.thread)
|
|
||||||
else:
|
|
||||||
state.deleteModal.show(p)
|
|
||||||
|
|
||||||
proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) =
|
|
||||||
loadMore(start, post.moreBefore) # TODO: Don't load all!
|
|
||||||
|
|
||||||
proc genLoadMore(post: Post, start: int): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="information load-more-posts",
|
|
||||||
onClick=(e: Event, n: VNode) => onLoadMore(e, n, start, post)):
|
|
||||||
tdiv(class="information-icon"):
|
|
||||||
italic(class="fas fa-comment-dots")
|
|
||||||
tdiv(class="information-main"):
|
|
||||||
if state.loading:
|
|
||||||
tdiv(class="loading loading-lg")
|
|
||||||
else:
|
|
||||||
tdiv(class="information-title"):
|
|
||||||
text "Load more posts "
|
|
||||||
span(class="more-post-count"):
|
|
||||||
text "(" & $post.moreBefore.len & ")"
|
|
||||||
|
|
||||||
proc 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 =
|
|
||||||
let loggedIn = currentUser.isSome()
|
|
||||||
let authoredByUser =
|
|
||||||
loggedIn and currentUser.get().name == post.author.name
|
|
||||||
let currentAdmin =
|
|
||||||
currentUser.isSome() and currentUser.get().rank == Admin
|
|
||||||
|
|
||||||
# Don't show buttons if the post is being edited.
|
|
||||||
if state.editing.isSome() and state.editing.get() == post:
|
|
||||||
return buildHtml(tdiv())
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="post-buttons"):
|
|
||||||
if authoredByUser or currentAdmin:
|
|
||||||
tdiv(class="edit-button", onClick=(e: Event, n: VNode) =>
|
|
||||||
onEditClick(e, n, post)):
|
|
||||||
button(class="btn"):
|
|
||||||
italic(class="far fa-edit")
|
|
||||||
tdiv(class="delete-button",
|
|
||||||
onClick=(e: Event, n: VNode) => onDeleteClick(e, n, post)):
|
|
||||||
button(class="btn"):
|
|
||||||
italic(class="far fa-trash-alt")
|
|
||||||
|
|
||||||
render(state.likeButton, post, currentUser)
|
|
||||||
|
|
||||||
if loggedIn:
|
|
||||||
tdiv(class="flag-button"):
|
|
||||||
button(class="btn"):
|
|
||||||
italic(class="far fa-flag")
|
|
||||||
|
|
||||||
tdiv(class="reply-button"):
|
|
||||||
button(class="btn", onClick=(e: Event, n: VNode) =>
|
|
||||||
onReplyClick(e, n, some(post))):
|
|
||||||
italic(class="fas fa-reply")
|
|
||||||
text " Reply"
|
|
||||||
|
|
||||||
proc genPost(
|
|
||||||
post: Post, thread: Thread, currentUser: Option[User], highlight: bool
|
|
||||||
): VNode =
|
|
||||||
let postCopy = post # TODO: Another workaround here, closure capture :(
|
|
||||||
|
|
||||||
let originalPost = thread.author == post.author
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class=class({"highlight": highlight, "original-post": originalPost}, "post"),
|
|
||||||
id = $post.id):
|
|
||||||
tdiv(class="post-icon"):
|
|
||||||
render(post.author, "post-avatar")
|
|
||||||
tdiv(class="post-main"):
|
|
||||||
tdiv(class="post-title"):
|
|
||||||
tdiv(class="post-username"):
|
|
||||||
text post.author.name
|
|
||||||
renderUserRank(post.author)
|
|
||||||
tdiv(class="post-metadata"):
|
|
||||||
if post.replyingTo.isSome():
|
|
||||||
let replyingTo = post.replyingTo.get()
|
|
||||||
tdiv(class="post-replyingTo"):
|
|
||||||
a(href=renderPostUrl(replyingTo)):
|
|
||||||
italic(class="fas fa-reply")
|
|
||||||
renderUserMention(replyingTo.author.get())
|
|
||||||
if post.history.len > 0:
|
|
||||||
let title = post.lastEdit.creation.fromUnix().local.
|
|
||||||
format("'Last modified' MMM d, yyyy HH:mm")
|
|
||||||
tdiv(class="post-history", title=title):
|
|
||||||
span(class="edit-count"):
|
|
||||||
text $post.history.len
|
|
||||||
italic(class="fas fa-pencil-alt")
|
|
||||||
|
|
||||||
let title = post.info.creation.fromUnix().local.
|
|
||||||
format("MMM d, yyyy HH:mm")
|
|
||||||
a(href=renderPostUrl(post, thread), title=title):
|
|
||||||
text renderActivity(post.info.creation)
|
|
||||||
tdiv(class="post-content"):
|
|
||||||
if state.editing.isSome() and state.editing.get() == post:
|
|
||||||
render(state.editBox, postCopy)
|
|
||||||
else:
|
|
||||||
let content =
|
|
||||||
if post.history.len > 0:
|
|
||||||
post.lastEdit.content
|
|
||||||
else:
|
|
||||||
post.info.content
|
|
||||||
verbatim(content)
|
|
||||||
|
|
||||||
genPostButtons(postCopy, currentUser)
|
|
||||||
|
|
||||||
proc genTimePassed(prevPost: Post, post: Option[Post], last: bool): VNode =
|
|
||||||
var latestTime =
|
|
||||||
if post.isSome: post.get().info.creation.fromUnix()
|
|
||||||
else: getTime()
|
|
||||||
|
|
||||||
# TODO: Use `between` once it's merged into stdlib.
|
|
||||||
let
|
|
||||||
tmpl =
|
|
||||||
if last: [
|
|
||||||
"A long time since last reply",
|
|
||||||
"$1 year since last reply",
|
|
||||||
"$1 years since last reply",
|
|
||||||
"$1 month since last reply",
|
|
||||||
"$1 months since last reply",
|
|
||||||
]
|
|
||||||
else: [
|
|
||||||
"Some time later",
|
|
||||||
"$1 year later", "$1 years later",
|
|
||||||
"$1 month later", "$1 months later"
|
|
||||||
]
|
|
||||||
var diffStr = tmpl[0]
|
|
||||||
let diff = latestTime - prevPost.info.creation.fromUnix()
|
|
||||||
if diff.inWeeks > 48:
|
|
||||||
let years = diff.inWeeks div 48
|
|
||||||
diffStr =
|
|
||||||
(if years == 1: tmpl[1] else: tmpl[2]) % $years
|
|
||||||
elif diff.inWeeks > 4:
|
|
||||||
let months = diff.inWeeks div 4
|
|
||||||
diffStr =
|
|
||||||
(if months == 1: tmpl[3] else: tmpl[4]) % $months
|
|
||||||
else:
|
|
||||||
return buildHtml(tdiv())
|
|
||||||
|
|
||||||
# PROTIP: Good thread ID to test this with is: 1267.
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="information time-passed"):
|
|
||||||
tdiv(class="information-icon"):
|
|
||||||
italic(class="fas fa-clock")
|
|
||||||
tdiv(class="information-main"):
|
|
||||||
tdiv(class="information-title"):
|
|
||||||
text diffStr
|
|
||||||
|
|
||||||
proc renderPostList*(threadId: int, postId: Option[int],
|
|
||||||
currentUser: Option[User]): VNode =
|
|
||||||
if state.list.isSome() and state.list.get().thread.id != threadId:
|
|
||||||
state.list = none[PostList]()
|
|
||||||
state.status = Http200
|
|
||||||
|
|
||||||
if state.status != Http200:
|
|
||||||
return renderError("Couldn't retrieve posts.", state.status)
|
|
||||||
|
|
||||||
if state.list.isNone:
|
|
||||||
var params = @[("id", $threadId)]
|
|
||||||
if postId.isSome():
|
|
||||||
params.add(("anchor", $postId.get()))
|
|
||||||
let uri = makeUri("posts.json", params)
|
|
||||||
if not state.loading:
|
|
||||||
state.loading = true
|
|
||||||
ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, postId))
|
|
||||||
|
|
||||||
return buildHtml(tdiv(class="loading loading-lg"))
|
|
||||||
|
|
||||||
let list = state.list.get()
|
|
||||||
result = buildHtml():
|
|
||||||
section(class="container grid-xl"):
|
|
||||||
tdiv(id="thread-title", class="title"):
|
|
||||||
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:
|
|
||||||
italic(class="fas fa-lock fa-xs",
|
|
||||||
title="Thread cannot be replied to")
|
|
||||||
text "Locked"
|
|
||||||
if list.thread.isModerated:
|
|
||||||
italic(class="fas fa-eye-slash fa-xs",
|
|
||||||
title="Thread is moderated")
|
|
||||||
text "Moderated"
|
|
||||||
if list.thread.isSolved:
|
|
||||||
italic(class="fas fa-check-square fa-xs",
|
|
||||||
title="Thread has a solution")
|
|
||||||
text "Solved"
|
|
||||||
genCategories(list.thread, currentUser)
|
|
||||||
tdiv(class="posts"):
|
|
||||||
var prevPost: Option[Post] = none[Post]()
|
|
||||||
for i, post in list.posts:
|
|
||||||
if not post.visibleTo(currentUser): continue
|
|
||||||
|
|
||||||
if prevPost.isSome:
|
|
||||||
genTimePassed(prevPost.get(), some(post), false)
|
|
||||||
if post.moreBefore.len > 0:
|
|
||||||
genLoadMore(post, i)
|
|
||||||
let highlight = postId.isSome() and postId.get() == post.id
|
|
||||||
genPost(post, list.thread, currentUser, highlight)
|
|
||||||
prevPost = some(post)
|
|
||||||
|
|
||||||
if prevPost.isSome:
|
|
||||||
genTimePassed(prevPost.get(), none[Post](), true)
|
|
||||||
|
|
||||||
tdiv(id="thread-buttons"):
|
|
||||||
button(class="btn btn-secondary",
|
|
||||||
onClick=(e: Event, n: VNode) =>
|
|
||||||
onReplyClick(e, n, none[Post]())):
|
|
||||||
italic(class="fas fa-reply")
|
|
||||||
text " Reply"
|
|
||||||
|
|
||||||
render(state.lockButton, list.thread, currentUser)
|
|
||||||
render(state.pinButton, list.thread, currentUser)
|
|
||||||
|
|
||||||
render(state.replyBox, list.thread, state.replyingTo, false)
|
|
||||||
|
|
||||||
render(state.deleteModal)
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import options, httpcore, json, sugar, times, strutils
|
|
||||||
|
|
||||||
import threadlist, post, error, user
|
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
from dom import document
|
|
||||||
include karax/prelude
|
|
||||||
import karax/[kajax, kdom]
|
|
||||||
import karaxutils, profilesettings
|
|
||||||
|
|
||||||
type
|
|
||||||
ProfileTab* = enum
|
|
||||||
Overview, Settings
|
|
||||||
|
|
||||||
ProfileState* = ref object
|
|
||||||
profile: Option[Profile]
|
|
||||||
settings: Option[ProfileSettings]
|
|
||||||
currentTab: ProfileTab
|
|
||||||
loading: bool
|
|
||||||
status: HttpCode
|
|
||||||
|
|
||||||
proc newProfileState*(): ProfileState =
|
|
||||||
ProfileState(
|
|
||||||
loading: false,
|
|
||||||
status: Http200,
|
|
||||||
currentTab: Overview
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onProfile(httpStatus: int, response: kstring, state: ProfileState) =
|
|
||||||
# TODO: Try to abstract these.
|
|
||||||
state.loading = false
|
|
||||||
state.status = httpStatus.HttpCode
|
|
||||||
if state.status != Http200: return
|
|
||||||
|
|
||||||
let parsed = parseJson($response)
|
|
||||||
let profile = to(parsed, Profile)
|
|
||||||
|
|
||||||
state.profile = some(profile)
|
|
||||||
state.settings = some(newProfileSettings(profile))
|
|
||||||
|
|
||||||
dom.document.title = profile.user.name & " - " & dom.document.title
|
|
||||||
|
|
||||||
proc genPostLink(link: PostLink): VNode =
|
|
||||||
let url = renderPostUrl(link)
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="profile-post"):
|
|
||||||
tdiv(class="profile-post-main"):
|
|
||||||
tdiv(class="profile-post-title"):
|
|
||||||
a(href=url):
|
|
||||||
text link.topic
|
|
||||||
tdiv(class="profile-post-time"):
|
|
||||||
let title = link.creation.fromUnix().local.
|
|
||||||
format("MMM d, yyyy HH:mm")
|
|
||||||
p(title=title):
|
|
||||||
text renderActivity(link.creation)
|
|
||||||
|
|
||||||
proc render*(
|
|
||||||
state: ProfileState,
|
|
||||||
username: kstring,
|
|
||||||
currentUser: Option[User]
|
|
||||||
): VNode =
|
|
||||||
|
|
||||||
if state.profile.isSome() and state.profile.get().user.name != username:
|
|
||||||
state.profile = none[Profile]()
|
|
||||||
state.status = Http200
|
|
||||||
|
|
||||||
if state.status != Http200:
|
|
||||||
return renderError("Couldn't retrieve profile.", state.status)
|
|
||||||
|
|
||||||
if state.profile.isNone:
|
|
||||||
if not state.loading:
|
|
||||||
state.loading = true
|
|
||||||
let uri = makeUri("profile.json", ("username", $username))
|
|
||||||
ajaxGet(uri, @[], (s: int, r: kstring) => onProfile(s, r, state))
|
|
||||||
|
|
||||||
return buildHtml(tdiv(class="loading loading-lg"))
|
|
||||||
|
|
||||||
let profile = state.profile.get()
|
|
||||||
result = buildHtml():
|
|
||||||
section(class="container grid-xl"):
|
|
||||||
tdiv(class="profile"):
|
|
||||||
tdiv(class="profile-icon"):
|
|
||||||
render(profile.user, "profile-avatar")
|
|
||||||
tdiv(class="profile-content"):
|
|
||||||
h2(class="profile-title"):
|
|
||||||
text profile.user.name
|
|
||||||
|
|
||||||
tdiv(class="profile-stats"):
|
|
||||||
dl():
|
|
||||||
dt(text "Joined")
|
|
||||||
dd(text threadlist.renderActivity(profile.joinTime))
|
|
||||||
if profile.posts.len > 0:
|
|
||||||
dt(text "Last Post")
|
|
||||||
dd(text renderActivity(profile.posts[0].creation))
|
|
||||||
dt(text "Last Online")
|
|
||||||
dd(text renderActivity(profile.user.lastOnline))
|
|
||||||
dt(text "Posts")
|
|
||||||
dd():
|
|
||||||
if profile.postCount > 999:
|
|
||||||
text $(profile.postCount / 1000) & "k"
|
|
||||||
else:
|
|
||||||
text $profile.postCount
|
|
||||||
dt(text "Threads")
|
|
||||||
dd():
|
|
||||||
if profile.threadCount > 999:
|
|
||||||
text $(profile.threadCount / 1000) & "k"
|
|
||||||
else:
|
|
||||||
text $profile.threadCount
|
|
||||||
dt(text "Rank")
|
|
||||||
dd(text $profile.user.rank)
|
|
||||||
|
|
||||||
if currentUser.isSome():
|
|
||||||
let user = currentUser.get()
|
|
||||||
if user.name == profile.user.name or user.rank >= Moderator:
|
|
||||||
ul(class="tab profile-tabs"):
|
|
||||||
li(class=class(
|
|
||||||
{"active": state.currentTab == Overview},
|
|
||||||
"tab-item"
|
|
||||||
),
|
|
||||||
onClick=(e: Event, n: VNode) => (state.currentTab = Overview)
|
|
||||||
):
|
|
||||||
a(id="overview-tab", class="c-hand"):
|
|
||||||
text "Overview"
|
|
||||||
li(class=class(
|
|
||||||
{"active": state.currentTab == Settings},
|
|
||||||
"tab-item"
|
|
||||||
),
|
|
||||||
onClick=(e: Event, n: VNode) => (state.currentTab = Settings)
|
|
||||||
):
|
|
||||||
a(id="settings-tab", class="c-hand"):
|
|
||||||
italic(class="fas fa-cog")
|
|
||||||
text " Settings"
|
|
||||||
|
|
||||||
case state.currentTab
|
|
||||||
of Overview:
|
|
||||||
if profile.posts.len > 0 or profile.threads.len > 0:
|
|
||||||
tdiv(class="columns"):
|
|
||||||
tdiv(class="column col-6"):
|
|
||||||
h4(text "Latest Posts")
|
|
||||||
tdiv(class="posts"):
|
|
||||||
for post in profile.posts:
|
|
||||||
genPostLink(post)
|
|
||||||
tdiv(class="column col-6"):
|
|
||||||
h4(text "Latest Threads")
|
|
||||||
tdiv(class="posts"):
|
|
||||||
for thread in profile.threads:
|
|
||||||
genPostLink(thread)
|
|
||||||
of Settings:
|
|
||||||
if state.settings.isSome():
|
|
||||||
render(state.settings.get(), currentUser)
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import httpcore, options, sugar, json, strutils, strformat
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax/[kajax, kdom]
|
|
||||||
|
|
||||||
import post, karaxutils, postbutton, error, delete, user
|
|
||||||
|
|
||||||
type
|
|
||||||
ProfileSettings* = ref object
|
|
||||||
loading: bool
|
|
||||||
status: HttpCode
|
|
||||||
error: Option[PostError]
|
|
||||||
email: kstring
|
|
||||||
rank: Rank
|
|
||||||
deleteModal: DeleteModal
|
|
||||||
resetPassword: PostButton
|
|
||||||
profile: Profile
|
|
||||||
|
|
||||||
proc onUserDelete(user: User) =
|
|
||||||
window.location.href = makeUri("/")
|
|
||||||
|
|
||||||
proc resetSettings(state: ProfileSettings) =
|
|
||||||
let profile = state.profile
|
|
||||||
if profile.email.isSome():
|
|
||||||
state.email = profile.email.get()
|
|
||||||
else:
|
|
||||||
state.email = ""
|
|
||||||
state.rank = profile.user.rank
|
|
||||||
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
proc newProfileSettings*(profile: Profile): ProfileSettings =
|
|
||||||
result = ProfileSettings(
|
|
||||||
status: Http200,
|
|
||||||
deleteModal: newDeleteModal(nil, nil, onUserDelete),
|
|
||||||
resetPassword: newResetPasswordButton(profile.user.name),
|
|
||||||
profile: profile
|
|
||||||
)
|
|
||||||
resetSettings(result)
|
|
||||||
|
|
||||||
proc onProfilePost(httpStatus: int, response: kstring,
|
|
||||||
state: ProfileSettings) =
|
|
||||||
postFinished:
|
|
||||||
state.profile.email = some($state.email)
|
|
||||||
state.profile.user.rank = state.rank
|
|
||||||
|
|
||||||
proc onEmailChange(event: Event, node: VNode, state: ProfileSettings) =
|
|
||||||
state.email = node.value
|
|
||||||
|
|
||||||
if state.profile.user.rank != Admin:
|
|
||||||
if state.email != state.profile.email.get():
|
|
||||||
state.rank = EmailUnconfirmed
|
|
||||||
else:
|
|
||||||
state.rank = state.profile.user.rank
|
|
||||||
|
|
||||||
proc onRankChange(event: Event, node: VNode, state: ProfileSettings) =
|
|
||||||
state.rank = parseEnum[Rank]($node.value)
|
|
||||||
|
|
||||||
proc save(state: ProfileSettings) =
|
|
||||||
if state.loading:
|
|
||||||
return
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let formData = newFormData()
|
|
||||||
formData.append("email", state.email)
|
|
||||||
formData.append("rank", $state.rank)
|
|
||||||
formData.append("username", $state.profile.user.name)
|
|
||||||
let uri = makeUri("/saveProfile")
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) => onProfilePost(s, r, state))
|
|
||||||
|
|
||||||
proc needsSave(state: ProfileSettings): bool =
|
|
||||||
if state.profile.email.isSome():
|
|
||||||
result = state.email != state.profile.email.get()
|
|
||||||
result = result or state.rank != state.profile.user.rank
|
|
||||||
|
|
||||||
proc render*(state: ProfileSettings,
|
|
||||||
currentUser: Option[User]): VNode =
|
|
||||||
let canEditRank = currentUser.isSome() and
|
|
||||||
currentUser.get().rank > state.profile.user.rank
|
|
||||||
let canResetPassword = state.profile.user.rank > EmailUnconfirmed
|
|
||||||
|
|
||||||
let rankSelect = buildHtml(tdiv()):
|
|
||||||
if canEditRank:
|
|
||||||
select(id="rank-field",
|
|
||||||
class="form-select", value = $state.rank,
|
|
||||||
onchange=(e: Event, n: VNode) => onRankChange(e, n, state)):
|
|
||||||
for r in Rank:
|
|
||||||
option(text $r, id="rank-" & toLowerAscii($r))
|
|
||||||
p(class="form-input-hint text-warning"):
|
|
||||||
text "You can modify anyone's rank. Remember: with " &
|
|
||||||
"great power comes great responsibility."
|
|
||||||
else:
|
|
||||||
input(id="rank-field", class="form-input",
|
|
||||||
`type`="text", disabled="", value = $state.rank)
|
|
||||||
p(class="form-input-hint"):
|
|
||||||
text "Your rank determines the actions you can perform " &
|
|
||||||
"on the forum."
|
|
||||||
case state.rank:
|
|
||||||
of Spammer, Troll:
|
|
||||||
p(class="form-input-hint text-warning"):
|
|
||||||
text "Your account was banned."
|
|
||||||
of EmailUnconfirmed:
|
|
||||||
p(class="form-input-hint text-warning"):
|
|
||||||
text "You cannot post until you confirm your email."
|
|
||||||
of Moderated:
|
|
||||||
p(class="form-input-hint text-warning"):
|
|
||||||
text "Your account is under moderation. This is a spam prevention "&
|
|
||||||
"measure. You can write posts but only moderators and admins "&
|
|
||||||
"will see them until your account is verified by them."
|
|
||||||
else:
|
|
||||||
discard
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="columns"):
|
|
||||||
tdiv(class="column col-6"):
|
|
||||||
form(class="form-horizontal"):
|
|
||||||
tdiv(class="form-group"):
|
|
||||||
tdiv(class="col-3 col-sm-12"):
|
|
||||||
label(class="form-label"):
|
|
||||||
text "Username"
|
|
||||||
tdiv(class="col-9 col-sm-12"):
|
|
||||||
input(class="form-input",
|
|
||||||
`type`="text",
|
|
||||||
value=state.profile.user.name,
|
|
||||||
disabled="")
|
|
||||||
p(class="form-input-hint"):
|
|
||||||
text fmt("Users can refer to you by writing" &
|
|
||||||
" @{state.profile.user.name} in their posts.")
|
|
||||||
if state.profile.email.isSome():
|
|
||||||
tdiv(class="form-group"):
|
|
||||||
tdiv(class="col-3 col-sm-12"):
|
|
||||||
label(class="form-label"):
|
|
||||||
text "Email"
|
|
||||||
tdiv(class="col-9 col-sm-12"):
|
|
||||||
input(id="email-input", class="form-input",
|
|
||||||
`type`="text", value=state.email,
|
|
||||||
oninput=(e: Event, n: VNode) =>
|
|
||||||
onEmailChange(e, n, state)
|
|
||||||
)
|
|
||||||
p(class="form-input-hint"):
|
|
||||||
text "Your avatar is linked to this email and can be " &
|
|
||||||
"changed at "
|
|
||||||
a(href="https://gravatar.com/emails"):
|
|
||||||
text "gravatar.com"
|
|
||||||
text ". Note that any changes to your email will " &
|
|
||||||
"require email verification."
|
|
||||||
tdiv(class="form-group"):
|
|
||||||
tdiv(class="col-3 col-sm-12"):
|
|
||||||
label(class="form-label"):
|
|
||||||
text "Rank"
|
|
||||||
tdiv(class="col-9 col-sm-12"):
|
|
||||||
rankSelect
|
|
||||||
tdiv(class="form-group"):
|
|
||||||
tdiv(class="col-3 col-sm-12"):
|
|
||||||
label(class="form-label"):
|
|
||||||
text "Password"
|
|
||||||
tdiv(class="col-9 col-sm-12"):
|
|
||||||
render(state.resetPassword,
|
|
||||||
disabled=not canResetPassword)
|
|
||||||
tdiv(class="form-group"):
|
|
||||||
tdiv(class="col-3 col-sm-12"):
|
|
||||||
label(class="form-label"):
|
|
||||||
text "Account"
|
|
||||||
tdiv(class="col-9 col-sm-12"):
|
|
||||||
button(id="delete-account-btn",
|
|
||||||
class="btn btn-secondary", `type`="button",
|
|
||||||
onClick=(e: Event, n: VNode) =>
|
|
||||||
(state.deleteModal.show(state.profile.user))):
|
|
||||||
italic(class="fas fa-times")
|
|
||||||
text " Delete account"
|
|
||||||
|
|
||||||
tdiv(class="float-right"):
|
|
||||||
if state.error.isSome():
|
|
||||||
span(class="text-error"):
|
|
||||||
text state.error.get().message
|
|
||||||
|
|
||||||
button(id="cancel-btn",
|
|
||||||
class=class(
|
|
||||||
{"disabled": not needsSave(state)}, "btn btn-link"
|
|
||||||
),
|
|
||||||
onClick=(e: Event, n: VNode) => (resetSettings(state))):
|
|
||||||
text "Cancel"
|
|
||||||
|
|
||||||
button(id="save-btn",
|
|
||||||
class=class(
|
|
||||||
{"disabled": not needsSave(state)}, "btn btn-primary"
|
|
||||||
),
|
|
||||||
onClick=(e: Event, n: VNode) => save(state),
|
|
||||||
id="save-btn"):
|
|
||||||
italic(class="fas fa-save")
|
|
||||||
text " Save"
|
|
||||||
|
|
||||||
render(state.deleteModal)
|
|
||||||
|
|
||||||
# TODO: I really should just be able to set the `value` attr.
|
|
||||||
# TODO: This doesn't work when settings are reset for some reason.
|
|
||||||
let rankField = getVNodeById("rank-field")
|
|
||||||
if not rankField.isNil:
|
|
||||||
rankField.setInputText($state.rank)
|
|
||||||
let emailField = getVNodeById("email-field")
|
|
||||||
if not emailField.isNil:
|
|
||||||
emailField.setInputText($state.email)
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import strformat, options, httpcore, json, sugar
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
from dom import getElementById, scrollIntoView, setTimeout
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [vstyles, kajax, kdom]
|
|
||||||
|
|
||||||
import karaxutils, threadlist, post, error, user
|
|
||||||
|
|
||||||
type
|
|
||||||
ReplyBox* = ref object
|
|
||||||
shown: bool
|
|
||||||
text: kstring
|
|
||||||
preview: bool
|
|
||||||
loading: bool
|
|
||||||
error: Option[PostError]
|
|
||||||
rendering: Option[kstring]
|
|
||||||
onPost: proc (id: int)
|
|
||||||
|
|
||||||
proc newReplyBox*(onPost: proc (id: int)): ReplyBox =
|
|
||||||
ReplyBox(
|
|
||||||
text: "",
|
|
||||||
onPost: onPost
|
|
||||||
)
|
|
||||||
|
|
||||||
proc performScroll() =
|
|
||||||
let replyBox = dom.document.getElementById("reply-box")
|
|
||||||
replyBox.scrollIntoView()
|
|
||||||
|
|
||||||
proc show*(state: ReplyBox) =
|
|
||||||
# Scroll to the reply box.
|
|
||||||
if not state.shown:
|
|
||||||
# TODO: It would be nice for Karax to give us an event when it renders
|
|
||||||
# things. That way we can remove this crappy hack.
|
|
||||||
discard dom.window.setTimeout(performScroll, 50)
|
|
||||||
else:
|
|
||||||
performScroll()
|
|
||||||
|
|
||||||
state.shown = true
|
|
||||||
|
|
||||||
proc getText*(state: ReplyBox): kstring = state.text
|
|
||||||
proc setText*(state: ReplyBox, text: kstring) = state.text = text
|
|
||||||
|
|
||||||
proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) =
|
|
||||||
postFinished:
|
|
||||||
echo response
|
|
||||||
state.rendering = some[kstring](response)
|
|
||||||
|
|
||||||
proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) =
|
|
||||||
state.preview = true
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
state.rendering = none[kstring]()
|
|
||||||
|
|
||||||
let formData = newFormData()
|
|
||||||
formData.append("msg", state.text)
|
|
||||||
let uri = makeUri("/preview")
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) => onPreviewPost(s, r, state))
|
|
||||||
|
|
||||||
proc onMessageClick(e: Event, n: VNode, state: ReplyBox) =
|
|
||||||
state.preview = false
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
proc onReplyPost(httpStatus: int, response: kstring, state: ReplyBox) =
|
|
||||||
postFinished:
|
|
||||||
state.text = ""
|
|
||||||
state.shown = false
|
|
||||||
state.onPost(parseJson($response).getInt())
|
|
||||||
|
|
||||||
proc onReplyClick(e: Event, n: VNode, state: ReplyBox,
|
|
||||||
thread: Thread, replyingTo: Option[Post]) =
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let formData = newFormData()
|
|
||||||
formData.append("msg", state.text)
|
|
||||||
formData.append("threadId", $thread.id)
|
|
||||||
if replyingTo.isSome:
|
|
||||||
formData.append("replyingTo", $replyingTo.get().id)
|
|
||||||
let uri = makeUri("/createPost")
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) => onReplyPost(s, r, state))
|
|
||||||
|
|
||||||
proc onCancelClick(e: Event, n: VNode, state: ReplyBox) =
|
|
||||||
# TODO: Double check reply box contents and ask user whether to discard.
|
|
||||||
state.shown = false
|
|
||||||
|
|
||||||
proc onChange(e: Event, n: VNode, state: ReplyBox) =
|
|
||||||
# TODO: Please document this better in Karax.
|
|
||||||
state.text = n.value
|
|
||||||
|
|
||||||
proc renderContent*(state: ReplyBox, thread: Option[Thread],
|
|
||||||
post: Option[Post]): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="panel"):
|
|
||||||
tdiv(class="panel-nav"):
|
|
||||||
ul(class="tab tab-block"):
|
|
||||||
li(class=class({"active": not state.preview}, "tab-item"),
|
|
||||||
onClick=(e: Event, n: VNode) =>
|
|
||||||
onMessageClick(e, n, state)):
|
|
||||||
a(class="c-hand"):
|
|
||||||
text "Message"
|
|
||||||
li(class=class({"active": state.preview}, "tab-item"),
|
|
||||||
onClick=(e: Event, n: VNode) =>
|
|
||||||
onPreviewClick(e, n, state)):
|
|
||||||
a(class="c-hand"):
|
|
||||||
text "Preview"
|
|
||||||
tdiv(class="panel-body"):
|
|
||||||
if state.preview:
|
|
||||||
if state.loading:
|
|
||||||
tdiv(class="loading")
|
|
||||||
elif state.rendering.isSome():
|
|
||||||
verbatim(state.rendering.get())
|
|
||||||
else:
|
|
||||||
textarea(id="reply-textarea",
|
|
||||||
class="form-input post-text-area", rows="5",
|
|
||||||
onChange=(e: Event, n: VNode) =>
|
|
||||||
onChange(e, n, state),
|
|
||||||
value=state.text)
|
|
||||||
a(href=makeUri("/about/rst"), target="blank_"):
|
|
||||||
text "Styling with RST is supported"
|
|
||||||
|
|
||||||
if state.error.isSome():
|
|
||||||
span(class="text-error",
|
|
||||||
style=style(StyleAttr.marginTop, "0.4rem")):
|
|
||||||
text state.error.get().message
|
|
||||||
|
|
||||||
if thread.isSome:
|
|
||||||
tdiv(class="panel-footer"):
|
|
||||||
button(class=class(
|
|
||||||
{"loading": state.loading},
|
|
||||||
"btn btn-primary float-right"
|
|
||||||
),
|
|
||||||
onClick=(e: Event, n: VNode) =>
|
|
||||||
onReplyClick(e, n, state, thread.get(), post)):
|
|
||||||
text "Reply"
|
|
||||||
button(class="btn btn-link float-right",
|
|
||||||
onClick=(e: Event, n: VNode) =>
|
|
||||||
onCancelClick(e, n, state)):
|
|
||||||
text "Cancel"
|
|
||||||
|
|
||||||
proc render*(state: ReplyBox, thread: Thread, post: Option[Post],
|
|
||||||
hasMore: bool): VNode =
|
|
||||||
if not state.shown:
|
|
||||||
return buildHtml(tdiv(id="reply-box"))
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class=class({"no-border": hasMore}, "information"), id="reply-box"):
|
|
||||||
tdiv(class="information-icon"):
|
|
||||||
italic(class="fas fa-reply")
|
|
||||||
tdiv(class="information-main", style=style(StyleAttr.width, "100%")):
|
|
||||||
tdiv(class="information-title"):
|
|
||||||
if post.isNone:
|
|
||||||
text fmt("Replying to \"{thread.topic}\"")
|
|
||||||
else:
|
|
||||||
text "Replying to "
|
|
||||||
renderUserMention(post.get().author)
|
|
||||||
tdiv(class="post-buttons",
|
|
||||||
style=style(StyleAttr.marginTop, "-0.3rem")):
|
|
||||||
a(href=renderPostUrl(post.get(), thread)):
|
|
||||||
button(class="btn"):
|
|
||||||
italic(class="fas fa-arrow-up")
|
|
||||||
tdiv(class="information-content"):
|
|
||||||
renderContent(state, some(thread), post)
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import sugar, httpcore, options, json
|
|
||||||
import dom except Event, KeyboardEvent
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax, kdom]
|
|
||||||
|
|
||||||
import error
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
type
|
|
||||||
ResetPassword* = ref object
|
|
||||||
loading: bool
|
|
||||||
status: HttpCode
|
|
||||||
error: Option[PostError]
|
|
||||||
newPassword: kstring
|
|
||||||
|
|
||||||
proc newResetPassword*(): ResetPassword =
|
|
||||||
ResetPassword(
|
|
||||||
status: Http200,
|
|
||||||
newPassword: ""
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onPassChange(e: Event, n: VNode, state: ResetPassword) =
|
|
||||||
state.newPassword = n.value
|
|
||||||
|
|
||||||
proc onPost(httpStatus: int, response: kstring, state: ResetPassword) =
|
|
||||||
postFinished:
|
|
||||||
navigateTo(makeUri("/resetPassword/success"))
|
|
||||||
|
|
||||||
proc onSetClick(
|
|
||||||
ev: Event, n: VNode,
|
|
||||||
state: ResetPassword
|
|
||||||
) =
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let uri = makeUri("resetPassword", ("newPassword", $state.newPassword))
|
|
||||||
ajaxPost(uri, @[], "",
|
|
||||||
(s: int, r: kstring) => onPost(s, r, state))
|
|
||||||
|
|
||||||
proc render*(state: ResetPassword): VNode =
|
|
||||||
if state.loading:
|
|
||||||
return buildHtml(tdiv(class="loading"))
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
section(class="container grid-xl"):
|
|
||||||
tdiv(id="resetpassword"):
|
|
||||||
tdiv(class="title"):
|
|
||||||
p(): text "Reset Password"
|
|
||||||
tdiv(class="content"):
|
|
||||||
label(class="form-label", `for`="password"):
|
|
||||||
text "Password"
|
|
||||||
input(class="form-input", `type`="password", name="password",
|
|
||||||
placeholder="Type your new password here",
|
|
||||||
oninput=(e: Event, n: VNode) => onPassChange(e, n, state))
|
|
||||||
if state.error.isSome():
|
|
||||||
p(class="text-error"):
|
|
||||||
text state.error.get().message
|
|
||||||
tdiv(class="footer"):
|
|
||||||
button(class=class(
|
|
||||||
{"loading": state.loading},
|
|
||||||
"btn btn-primary"
|
|
||||||
),
|
|
||||||
onClick=(ev: Event, n: VNode) =>
|
|
||||||
(onSetClick(ev, n, state))):
|
|
||||||
text "Set password"
|
|
||||||
|
|
||||||
|
|
||||||
type
|
|
||||||
ResetPasswordModal* = ref object
|
|
||||||
shown: bool
|
|
||||||
loading: bool
|
|
||||||
error: Option[PostError]
|
|
||||||
sent: bool
|
|
||||||
|
|
||||||
proc onPost(httpStatus: int, response: kstring, state: ResetPasswordModal) =
|
|
||||||
postFinished:
|
|
||||||
state.sent = true
|
|
||||||
|
|
||||||
proc onClick(ev: Event, n: VNode, state: ResetPasswordModal) =
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let uri = makeUri("sendResetPassword")
|
|
||||||
let form = dom.document.getElementById("resetpassword-form")
|
|
||||||
# TODO: This is a hack, karax should support this.
|
|
||||||
let formData = newFormData(form)
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) => onPost(s, r, state))
|
|
||||||
|
|
||||||
ev.preventDefault()
|
|
||||||
|
|
||||||
proc onClose(ev: Event, n: VNode, state: ResetPasswordModal) =
|
|
||||||
state.shown = false
|
|
||||||
ev.preventDefault()
|
|
||||||
|
|
||||||
proc newResetPasswordModal*(): ResetPasswordModal =
|
|
||||||
ResetPasswordModal(
|
|
||||||
shown: false
|
|
||||||
)
|
|
||||||
|
|
||||||
proc show*(state: ResetPasswordModal) =
|
|
||||||
state.shown = true
|
|
||||||
|
|
||||||
proc onKeyDown(e: Event, n: VNode, state: ResetPasswordModal) =
|
|
||||||
let event = cast[KeyboardEvent](e)
|
|
||||||
if event.key == "Enter":
|
|
||||||
onClick(e, n, state)
|
|
||||||
|
|
||||||
proc render*(state: ResetPasswordModal,
|
|
||||||
recaptchaSiteKey: Option[string]): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class=class({"active": state.shown}, "modal"),
|
|
||||||
id="resetpassword-modal"):
|
|
||||||
a(href="", class="modal-overlay", "aria-label"="close",
|
|
||||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
|
||||||
tdiv(class="modal-container"):
|
|
||||||
tdiv(class="modal-header"):
|
|
||||||
a(href="", class="btn btn-clear float-right",
|
|
||||||
"aria-label"="close",
|
|
||||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
|
||||||
tdiv(class="modal-title h5"):
|
|
||||||
text "Reset your password"
|
|
||||||
tdiv(class="modal-body"):
|
|
||||||
tdiv(class="content"):
|
|
||||||
form(id="resetpassword-form",
|
|
||||||
onKeyDown=(ev: Event, n: VNode) => onKeyDown(ev, n, state)):
|
|
||||||
genFormField(
|
|
||||||
state.error,
|
|
||||||
"email",
|
|
||||||
"Enter your email or username and we will send you a " &
|
|
||||||
"password reset email.",
|
|
||||||
"text",
|
|
||||||
true,
|
|
||||||
placeholder="Username or email"
|
|
||||||
)
|
|
||||||
if recaptchaSiteKey.isSome:
|
|
||||||
tdiv(id="recaptcha"):
|
|
||||||
tdiv(class="g-recaptcha",
|
|
||||||
"data-sitekey"=recaptchaSiteKey.get())
|
|
||||||
script(src="https://www.google.com/recaptcha/api.js")
|
|
||||||
tdiv(class="modal-footer"):
|
|
||||||
if state.sent:
|
|
||||||
span(class="text-success"):
|
|
||||||
italic(class="fas fa-check-circle")
|
|
||||||
text " Sent"
|
|
||||||
else:
|
|
||||||
button(class=class(
|
|
||||||
{"loading": state.loading},
|
|
||||||
"btn btn-primary"
|
|
||||||
),
|
|
||||||
`type`="button",
|
|
||||||
onClick=(ev: Event, n: VNode) => onClick(ev, n, state)):
|
|
||||||
text "Reset password"
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
|
|
||||||
import user, options, httpcore, json, times
|
|
||||||
type
|
|
||||||
SearchResultKind* = enum
|
|
||||||
ThreadMatch, PostMatch
|
|
||||||
|
|
||||||
SearchResult* = object
|
|
||||||
kind*: SearchResultKind
|
|
||||||
threadId*: int
|
|
||||||
postId*: int
|
|
||||||
threadTitle*: string
|
|
||||||
postContent*: string
|
|
||||||
author*: User
|
|
||||||
creation*: int64
|
|
||||||
|
|
||||||
proc isModerated*(searchResult: SearchResult): bool =
|
|
||||||
return searchResult.author.rank <= Moderated
|
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
from dom import nil
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax]
|
|
||||||
|
|
||||||
import karaxutils, error, threadlist, sugar
|
|
||||||
|
|
||||||
type
|
|
||||||
Search* = ref object
|
|
||||||
list: Option[seq[SearchResult]]
|
|
||||||
loading: bool
|
|
||||||
status: HttpCode
|
|
||||||
query: string
|
|
||||||
|
|
||||||
proc newSearch*(): Search =
|
|
||||||
Search(
|
|
||||||
list: none[seq[SearchResult]](),
|
|
||||||
loading: false,
|
|
||||||
status: Http200,
|
|
||||||
query: ""
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onList(httpStatus: int, response: kstring, state: Search) =
|
|
||||||
state.loading = false
|
|
||||||
state.status = httpStatus.HttpCode
|
|
||||||
if state.status != Http200: return
|
|
||||||
|
|
||||||
let parsed = parseJson($response)
|
|
||||||
let list = to(parsed, seq[SearchResult])
|
|
||||||
|
|
||||||
state.list = some(list)
|
|
||||||
|
|
||||||
proc genSearchResult(searchResult: SearchResult): VNode =
|
|
||||||
let url = renderPostUrl(searchResult.threadId, searchResult.postId)
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class="post", id = $searchResult.postId):
|
|
||||||
tdiv(class="post-icon"):
|
|
||||||
render(searchResult.author, "post-avatar")
|
|
||||||
tdiv(class="post-main"):
|
|
||||||
tdiv(class="post-title"):
|
|
||||||
tdiv(class="thread-title"):
|
|
||||||
a(href=url, onClick=anchorCB):
|
|
||||||
verbatim(searchResult.threadTitle)
|
|
||||||
tdiv(class="post-username"):
|
|
||||||
text searchResult.author.name
|
|
||||||
renderUserRank(searchResult.author)
|
|
||||||
tdiv(class="post-metadata"):
|
|
||||||
# TODO: History and replying to.
|
|
||||||
let title = searchResult.creation.fromUnix().local.
|
|
||||||
format("MMM d, yyyy HH:mm")
|
|
||||||
a(href=url, title=title, onClick=anchorCB):
|
|
||||||
text renderActivity(searchResult.creation)
|
|
||||||
tdiv(class="post-content"):
|
|
||||||
verbatim(searchResult.postContent)
|
|
||||||
|
|
||||||
proc render*(state: Search, query: string, currentUser: Option[User]): VNode =
|
|
||||||
if state.query != query:
|
|
||||||
state.list = none[seq[SearchResult]]()
|
|
||||||
state.status = Http200
|
|
||||||
state.query = query
|
|
||||||
|
|
||||||
if state.status != Http200:
|
|
||||||
return renderError("Couldn't retrieve search results.", state.status)
|
|
||||||
|
|
||||||
if state.list.isNone:
|
|
||||||
var params = @[("q", state.query)]
|
|
||||||
let uri = makeUri("search.json", params)
|
|
||||||
ajaxGet(uri, @[], (s: int, r: kstring) => onList(s, r, state))
|
|
||||||
|
|
||||||
return buildHtml(tdiv(class="loading loading-lg"))
|
|
||||||
|
|
||||||
let list = state.list.get()
|
|
||||||
result = buildHtml():
|
|
||||||
section(class="container grid-xl"):
|
|
||||||
tdiv(class="title"):
|
|
||||||
p(): text "Search results"
|
|
||||||
tdiv(class="searchresults"):
|
|
||||||
if list.len == 0:
|
|
||||||
renderMessage("No results found", "", "fa-exclamation")
|
|
||||||
else:
|
|
||||||
for searchResult in list:
|
|
||||||
if not searchResult.visibleTo(currentUser): continue
|
|
||||||
genSearchResult(searchResult)
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
when defined(js):
|
|
||||||
import sugar, httpcore, options, json
|
|
||||||
import dom except Event
|
|
||||||
import jsffi except `&`
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [kajax, kdom]
|
|
||||||
|
|
||||||
import error
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
type
|
|
||||||
SignupModal* = ref object
|
|
||||||
shown: bool
|
|
||||||
loading: bool
|
|
||||||
onSignUp, onLogIn: proc ()
|
|
||||||
error: Option[PostError]
|
|
||||||
|
|
||||||
proc onSignUpPost(httpStatus: int, response: kstring, state: SignupModal) =
|
|
||||||
postFinished:
|
|
||||||
state.shown = false
|
|
||||||
state.onSignUp()
|
|
||||||
|
|
||||||
proc onSignUpClick(ev: Event, n: VNode, state: SignupModal) =
|
|
||||||
state.loading = true
|
|
||||||
state.error = none[PostError]()
|
|
||||||
|
|
||||||
let uri = makeUri("signup")
|
|
||||||
let form = dom.document.getElementById("signup-form")
|
|
||||||
# TODO: This is a hack, karax should support this.
|
|
||||||
let formData = newFormData(form)
|
|
||||||
ajaxPost(uri, @[], formData.to(cstring),
|
|
||||||
(s: int, r: kstring) => onSignUpPost(s, r, state))
|
|
||||||
|
|
||||||
proc onClose(ev: Event, n: VNode, state: SignupModal) =
|
|
||||||
state.shown = false
|
|
||||||
ev.preventDefault()
|
|
||||||
|
|
||||||
proc newSignupModal*(onSignUp, onLogIn: proc ()): SignupModal =
|
|
||||||
SignupModal(
|
|
||||||
shown: false,
|
|
||||||
onLogIn: onLogIn,
|
|
||||||
onSignUp: onSignUp
|
|
||||||
)
|
|
||||||
|
|
||||||
proc show*(state: SignupModal) =
|
|
||||||
state.shown = true
|
|
||||||
|
|
||||||
proc render*(state: SignupModal, recaptchaSiteKey: Option[string]): VNode =
|
|
||||||
setForeignNodeId("recaptcha")
|
|
||||||
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(class=class({"active": state.shown}, "modal"),
|
|
||||||
id="signup-modal"):
|
|
||||||
a(href="", class="modal-overlay", "aria-label"="close",
|
|
||||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
|
||||||
tdiv(class="modal-container"):
|
|
||||||
tdiv(class="modal-header"):
|
|
||||||
a(href="", class="btn btn-clear float-right",
|
|
||||||
"aria-label"="close",
|
|
||||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
|
||||||
tdiv(class="modal-title h5"):
|
|
||||||
text "Create a new account"
|
|
||||||
tdiv(class="modal-body"):
|
|
||||||
tdiv(class="content"):
|
|
||||||
form(id="signup-form"):
|
|
||||||
genFormField(state.error, "email", "Email", "email", false)
|
|
||||||
genFormField(state.error, "username", "Username", "text", false)
|
|
||||||
genFormField(
|
|
||||||
state.error,
|
|
||||||
"password",
|
|
||||||
"Password",
|
|
||||||
"password",
|
|
||||||
true
|
|
||||||
)
|
|
||||||
if recaptchaSiteKey.isSome:
|
|
||||||
tdiv(id="recaptcha"):
|
|
||||||
tdiv(class="g-recaptcha",
|
|
||||||
"data-sitekey"=recaptchaSiteKey.get())
|
|
||||||
script(src="https://www.google.com/recaptcha/api.js")
|
|
||||||
tdiv(class="modal-footer"):
|
|
||||||
button(class=class({"loading": state.loading},
|
|
||||||
"btn btn-primary create-account-btn"),
|
|
||||||
onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)):
|
|
||||||
text "Create account"
|
|
||||||
button(class="btn login-btn",
|
|
||||||
onClick=(ev: Event, n: VNode) =>
|
|
||||||
(state.onLogIn(); state.shown = false)):
|
|
||||||
text "Log in"
|
|
||||||
|
|
||||||
p(class="license-text text-gray"):
|
|
||||||
text "By registering, you agree to the "
|
|
||||||
a(id="license", href=makeUri("/about/license"),
|
|
||||||
onClick=(ev: Event, n: VNode) =>
|
|
||||||
(state.shown = false; anchorCB(ev, n))):
|
|
||||||
text "content license"
|
|
||||||
text "."
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
import strformat, times, options, json, httpcore, sugar
|
|
||||||
|
|
||||||
import category, user
|
|
||||||
|
|
||||||
type
|
|
||||||
Thread* = object
|
|
||||||
id*: int
|
|
||||||
topic*: string
|
|
||||||
category*: Category
|
|
||||||
author*: User
|
|
||||||
users*: seq[User]
|
|
||||||
replies*: int
|
|
||||||
views*: int
|
|
||||||
activity*: int64 ## Unix timestamp
|
|
||||||
creation*: int64 ## Unix timestamp
|
|
||||||
isLocked*: bool
|
|
||||||
isSolved*: bool
|
|
||||||
isPinned*: bool
|
|
||||||
|
|
||||||
ThreadList* = ref object
|
|
||||||
threads*: seq[Thread]
|
|
||||||
moreCount*: int ## How many more threads are left
|
|
||||||
|
|
||||||
proc isModerated*(thread: Thread): bool =
|
|
||||||
## Determines whether the specified thread is under moderation.
|
|
||||||
## (i.e. whether the specified thread is invisible to ordinary users).
|
|
||||||
thread.author.rank <= Moderated
|
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
import sugar
|
|
||||||
include karax/prelude
|
|
||||||
import karax / [vstyles, kajax, kdom]
|
|
||||||
|
|
||||||
import karaxutils, error, user, mainbuttons
|
|
||||||
|
|
||||||
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]())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
state = newState()
|
|
||||||
|
|
||||||
proc visibleTo*[T](thread: T, user: Option[User]): bool =
|
|
||||||
## Determines whether the specified thread (or post) should be
|
|
||||||
## shown to the user. This procedure is generic and works on any
|
|
||||||
## object with a `isModerated` proc.
|
|
||||||
##
|
|
||||||
## The rules for this are determined by the rank of the user, their
|
|
||||||
## settings (TODO), and whether the thread's creator is moderated or not.
|
|
||||||
##
|
|
||||||
## The ``user`` argument refers to the currently logged in user.
|
|
||||||
mixin isModerated
|
|
||||||
if user.isNone(): return not thread.isModerated
|
|
||||||
|
|
||||||
let rank = user.get().rank
|
|
||||||
if rank < Rank.Moderator and thread.isModerated:
|
|
||||||
return thread.author == user.get()
|
|
||||||
|
|
||||||
return true
|
|
||||||
|
|
||||||
proc genUserAvatars(users: seq[User]): VNode =
|
|
||||||
result = buildHtml(td(class="thread-users")):
|
|
||||||
for user in users:
|
|
||||||
render(user, "avatar avatar-sm", showStatus=true)
|
|
||||||
text " "
|
|
||||||
|
|
||||||
proc renderActivity*(activity: int64): string =
|
|
||||||
let currentTime = getTime()
|
|
||||||
let activityTime = fromUnix(activity)
|
|
||||||
let duration = currentTime - activityTime
|
|
||||||
if currentTime.local().year != activityTime.local().year:
|
|
||||||
return activityTime.local().format("MMM yyyy")
|
|
||||||
elif duration.inDays > 30 and duration.inDays < 300:
|
|
||||||
return activityTime.local().format("MMM dd")
|
|
||||||
elif duration.inDays != 0:
|
|
||||||
return $duration.inDays & "d"
|
|
||||||
elif duration.inHours != 0:
|
|
||||||
return $duration.inHours & "h"
|
|
||||||
elif duration.inMinutes != 0:
|
|
||||||
return $duration.inMinutes & "m"
|
|
||||||
else:
|
|
||||||
return $duration.inSeconds & "s"
|
|
||||||
|
|
||||||
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): 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})):
|
|
||||||
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")
|
|
||||||
if thread.isModerated:
|
|
||||||
italic(class="fas fa-eye-slash fa-xs",
|
|
||||||
title="Thread is moderated")
|
|
||||||
if thread.isSolved:
|
|
||||||
italic(class="fas fa-check-square fa-xs",
|
|
||||||
title="Thread has a solution")
|
|
||||||
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})):
|
|
||||||
render(thread.category)
|
|
||||||
genUserAvatars(thread.users)
|
|
||||||
td(class="thread-replies"): text $thread.replies
|
|
||||||
td(class="hide-sm" & class({
|
|
||||||
"views-text": thread.views < 999,
|
|
||||||
"popular-text": thread.views > 999 and thread.views < 5000,
|
|
||||||
"super-popular-text": thread.views > 5000
|
|
||||||
})):
|
|
||||||
if thread.views > 999:
|
|
||||||
text fmt"{thread.views/1000:.1f}k"
|
|
||||||
else:
|
|
||||||
text $thread.views
|
|
||||||
|
|
||||||
let friendlyCreation = thread.creation.fromUnix.local.format(
|
|
||||||
"'First post:' MMM d, yyyy HH:mm'\n'"
|
|
||||||
)
|
|
||||||
let friendlyActivity = thread.activity.fromUnix.local.format(
|
|
||||||
"'Last reply:' MMM d, yyyy HH:mm"
|
|
||||||
)
|
|
||||||
td(class=class({"is-new": isNew, "is-old": isOld}, "thread-time"),
|
|
||||||
title=friendlyCreation & friendlyActivity):
|
|
||||||
text renderActivity(thread.activity)
|
|
||||||
|
|
||||||
proc onThreadList(httpStatus: int, response: kstring) =
|
|
||||||
state.loading = false
|
|
||||||
state.status = httpStatus.HttpCode
|
|
||||||
if state.status != Http200: return
|
|
||||||
|
|
||||||
let parsed = parseJson($response)
|
|
||||||
let list = to(parsed, ThreadList)
|
|
||||||
|
|
||||||
if state.list.isSome:
|
|
||||||
state.list.get().threads.add(list.threads)
|
|
||||||
state.list.get().moreCount = list.moreCount
|
|
||||||
else:
|
|
||||||
state.list = some(list)
|
|
||||||
|
|
||||||
proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) =
|
|
||||||
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)
|
|
||||||
|
|
||||||
proc getInfo(
|
|
||||||
list: seq[Thread], i: int, currentUser: Option[User]
|
|
||||||
): tuple[isLastUnseen, isNew: bool] =
|
|
||||||
## Determines two properties about a thread.
|
|
||||||
##
|
|
||||||
## * isLastUnseen - Whether this is the last thread that had new
|
|
||||||
## activity since the last time the user visited the forum.
|
|
||||||
## * isNew - Whether this thread was created during the time that the
|
|
||||||
# user was absent from the forum.
|
|
||||||
let previousVisitAt =
|
|
||||||
if currentUser.isSome(): currentUser.get().previousVisitAt
|
|
||||||
else: getTime().toUnix
|
|
||||||
assert previousVisitAt != 0
|
|
||||||
|
|
||||||
let thread = list[i]
|
|
||||||
let isUnseen = thread.activity > previousVisitAt
|
|
||||||
let isNextUnseen = i+1 < list.len and list[i+1].activity > previousVisitAt
|
|
||||||
|
|
||||||
return (
|
|
||||||
isLastUnseen: isUnseen and (not isNextUnseen),
|
|
||||||
isNew: thread.creation > previousVisitAt
|
|
||||||
)
|
|
||||||
|
|
||||||
proc genThreadList(currentUser: Option[User], categoryId: Option[int]): 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)
|
|
||||||
|
|
||||||
return buildHtml(tdiv(class="loading loading-lg"))
|
|
||||||
|
|
||||||
let displayCategory = categoryId.isNone
|
|
||||||
|
|
||||||
let list = state.list.get()
|
|
||||||
result = buildHtml():
|
|
||||||
section(class="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"
|
|
||||||
tbody():
|
|
||||||
for i in 0 ..< list.threads.len:
|
|
||||||
let thread = list.threads[i]
|
|
||||||
if not visibleTo(thread, currentUser): continue
|
|
||||||
|
|
||||||
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)
|
|
||||||
if isLastUnseen and (not isLastThread):
|
|
||||||
tr(class="last-visit-separator"):
|
|
||||||
td(colspan="6"):
|
|
||||||
span(text "last visit")
|
|
||||||
|
|
||||||
if list.moreCount > 0:
|
|
||||||
tr(class="load-more-separator"):
|
|
||||||
if state.loading:
|
|
||||||
td(colspan="6"):
|
|
||||||
tdiv(class="loading loading-lg")
|
|
||||||
else:
|
|
||||||
td(colspan="6",
|
|
||||||
onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))):
|
|
||||||
span(text "load more threads")
|
|
||||||
|
|
||||||
proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode =
|
|
||||||
result = buildHtml(tdiv):
|
|
||||||
state.mainButtons.render(currentUser, categoryId=categoryId)
|
|
||||||
genThreadList(currentUser, categoryId)
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import times, options
|
|
||||||
|
|
||||||
type
|
|
||||||
# If you add more "Banned" states, be sure to modify forum's threadsQuery too.
|
|
||||||
Rank* {.pure.} = enum ## serialized as 'status'
|
|
||||||
Spammer ## spammer: every post is invisible
|
|
||||||
Moderated ## new member: posts manually reviewed before everybody
|
|
||||||
## can see them
|
|
||||||
Troll ## troll: cannot write new posts
|
|
||||||
Banned ## A non-specific ban
|
|
||||||
EmailUnconfirmed ## member with unconfirmed email address. Their posts
|
|
||||||
## are visible, but cannot make new posts. This is so that
|
|
||||||
## when a user with existing posts changes their email,
|
|
||||||
## their posts don't disappear.
|
|
||||||
User ## Ordinary user
|
|
||||||
Moderator ## Moderator: can change a user's rank
|
|
||||||
Admin ## Admin: can do everything
|
|
||||||
|
|
||||||
User* = object
|
|
||||||
id*: string
|
|
||||||
name*: string
|
|
||||||
avatarUrl*: string
|
|
||||||
lastOnline*: int64
|
|
||||||
previousVisitAt*: int64 ## Tracks the "last visit" line position
|
|
||||||
rank*: Rank
|
|
||||||
isDeleted*: bool
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
proc canPost*(rank: Rank): bool =
|
|
||||||
## Determines whether the specified rank can make new posts.
|
|
||||||
rank >= Rank.User or rank == Moderated
|
|
||||||
|
|
||||||
proc isBanned*(rank: Rank): bool =
|
|
||||||
rank in {Spammer, Troll, Banned}
|
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
include karax/prelude
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
proc render*(user: User, class: string, showStatus=false): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
a(href=renderProfileUrl(user.name), onClick=anchorCB):
|
|
||||||
figure(class=class):
|
|
||||||
img(src=user.avatarUrl, title=user.name)
|
|
||||||
if user.isOnline and showStatus:
|
|
||||||
italic(class="avatar-presence online")
|
|
||||||
|
|
||||||
proc renderUserMention*(user: User): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
a(class="user-mention",
|
|
||||||
href=makeUri("/profile/" & user.name),
|
|
||||||
onClick=anchorCB):
|
|
||||||
text "@" & user.name
|
|
||||||
|
|
||||||
proc renderUserRank*(user: User): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
case user.rank
|
|
||||||
of Spammer, Troll, Banned:
|
|
||||||
italic(class="fas fa-eye-ban",
|
|
||||||
title="User is banned")
|
|
||||||
of Rank.User, EmailUnconfirmed:
|
|
||||||
span()
|
|
||||||
of Moderated:
|
|
||||||
italic(class="fas fa-eye-slash",
|
|
||||||
title="User is moderated")
|
|
||||||
of Moderator:
|
|
||||||
italic(class="fas fa-shield-alt",
|
|
||||||
title="User is a moderator")
|
|
||||||
of Admin:
|
|
||||||
italic(class="fas fa-chess-knight",
|
|
||||||
title="User is an admin")
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
|
|
||||||
when defined(js):
|
|
||||||
import sugar
|
|
||||||
|
|
||||||
include karax/prelude
|
|
||||||
import karax/[vstyles]
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
import user
|
|
||||||
type
|
|
||||||
UserMenu* = ref object
|
|
||||||
shown: bool
|
|
||||||
user: User
|
|
||||||
onLogout: proc ()
|
|
||||||
|
|
||||||
proc newUserMenu*(onLogout: proc ()): UserMenu =
|
|
||||||
UserMenu(
|
|
||||||
shown: false,
|
|
||||||
onLogout: onLogout
|
|
||||||
)
|
|
||||||
|
|
||||||
proc onClick(e: Event, n: VNode, state: UserMenu) =
|
|
||||||
state.shown = not state.shown
|
|
||||||
|
|
||||||
proc render*(state: UserMenu, user: User): VNode =
|
|
||||||
result = buildHtml():
|
|
||||||
tdiv(id="profile-btn"):
|
|
||||||
figure(class="avatar c-hand",
|
|
||||||
onClick=(e: Event, n: VNode) => onClick(e, n, state)):
|
|
||||||
img(src=user.avatarUrl, title=user.name)
|
|
||||||
if user.isOnline:
|
|
||||||
italic(class="avatar-presense online")
|
|
||||||
|
|
||||||
tdiv(style=style([
|
|
||||||
(StyleAttr.width, kstring"999999px"),
|
|
||||||
(StyleAttr.height, kstring"999999px"),
|
|
||||||
(StyleAttr.position, kstring"absolute"),
|
|
||||||
(StyleAttr.left, kstring"0"),
|
|
||||||
(StyleAttr.top, kstring"0"),
|
|
||||||
(
|
|
||||||
StyleAttr.display,
|
|
||||||
if state.shown: kstring"block" else: kstring"none"
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
onClick=(e: Event, n: VNode) => (state.shown = false))
|
|
||||||
|
|
||||||
ul(class="menu menu-right", style=style(
|
|
||||||
StyleAttr.display, if state.shown: "inherit" else: "none"
|
|
||||||
)):
|
|
||||||
li(class="menu-item"):
|
|
||||||
tdiv(class="tile tile-centered"):
|
|
||||||
tdiv(class="tile-icon"):
|
|
||||||
img(class="avatar", src=user.avatarUrl,
|
|
||||||
title=user.name)
|
|
||||||
tdiv(id="profile-name", class="tile-content"):
|
|
||||||
text user.name
|
|
||||||
li(class="divider")
|
|
||||||
li(class="menu-item"):
|
|
||||||
a(id="myprofile-btn",
|
|
||||||
href=makeUri("/profile/" & user.name)):
|
|
||||||
text "My profile"
|
|
||||||
li(class="menu-item c-hand"):
|
|
||||||
a(id="logout-btn",
|
|
||||||
onClick = (e: Event, n: VNode) =>
|
|
||||||
(state.shown=false; state.onLogout())):
|
|
||||||
text "Logout"
|
|
||||||
73
src/fts.sql
73
src/fts.sql
|
|
@ -1,73 +0,0 @@
|
||||||
-- selects just threads,
|
|
||||||
-- those where title doesn't coinside with some of its posts' titles
|
|
||||||
-- by now selects only the threads title (no post snippet)
|
|
||||||
SELECT
|
|
||||||
thread_id,
|
|
||||||
snippet(thread_fts, '<b>', '</b>', '<b>...</b>') AS thread,
|
|
||||||
post_id,
|
|
||||||
post_content,
|
|
||||||
cdate,
|
|
||||||
person.id,
|
|
||||||
person.name AS author,
|
|
||||||
person.email AS email,
|
|
||||||
strftime('%s', person.lastOnline) AS lastOnline,
|
|
||||||
strftime('%s', person.previousVisitAt) AS previousVisitAt,
|
|
||||||
person.status AS status,
|
|
||||||
person.isDeleted as person_isDeleted,
|
|
||||||
0 AS what
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
thread_fts.id AS thread_id,
|
|
||||||
post.id AS post_id,
|
|
||||||
post.content AS post_content,
|
|
||||||
strftime('%s', post.creation) AS cdate,
|
|
||||||
MIN(post.creation) AS cdate,
|
|
||||||
post.author AS author_id
|
|
||||||
FROM thread_fts
|
|
||||||
JOIN post ON post.thread=thread_id
|
|
||||||
WHERE thread_fts MATCH ?
|
|
||||||
GROUP BY thread_id, post_id
|
|
||||||
HAVING thread_id NOT IN (
|
|
||||||
SELECT thread
|
|
||||||
FROM post_fts JOIN post USING(id)
|
|
||||||
WHERE post_fts MATCH ?
|
|
||||||
)
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
)
|
|
||||||
JOIN thread_fts ON thread_fts.id=thread_id
|
|
||||||
JOIN person ON person.id=author_id
|
|
||||||
WHERE thread_fts MATCH ?
|
|
||||||
UNION
|
|
||||||
-- the main query, selects posts
|
|
||||||
SELECT
|
|
||||||
thread.id AS thread_id,
|
|
||||||
thread.name AS thread,
|
|
||||||
post.id AS post_id,
|
|
||||||
CASE what WHEN 1
|
|
||||||
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,
|
|
||||||
strftime('%s', person.previousVisitAt) AS previousVisitAt,
|
|
||||||
person.status AS status,
|
|
||||||
person.isDeleted as person_isDeleted,
|
|
||||||
what
|
|
||||||
FROM post_fts JOIN (
|
|
||||||
-- inner query, selects ids of matching posts, orders and limits them,
|
|
||||||
-- so snippets only for limited count of posts are created (in outer query)
|
|
||||||
SELECT id, strftime('%s', post.creation) AS cdate, thread, 1 AS what, post.author AS author
|
|
||||||
FROM post_fts JOIN post USING(id)
|
|
||||||
WHERE post_fts.content MATCH ?
|
|
||||||
ORDER BY what, cdate DESC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
) AS post USING(id)
|
|
||||||
JOIN thread ON thread.id=thread
|
|
||||||
JOIN person ON person.id=author
|
|
||||||
WHERE post_fts MATCH ?
|
|
||||||
ORDER BY what ASC, cdate DESC
|
|
||||||
LIMIT 300 -- hardcoded limit just in case
|
|
||||||
;
|
|
||||||
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import db_sqlite, strutils
|
|
||||||
|
|
||||||
let origFile = "nimforum.db-21-05-18"
|
|
||||||
let targetFile = "nimforum-blank.db"
|
|
||||||
|
|
||||||
let orig = open(connection=origFile, user="", password="",
|
|
||||||
database="nimforum")
|
|
||||||
let target = open(connection=targetFile, user="", password="",
|
|
||||||
database="nimforum")
|
|
||||||
|
|
||||||
block:
|
|
||||||
let fields = "id, name, views, modified"
|
|
||||||
for thread in getAllRows(orig, sql("select $1 from thread;" % fields)):
|
|
||||||
target.exec(
|
|
||||||
sql("""
|
|
||||||
insert into thread($1)
|
|
||||||
values (?, ?, ?, ?)
|
|
||||||
""" % fields),
|
|
||||||
thread
|
|
||||||
)
|
|
||||||
|
|
||||||
block:
|
|
||||||
let fields = "id, name, password, email, creation, salt, status, lastOnline"
|
|
||||||
for person in getAllRows(orig, sql("select $1 from person;" % fields)):
|
|
||||||
target.exec(
|
|
||||||
sql("""
|
|
||||||
insert into person($1)
|
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""" % fields),
|
|
||||||
person
|
|
||||||
)
|
|
||||||
|
|
||||||
block:
|
|
||||||
let fields = "id, author, ip, content, thread, creation"
|
|
||||||
for post in getAllRows(orig, sql("select $1 from post;" % fields)):
|
|
||||||
target.exec(
|
|
||||||
sql("""
|
|
||||||
insert into post($1)
|
|
||||||
values (?, ?, ?, ?, ?, ?)
|
|
||||||
""" % fields),
|
|
||||||
post
|
|
||||||
)
|
|
||||||
|
|
||||||
block:
|
|
||||||
let fields = "id, name"
|
|
||||||
for t in getAllRows(orig, sql("select $1 from thread_fts;" % fields)):
|
|
||||||
target.exec(
|
|
||||||
sql("""
|
|
||||||
insert into thread_fts($1)
|
|
||||||
values (?, ?)
|
|
||||||
""" % fields),
|
|
||||||
t
|
|
||||||
)
|
|
||||||
|
|
||||||
block:
|
|
||||||
let fields = "id, content"
|
|
||||||
for p in getAllRows(orig, sql("select $1 from post_fts;" % fields)):
|
|
||||||
target.exec(
|
|
||||||
sql("""
|
|
||||||
insert into post_fts($1)
|
|
||||||
values (?, ?)
|
|
||||||
""" % fields),
|
|
||||||
p
|
|
||||||
)
|
|
||||||
|
|
||||||
echo("Imported!")
|
|
||||||
107
src/main.tmpl
107
src/main.tmpl
|
|
@ -1,107 +0,0 @@
|
||||||
#? stdtmpl | standard
|
|
||||||
#template `!`(idx: untyped): untyped =
|
|
||||||
# row[idx]
|
|
||||||
#end template
|
|
||||||
#proc genRSSHeaders(c: TForumData): string =
|
|
||||||
# result = ""
|
|
||||||
<link href="${c.req.makeUri("/threadActivity.xml")}" title="Thread activity"
|
|
||||||
type="application/atom+xml" rel="alternate">
|
|
||||||
<link href="${c.req.makeUri("/postActivity.xml")}" title="Post activity"
|
|
||||||
type="application/atom+xml" rel="alternate">
|
|
||||||
#end proc
|
|
||||||
#
|
|
||||||
#proc genThreadsRSS(c: TForumData): string =
|
|
||||||
# result = ""
|
|
||||||
# const query = sql"""SELECT A.id, A.name,
|
|
||||||
# strftime('%Y-%m-%dT%H:%M:%SZ', (A.modified)),
|
|
||||||
# COUNT(B.id), C.name, B.content, B.id
|
|
||||||
# FROM thread AS A, post AS B, person AS C
|
|
||||||
# WHERE A.id = b.thread AND B.author = C.id
|
|
||||||
# GROUP BY B.thread
|
|
||||||
# ORDER BY modified DESC LIMIT ?"""
|
|
||||||
# const threadId = 0
|
|
||||||
# const name = 1
|
|
||||||
# const threadDate = 2
|
|
||||||
# const postCount = 3
|
|
||||||
# const postAuthor = 4
|
|
||||||
# const postContent = 5
|
|
||||||
# const postId = 6
|
|
||||||
# let frontQuery = c.req.makeUri("/")
|
|
||||||
# let recent = getValue(db, sql"""SELECT
|
|
||||||
# strftime('%Y-%m-%dT%H:%M:%SZ', (modified)) FROM thread
|
|
||||||
# ORDER BY modified DESC LIMIT 1""")
|
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
||||||
<title>${config.name} thread activity</title>
|
|
||||||
<link href="${c.req.makeUri("/threadActivity.xml")}" rel="self" />
|
|
||||||
<link href="${frontQuery}" />
|
|
||||||
<id>${frontQuery}</id>
|
|
||||||
<updated>${recent}</updated>
|
|
||||||
# for row in rows(db, query, 10):
|
|
||||||
<entry>
|
|
||||||
<title>${xmlEncode(!name)}</title>
|
|
||||||
<id>urn:entry:${!threadid}</id>
|
|
||||||
# let url = c.genThreadUrl(threadid = !threadid) &
|
|
||||||
# "#" & !postId
|
|
||||||
<link rel="alternate" type="text/html"
|
|
||||||
href="${c.req.makeUri(url)}"/>
|
|
||||||
<published>${!threadDate}</published>
|
|
||||||
<updated>${!threadDate}</updated>
|
|
||||||
<author><name>${xmlEncode(!postAuthor)}</name></author>
|
|
||||||
<content type="html"
|
|
||||||
>Posts ${!postCount}, ${xmlEncode(!postAuthor)} said:
|
|
||||||
<p>
|
|
||||||
${xmlEncode(rstToHtml(!postContent))}</content>
|
|
||||||
</entry>
|
|
||||||
# end for
|
|
||||||
</feed>
|
|
||||||
#end proc
|
|
||||||
#
|
|
||||||
#proc genPostsRSS(c: TForumData): string =
|
|
||||||
# result = ""
|
|
||||||
# const query = sql"""SELECT A.id, B.name, A.content, A.thread, T.name,
|
|
||||||
# strftime('%Y-%m-%dT%H:%M:%SZ', A.creation),
|
|
||||||
# A.creation, COUNT(C.id)
|
|
||||||
# FROM post AS A, person AS B, post AS C, thread AS T
|
|
||||||
# WHERE A.author = B.id AND A.thread = C.thread AND C.id <= A.id
|
|
||||||
# AND T.id = A.thread
|
|
||||||
# GROUP BY A.id
|
|
||||||
# ORDER BY A.creation DESC LIMIT 10"""
|
|
||||||
# const postId = 0
|
|
||||||
# const postAuthor = 1
|
|
||||||
# const postContent = 2
|
|
||||||
# const postThread = 3
|
|
||||||
# const postHeader = 4
|
|
||||||
# const postRssDate = 5
|
|
||||||
# const postHumanDate = 6
|
|
||||||
# const postPosition = 7
|
|
||||||
# let frontQuery = c.req.makeUri("/")
|
|
||||||
# let recent = getValue(db, sql"""SELECT
|
|
||||||
# strftime('%Y-%m-%dT%H:%M:%SZ', creation) FROM post
|
|
||||||
# ORDER BY creation DESC LIMIT 1""")
|
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
||||||
<title>${config.name} post activity</title>
|
|
||||||
<link href="${c.req.makeUri("/postActivity.xml")}" rel="self" />
|
|
||||||
<link href="${frontQuery}" />
|
|
||||||
<id>${frontQuery}</id>
|
|
||||||
<updated>${recent}</updated>
|
|
||||||
# for row in rows(db, query):
|
|
||||||
<entry>
|
|
||||||
<title>${xmlEncode(!postHeader)}</title>
|
|
||||||
<id>urn:entry:${!postId}</id>
|
|
||||||
# let url = c.genThreadUrl(threadid = !postThread) &
|
|
||||||
# "#" & !postId
|
|
||||||
<link rel="alternate" type="text/html"
|
|
||||||
href="${c.req.makeUri(url)}"/>
|
|
||||||
<published>${!postRssDate}</published>
|
|
||||||
<updated>${!postRssDate}</updated>
|
|
||||||
<author><name>${xmlEncode(!postAuthor)}</name></author>
|
|
||||||
<content type="html"
|
|
||||||
>On ${xmlEncode(!postHumanDate)}, ${xmlEncode(!postAuthor)} said:
|
|
||||||
<p>
|
|
||||||
${xmlEncode(rstToHtml(!postContent))}</content>
|
|
||||||
</entry>
|
|
||||||
# end for
|
|
||||||
</feed>
|
|
||||||
#end proc
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
|
|
||||||
# we need the documentation generator of the compiler:
|
|
||||||
path="$lib/packages/docutils"
|
|
||||||
path="$nim"
|
|
||||||
|
|
||||||
-d:ssl
|
|
||||||
|
|
||||||
# --threads:on
|
|
||||||
# --threadAnalysis:off
|
|
||||||
|
|
@ -1,384 +0,0 @@
|
||||||
#
|
|
||||||
#
|
|
||||||
# The Nim Forum
|
|
||||||
# (c) Copyright 2018 Andreas Rumpf, Dominik Picheta
|
|
||||||
# Look at license.txt for more info.
|
|
||||||
# All rights reserved.
|
|
||||||
#
|
|
||||||
# Script to initialise the nimforum.
|
|
||||||
|
|
||||||
import strutils, db_sqlite, os, times, json, options, terminal
|
|
||||||
|
|
||||||
import auth, frontend/user
|
|
||||||
|
|
||||||
proc backup(path: string, contents: Option[string]=none[string]()) =
|
|
||||||
if existsFile(path):
|
|
||||||
if contents.isSome() and readFile(path) == contents.get():
|
|
||||||
# Don't backup if the files are equivalent.
|
|
||||||
echo("Not backing up because new file is the same.")
|
|
||||||
return
|
|
||||||
|
|
||||||
let backupPath = path & "." & $getTime().toUnix()
|
|
||||||
echo(path, " already exists. Moving to ", backupPath)
|
|
||||||
moveFile(path, backupPath)
|
|
||||||
|
|
||||||
proc 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],
|
|
||||||
filename="nimforum.db") =
|
|
||||||
let
|
|
||||||
path = getCurrentDir() / filename
|
|
||||||
isTest = "-test" in filename
|
|
||||||
isDev = "-dev" in filename
|
|
||||||
|
|
||||||
if not isDev and not isTest:
|
|
||||||
backup(path)
|
|
||||||
|
|
||||||
removeFile(path)
|
|
||||||
|
|
||||||
var db = open(connection=path, user="", password="",
|
|
||||||
database="nimforum")
|
|
||||||
|
|
||||||
const
|
|
||||||
userNameType = "varchar(20)"
|
|
||||||
passwordType = "varchar(300)"
|
|
||||||
emailType = "varchar(254)" # https://stackoverflow.com/a/574698/492186
|
|
||||||
|
|
||||||
# -- Category
|
|
||||||
|
|
||||||
db.exec(sql"""
|
|
||||||
create table category(
|
|
||||||
id integer primary key,
|
|
||||||
name varchar(100) not null,
|
|
||||||
description varchar(500) not null,
|
|
||||||
color varchar(10) not null
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
|
||||||
db.exec(sql"""
|
|
||||||
insert into category (id, name, description, color)
|
|
||||||
values (0, 'Unsorted', 'No category has been chosen yet.', '');
|
|
||||||
""")
|
|
||||||
|
|
||||||
# -- Thread
|
|
||||||
|
|
||||||
db.exec(sql"""
|
|
||||||
create table thread(
|
|
||||||
id integer primary key,
|
|
||||||
name varchar(100) not null,
|
|
||||||
views integer not null,
|
|
||||||
modified timestamp not null default (DATETIME('now')),
|
|
||||||
category integer not null default 0,
|
|
||||||
isLocked boolean not null default 0,
|
|
||||||
solution integer,
|
|
||||||
isDeleted boolean not null default 0,
|
|
||||||
isPinned boolean not null default 0,
|
|
||||||
|
|
||||||
foreign key (category) references category(id),
|
|
||||||
foreign key (solution) references post(id)
|
|
||||||
);""", [])
|
|
||||||
|
|
||||||
db.exec(sql"""
|
|
||||||
create unique index ThreadNameIx on thread (name);
|
|
||||||
""", [])
|
|
||||||
|
|
||||||
# -- Person
|
|
||||||
|
|
||||||
db.exec(sql("""
|
|
||||||
create table person(
|
|
||||||
id integer primary key,
|
|
||||||
name $# not null,
|
|
||||||
password $# not null,
|
|
||||||
email $# not null,
|
|
||||||
creation timestamp not null default (DATETIME('now')),
|
|
||||||
salt varbin(128) not null,
|
|
||||||
status varchar(30) not null,
|
|
||||||
lastOnline timestamp not null default (DATETIME('now')),
|
|
||||||
previousVisitAt timestamp not null default (DATETIME('now')),
|
|
||||||
isDeleted boolean not null default 0,
|
|
||||||
needsPasswordReset boolean not null default 0
|
|
||||||
);""" % [userNameType, passwordType, emailType]), [])
|
|
||||||
|
|
||||||
db.exec(sql"""
|
|
||||||
create unique index UserNameIx on person (name);
|
|
||||||
""", [])
|
|
||||||
db.exec sql"create index PersonStatusIdx on person(status);"
|
|
||||||
|
|
||||||
# Create default user.
|
|
||||||
db.createUser(admin, Admin)
|
|
||||||
|
|
||||||
# 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"""
|
|
||||||
insert into category (name, description, color)
|
|
||||||
values ('Libraries', 'Libraries and library development', '0198E1'),
|
|
||||||
('Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
|
|
||||||
('Fun', 'Posts that are just for fun', '00897B'),
|
|
||||||
('Potential Issues', 'Potential Nim compiler issues', 'E53935');
|
|
||||||
""")
|
|
||||||
|
|
||||||
# -- Post
|
|
||||||
|
|
||||||
db.exec(sql"""
|
|
||||||
create table post(
|
|
||||||
id integer primary key,
|
|
||||||
author integer not null,
|
|
||||||
ip inet not null,
|
|
||||||
content varchar(1000) not null,
|
|
||||||
thread integer not null,
|
|
||||||
creation timestamp not null default (DATETIME('now')),
|
|
||||||
isDeleted boolean not null default 0,
|
|
||||||
replyingTo integer,
|
|
||||||
|
|
||||||
foreign key (thread) references thread(id),
|
|
||||||
foreign key (author) references person(id),
|
|
||||||
foreign key (replyingTo) references post(id)
|
|
||||||
);""", [])
|
|
||||||
|
|
||||||
db.exec sql"create index PostByAuthorIdx on post(thread, author);"
|
|
||||||
|
|
||||||
db.exec(sql"""
|
|
||||||
create table postRevision(
|
|
||||||
id integer primary key,
|
|
||||||
creation timestamp not null default (DATETIME('now')),
|
|
||||||
original integer not null,
|
|
||||||
content varchar(1000) not null,
|
|
||||||
|
|
||||||
foreign key (original) references post(id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# -- Session
|
|
||||||
|
|
||||||
db.exec(sql("""
|
|
||||||
create table session(
|
|
||||||
id integer primary key,
|
|
||||||
ip inet not null,
|
|
||||||
key $# not null,
|
|
||||||
userid integer not null,
|
|
||||||
lastModified timestamp not null default (DATETIME('now')),
|
|
||||||
foreign key (userid) references person(id)
|
|
||||||
);""" % [passwordType]), [])
|
|
||||||
|
|
||||||
# -- Likes
|
|
||||||
|
|
||||||
db.exec(sql("""
|
|
||||||
create table like(
|
|
||||||
id integer primary key,
|
|
||||||
author integer not null,
|
|
||||||
post integer not null,
|
|
||||||
creation timestamp not null default (DATETIME('now')),
|
|
||||||
|
|
||||||
foreign key (author) references person(id),
|
|
||||||
foreign key (post) references post(id)
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# -- Report
|
|
||||||
|
|
||||||
db.exec(sql("""
|
|
||||||
create table report(
|
|
||||||
id integer primary key,
|
|
||||||
author integer not null,
|
|
||||||
post integer not null,
|
|
||||||
kind varchar(30) not null,
|
|
||||||
content varchar(500) not null default '',
|
|
||||||
|
|
||||||
foreign key (author) references person(id),
|
|
||||||
foreign key (post) references post(id)
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# -- FTS
|
|
||||||
|
|
||||||
if not db.tryExec(sql"""
|
|
||||||
CREATE VIRTUAL TABLE thread_fts USING fts4 (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL
|
|
||||||
);""", []):
|
|
||||||
echo "thread_fts table already exists or fts4 not supported"
|
|
||||||
else:
|
|
||||||
db.exec(sql"""
|
|
||||||
INSERT INTO thread_fts
|
|
||||||
SELECT id, name FROM thread;
|
|
||||||
""", [])
|
|
||||||
if not db.tryExec(sql"""
|
|
||||||
CREATE VIRTUAL TABLE post_fts USING fts4 (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
content VARCHAR(1000) NOT NULL
|
|
||||||
);""", []):
|
|
||||||
echo "post_fts table already exists or fts4 not supported"
|
|
||||||
else:
|
|
||||||
db.exec(sql"""
|
|
||||||
INSERT INTO post_fts
|
|
||||||
SELECT id, content FROM post;
|
|
||||||
""", [])
|
|
||||||
|
|
||||||
close(db)
|
|
||||||
|
|
||||||
proc initialiseConfig(
|
|
||||||
name, title, hostname: string,
|
|
||||||
recaptcha: tuple[siteKey, secretKey: string],
|
|
||||||
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
|
|
||||||
isDev: bool,
|
|
||||||
dbPath: string,
|
|
||||||
ga: string=""
|
|
||||||
) =
|
|
||||||
let path = getCurrentDir() / "forum.json"
|
|
||||||
|
|
||||||
var j = %{
|
|
||||||
"name": %name,
|
|
||||||
"title": %title,
|
|
||||||
"hostname": %hostname,
|
|
||||||
"recaptchaSiteKey": %recaptcha.siteKey,
|
|
||||||
"recaptchaSecretKey": %recaptcha.secretKey,
|
|
||||||
"smtpAddress": %smtp.address,
|
|
||||||
"smtpUser": %smtp.user,
|
|
||||||
"smtpPassword": %smtp.password,
|
|
||||||
"smtpFromAddr": %smtp.fromAddr,
|
|
||||||
"smtpTls": %smtp.tls,
|
|
||||||
"isDev": %isDev,
|
|
||||||
"dbPath": %dbPath
|
|
||||||
}
|
|
||||||
if ga.len > 0:
|
|
||||||
j["ga"] = %ga
|
|
||||||
|
|
||||||
backup(path, some(pretty(j)))
|
|
||||||
writeFile(path, pretty(j))
|
|
||||||
|
|
||||||
proc question(q: string): string =
|
|
||||||
while result.len == 0:
|
|
||||||
stdout.write(q)
|
|
||||||
result = stdin.readLine()
|
|
||||||
|
|
||||||
proc setup() =
|
|
||||||
echo("""
|
|
||||||
Welcome to the NimForum setup script. Please answer the following questions.
|
|
||||||
These can be changed later in the generated forum.json file.
|
|
||||||
""")
|
|
||||||
|
|
||||||
let name = question("Forum full name: ")
|
|
||||||
let title = question("Forum short name: ")
|
|
||||||
|
|
||||||
let hostname = question("Forum hostname: ")
|
|
||||||
|
|
||||||
let adminUser = question("Admin username: ")
|
|
||||||
let adminPass = readPasswordFromStdin("Admin password: ")
|
|
||||||
let adminEmail = question("Admin email: ")
|
|
||||||
|
|
||||||
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 " &
|
|
||||||
"and then answer these questions: https://www.google.com/recaptcha/admin")
|
|
||||||
let recaptchaSiteKey = question("Recaptcha site key: ")
|
|
||||||
let recaptchaSecretKey = question("Recaptcha secret key: ")
|
|
||||||
|
|
||||||
|
|
||||||
echo("The following questions are related to smtp. You must set up a \n" &
|
|
||||||
"mailing server for your forum or use an external service.")
|
|
||||||
let smtpAddress = question("SMTP address (eg: mail.hostname.com): ")
|
|
||||||
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.")
|
|
||||||
stdout.write("Google Analytics (eg: UA-12345678-1): ")
|
|
||||||
let ga = stdin.readLine().strip()
|
|
||||||
|
|
||||||
let dbPath = "nimforum.db"
|
|
||||||
initialiseConfig(
|
|
||||||
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
|
|
||||||
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
|
|
||||||
dbPath, ga
|
|
||||||
)
|
|
||||||
|
|
||||||
initialiseDb(
|
|
||||||
admin=(adminUser, adminPass, adminEmail),
|
|
||||||
dbPath
|
|
||||||
)
|
|
||||||
|
|
||||||
echo("Setup complete!")
|
|
||||||
|
|
||||||
proc echoHelp() =
|
|
||||||
quit("""
|
|
||||||
Usage: setup_nimforum opts
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--setup Performs first time setup for end users.
|
|
||||||
|
|
||||||
Development options:
|
|
||||||
--dev Creates a new development DB and config.
|
|
||||||
--test Creates a new test DB and config.
|
|
||||||
--blank Creates a new blank DB.
|
|
||||||
""")
|
|
||||||
|
|
||||||
when isMainModule:
|
|
||||||
if paramCount() > 0:
|
|
||||||
case paramStr(1)
|
|
||||||
of "--dev":
|
|
||||||
let dbPath = "nimforum-dev.db"
|
|
||||||
echo("Initialising nimforum for development...")
|
|
||||||
initialiseConfig(
|
|
||||||
"Development Forum",
|
|
||||||
"Development Forum",
|
|
||||||
"localhost",
|
|
||||||
recaptcha=("", ""),
|
|
||||||
smtp=("", "", "", "", false),
|
|
||||||
isDev=true,
|
|
||||||
dbPath
|
|
||||||
)
|
|
||||||
|
|
||||||
initialiseDb(
|
|
||||||
admin=("admin", "admin", "admin@localhost.local"),
|
|
||||||
dbPath
|
|
||||||
)
|
|
||||||
of "--test":
|
|
||||||
let dbPath = "nimforum-test.db"
|
|
||||||
echo("Initialising nimforum for testing...")
|
|
||||||
initialiseConfig(
|
|
||||||
"Test Forum",
|
|
||||||
"Test Forum",
|
|
||||||
"localhost",
|
|
||||||
recaptcha=("", ""),
|
|
||||||
smtp=("", "", "", "", false),
|
|
||||||
isDev=true,
|
|
||||||
dbPath
|
|
||||||
)
|
|
||||||
|
|
||||||
initialiseDb(
|
|
||||||
admin=("admin", "admin", "admin@localhost.local"),
|
|
||||||
dbPath
|
|
||||||
)
|
|
||||||
of "--blank":
|
|
||||||
let dbPath = "nimforum-blank.db"
|
|
||||||
echo("Initialising blank DB...")
|
|
||||||
initialiseDb(
|
|
||||||
admin=("", "", ""),
|
|
||||||
dbPath
|
|
||||||
)
|
|
||||||
of "--setup":
|
|
||||||
setup()
|
|
||||||
else:
|
|
||||||
echoHelp()
|
|
||||||
else:
|
|
||||||
echoHelp()
|
|
||||||
|
|
||||||
|
|
||||||
187
src/utils.nim
187
src/utils.nim
|
|
@ -1,187 +0,0 @@
|
||||||
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
|
|
||||||
htmlparser, streams, parseutils, options, logging
|
|
||||||
from times import getTime, utc, format
|
|
||||||
|
|
||||||
# Used to be:
|
|
||||||
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
|
|
||||||
let
|
|
||||||
UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this.
|
|
||||||
|
|
||||||
import frontend/[karaxutils, error]
|
|
||||||
export parseInt
|
|
||||||
|
|
||||||
type
|
|
||||||
Config* = object
|
|
||||||
smtpAddress*: string
|
|
||||||
smtpPort*: int
|
|
||||||
smtpUser*: string
|
|
||||||
smtpPassword*: string
|
|
||||||
smtpFromAddr*: string
|
|
||||||
smtpTls*: bool
|
|
||||||
smtpSsl*: bool
|
|
||||||
mlistAddress*: string
|
|
||||||
recaptchaSecretKey*: string
|
|
||||||
recaptchaSiteKey*: string
|
|
||||||
isDev*: bool
|
|
||||||
dbPath*: string
|
|
||||||
hostname*: string
|
|
||||||
name*, title*: string
|
|
||||||
ga*: string
|
|
||||||
port*: int
|
|
||||||
|
|
||||||
ForumError* = object of Exception
|
|
||||||
data*: PostError
|
|
||||||
|
|
||||||
proc newForumError*(message: string,
|
|
||||||
fields: seq[string] = @[]): ref ForumError =
|
|
||||||
new(result)
|
|
||||||
result.msg = message
|
|
||||||
result.data =
|
|
||||||
PostError(
|
|
||||||
errorFields: fields,
|
|
||||||
message: message
|
|
||||||
)
|
|
||||||
|
|
||||||
var docConfig: StringTableRef
|
|
||||||
|
|
||||||
docConfig = rstgen.defaultConfig()
|
|
||||||
docConfig["doc.listing_start"] = "<pre class='code' data-lang='$2'><code>"
|
|
||||||
docConfig["doc.listing_end"] = "</code><div class='code-buttons'><button class='btn btn-primary btn-sm'>Run</button></div></pre>"
|
|
||||||
|
|
||||||
proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
|
|
||||||
result = Config(smtpAddress: "", smtpPort: 25, smtpUser: "",
|
|
||||||
smtpPassword: "", mlistAddress: "")
|
|
||||||
let root = parseFile(filename)
|
|
||||||
result.smtpAddress = root{"smtpAddress"}.getStr("")
|
|
||||||
result.smtpPort = root{"smtpPort"}.getInt(25)
|
|
||||||
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("")
|
|
||||||
result.isDev = root{"isDev"}.getBool()
|
|
||||||
result.dbPath = root{"dbPath"}.getStr("nimforum.db")
|
|
||||||
result.hostname = root["hostname"].getStr()
|
|
||||||
result.name = root["name"].getStr()
|
|
||||||
result.title = root["title"].getStr()
|
|
||||||
result.ga = root{"ga"}.getStr()
|
|
||||||
result.port = root{"port"}.getInt(5000)
|
|
||||||
|
|
||||||
proc processGT(n: XmlNode, tag: string): (int, XmlNode, string) =
|
|
||||||
result = (0, newElement(tag), tag)
|
|
||||||
if n.kind == xnElement and len(n) == 1 and n[0].kind == xnElement:
|
|
||||||
return processGT(n[0], if n[0].kind == xnElement: n[0].tag else: tag)
|
|
||||||
|
|
||||||
var countGT = true
|
|
||||||
for c in items(n):
|
|
||||||
case c.kind
|
|
||||||
of xnText:
|
|
||||||
if c.text == ">" and countGT:
|
|
||||||
result[0].inc()
|
|
||||||
else:
|
|
||||||
countGT = false
|
|
||||||
result[1].add(newText(c.text))
|
|
||||||
else:
|
|
||||||
result[1].add(c)
|
|
||||||
|
|
||||||
proc blockquoteFinish(currentBlockquote, newNode: var XmlNode, n: XmlNode) =
|
|
||||||
if currentBlockquote.len > 0:
|
|
||||||
#echo(currentBlockquote.repr)
|
|
||||||
newNode.add(currentBlockquote)
|
|
||||||
currentBlockquote = newElement("blockquote")
|
|
||||||
newNode.add(n)
|
|
||||||
|
|
||||||
proc processQuotes(node: XmlNode): XmlNode =
|
|
||||||
# Bolt on quotes.
|
|
||||||
# TODO: Yes, this is ugly. I wrote it quickly. PRs welcome ;)
|
|
||||||
result = newElement("div")
|
|
||||||
var currentBlockquote = newElement("blockquote")
|
|
||||||
for n in items(node):
|
|
||||||
case n.kind
|
|
||||||
of xnElement:
|
|
||||||
case n.tag
|
|
||||||
of "p":
|
|
||||||
let (nesting, contentNode, _) = processGT(n, "p")
|
|
||||||
if nesting > 0:
|
|
||||||
var bq = currentBlockquote
|
|
||||||
for i in 1 ..< nesting:
|
|
||||||
var newBq = bq.child("blockquote")
|
|
||||||
if newBq.isNil:
|
|
||||||
newBq = newElement("blockquote")
|
|
||||||
bq.add(newBq)
|
|
||||||
bq = newBq
|
|
||||||
bq.add(contentNode)
|
|
||||||
else:
|
|
||||||
blockquoteFinish(currentBlockquote, result, n)
|
|
||||||
else:
|
|
||||||
blockquoteFinish(currentBlockquote, result, n)
|
|
||||||
of xnText:
|
|
||||||
if n.text[0] == '\10':
|
|
||||||
result.add(n)
|
|
||||||
else:
|
|
||||||
blockquoteFinish(currentBlockquote, result, n)
|
|
||||||
else:
|
|
||||||
blockquoteFinish(currentBlockquote, result, n)
|
|
||||||
|
|
||||||
proc replaceMentions(node: XmlNode): seq[XmlNode] =
|
|
||||||
assert node.kind == xnText
|
|
||||||
result = @[]
|
|
||||||
|
|
||||||
var current = ""
|
|
||||||
var i = 0
|
|
||||||
while i < len(node.text):
|
|
||||||
i += parseUntil(node.text, current, {'@'}, i)
|
|
||||||
if i >= len(node.text): break
|
|
||||||
if node.text[i] == '@':
|
|
||||||
i.inc # Skip @
|
|
||||||
var username = ""
|
|
||||||
i += parseWhile(node.text, username, UsernameIdent, i)
|
|
||||||
|
|
||||||
if username.len == 0:
|
|
||||||
result.add(newText(current & "@"))
|
|
||||||
else:
|
|
||||||
let el = <>span(
|
|
||||||
class="user-mention",
|
|
||||||
data-username=username,
|
|
||||||
newText("@" & username)
|
|
||||||
)
|
|
||||||
|
|
||||||
result.add(newText(current))
|
|
||||||
current = ""
|
|
||||||
result.add(el)
|
|
||||||
|
|
||||||
result.add(newText(current))
|
|
||||||
|
|
||||||
proc processMentions(node: XmlNode): XmlNode =
|
|
||||||
case node.kind
|
|
||||||
of xnText:
|
|
||||||
result = newElement("span")
|
|
||||||
for child in replaceMentions(node):
|
|
||||||
result.add(child)
|
|
||||||
of xnElement:
|
|
||||||
case node.tag
|
|
||||||
of "pre", "code", "tt", "a":
|
|
||||||
return node
|
|
||||||
else:
|
|
||||||
result = newElement(node.tag)
|
|
||||||
result.attrs = node.attrs
|
|
||||||
for n in items(node):
|
|
||||||
result.add(processMentions(n))
|
|
||||||
else:
|
|
||||||
return node
|
|
||||||
|
|
||||||
proc rstToHtml*(content: string): string =
|
|
||||||
result = rstgen.rstToHtml(content, {roSupportMarkdown},
|
|
||||||
docConfig)
|
|
||||||
try:
|
|
||||||
var node = parseHtml(newStringStream(result))
|
|
||||||
if node.kind == xnElement:
|
|
||||||
node = processQuotes(node)
|
|
||||||
node = processMentions(node)
|
|
||||||
result = ""
|
|
||||||
add(result, node, indWidth=0, addNewLines=false)
|
|
||||||
except:
|
|
||||||
warn("Could not parse rst html.")
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import options, osproc, streams, threadpool, os, strformat, httpclient
|
|
||||||
|
|
||||||
import webdriver
|
|
||||||
|
|
||||||
proc runProcess(cmd: string) =
|
|
||||||
let p = startProcess(
|
|
||||||
cmd,
|
|
||||||
options={
|
|
||||||
poStdErrToStdOut,
|
|
||||||
poEvalCommand
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
let o = p.outputStream
|
|
||||||
while p.running and (not o.atEnd):
|
|
||||||
echo cmd.substr(0, 10), ": ", o.readLine()
|
|
||||||
|
|
||||||
p.close()
|
|
||||||
|
|
||||||
const backend = "forum"
|
|
||||||
const port = 5000
|
|
||||||
const baseUrl = "http://localhost:" & $port & "/"
|
|
||||||
template withBackend(body: untyped): untyped =
|
|
||||||
## Starts a new backend instance.
|
|
||||||
|
|
||||||
spawn runProcess("nimble -y testbackend")
|
|
||||||
defer:
|
|
||||||
discard execCmd("killall " & backend)
|
|
||||||
|
|
||||||
echo("Waiting for server...")
|
|
||||||
var success = false
|
|
||||||
for i in 0..5:
|
|
||||||
sleep(5000)
|
|
||||||
try:
|
|
||||||
let client = newHttpClient()
|
|
||||||
doAssert client.getContent(baseUrl).len > 0
|
|
||||||
success = true
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
echo("Failed to getContent")
|
|
||||||
|
|
||||||
doAssert success
|
|
||||||
|
|
||||||
body
|
|
||||||
|
|
||||||
import browsertests/[scenario1, 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")
|
|
||||||
|
|
||||||
# Create a fresh DB for the tester.
|
|
||||||
doAssert(execCmd("nimble testdb") == QuitSuccess)
|
|
||||||
|
|
||||||
doAssert(execCmd("nimble -y frontend") == QuitSuccess)
|
|
||||||
echo("Waiting for geckodriver to startup...")
|
|
||||||
sleep(5000)
|
|
||||||
|
|
||||||
try:
|
|
||||||
let driver = newWebDriver()
|
|
||||||
let session = driver.createSession()
|
|
||||||
|
|
||||||
withBackend:
|
|
||||||
scenario1.test(session, baseUrl)
|
|
||||||
threads.test(session, baseUrl)
|
|
||||||
categories.test(session, baseUrl)
|
|
||||||
issue181.test(session, baseUrl)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
except:
|
|
||||||
sleep(10000) # See if we can grab any more output.
|
|
||||||
raise
|
|
||||||
|
|
||||||
when isMainModule:
|
|
||||||
main()
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
--threads:on
|
|
||||||
--path:"../src/frontend"
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
import unittest, common
|
|
||||||
import webdriver
|
|
||||||
|
|
||||||
import karaxutils
|
|
||||||
|
|
||||||
proc selectCategory(session: Session, name: string) =
|
|
||||||
with session:
|
|
||||||
click "#category-selection .dropdown-toggle"
|
|
||||||
click "#category-selection ." & name
|
|
||||||
|
|
||||||
proc createCategory(session: Session, baseUrl, name, color, description: string) =
|
|
||||||
with session:
|
|
||||||
navigate baseUrl
|
|
||||||
click "#categories-btn"
|
|
||||||
|
|
||||||
ensureExists "#add-category"
|
|
||||||
|
|
||||||
click "#add-category .plus-btn"
|
|
||||||
|
|
||||||
clear "#add-category input[name='name']"
|
|
||||||
clear "#add-category input[name='description']"
|
|
||||||
|
|
||||||
sendKeys "#add-category input[name='name']", name
|
|
||||||
setColor "#add-category input[name='color']", color
|
|
||||||
sendKeys "#add-category input[name='description']", description
|
|
||||||
|
|
||||||
click "#add-category #add-category-btn"
|
|
||||||
|
|
||||||
checkText "#category-" & name.slug(), name
|
|
||||||
|
|
||||||
proc categoriesUserTests(session: Session, baseUrl: string) =
|
|
||||||
let
|
|
||||||
title = "Category Test"
|
|
||||||
content = "Choosing category test"
|
|
||||||
|
|
||||||
suite "user tests":
|
|
||||||
|
|
||||||
with session:
|
|
||||||
navigate baseUrl
|
|
||||||
login "user", "user"
|
|
||||||
|
|
||||||
setup:
|
|
||||||
with session:
|
|
||||||
navigate baseUrl
|
|
||||||
|
|
||||||
test "no category add available":
|
|
||||||
with session:
|
|
||||||
click "#new-thread-btn"
|
|
||||||
|
|
||||||
checkIsNone "#add-category"
|
|
||||||
|
|
||||||
test "no category add available category page":
|
|
||||||
with session:
|
|
||||||
click "#categories-btn"
|
|
||||||
checkIsNone "#add-category"
|
|
||||||
|
|
||||||
test "can create category thread":
|
|
||||||
with session:
|
|
||||||
click "#new-thread-btn"
|
|
||||||
sendKeys "#thread-title", title
|
|
||||||
|
|
||||||
selectCategory "fun"
|
|
||||||
sendKeys "#reply-textarea", content
|
|
||||||
|
|
||||||
click "#create-thread-btn"
|
|
||||||
checkText "#thread-title .category", "Fun"
|
|
||||||
|
|
||||||
navigate baseUrl
|
|
||||||
|
|
||||||
ensureExists title, LinkTextSelector
|
|
||||||
|
|
||||||
test "can create category thread and change category":
|
|
||||||
with session:
|
|
||||||
let newTitle = title & " Selection"
|
|
||||||
click "#new-thread-btn"
|
|
||||||
sendKeys "#thread-title", newTitle
|
|
||||||
|
|
||||||
selectCategory "fun"
|
|
||||||
sendKeys "#reply-textarea", content
|
|
||||||
|
|
||||||
click "#create-thread-btn"
|
|
||||||
checkText "#thread-title .category", "Fun"
|
|
||||||
|
|
||||||
selectCategory "announcements"
|
|
||||||
|
|
||||||
checkText "#thread-title .category", "Announcements"
|
|
||||||
|
|
||||||
# Make sure there is no error
|
|
||||||
checkIsNone "#thread-title .text-error"
|
|
||||||
|
|
||||||
navigate baseUrl
|
|
||||||
|
|
||||||
ensureExists newTitle, LinkTextSelector
|
|
||||||
|
|
||||||
test "can navigate to categories page":
|
|
||||||
with session:
|
|
||||||
click "#categories-btn"
|
|
||||||
|
|
||||||
ensureExists "#categories-list"
|
|
||||||
|
|
||||||
test "can view post under category":
|
|
||||||
with session:
|
|
||||||
|
|
||||||
# create a few threads
|
|
||||||
click "#new-thread-btn"
|
|
||||||
sendKeys "#thread-title", "Post 1"
|
|
||||||
|
|
||||||
selectCategory "fun"
|
|
||||||
sendKeys "#reply-textarea", "Post 1"
|
|
||||||
|
|
||||||
click "#create-thread-btn"
|
|
||||||
navigate baseUrl
|
|
||||||
|
|
||||||
|
|
||||||
click "#new-thread-btn"
|
|
||||||
sendKeys "#thread-title", "Post 2"
|
|
||||||
|
|
||||||
selectCategory "announcements"
|
|
||||||
sendKeys "#reply-textarea", "Post 2"
|
|
||||||
|
|
||||||
click "#create-thread-btn"
|
|
||||||
navigate baseUrl
|
|
||||||
|
|
||||||
|
|
||||||
click "#new-thread-btn"
|
|
||||||
sendKeys "#thread-title", "Post 3"
|
|
||||||
|
|
||||||
selectCategory "unsorted"
|
|
||||||
sendKeys "#reply-textarea", "Post 3"
|
|
||||||
|
|
||||||
click "#create-thread-btn"
|
|
||||||
navigate baseUrl
|
|
||||||
|
|
||||||
|
|
||||||
click "#categories-btn"
|
|
||||||
ensureExists "#categories-list"
|
|
||||||
|
|
||||||
click "#category-unsorted"
|
|
||||||
checkText "#threads-list .thread-title a", "Post 3"
|
|
||||||
for element in session.waitForElements("#threads-list .category-name"):
|
|
||||||
# Have to user "innerText" because elements are hidden on this page
|
|
||||||
assert element.getProperty("innerText") == "Unsorted"
|
|
||||||
|
|
||||||
selectCategory "announcements"
|
|
||||||
checkText "#threads-list .thread-title a", "Post 2"
|
|
||||||
for element in session.waitForElements("#threads-list .category-name"):
|
|
||||||
assert element.getProperty("innerText") == "Announcements"
|
|
||||||
|
|
||||||
selectCategory "fun"
|
|
||||||
checkText "#threads-list .thread-title a", "Post 1"
|
|
||||||
for element in session.waitForElements("#threads-list .category-name"):
|
|
||||||
assert element.getProperty("innerText") == "Fun"
|
|
||||||
|
|
||||||
session.logout()
|
|
||||||
|
|
||||||
proc categoriesAdminTests(session: Session, baseUrl: string) =
|
|
||||||
suite "admin tests":
|
|
||||||
with session:
|
|
||||||
navigate baseUrl
|
|
||||||
login "admin", "admin"
|
|
||||||
|
|
||||||
test "can create category via dropdown":
|
|
||||||
let
|
|
||||||
name = "Category Test"
|
|
||||||
color = "#720904"
|
|
||||||
description = "This is a description"
|
|
||||||
|
|
||||||
with session:
|
|
||||||
click "#new-thread-btn"
|
|
||||||
|
|
||||||
ensureExists "#add-category"
|
|
||||||
|
|
||||||
click "#add-category .plus-btn"
|
|
||||||
|
|
||||||
clear "#add-category input[name='name']"
|
|
||||||
clear "#add-category input[name='description']"
|
|
||||||
|
|
||||||
sendKeys "#add-category input[name='name']", name
|
|
||||||
setColor "#add-category input[name='color']", color
|
|
||||||
sendKeys "#add-category input[name='description']", description
|
|
||||||
|
|
||||||
click "#add-category #add-category-btn"
|
|
||||||
|
|
||||||
checkText "#category-selection .selected-category", name
|
|
||||||
|
|
||||||
test "can create category on category page":
|
|
||||||
let
|
|
||||||
name = "Category Test Page"
|
|
||||||
color = "#70B4D4"
|
|
||||||
description = "This is a description on category page"
|
|
||||||
|
|
||||||
with session:
|
|
||||||
createCategory baseUrl, name, color, description
|
|
||||||
|
|
||||||
test "category adding disabled on admin logout":
|
|
||||||
with session:
|
|
||||||
navigate(baseUrl & "c/0")
|
|
||||||
ensureExists "#add-category"
|
|
||||||
logout()
|
|
||||||
|
|
||||||
checkIsNone "#add-category"
|
|
||||||
navigate baseUrl
|
|
||||||
|
|
||||||
login "admin", "admin"
|
|
||||||
|
|
||||||
session.logout()
|
|
||||||
|
|
||||||
proc test*(session: Session, baseUrl: string) =
|
|
||||||
session.navigate(baseUrl)
|
|
||||||
|
|
||||||
categoriesUserTests(session, baseUrl)
|
|
||||||
categoriesAdminTests(session, baseUrl)
|
|
||||||
|
|
||||||
session.navigate(baseUrl)
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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,43 +0,0 @@
|
||||||
import unittest, common
|
|
||||||
|
|
||||||
import webdriver
|
|
||||||
|
|
||||||
proc test*(session: Session, baseUrl: string) =
|
|
||||||
session.navigate(baseUrl)
|
|
||||||
|
|
||||||
# Sanity checks
|
|
||||||
test "shows sign up":
|
|
||||||
session.checkText("#signup-btn", "Sign up")
|
|
||||||
|
|
||||||
test "shows log in":
|
|
||||||
session.checkText("#login-btn", "Log in")
|
|
||||||
|
|
||||||
test "is empty":
|
|
||||||
session.checkIsNone("tr > td.thread-title")
|
|
||||||
|
|
||||||
# Logging in
|
|
||||||
test "can login/logout":
|
|
||||||
with session:
|
|
||||||
login("admin", "admin")
|
|
||||||
|
|
||||||
# Check whether we can log out.
|
|
||||||
logout()
|
|
||||||
# Verify we have logged out by looking for the log in button.
|
|
||||||
ensureExists "#login-btn"
|
|
||||||
|
|
||||||
test "can register":
|
|
||||||
with session:
|
|
||||||
register("test", "test")
|
|
||||||
logout()
|
|
||||||
|
|
||||||
test "can't register same username with different case":
|
|
||||||
with session:
|
|
||||||
register "test1", "test1", verify = false
|
|
||||||
logout()
|
|
||||||
|
|
||||||
navigate baseUrl
|
|
||||||
|
|
||||||
register "TEst1", "test1", verify = false
|
|
||||||
|
|
||||||
ensureExists "#signup-form .has-error"
|
|
||||||
navigate baseUrl
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
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)
|
|
||||||
14
todo.txt
Normal file
14
todo.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
Version 1.0
|
||||||
|
-----------
|
||||||
|
|
||||||
|
- even better spam avoidance
|
||||||
|
- Implement automated testing.
|
||||||
|
- Implement an "edit account" feature.
|
||||||
|
- Implement a "who is online" view.
|
||||||
|
|
||||||
|
|
||||||
|
Version 2.0
|
||||||
|
-----------
|
||||||
|
|
||||||
|
- Some email notification/RSS feed system would be nice.
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue