diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index fde1b09..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -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 - diff --git a/.gitignore b/.gitignore index fe26a8a..3b9d320 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,9 @@ # Wildcard patterns. *.swp nimcache/ -*.db* +*.db # Specific paths /createdb /forum /nimforum.db - -# Binaries -forum -createdb -editdb - -.vscode -forum.json* -browsertester -setup_nimforum -buildcss -nimforum.css - -/src/frontend/forum.js diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 6ea9ea9..0000000 --- a/.gitmodules +++ /dev/null @@ -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 diff --git a/README.md b/README.md index d7dedb4..1ab73c6 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,28 @@ -# nimforum +nimforum +======== -NimForum is a light-weight forum implementation -with many similarities to Discourse. It is implemented in -the [Nim](https://nim-lang.org) programming -language and uses SQLite for its database. +This is Nimrod's forum. The code depends on the RST parser of the Nimrod +compiler and on Jester. The code generating captchas for registration uses the +[cairo module](http://nimrod-lang.org/cairo.html), which requires you to have +the [cairo library](http://cairographics.org) installed when you run the forum, +or you will be greeted by a cryptic error message similar to: -## Examples in the wild + $ ./forum could not load: libcairo.so(1.2) -[![forum.nim-lang.org](https://i.imgur.com/hdIF5Az.png)](https://forum.nim-lang.org) +If you are using macosx and have installed the ``cairo`` library through +[MacPorts](https://www.macports.org) you still need to add the library path to +your ``LD_LIBRARY_PATH`` environment variable. Example: -

forum.nim-lang.org

+ $ LD_LIBRARY_PATH=/opt/local/lib/ ./forum -## 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. +Replace ``/opt/local/lib`` with the correct path on your system. # Copyright -Copyright (c) 2012-2018 Andreas Rumpf, Dominik Picheta. +Copyright (c) 2012-2013 Andreas Rumpf, Dominik Picheta. All rights reserved. # License -NimForum is licensed under the MIT license. +Nimforum is licensed under the MIT license. diff --git a/cache.nim b/cache.nim new file mode 100644 index 0000000..8bc4274 --- /dev/null +++ b/cache.nim @@ -0,0 +1,32 @@ +import tables, uri +type + CacheInfo = object + valid: bool + value: string + + CacheHolder = ref object + caches: Table[string, CacheInfo] + +proc normalizePath(x: string): string = + let u = parseUri(x) + result = u.path & (if u.query != "": '?' & u.query else: "") + +proc newCacheHolder*(): CacheHolder = + new result + result.caches = initTable[string, CacheInfo]() + +proc invalidate*(cache: CacheHolder, name: string) = + cache.caches.mget(name.normalizePath()).valid = false + +proc invalidateAll*(cache: CacheHolder) = + for key, val in mpairs(cache.caches): + val.valid = false + +template get*(cache: CacheHolder, name: string, grabValue: expr): expr = + ## Check to see if the cache contains value for ``name``. If it does and the + ## cache is valid then doesn't recalculate it but returns the cached version. + mixin normalizePath + let nName = name.normalizePath() + if not (cache.caches.hasKey(nName) and cache.caches[nName].valid): + cache.caches[nName] = CacheInfo(valid: true, value: grabValue) + cache.caches[nName].value diff --git a/captchas.nim b/captchas.nim new file mode 100644 index 0000000..d95767b --- /dev/null +++ b/captchas.nim @@ -0,0 +1,39 @@ +# +# +# The Nimrod Forum +# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta +# Look at license.txt for more info. +# All rights reserved. +# + +import cairo, os, strutils, jester + +proc getCaptchaFilename*(i: int): string {.inline.} = + result = "public/captchas/capture_" & $i & ".png" + +proc getCaptchaUrl*(req: PRequest, i: int): string = + result = req.makeUri("/captchas/capture_" & $i & ".png", absolute = false) + +proc createCaptcha*(file, text: string) = + var surface = imageSurfaceCreate(FORMAT_ARGB32, int32(10*text.len), int32(10)) + var cr = create(surface) + + selectFontFace(cr, "serif", FONT_SLANT_NORMAL, FONT_WEIGHT_BOLD) + setFontSize(cr, 12.0) + + setSourceRgb(cr, 1.0, 0.5, 0.0) + moveTo(cr, 0.0, 10.0) + showText(cr, 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: + createCaptcha("test.png", "1+33") + + diff --git a/createdb.nim b/createdb.nim new file mode 100644 index 0000000..1566089 --- /dev/null +++ b/createdb.nim @@ -0,0 +1,125 @@ +# +# +# The Nimrod Forum +# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta +# Look at license.txt for more info. +# All rights reserved. +# + +import strutils, db_sqlite + +var db = open(connection="nimforum.db", user="postgres", password="", + database="nimforum") + +const + TUserName = "varchar(20)" + TPassword = "varchar(32)" + TEmail = "varchar(30)" + +db.exec(sql""" +create table if not exists thread( + id integer primary key, + name varchar(100) not null, + views integer not null, + modified timestamp not null default (DATETIME('now')) +);""", []) + +db.exec(sql""" +create unique index if not exists ThreadNameIx on thread (name); +""", []) + +db.exec(sql(""" +create table if not exists person( + id integer primary key, + name $# not null, + password $# not null, + email $# not null, + creation timestamp not null default (DATETIME('now')), + salt varbin(128) not null, + status integer not null, + admin bool default false, + lastOnline timestamp not null default (DATETIME('now')) +);""" % [TUserName, TPassword, TEmail]), []) +# echo "person table already exists" + +db.exec(sql(""" +alter table person +add ban varchar(128) not null default '' +""")) + +db.exec(sql""" +create unique index if not exists UserNameIx on person (name); +""", []) + +# ----------------------- Forum ------------------------------------------------ + + +if not db.tryExec(sql""" +create table if not exists post( + id integer primary key, + author integer not null, + ip inet not null, + header varchar(100) not null, + content varchar(1000) not null, + thread integer not null, + creation timestamp not null default (DATETIME('now')), + + foreign key (thread) references thread(id), + foreign key (author) references person(id) +);""", []): + echo "post table already exists" + +# -------------------- Session ------------------------------------------------- + +if not db.tryExec(sql(""" +create table if not exists session( + id integer primary key, + ip inet not null, + password $# not null, + userid integer not null, + lastModified timestamp not null default (DATETIME('now')), + foreign key (userid) references person(id) +);""" % [TPassword]), []): + echo "session table already exists" + +if not db.tryExec(sql""" +create table if not exists antibot( + id integer primary key, + ip inet not null, + answer varchar(30) not null, + created timestamp not null default (DATETIME('now')) +);""", []): + echo "antibot table already exists" + +# -------------------- Search -------------------------------------------------- + +if not db.tryExec(sql""" + CREATE VIRTUAL TABLE thread_fts USING fts4 ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL + );""", []): + echo "thread_fts table already exists or fts4 not supported" +else: + db.exec(sql""" + INSERT INTO thread_fts + SELECT id, name FROM thread; + """, []) +if not db.tryExec(sql""" + CREATE VIRTUAL TABLE post_fts USING fts4 ( + id INTEGER PRIMARY KEY, + header VARCHAR(100) NOT NULL, + content VARCHAR(1000) NOT NULL + );""", []): + echo "post_fts table already exists or fts4 not supported" +else: + db.exec(sql""" + INSERT INTO post_fts + SELECT id, header, content FROM post; + """, []) + + +# ------------------------------------------------------------------------------ + +#discard stdin.readline() + +close(db) diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index cb3191a..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml deleted file mode 100644 index 8657235..0000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: "3.7" - -services: - forum: - build: - context: ../ - dockerfile: ./docker/Dockerfile - volumes: - - "../:/app" - ports: - - "5000:5000" - entrypoint: "/app/docker/entrypoint.sh" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100755 index d8f5923..0000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -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 diff --git a/editdb.nim b/editdb.nim new file mode 100644 index 0000000..ea28f93 --- /dev/null +++ b/editdb.nim @@ -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) \ No newline at end of file diff --git a/forms.tmpl b/forms.tmpl new file mode 100644 index 0000000..cd0133d --- /dev/null +++ b/forms.tmpl @@ -0,0 +1,369 @@ +#! stdtmpl +# +#template `%`(idx: expr): expr {.immediate.} = +# row[idx] +#end template +# +# +#proc genThreadsList(c: var TForumData, count: var int): string = +# const query = sql"select id, name, views, modified from thread order by modified desc limit ?, ?" +# const threadId = 0 +# const name = 1 +# const views = 2 +# +# result = "" +# count = 0 +
+
+
+ Topic + + + +
+
+
Users
+
Details
+
+
+ Activity + + + +
+
+
+
+# for row in rows(db, query, $((c.pageNum-1) * ThreadsPerPage), $ThreadsPerPage): +# inc(count) +
+
+
+ ${xmlEncode(%name)} + ${genPagenumLocalNav(c, (%threadid).parseInt)} +
+
+ + #let users = getAllRows(db, + # sql("select distinct name, email from person where id in " & + # "(select author from post where thread = ?)"), %threadId) +
+
+ #for i in 0 .. min(6, users.len-1): + + #end for +
+
+ + #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 replyProfileUrl = c.req.makeUri("profile/", false) & + # xmlEncode(latestReplyAuthor) + +# let posts = getValue(db, sql"select count(*) from post where thread = ?", %threadId) +
+
${xmlEncode(%views)}
+
$posts
+
+ + #let latestReplyDate = getValue(db, sql("SELECT strftime('%s', " & + # "(select creation from post where id = (select max(id) from post where thread = ?)))"), %threadId) + #let timeStr = formatTimestamp(latestReplyDate.parseInt()) +
+
+ $latestReplyAuthor replied $timeStr +
+
+
+# end for +
+ +#end proc +# +# +#proc genPostPreview(c: var TForumData, +# title, content, author, date: string): string = +# result = "" + +
+
+
+
+ #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(author) + ${xmlEncode(author)} +
+
+
+
+ #try: + ${content.rstToHtml} + #except EParseError: + # c.errorMsg = getCurrentExceptionMsg() + #end + ${xmlEncode(date)} +
+
+
+
+#end proc +# +# +#proc genPostsList(c: var TForumData, threadId: string, count: var int): 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 limit ?, ?""" +# const postId = 0 +# const userName = 1 +# const postHeader = 2 +# const postContent = 3 +# const postCreation = 4 +# const postAuthor = 5 +# const userEmail = 6 +# result = "" +# count = 0 +# let posts = getAllRows(db, query, threadId, $((c.pageNum-1) * PostsPerPage), $PostsPerPage) +# if posts.len < 1: return "" +# end if +
+
+
+ forum index > + ${posts[0][postHeader]} +
+
+
+
+# for row in posts: +# inc(count) + +
+
+
+ #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName) +
${genGravatar(%userEmail)}
+ ${xmlEncode(%userName)} + #if c.userId == %postAuthor and c.currentPost.subject.len == 0: +
Edit post + #elif c.isAdmin and c.currentPost.subject.len == 0: +
Edit post + #end if +
+
+
+
+ #try: + ${(%postContent).rstToHtml} + #except EParseError: + # c.errorMsg = getCurrentExceptionMsg() + #end + ${xmlEncode(%postCreation)} +
+
+
+# end for +
+#end proc +# +# +#proc genFormPost(c: var TForumData, action: string, +# topText, title, content: string, isEdit: bool): string = +# result = "" +
+ +
+
+
+
+ forum index > + $topText +
+
+
+
+ #if action == "doreply": + ${HiddenField(c, "subject", title)} + #else: + ${FieldValid(c, "subject", "Subject:")} + ${TextWidget(c, "subject", title, maxlength=100)} +
+ #end if + ${FieldValid(c, "content", "Content:")}
+ ${TextAreaWidget(c, "content", content)}
+ ${FormSession(c, action)} + + # if isEdit: + Delete Post
+ # end if + #if c.errorMsg != "": +
+ $c.errorMsg +
+ #end if +
+ + + + Syntax Cheatsheet +
+
+#end proc +# +# +#proc genFormRegister(c: var TForumData): string = +# result = "" +
+
+
+ forum index > + Register +
+
+
+
+ + + + + + + + + + + + + + + + + +
${FieldValid(c, "name", "Username:")}${TextWidget(c, "name", reuseText, maxlength=20)}
${FieldValid(c, "new_password", "Password:")}
${FieldValid(c, "email", "E-Mail:")}${TextWidget(c, "email", reuseText, maxlength=30)}
${FieldValid(c, "antibot", "What is " & antibot(c) & "?")}${TextWidget(c, "antibot", "", maxlength=4)}
+ #if c.errorMsg != "": +
+ $c.errorMsg +
+ #end if + +
+#end proc +# +#proc genFormLogin(c: var TForumData): string = +# result = "" +# if not c.loggedIn: +
+ + + +
Username: +
Password: +
+ +
+ $c.loginErrorMsg +# else: + You're already logged in! +# end if +#end proc +# +# +#proc genListOnline(c: var TForumData, stats: TForumStats): string = +# result = "" +# var active: seq[string] = @[] +# for i in stats.activeUsers: +# active.add(i.nick) +# end for +# let profileUrl = c.req.makeUri("profile/", false) & +# xmlEncode(stats.newestMember.nick) + + ${stats.activeUsers.len} of ${stats.totalUsers} users online  |  + ${stats.totalThreads} threads  |  ${stats.totalPosts} posts  |  + newest member: ${stats.newestMember.nick} +#end proc +# +# +# +# +#proc genSearchResults(c: var TForumData, +# results: iterator: db_sqlite.TRow {.closure, tags: [FReadDB].}, +# count: var int): string = +# const threadId = 0 +# const threadName = 1 +# const postId = 2 +# const postHeader = 3 +# const postContent = 4 +# const userName = 5 +# const postCreation = 6 +# const postAuthor = 7 +# const userEmail = 8 +# const what = 9 +# result = "" +# count = 0 +# var whCount: array[bool, int] +
+
+
+ Search results for: ${xmlEncode(c.search.replace(""","\""))}. +
+
+
+
+# for row in results(): +# inc(count) +# let isThread = %what == "0" +# inc(whCount[isThread]) +# let postUrl = c.genThreadUrl(%postId,"",%threadId,"") +# let threadUrl = c.genThreadUrl("","",%threadId) +# var headersDiffer = false +
+
+
+ #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName) +
${genGravatar(%userEmail, 40)}
+
${xmlEncode(%userName)}
+ #if c.userId == %postAuthor and c.currentPost.subject.len == 0: +
Edit post + #elif c.isAdmin and c.currentPost.subject.len == 0: +
Edit post + #end if +
+
+
+
+ #if %postHeader != "": + + #end if + #if not isThread: + #try: + ${(%postContent).rstToHtml} + #except EParseError: + # c.errorMsg = getCurrentExceptionMsg() + ${xmlEncode(%postContent)} + #end + #end if + ${xmlEncode(%postCreation)} +
+
+
+# end for +
+# if c.pageNum > 1: +
+ + +
+# end if +# if whCount[true] == ThreadsPerPage or whCount[false] == ThreadsPerPage: +
+ + +
+# end if +#end proc +# diff --git a/forum.nim b/forum.nim new file mode 100644 index 0000000..4aa0338 --- /dev/null +++ b/forum.nim @@ -0,0 +1,1111 @@ +# +# +# The Nim Forum +# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta +# Look at license.txt for more info. +# All rights reserved. +# + +import + os, strutils, times, md5, strtabs, cgi, math, db_sqlite, matchers, + rst, rstgen, captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils + +when not defined(windows): + import bcrypt # TODO + +from htmlgen import tr, th, td, span + +const + unselectedThread = -1 + transientThread = 0 + + ThreadsPerPage = 15 + PostsPerPage = 10 + MaxPagesFromCurrent = 8 + noPageNums = ["/login", "/register", "/dologin", "/doregister", "/profile"] + noHomeBtn = ["/", "/login", "/register", "/dologin", "/doregister", "/profile"] + banReasonDeactivated = "DEACTIVATED" + +type + TCrud = enum crCreate, crRead, crUpdate, crDelete + + TSession = object of RootObj + threadid: int + postid: int + userName, userPass, email: string + isAdmin: bool + + TPost = tuple[subject, content: string] + + TForumData = object of TSession + req: PRequest + userid: string + actionContent: string + errorMsg, loginErrorMsg: string + invalidField: string + currentPost: TPost ## Only used for reply previews + startTime: float + isThreadsList: bool + pageNum: int + totalPosts: int + search: string + noPagenumumNav: bool + + 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]] + + TUserInfo = object + nick: string + posts: int + threads: int + lastOnline: int + email: string + ban: string + +var + db: TDbConn + docConfig: StringTableRef + isFTSAvailable: bool + +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: "") + + c.search = "" + +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 """""" % [ + name, $maxlength, x, if size != -1: "size=\"" & $size & "\"" else: ""] + +proc HiddenField(c: TForumData, name, defaultText: string): string = + let x = if defaultText != reuseText: defaultText + else: xmlEncode(c.req.params[name]) + return """""" % [name, x] + +proc TextAreaWidget(c: TForumData, name, defaultText: string): string = + let x = if defaultText != reuseText: defaultText + else: xmlEncode(c.req.params[name]) + return """""" % [ + name, x] + +proc FieldValid(c: TForumData, name, text: string): string = + if name == c.invalidField: + result = """$1""" % text + else: + result = text + +proc genThreadUrl(c: TForumData, postId = "", action = "", threadid = "", pageNum = ""): string = + result = "/t/" & (if threadid == "": $c.threadId else: threadid) + if pageNum != "": + result.add("/" & pageNum) + if action != "": + result.add("?action=" & action) + if postId != "": + result.add("&postid=" & postid) + elif postId != "": + result.add("#" & postId) + result = c.req.makeUri(result, absolute = false) + +proc FormSession(c: var TForumData, nextAction: string): string = + return """ + """ % [ + $c.threadId, $c.postid] + +proc UrlButton(c: var TForumData, text, url: string): string = + return ("""$2""") % [ + url, text] + +proc genButtons(c: var TForumData, btns: seq[TStyledButton]): string = + if btns.len == 1: + var anchor = "" + + result = ("""$2""") % [ + 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(("""$2""") % [ + btns[i].link, btns[i].text, class, anchor]) + +proc toInterval(diff: int64): TimeInterval = + var remaining = diff + let years = remaining div 31536000 + remaining -= years * 31536000 + let months = remaining div 2592000 + remaining -= months * 2592000 + let days = remaining div 86400 + remaining -= days * 86400 + let hours = remaining div 3600 + remaining -= hours * 3600 + let minutes = remaining div 60 + remaining -= minutes * 60 + result = initInterval(0, remaining.int, minutes.int, hours.int, days.int, + months.int, years.int) + +proc formatTimestamp(t: int): string = + let t2 = Time(t) + let now = getTime() + let diff = (now - t2).toInterval() + if diff.years > 0: + return getGMTime(t2).format("MMMM d',' yyyy") + elif diff.months > 0: + return $diff.months & (if diff.months > 1: " months ago" else: " month ago") + elif diff.days > 0: + return $diff.days & (if diff.days > 1: " days ago" else: " day ago") + elif diff.hours > 0: + return $diff.hours & (if diff.hours > 1: " hours ago" else: " hour ago") + elif diff.minutes > 0: + return $diff.minutes & + (if diff.minutes > 1: " minutes ago" else: " minute ago") + else: + return "just now" + +proc getGravatarUrl(email: string, size = 80): string = + let emailMD5 = email.toLower.toMD5 + return ("http://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size & + "&d=identicon") + +proc genGravatar(email: string, size: int = 80): string = + result = "" % + [$size, $size, getGravatarUrl(email, size)] + +proc randomSalt(): string = + result = "" + for i in 0..127: + var r = random(225) + if r >= 32 and r <= 126: + result.add(chr(random(225))) + +proc devRandomSalt(): string = + when defined(posix): + result = "" + var f = open("/dev/urandom") + var randomBytes: array[0..127, char] + discard f.readBuffer(addr(randomBytes), 128) + for i in 0..127: + if ord(randomBytes[i]) >= 32 and ord(randomBytes[i]) <= 126: + result.add(randomBytes[i]) + f.close() + else: + result = randomSalt() + +proc makeSalt(): string = + ## Creates a salt using a cryptographically secure random number generator. + ## + ## Ensures that the resulting salt contains no ``\0``. + try: + result = devRandomSalt() + except IOError: + result = randomSalt() + + var newResult = "" + for i in 0 .. 0 + +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 = """""" % 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, " & + "ban) 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: varargs[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 {.inject.} = c.req.params["subject"] + if subject.strip.len < 3: + return setError(c, "subject", "Subject not long enough") + +template retrContent(c: expr) = + let content {.inject.} = c.req.params["content"] + if content.strip.len < 10: + return setError(c, "content", "Content not long enough") + 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) {.immediate, dirty.} = + c.currentPost.subject = subject + c.currentPost.content = content + +template writeToDb(c, cr, setPostId: expr) = + let retID = insertID(db, crud(cr, "post", "author", "ip", "header", "content", "thread"), + c.userId, c.req.ip, subject, content, $c.threadId, "") + discard tryExec(db, crud(cr, "post_fts", "id", "header", "content"), + retID.int, subject, content) + if setPostId: + c.postId = retID.int + +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") + discard tryExec(db, crud(crDelete, "post_fts"), $postId) + # 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 + discard tryExec(db, sql"delete from thread_fts where id not in (select thread from post)") + else: + # Update corresponding thread's modified field. + let getModifiedSql = "(select creation from post where post.thread = ?" & + " order by creation desc limit 1)" + let updateSql = sql("update thread set modified=" & getModifiedSql & + " where id = ?") + if not tryExec(db, updateSql, $c.threadId, $c.threadId): + return setError(c, "", "database error") + result = true + else: + checkOwnership(c, $postId) + retrPost(c) + exec(db, crud(crUpdate, "post", "header", "content"), + subject, content, $postId) + exec(db, crud(crUpdate, "post_fts", "header", "content"), + subject, content, $postId) + # Check if post is the first post of the thread. + let rows = db.getAllRows(sql("select id, thread, creation from post " & + "where thread = ? order by creation asc"), $c.threadId) + if rows[0][0] == $postId: + exec(db, crud(crUpdate, "thread", "name"), subject, $c.threadId) + result = true + +proc reply(c: var TForumData): bool = + checkLogin(c) + retrPost(c) + if c.isPreview: + setPreviewData(c) + else: + writeToDb(c, crCreate, true) + + 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") + discard tryExec(db, crud(crCreate, "thread_fts", "id", "name"), + c.threadID, c.req.params["subject"]) + writeToDb(c, crCreate, false) + discard tryExec(db, sql"insert into post_fts(post_fts) values('optimize')") + discard tryExec(db, sql"insert into post_fts(thread_fts) values('optimize')") + result = true + +proc login(c: var TForumData, name, pass: string): bool = + # get form data: + const query = + sql"select id, name, password, email, salt, admin, ban from person where name = ?" + if name.len == 0: + return c.setError("name", "Username cannot be nil.") + var success = false + for row in fastRows(db, query, name): + if row[2] == makePassword(pass, row[4], row[2]): + case row[6] + of "": discard + of banReasonDeactivated: + return c.setError("name", "Your account has been deactivated.") + else: + return c.setError("name", "You have been banned: " & row[6]) + 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 setBan(c: var TForumData, nick, reason: string): bool = + const query = + sql("update person set ban = ? where name = ?") + return tryExec(db, query, reason, nick) + +proc hasReplyBtn(c: var TForumData): bool = + result = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply" + result = result and c.req.params["action"] != "reply" + # If the user is not logged in and there are no page numbers then we shouldn't + # generate the div. + let pages = ceil(c.totalPosts / PostsPerPage).int + result = result and (pages > 1 or c.loggedIn) + return c.threadId >= 0 and result + +proc genActionMenu(c: var TForumData): string = + result = "" + var btns: seq[TStyledButton] = @[] + # TODO: Make this detection better? + if c.req.pathInfo.normalizeUri notin noHomeBtn and not c.isThreadsList: + btns.add(("Thread List", c.req.makeUri("/", false))) + #echo c.loggedIn + if c.loggedIn: + let hasReplyBtn = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply" + if c.threadId >= 0 and hasReplyBtn: + let replyUrl = c.genThreadUrl(action = "reply", + pageNum = $(ceil(c.totalPosts / PostsPerPage).int)) & "#reply" + btns.add(("Reply", replyUrl)) + btns.add(("New Thread", c.req.makeUri("/newthread", false))) + result = c.genButtons(btns) + +proc getStats(c: var TForumData, simple: bool): 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 + if not simple: + var newestMemberCreation = 0 + result.activeUsers = @[] + result.newestMember = ("", -1, false) + 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() - Time(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 + +proc genPagenumNav(c: var TForumData, stats: TForumStats): string = + result = "" + var + firstUrl = "" + prevUrl = "" + totalPages = 0 + lastUrl = "" + nextUrl = "" + + if c.isThreadsList: + firstUrl = c.req.makeUri("/") + prevUrl = c.req.makeUri(if c.pageNum == 1: "/" else: "/page/" & $(c.pageNum-1)) + totalPages = ceil(stats.totalThreads / ThreadsPerPage).int + lastUrl = c.req.makeUri("/page/" & $(totalPages)) + nextUrl = c.req.makeUri("/page/" & $(c.pageNum+1)) + else: + firstUrl = c.req.makeUri("/t/" & $c.threadId) + if c.pageNum == 1: + prevUrl = firstUrl + else: + prevUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum-1)) + totalPages = ceil(c.totalPosts / PostsPerPage).int + lastUrl = c.req.makeUri(firstUrl & "/" & $(totalPages)) + nextUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum+1)) + + if totalPages <= 1: + return "" + + var firstTag = "" + var prevTag = "" + if c.pageNum == 1: + firstTag = span("<<") + prevTag = span("<••") + else: + firstTag = htmlgen.a(href=firstUrl, "<<") + prevTag = htmlgen.a(href=prevUrl, "<••") + prevTag.add(htmlgen.link(rel="previous", href=prevUrl)) + result.add(firstTag) + result.add(prevTag) + + # Numbers + var pages = "" # Tags + # cutting numbers to the left and to the right tp MaxPagesFromCurrent + let firstToShow = max(1, c.pageNum - MaxPagesFromCurrent) + let lastToShow = min(totalPages, c.pageNum + MaxPagesFromCurrent) + if firstToShow > 1: pages.add(span("...")) + for i in firstToShow .. lastToShow: + if i == c.pageNum: + pages.add(span($(i))) + else: + var pageUrl = "" + if c.isThreadsList: + pageUrl = c.req.makeUri("/page/" & $(i)) + else: + pageUrl = c.req.makeUri(firstUrl & "/" & $(i)) + + pages.add(htmlgen.a(href = pageUrl, $(i))) + if lastToShow < totalPages: pages.add(span("...")) + result.add(pages) + + # Right + var lastTag = "" + var nextTag = "" + if c.pageNum == totalPages: + lastTag = span(">>") + nextTag = span("••>") + else: + lastTag = htmlgen.a(href=lastUrl, ">>") + nextTag = htmlgen.a(href=nextUrl, "••>") + nextTag.add(htmlgen.link(rel="next",href=nextUrl)) + result.add(nextTag) + result.add(lastTag) + +proc gatherTotalPostsByID(c: var TForumData, thrid: int): int = + ## Gets the total post count of a thread. + result = getValue(db, sql"select count(*) from post where thread = ?", $thrid).parseInt + +proc gatherTotalPosts(c: var TForumData) = + if c.totalPosts > 0: return + # Gather some data. + const totalPostsQuery = + sql"select count(*) from post p, person u where u.id = p.author and p.thread = ?" + c.totalPosts = getValue(db, totalPostsQuery, $c.threadId).parseInt + +proc getPagesInThread(c: var TForumData): int = + c.gatherTotalPosts() # Get total post count + result = ceil(c.totalPosts / PostsPerPage).int-1 + +proc getPagesInThreadByID(c: var TForumData, thrid: int): int = + result = ceil(c.gatherTotalPostsByID(thrid) / PostsPerPage).int + +proc getThreadTitle(thrid: int, pageNum: int): string = + result = getValue(db, sql"select name from thread where id = ?", $thrid) + if pageNum notin {0,1}: + result.add(" - Page " & $pageNum) + +proc genPagenumLocalNav(c: var TForumData, thrid: int): string = + result = "" + const maxPostPages = 6 # Maximum links to pages shown. + const hmpp = maxPostPages div 2 + # 1 2 3 ... 10 11 12 + var currentThrURL = "/t/" & $thrid & "/" + let totalPagesInThread = c.getPagesInThreadByID(thrid) + if totalPagesInThread <= 1: return + var i = 1 + while i <= totalPagesInThread: + result.add(htmlgen.a(href=c.req.makeUri(currentThrURL & $i), $i)) + if i == hmpp and totalPagesInThread-i > hmpp: + result.add(span("...")) + # skip to the last 3 + i = totalPagesInThread-(hmpp-1) + else: + inc(i) + + result = htmlgen.span(class = "pages", result) + +proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool = + ui.nick = nick + const getUIDQuery = sql"select id from person where name = ?" + var uid = getValue(db, getUIDQuery, nick) + if uid == "": return false + result = true + const totalPostsQuery = + sql"SELECT count(*) FROM post WHERE author = ?" + ui.posts = getValue(db, totalPostsQuery, uid).parseInt + const totalThreadsQuery = + sql("select count(*) from thread where id in (select thread from post where" & + " author = ? and post.id in (select min(id) from post group by thread))") + + ui.threads = getValue(db, totalThreadsQuery, uid).parseInt + const lastOnlineQuery = + sql"select strftime('%s', lastOnline) from person where id = ?" + let lastOnlineDBVal = getValue(db, lastOnlineQuery, uid) + ui.lastOnline = if lastOnlineDBVal != "": lastOnlineDBVal.parseInt else: -1 + ui.email = getValue(db, sql"select email from person where id = ?", uid) + ui.ban = getValue(db, sql"select ban from person where id = ?", uid) + +proc genSetUserStatusUrl(c: var TForumData, nick: string, typ: string): string = + c.req.makeUri("/setUserStatus?nick=$1&type=$2" % [nick, typ]) + +proc genProfile(c: var TForumData, ui: TUserInfo): string = + result = "" + + result.add(htmlgen.`div`(id = "talk-head", + htmlgen.`div`(class="info-post", + htmlgen.`div`( + htmlgen.a(href = c.req.makeUri("/"), + span(style = "font-weight: bold;", "forum index") + ), + " > " & ui.nick & "'s profile" + ) + ) + ) + ) + result.add(htmlgen.`div`(id = "avatar", genGravatar(ui.email, 250))) + let t2 = if ui.lastOnline != -1: getGMTime(Time(ui.lastOnline)) + else: getGMTime(getTime()) + + result.add(htmlgen.`div`(id = "info", + htmlgen.table( + tr( + th("Nickname"), + td(ui.nick) + ), + tr( + th("Threads"), + td($ui.threads) + ), + tr( + th("Posts"), + td($ui.posts) + ), + tr( + th("Last Online"), + td(if ui.lastOnline != -1: t2.format("dd/MM/yy HH':'mm 'UTC'") + else: "Never") + ), + tr( + th("Status"), + td(case ui.ban + of banReasonDeactivated: + "Deactivated" + of "": + "Active" + else: + "Banned: " & ui.ban + ) + ), + tr( + th(""), + td(if c.isAdmin and ui.ban != banReasonDeactivated: + if ui.ban == "": + htmlgen.a( + href=c.genSetUserStatusUrl(ui.nick, "ban"), + "Ban user") + else: + htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "unban"), + "Unban user") + else: "") + ), + tr( + th(""), + td(if c.userName == ui.nick or c.isAdmin: + if ui.ban == "": + htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "deactivate"), + "Deactivate user") + elif ui.ban == banReasonDeactivated: + htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "activate"), + "Activate user") + else: "" + else: "") + ) + ) + )) + + result = htmlgen.`div`(id = "profile", + htmlgen.`div`(id = "left", result)) + +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 {.inject.}: TForumData + init(c) + c.req = request + c.startTime = epochTime() + c.isThreadsList = false + c.pageNum = 1 + if request.cookies.len > 0: + checkLoggedIn(c) + +routes: + get "/": + createTFD() + c.isThreadsList = true + var count = 0 + let threadList = genThreadsList(c, count) + let data = genMain(c, threadList, + additionalHeaders = genRSSHeaders(c), showRssLinks = true) + resp data + + get "/threadActivity.xml": + createTFD() + c.isThreadsList = true + resp genThreadsRSS(c), "application/atom+xml" + + get "/postActivity.xml": + createTFD() + resp genPostsRSS(c), "application/atom+xml" + + get "/t/@threadid/?@page?/?@postid?/?": + createTFD() + parseInt(@"threadid", c.threadId, -1..1000_000) + + if c.threadId == unselectedThread: + # Thread has just been deleted. + redirect(uri("/")) + + if @"page".len > 0: + parseInt(@"page", c.pageNum, 0..1000_000) + if @"postid".len > 0: + parseInt(@"postid", c.postId, 0..1000_000) + cond (c.pageNum > 0) + var count = 0 + var pSubject = getThreadTitle(c.threadid, c.pageNum) + cond validThreadId(c) + gatherTotalPosts(c) + if (@"action").len > 0: + var title = "" + case @"action" + of "reply": + let subject = getValue(db, + sql"select header from post where id = (select max(id) from post where thread = ?)", + $c.threadId).prependRe + body = genPostsList(c, $c.threadId, count) + cond count != 0 + body.add genFormPost(c, "doreply", "Reply", subject, "", false) + title = "Replying to thread: " & pSubject + of "edit": + cond c.postId != -1 + const query = sql"select header, content from post where id = ?" + let row = getRow(db, query, $c.postId) + let header = ||row[0] + let content = ||row[1] + body = genFormPost(c, "doedit", "Edit", header, content, true) + title = "Editing post" + else: discard + resp c.genMain(body, title & " - Nim Forum") + else: + incrementViews(c) + let posts = genPostsList(c, $c.threadId, count) + cond count != 0 + resp genMain(c, posts, pSubject & " - Nim Forum") + + get "/page/?@page?/?": + createTFD() + c.isThreadsList = true + cond (@"page" != "") + parseInt(@"page", c.pageNum, 0..1000_000) + cond (c.pageNum > 0) + var count = 0 + let list = genThreadsList(c, count) + if count == 0: + pass() + resp genMain(c, list, "Page " & $c.pageNum & " - Nim Forum", + genRSSHeaders(c), showRssLinks = true) + + get "/profile/@nick/?": + createTFD() + cond (@"nick" != "") + var userinfo: TUserInfo + if gatherUserInfo(c, @"nick", userinfo): + resp genMain(c, c.genProfile(userinfo), + @"nick" & "'s profile - Nim Forum") + else: + halt() + + get "/login/?": + createTFD() + resp genMain(c, genFormLogin(c), "Log in - Nim Forum") + + get "/logout/?": + createTFD() + logout(c) + redirect(uri("/")) + + get "/register/?": + createTFD() + resp genMain(c, genFormRegister(c), "Register - Nim Forum") + + template readIDs(): stmt = + # Retrieve the threadid, postid and pagenum + if (@"threadid").len > 0: + parseInt(@"threadid", c.threadId, -1..1000_000) + if (@"postid").len > 0: + parseInt(@"postid", c.postId, -1..1000_000) + + template finishLogin(): 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(), "Nim Forum - " & + (if c.isPreview: "Preview" else: "Error")) + + post "/dologin": + createTFD() + if login(c, @"name", @"password"): + finishLogin() + else: + c.isThreadsList = true + var count = 0 + let threadList = genThreadsList(c, count) + let data = genMain(c, threadList, + additionalHeaders = genRSSHeaders(c), showRssLinks = true) + resp data + + post "/doregister": + createTFD() + if 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(pageNum = $(c.getPagesInThread+1)) & "#" & $c.postId) + else: + var count = 0 + if c.isPreview: + c.pageNum = c.getPagesInThread+1 + body = genPostsList(c, $c.threadId, count) + handleError("doreply", "Reply", false) + + post "/doedit": + createTFD() + readIDs() + if edit(c, c.postId): + redirect(c.genThreadUrl(postId = $c.postId, + pageNum = $(c.getPagesInThread+1))) + else: + body = "" + handleError("doedit", "Edit", true) + + get "/newthread/?": + createTFD() + resp genMain(c, genFormPost(c, "donewthread", "New thread", "", "", false), + "New Thread - Nim Forum") + + get "/setUserStatus/?": + createTFD() + cond (@"nick" != "") + cond (@"type" != "") + var formBody = "" + var del = false + var content = "" + echo("Got type: ", @"type") + case @"type" + of "ban": + formBody.add "" & + "" + content = + htmlgen.p("Please enter a reason for banning this user:") + of "unban": + formBody.add "" + content = + htmlgen.p("Are you sure you wish to unban ", htmlgen.b(@"nick"), + "?") + del = true + of "deactivate": + formBody.add "" & + "" + content = + htmlgen.p("Are you sure you wish to deactivate ", htmlgen.b(@"nick"), + "?") + of "activate": + formBody.add "" + content = + htmlgen.p("Are you sure you wish to activate ", htmlgen.b(@"nick"), + "?") + del = true + formBody.add "" + content = htmlgen.form(action = c.req.makeUri("/dosetban"), + `method` = "POST", formBody) & content + resp genMain(c, content, "Set user status - Nim Forum") + + post "/dosetban": + createTFD() + cond (@"nick" != "") + if not c.isAdmin and @"nick" != c.userName: + resp genMain(c, "You cannot ban this user.", "Error - Nim Forum") + if @"reason" == "" and @"del" != "true": + resp genMain(c, "Invalid ban reason.", "Error - Nim Forum") + let result = + if @"del" == "true": + # Remove the ban. + setBan(c, @"nick", "") + else: + setBan(c, @"nick", @"reason") + if result: + redirect(c.req.makeUri("/profile/" & @"nick")) + else: + resp genMain(c, "Failed to change the ban status of user.", + "Error - Nim Forum") + + const licenseRst = slurp("static/license.rst") + get "/license": + createTFD() + resp genMain(c, rstToHtml(licenseRst), "Content license - Nim Forum") + + post "/search/?@page?": + cond isFTSAvailable + createTFD() + c.isThreadsList = true + c.noPagenumumNav = true + var count = 0 + var q = @"q" + for i in 0 .. q.len-1: + if q[i].int < 32: q[i] = ' ' + elif q[i] == '\'': q[i] = '"' + c.search = q.replace("\"","""); + if @"page".len > 0: + parseInt(@"page", c.pageNum, 0..1000_000) + cond (c.pageNum > 0) + iterator searchResults(): db_sqlite.TRow {.closure, tags: [FReadDB].} = + const queryFT = "fts.sql".slurp.sql + for rowFT in fastRows(db, queryFT, + [q,q,$ThreadsPerPage,$c.pageNum,$ThreadsPerPage,q, + q,q,$ThreadsPerPage,$c.pageNum,$ThreadsPerPage,q]): + yield rowFT + resp genMain(c, genSearchResults(c, searchResults, count), + additionalHeaders = genRSSHeaders(c), showRssLinks = true) + + # tries first to read html, then to read rst, convert ot html, cache and return + template textPage(path: string): stmt = + createTFD() + #c.isThreadsList = true + var page = "" + if existsFile(path): + page = readFile(path) + else: + let basePath = + if path[path.high] == '/': path & "index" + elif path.endsWith(".html"): path[-5 .. -1] + else: path + if existsFile(basePath & ".html"): + page = readFile(basePath & ".html") + elif existsFile(basePath & ".rst"): + page = readFile(basePath & ".rst").rstToHtml + writeFile(basePath & ".html", page) + resp genMain(c, page) + get "/search-help": + textPage "static/search-help" + +when isMainModule: + docConfig = rstgen.defaultConfig() + docConfig["doc.smiley_format"] = "/images/smilieys/$1.png" + math.randomize() + db = open(connection="nimforum.db", user="postgres", password="", + database="nimforum") + isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & + "type='table' AND name='post_fts'")).len == 1 + var http = true + if paramCount() > 0: + if paramStr(1) == "scgi": + http = false + + #run("", port = TPort(9000), http = http) + + runForever() + db.close() + diff --git a/forum.nim.cfg b/forum.nim.cfg new file mode 100644 index 0000000..9beea07 --- /dev/null +++ b/forum.nim.cfg @@ -0,0 +1,5 @@ + +# we need the documentation generator of the compiler: +--path:"$nimrod/lib/packages/docutils" + +--path:"$nimrod" diff --git a/src/fts.sql b/fts.sql similarity index 71% rename from src/fts.sql rename to fts.sql index 1590a05..777eb99 100644 --- a/src/fts.sql +++ b/fts.sql @@ -4,23 +4,19 @@ SELECT thread_id, snippet(thread_fts, '', '', '...') AS thread, - post_id, - post_content, - cdate, - person.id, + 0 AS post_id, + '' AS header, + '' AS content, person.name AS author, + cdate, + author_id, 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, + post.creation AS cdate, MIN(post.creation) AS cdate, post.author AS author_id FROM thread_fts @@ -32,7 +28,7 @@ SELECT FROM post_fts JOIN post USING(id) WHERE post_fts MATCH ? ) - LIMIT ? OFFSET ? + LIMIT ? OFFSET (? - 1) * ? ) JOIN thread_fts ON thread_fts.id=thread_id JOIN person ON person.id=author_id @@ -44,25 +40,30 @@ SELECT thread.name AS thread, post.id AS post_id, CASE what WHEN 1 + THEN snippet(post_fts, '', '', '...', what) + ELSE post_fts.header END AS header, + CASE what WHEN 2 THEN snippet(post_fts, '**', '**', '...', what, -45) ELSE SUBSTR(post_fts.content, 1, 200) END AS content, - cdate, - person.id, person.name AS author, + cdate, + post.author AS author_id, 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 + SELECT id, post.creation AS cdate, thread, 1 AS what, post.author AS author + FROM post_fts JOIN post USING(id) + WHERE post_fts.header MATCH ? + GROUP BY post.header + HAVING SUBSTR(post.header,1,3)<>'Re:' + UNION + SELECT id, post.creation AS cdate, thread, 2 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 ? + LIMIT ? OFFSET (? - 1) * ? ) AS post USING(id) JOIN thread ON thread.id=thread JOIN person ON person.id=author diff --git a/license.txt b/license.txt index 3eb168b..a7c088e 100644 --- a/license.txt +++ b/license.txt @@ -1,4 +1,4 @@ -Copyright (C) 2018 Andreas Rumpf, Dominik Picheta +Copyright (C) 2013 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 @@ -7,7 +7,7 @@ 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 +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 @@ -15,4 +15,4 @@ 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. + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/localhost.local/public/css/custom-style.scss b/localhost.local/public/css/custom-style.scss deleted file mode 100644 index 6c9e50c..0000000 --- a/localhost.local/public/css/custom-style.scss +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/localhost.local/public/images/logo.png b/localhost.local/public/images/logo.png deleted file mode 100644 index 4ac52b7..0000000 Binary files a/localhost.local/public/images/logo.png and /dev/null differ diff --git a/main.tmpl b/main.tmpl new file mode 100644 index 0000000..235e4aa --- /dev/null +++ b/main.tmpl @@ -0,0 +1,284 @@ +#! stdtmpl +#proc genMain(c: var TForumData, content: string, title = "Nimrod Forum", +# additional_headers = "", showRssLinks = false): string = +# result = "" +# var stats: TForumStats +# if c.isThreadsList: stats = c.getStats(false) +# else: +# stats = c.getStats(true) +# end if + + + + ${xmlEncode(title)} + + ${additional_headers} + + + + + #let frontQuery = c.req.makeUri("/") + + + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ ${content} + #if c.isThreadsList: +
+
+
+ ${c.genListOnline(stats)} +
+
+
+ #if not c.noPagenumumNav: +
+ ${genPagenumNav(c, stats)} +
+ #end if + #elif hasReplyBtn(c): +
+
+
+ ${genPagenumNav(c, stats)} +
+
+
+ #if c.loggedIn(): + #let replyUri = c.req.makeUri(c.req.path & "?action=reply#reply") + +
+ Reply +
+
+ #end if +
+
+ #end if + +
+ +
+
+ + + + + + + + + + +#end proc +# +#proc genRSSHeaders(c: var TForumData): string = +# result = "" + + +#end proc +# +#proc genThreadsRSS(c: var 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""") + + + Nimrod forum thread activity + + + ${frontQuery} + ${recent} +# for row in rows(db, query, 10): + + ${xmlEncode(%name)} + urn:entry:${%threadid} + # let url = c.genThreadUrl(threadid = %threadid, + # pageNum = $(ceil(parseInt(%postCount) / PostsPerPage).int)) & + # "#" & %postId + + ${%threadDate} + ${%threadDate} + ${xmlEncode(%postAuthor)} + Posts ${%postCount}, ${xmlEncode(%postAuthor)} said: +<p> +${xmlEncode(rstToHtml(%postContent))} + +# end for + +#end proc +# +#proc genPostsRSS(c: var TForumData): string = +# result = "" +# const query = sql"""SELECT A.id, B.name, A.content, A.thread, +# A.header, strftime('%Y-%m-%dT%H:%M:%SZ', A.creation), +# A.creation, COUNT(C.id) +# FROM post AS A, person AS B, post AS C +# WHERE A.author = B.id AND A.thread = C.thread AND C.id <= A.id +# GROUP BY A.id +# ORDER BY A.creation DESC LIMIT ?""" +# 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""") + + + Nimrod forum post activity + + + ${frontQuery} + ${recent} +# for row in rows(db, query, 10): + + ${xmlEncode(%postHeader)} + urn:entry:${%postId} + # let url = c.genThreadUrl(threadid = %postThread, + # pageNum = $(ceil(parseInt(%postPosition) / PostsPerPage).int)) & + # "#" & %postId + + ${%postRssDate} + ${%postRssDate} + ${xmlEncode(%postAuthor)} + On ${xmlEncode(%postHumanDate)}, ${xmlEncode(%postAuthor)} said: +<p> +${xmlEncode(rstToHtml(%postContent))} + +# end for + +#end proc diff --git a/mockup/index.html b/mockup/index.html deleted file mode 100644 index b836674..0000000 --- a/mockup/index.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - The Nim programming language forum - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TopicCategoryUsersRepliesViewsActivity
Few mixed up questions
help
-
- -
-
- -
-
-
-
-
-
-
-
554745m
Lexers and parsers in Nim
community
-
-
-
01444m
I need help 2
help
-
- -
-
- -
-
-
-
41d
- - last visit - -
Nim v1.0 is here!
announcement
-
- -
-
- -
-
44d
- - load more threads - -
-
- - - - \ No newline at end of file diff --git a/mockup/thread.html b/mockup/thread.html deleted file mode 100644 index 298bb80..0000000 --- a/mockup/thread.html +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - The Nim programming language forum - - - - - - - - - -
-
-

Lexers and parsers in nim

-
community -
-
-
-
-
- Avatar -
-
-
-
-
- ErikCampobadal -
-
Jan 2015
-
-
-

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

- -

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

- -

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

-
-
- -
- -
-
- -
-
-
-
- -
-
-
- Avatar -
-
-
-
-
- twetzel59 -
-
Jan 2015
-
-
-

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

- -

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

- -

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

-
-
- -
- -
-
- -
-
-
-
- -
-
- -
-
-
- 3 years later -
-
-
- -
-
-
- Avatar -
-
-
-
-
- dom96 -
-
32m
-
-
-

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

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

The greatest function ever written is hello.

-
-

Designing websites is often a pain.

-
Multi-level baby!
-

True that.

-

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

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

We also want to be able to highlight user mentions:

-

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

-
-
- -
- -
-
- -
-
-
-
- - - -
-
- -
-
-
- Replying to "Lexers and parsers in nim" -
-
- -
-
-
- -
-
- - - - \ No newline at end of file diff --git a/nimforum.babel b/nimforum.babel new file mode 100644 index 0000000..bcd7c68 --- /dev/null +++ b/nimforum.babel @@ -0,0 +1,11 @@ +[Package] +name = "nimforum" +version = "0.1.0" +author = "Dominik Picheta" +description = "Nimrod forum" +license = "MIT" + +bin = "forum" + +[Deps] +Requires: "nimrod >= 0.9.2, cairo#head, jester#head, bcrypt#head" diff --git a/nimforum.nimble b/nimforum.nimble deleted file mode 100644 index 58a22f7..0000000 --- a/nimforum.nimble +++ /dev/null @@ -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" diff --git a/public/captchas/.gitignore b/public/captchas/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/public/captchas/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/public/css/arrow.js b/public/css/arrow.js new file mode 100644 index 0000000..f3e1832 --- /dev/null +++ b/public/css/arrow.js @@ -0,0 +1,12 @@ +"use strict"; + +function positionGlowArrow() { + var headLinks = document.getElementById("head-links"); + var activeLink = headLinks.getElementsByClassName("active")[0] + if (activeLink == undefined || activeLink == null) + return; + + var offset = (headLinks.offsetWidth - activeLink.offsetLeft) - (activeLink.offsetWidth / 2) - 133; + var glowArrow = document.getElementById("glow-arrow"); + glowArrow.style.right = offset + "px"; +} \ No newline at end of file diff --git a/public/css/forum.js b/public/css/forum.js new file mode 100644 index 0000000..7af672b --- /dev/null +++ b/public/css/forum.js @@ -0,0 +1,5 @@ +"use strict"; + +window.onload = function() { + positionGlowArrow(); +}; diff --git a/public/css/nimforum.scss b/public/css/nimforum.scss deleted file mode 100644 index 2daecdb..0000000 --- a/public/css/nimforum.scss +++ /dev/null @@ -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; - } -} diff --git a/public/css/spectre b/public/css/spectre deleted file mode 160000 index 7a6af53..0000000 --- a/public/css/spectre +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7a6af53bca72b549b2cbf1948763348bd5b30dcd diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..d6c2147 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,653 @@ + +a, a * { cursor:pointer; } + +html { margin:0; overflow-x:auto; } +body { + overflow-x:hidden; + min-width:1030px; + margin:0; + font: 13pt Helvetica,Arial,sans-serif; + background:#152534 url("/images/bg.jpg") no-repeat fixed center top; } + +pre { color: #F5F5F5;} +pre, pre * { cursor:text; } +pre .Comment { color:#6D6D6D; font-style:italic; } +pre .Keyword { color:#43A8CF; font-weight:bold; } +pre .Type { color:#128B7D; font-weight:bold; } +pre .Operator { font-weight: bold; } +pre .atr { color:#128B7D; font-weight:bold; font-style:italic; } +pre .def { color:#CAD6E4; font-weight:bold; font-style:italic; } +pre .StringLit { color:#854D6A; font-weight:bold; } +pre .DecNumber, pre .FloatNumber { color:#8AB647; } +pre .tab { border-left:1px dotted rgba(67,168,207,0.4); } +pre .end { background:url("/images/tabEnd.png") no-repeat left bottom; } +pre .EscapeSequence +{ + color: #C08D12; +} + +.tall { height:100%; } +.pre { padding:0 5px; font: 11pt "DejaVu Sans Mono",monospace; background:rgba(255,255,255,.30); border-radius:3px; } + +.page-layout { margin:0 auto; width:1000px; } +.docs-layout { margin:0 40px; } +.talk-layout { margin:0 40px; } +.wide-layout { margin:0 auto; } + +#head { height:100px; background:url("/images/head.png") repeat-x bottom; } +#head.docs { margin-left:280px; background:rgba(0,0,0,.25) url("/images/head-fade.png") no-repeat right top; } +#head > div { position:relative } + + #head-logo { + position:absolute; + left:-390px; + top:0; + width:917px; + height:268px; + pointer-events:none; + background:url("/images/logo.png") no-repeat; } + #head.docs #head-logo { left:-381px; position:fixed; } + #head.forum #head-logo { left:-370px; } + + #head-logo-link { + position:absolute; + display:block; + top:10px; + left:10px; + width:236px; + height:85px; } + #head.docs #head-logo-link { left:-260px; } + #head.forum #head-logo-link { left:30px; } + + #head-links { position:absolute; right:0; bottom:13px; } + #head.docs #head-links, + #head.forum #head-links { right:20px; } + #head-links > a { + display:block; + float:left; + padding:10px 25px 25px 25px; + color:rgba(255,255,255,.5); + font-size:14pt; + text-decoration:none; + letter-spacing:1px; + background:url("/images/head-link.png") no-repeat center bottom; + transition: + color 0.3s ease-in-out, + text-shadow 0.4s ease-in-out; } + #head-links > a:hover, + #head-links > a.active { + color:#1cb3ec; + text-shadow:0 0 4px rgba(28,179,236,.8); + background-image:url("/images/head-link_hover.png"); } + + #head-banner { width:200px; height:100px; background:#000; } + +#neck { z-index:0; height:40px; } +#neck.home { height:370px; } +#neck > div { position:relative } + + #glow-arrow { + position:absolute; + top:-9px; + left:0; + right:-16px; + height:48px; + background:url("/images/glow-arrow.png") no-repeat right; } + glow-arrow.docs { left:280px; } + + #glow-line-vert { + position:fixed; + top:100px; + left:280px; + width:3px; + height:844px; + background:url("/images/glow-line-vert.png") no-repeat; } + + +#body { z-index:1; position:relative; background:rgba(220,231,248,.6); } +#body.docs { margin:0 40px 20px 320px; } +#body.forum { margin:0 40px 20px 40px; min-height: 700px; } + + #body-border { + position:absolute; + top:-25px; + left:0; + right:0; + height:35px; + background:rgba(0,0,0,.25); } + + #body-border-left { + position:absolute; + left:-25px; + top:-25px; + bottom:-25px; + width:35px; + background:rgba(0,0,0,.25); } + + #body-border-right { + position:absolute; + right:-25px; + top:-25px; + bottom:-25px; + width:35px; + background:rgba(0,0,0,.25); } + + #body-border-bottom { + position:absolute; + left:10px; + right:10px; + bottom:-25px; + height:35px; + background:rgba(0,0,0,.25); } + + #body.docs #body-border, + #body.forum #body-border { left:10px; right:10px; } + + #glow-line { + position:absolute; + top:-27px; + left:100px; + right:-25px; + height:3px; + background:url("/images/glow-line.png") no-repeat left; } + #glow-line-bottom { + position:absolute; + bottom:-27px; + left:-25px; + right:100px; + height:3px; + background:url("/images/glow-line2.png") no-repeat right; } + + #content { padding:40px 0; } + #content.page { width:680px; min-height:800px; padding-left:20px; } + #content h1 { font-size:20pt; letter-spacing:1px; color:rgba(0,0,0,.75); } + #content h2 { font-size:16pt; letter-spacing:1px; color:rgba(0,0,0,.7); margin-top:40px; } + #content p { text-align:justify; color: #1D1D1D; margin: 5pt 0pt; } + #content a { color:#CEDAE9; text-decoration:none; } + #content a:hover { color:#fff; } + #content ul { padding-left:20px; } + #content li { margin-bottom:10px; text-align:justify; } + + #talk-heads { overflow:auto; margin:0 8px 0 8px; } + #talk-heads > div { float:left; font-size:120%; font-weight:bold; } + #talk-heads > .topic { width:45%; } + #talk-heads > .detail { width:15%; } + #talk-heads > .activity { width:25%; } + #talk-heads > .users { width:15%; } + #talk-heads > div > div { margin:0 10px 10px 10px; padding:0 10px 10px 10px; border-bottom:1px dashed rgba(0,0,0,0.4); } + #talk-heads > .topic > div { margin-left:0; } + #talk-heads > .activity > div { margin-right:0; } + + #talk-thread > div { + background-color: rgba(255, 255, 255, 0.5); + } + #talk-thread > div, + #talk-threads > div { + position:relative; + margin:5px 0; + overflow:auto; + border-radius:3px; + border:8px solid rgba(0,0,0,.8); + border-top:none; + border-bottom:none; + } + #talk-threads > div + { + line-height: 150%; + background:rgba(0,0,0,0.1); + } + #talk-threads > div:nth-child(odd) { background:rgba(0,0,0,0.2); } + #talk-thread > div > div, + #talk-threads > div > div + { + float:left; + text-overflow: ellipsis; + overflow: hidden; + font-size: 13pt; + } + #talk-threads > div > div > div { margin: 5px 10px; } + #talk-thread > div > div > div { margin: 15px 10px; } + #talk-thread > div > .topic + { + margin-top: 15pt; + white-space: normal; + } + #talk-thread > div > .topic > div + { + margin-left: 15px; + } + #talk-thread > div > .topic > div > span.date + { + position: absolute; + top: 5px; + right: 10pt; + border-bottom: 1px dashed; + color: #3D3D3D; + } + #talk-threads > div > .topic { width:45%; } + #talk-threads > div > .users { width:15%; overflow:hidden; height: 30px; } + #talk-threads > div > .users > div > img + { + margin-bottom: -4pt; + cursor: help; + } + #talk-threads > div > .detail { width:16%; overflow:hidden; } + #talk-thread > div > .author, + #talk-threads > div > .activity { + overflow:hidden; + background:rgba(0,0,0,0.8); + color: white; + + } + #talk-thread > div > .author { + width: 15%; + } + #talk-threads > div > .activity { + width:24%; + font-size: 9pt; + } + #talk-threads > div > .activity a + { + color: #1CB3EC; + } + #talk-threads > div > .activity a:hover + { + color: #ffffff; + } + #talk-thread > div > .author { + height: 100%; + position: absolute; + } + #talk-thread > div > .author a, + #talk-threads > div > .author a { color:#1cb3ec !important; } + #talk-thread > div > .author a:hover, + #talk-threads > div > .author a:hover { color:#fff !important; } + #talk-threads > div > .topic .pages { float:right; } + #talk-threads > div > .topic .pages > a + { + margin-right: 5pt; + } + #talk-threads > div > .topic > div > a + { + font-weight:bold; + white-space: nowrap; + } + #talk-threads > div > .detail > div { float:left; margin:0; } + #talk-threads > div > .detail > div > div { margin-left:15px; padding: 5px 5px 5px 22px; } + #talk-threads > div > .detail > div { width:50%; } + #talk-threads > div > .detail > div:first-child > div { background:url("/images/forum-views.png") no-repeat left; cursor: help; } + #talk-threads > div > .detail > div:last-child > div { background:url("/images/forum-posts.png") no-repeat left; cursor: help; } + + #talk-thread > div { margin:20px 0; min-height:160px; padding-bottom: 10pt; } + #talk-thread > div > .author > div > .avatar { margin-top:20px; } + #talk-thread > div > .author > div > .name { } + #talk-thread > div > .author > div > .date { font-size: 8pt; color: white; } + #talk-thread > div > .topic { width:85%; padding-bottom:10px; margin-left: 15%; } + #talk-thread > div > .topic pre { + overflow:auto; + margin:0; + padding:15px 10px; + font-size:10pt; + font-style:normal; + line-height:14pt; + background:rgba(0,0,0,.75); + border-left:8px solid rgba(0,0,0,.3); + margin-bottom: 10pt; + font-family: "DejaVu Sans Mono", monospace; + } + #talk-thread > div > .topic a, #talk-thread > div > .topic a:visited + { + color: #3680C9; + text-decoration: none; + } + #talk-thread > div > .topic a:hover + { + text-decoration: underline; + } + #talk-head, + #talk-info { + overflow:auto; + border-radius:3px; + border:8px solid rgba(0,0,0,.2); + border-top:none; + border-bottom:none; + background:rgba(0,0,0,0.1); } + #talk-head { margin-bottom:20px; } + #talk-info { margin-top:20px; } + #talk-head > div, + #talk-info > div { float:left; } + #talk-head > .info, + #talk-info > .info { width:80%; } + #talk-head > .info-post, + #talk-info > .info-post { width: 85%; } + #talk-head > .user, + #talk-info > .user { width:20%; background:rgba(0,0,0,.2); } + #talk-head > .user-post, + #talk-info > .user-post { width: 15%; background:rgba(0,0,0,.2); } + #talk-info > .user-post .reply { font-weight:bold; padding-left:22px; background:url("/images/forum-reply.png") no-repeat left; } + #talk-info > .user-post a span + { + color: #CEDAE9 !important; + } + #talk-info > .user-post > a > div:hover > span + { + color: #fff !important; + } + #talk-head > div > div, + #talk-info > div > div, + #talk-info > div > a > div { padding:5px 20px; color: #1a1a1a; } + #talk-head > div > div { color: #353535; } + #talk-head > .detail > div { float:left; margin:0; } + #talk-head > .detail > div > div { padding-left:22px; } + #talk-head > .detail > div:first-child > div { background:url("/images/forum-views.png") no-repeat left; } + #talk-head > .detail > div:last-child > div { background:url("/images/forum-posts.png") no-repeat left; } + + #talk-nav { margin:20px 8px 0 8px; padding-top:10px; border-top:1px dashed rgba(0,0,0,0.4); text-align:center; } + #talk-nav > a.active { text-decoration:underline !important; } + #talk-nav > a, #talk-nav > span, #talk-info > .info-post > div > a, + #talk-info > .info-post > div > span { margin-left: 5pt; } + + .standout { + padding:5px 30px; + margin-bottom:20px; + border:8px solid rgba(0,0,0,.8); + border-right-width:16px; + border-top-width:0; + border-bottom-width:0; + border-radius:3px; + background:rgba(0,0,0,0.1); + box-shadow:1px 3px 12px rgba(0,0,0,.4); } + .standout h3 { margin-bottom:10px; padding-bottom:10px; border-bottom:1px dashed rgba(0,0,0,.8); } + .standout li { margin:0 !important; padding-top:10px; border-top:1px dashed rgba(0,0,0,.2); } + .standout ul { padding-bottom:5px; } + .standout ul.tools { list-style:url("/images/docs-tools.png"); } + .standout ul.library { list-style:url("/images/docs-library.png"); } + .standout ul.internal { list-style:url("/images/docs-internal.png"); } + .standout ul.tutorial { list-style:url("/images/docs-tutorial.png"); } + .standout ul.example { list-style:url("/images/docs-example.png"); } + .standout li:first-child { padding-top:0; border-top:none; } + .standout li p { margin:0 0 10px 0 !important; line-height:130%; } + .standout li > a { font-weight:bold; } + + .forum-user-info, + .forum-user-info * { cursor:help } + +#foot { height:150px; position:relative; top:-10px; letter-spacing:1px; } +#foot.home { background:url("/images/foot.png") repeat-x top; height:200px; } +#foot.docs { margin-left:320px; margin-right:40px; } +#foot.forum { margin-left:40px; margin-right:40px; } +#foot > div { position:relative; } +#foot.home > div { width:960px; } +#foot h4 { font-size:11pt; color:rgba(255,255,255,.4); margin:40px 0 6px 0; } +#foot a:hover { color:#fff; } + + #foot-links { float:left; } + #foot-links > div { float:left; padding:0 40px 0 0; line-height:120%; } + #foot-links a { display:block; font-size:10pt; color:rgba(255,255,255,.3); text-decoration:none; } + #foot-legal { float:right; font-size:10pt; color:rgba(255,255,255,.3); line-height:150%; text-align:right; } + #foot-legal a { color:inherit; text-decoration:none; } + #foot-legal > h4 > a { color:inherit; } + + #mascot { + z-index:2; + position:absolute; + top:-340px; + right:25px; + width:202px; + height:319px; + background:url("/images/mascot.png") no-repeat; } + +article#content +{ + width: 80%; + display: inline-block; +} + +div#sidebar +{ + background-color: rgba(255, 255, 255, 0.1); + + border-left: 8px solid rgba(0, 0, 0, 0.8); + border-right: 8px solid rgba(0, 0, 0, 0.8); + border-bottom: 8px solid rgba(0, 0, 0, 0.8); + border-radius: 3px; + + width: 15%; + margin-top: 40px; + + display: inline-block; + float: right; + + color: #FFF; +} + +div#sidebar .title +{ + background-color: rgba(0, 0, 0, 0.8); + color: #FFF; + text-align: center; + padding: 10pt; +} + +div#sidebar .content +{ + padding: 12pt; + overflow: auto; + +} + +div#sidebar .content .button +{ + background-color: rgba(0,0,0,0.2); + text-decoration: none; + color: #FFF; + padding: 4pt; + float: right; + border-bottom: 2px solid rgba(0,0,0,0.24); + font-size: 11pt; + margin-top: 5pt; +} + +div#sidebar .content .button:hover +{ + border-bottom: 2px solid rgba(0,0,0,0.5); +} + +div#sidebar .content input +{ + width: 99%; + margin-bottom: 10pt; + margin-top: 2pt; + + border: 1px solid #6D6D6D; + font-size: 12pt; +} + +div#sidebar .content a.avatar img +{ + float: left; + margin-top: 5pt; +} + +div#sidebar .content a.user +{ + background-color: rgba(0, 0, 0, 0.8); + color: #1cb3ec; + padding: 5pt; + width: 93%; + display: block; + text-align: center; + text-decoration: none; +} + +div#sidebar .content a.user:hover +{ + color: #FFF; +} + +div#sidebar .user .button +{ + float: left; + margin-top: 5pt; + width: 52.5%; +} + +div#sidebar .user .logout +{ + clear: left; + width: 52pt; + text-align: center; + margin-left: 0pt; +} + +div#sidebar .user .avatar > img +{ + margin-right: 5pt; +} + +div#sidebar .content .search +{ + text-align: center; + margin: auto; + display: block; + width: 95%; +} + +span.error +{ + float: left; + width: 100%; + color: #FF4848; + text-align: center; + font-size: 10pt; + background-color: rgba(0,0,0,0.8); + padding: 5pt 0pt; + font-weight: bold; +} + +section#body #content span.error +{ + width: 25%; + margin-top: 5px; + margin-bottom: 5px; +} + +article#content form +{ + border-right: 8px solid rgba(0, 0, 0, 0.2); + background-color: rgba(255, 255, 255, 0.1); + padding: 10pt 20pt; +} + +article#content form > input, article#content form > textarea +{ + border: 1px solid #6D6D6D; +} + +article#content form > input[type=text] +{ + width: 70%; + min-width: 500px; +} + +article#content form > textarea +{ + width: 100%; + height: 200px; +} + +article#content form > input:focus, article#content form > textarea:focus +{ + border: 1px solid #1cb3ec; +} + +hr +{ + border: 1px solid #3D3D3D; +} + +.activity .isoDate +{ + display: none; +} + + +/* highlighting current post */ + +div:target { + background: rgba(139, 218, 255, 0.25) !important; +} + +/* full-text search */ + +.searchResults h4 b, +.searchResults h5 b { + border-bottom: 1px dotted #ffffff; +} +.titleHeader { + margin-right: 1em; + color: #121212; + font-weight: bold; +} + +.postTitle b { + border-bottom: 1px solid #D7300C; +} + +.postTitle a:hover { + text-decoration: none !important; + border-bottom: 1px solid #D7300C; +} + +.searchForm { + margin-top: 0px; + margin-right: 1em; + margin-bottom: 0px; + margin-left: 1em; +} + +.searchHelp { + color: #000000 !important; + float: right; + font-size: 11px; + left: -17px; + top: 3px; + position: relative; + text-decoration: none; + text-shadow: #FFFF00 1px 1px 2px; + cursor: help; +} + +#talk-thread.searchResults > div > div > div { + margin: 15px 8px; +} + +form.searchNav { + display: inline; + border: none !important; + background: transparent !important; +} + +.searchNav input { + background: #858C97; + color: #000000; + border: 1px solid #333; +} + +.clear { + clear: both; + height: 1px; +} + +img.smiley { + width: 20px; + height: 20px; + vertical-align: middle; + margin: 0; +} + +img.rssfeed { + width: 16px; + float: right; + margin-top: 10px; +} diff --git a/public/css/syntax.scss b/public/css/syntax.scss deleted file mode 100644 index 14dfa49..0000000 --- a/public/css/syntax.scss +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/public/images/Feed-icon.svg b/public/images/Feed-icon.svg new file mode 100644 index 0000000..b325149 --- /dev/null +++ b/public/images/Feed-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/bg.jpg b/public/images/bg.jpg new file mode 100644 index 0000000..4e33a79 Binary files /dev/null and b/public/images/bg.jpg differ diff --git a/public/images/forum-posts.png b/public/images/forum-posts.png new file mode 100644 index 0000000..a9ef428 Binary files /dev/null and b/public/images/forum-posts.png differ diff --git a/public/images/forum-reply.png b/public/images/forum-reply.png new file mode 100644 index 0000000..1c1cfb7 Binary files /dev/null and b/public/images/forum-reply.png differ diff --git a/public/images/forum-views.png b/public/images/forum-views.png new file mode 100644 index 0000000..93ef8ed Binary files /dev/null and b/public/images/forum-views.png differ diff --git a/public/images/glow-arrow.png b/public/images/glow-arrow.png new file mode 100644 index 0000000..436d32f Binary files /dev/null and b/public/images/glow-arrow.png differ diff --git a/public/images/glow-line.png b/public/images/glow-line.png new file mode 100644 index 0000000..6607bde Binary files /dev/null and b/public/images/glow-line.png differ diff --git a/public/images/glow-line2.png b/public/images/glow-line2.png new file mode 100644 index 0000000..aea52fd Binary files /dev/null and b/public/images/glow-line2.png differ diff --git a/public/images/head-link.png b/public/images/head-link.png new file mode 100644 index 0000000..d97cba5 Binary files /dev/null and b/public/images/head-link.png differ diff --git a/public/images/head-link_hover.png b/public/images/head-link_hover.png new file mode 100644 index 0000000..27edf3b Binary files /dev/null and b/public/images/head-link_hover.png differ diff --git a/public/images/head.png b/public/images/head.png new file mode 100644 index 0000000..009f867 Binary files /dev/null and b/public/images/head.png differ diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..85d3d2e Binary files /dev/null and b/public/images/logo.png differ diff --git a/public/images/smilieys/icon_cool.png b/public/images/smilieys/icon_cool.png new file mode 100644 index 0000000..37e55e3 Binary files /dev/null and b/public/images/smilieys/icon_cool.png differ diff --git a/public/images/smilieys/icon_e_biggrin.png b/public/images/smilieys/icon_e_biggrin.png new file mode 100644 index 0000000..d54cc5d Binary files /dev/null and b/public/images/smilieys/icon_e_biggrin.png differ diff --git a/public/images/smilieys/icon_e_confused.png b/public/images/smilieys/icon_e_confused.png new file mode 100644 index 0000000..17dd8ea Binary files /dev/null and b/public/images/smilieys/icon_e_confused.png differ diff --git a/public/images/smilieys/icon_e_sad.png b/public/images/smilieys/icon_e_sad.png new file mode 100644 index 0000000..1ef0291 Binary files /dev/null and b/public/images/smilieys/icon_e_sad.png differ diff --git a/public/images/smilieys/icon_e_smile.png b/public/images/smilieys/icon_e_smile.png new file mode 100644 index 0000000..f7ee8aa Binary files /dev/null and b/public/images/smilieys/icon_e_smile.png differ diff --git a/public/images/smilieys/icon_e_surprised.png b/public/images/smilieys/icon_e_surprised.png new file mode 100644 index 0000000..60a82d4 Binary files /dev/null and b/public/images/smilieys/icon_e_surprised.png differ diff --git a/public/images/smilieys/icon_e_wink.png b/public/images/smilieys/icon_e_wink.png new file mode 100644 index 0000000..e32171d Binary files /dev/null and b/public/images/smilieys/icon_e_wink.png differ diff --git a/public/images/smilieys/icon_exclaim.png b/public/images/smilieys/icon_exclaim.png new file mode 100644 index 0000000..9391f2b Binary files /dev/null and b/public/images/smilieys/icon_exclaim.png differ diff --git a/public/images/smilieys/icon_mad.png b/public/images/smilieys/icon_mad.png new file mode 100644 index 0000000..3029c96 Binary files /dev/null and b/public/images/smilieys/icon_mad.png differ diff --git a/public/images/smilieys/icon_neutral.png b/public/images/smilieys/icon_neutral.png new file mode 100644 index 0000000..099cf9d Binary files /dev/null and b/public/images/smilieys/icon_neutral.png differ diff --git a/public/images/smilieys/icon_razz.png b/public/images/smilieys/icon_razz.png new file mode 100644 index 0000000..73401ea Binary files /dev/null and b/public/images/smilieys/icon_razz.png differ diff --git a/public/js/arrow.js b/public/js/arrow.js new file mode 100644 index 0000000..f3e1832 --- /dev/null +++ b/public/js/arrow.js @@ -0,0 +1,12 @@ +"use strict"; + +function positionGlowArrow() { + var headLinks = document.getElementById("head-links"); + var activeLink = headLinks.getElementsByClassName("active")[0] + if (activeLink == undefined || activeLink == null) + return; + + var offset = (headLinks.offsetWidth - activeLink.offsetLeft) - (activeLink.offsetWidth / 2) - 133; + var glowArrow = document.getElementById("glow-arrow"); + glowArrow.style.right = offset + "px"; +} \ No newline at end of file diff --git a/public/js/forum.js b/public/js/forum.js new file mode 100644 index 0000000..7af672b --- /dev/null +++ b/public/js/forum.js @@ -0,0 +1,5 @@ +"use strict"; + +window.onload = function() { + positionGlowArrow(); +}; diff --git a/public/karax.html b/public/karax.html deleted file mode 100644 index 3f7f41b..0000000 --- a/public/karax.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - $title - - - - - - - - - - - -
- - - - diff --git a/public/license.rst b/public/license.rst deleted file mode 100644 index beebae3..0000000 --- a/public/license.rst +++ /dev/null @@ -1,38 +0,0 @@ -Content license -=============== - -All the content contributed to $hostname is `cc-wiki (aka cc-by-sa) -`_ 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 -`_. - -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! diff --git a/public/rst.rst b/rst.txt similarity index 62% rename from public/rst.rst rename to rst.txt index b5db812..70bbd63 100644 --- a/public/rst.rst +++ b/rst.txt @@ -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 -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 `official RST cheat sheet `_ @@ -11,8 +12,9 @@ for further information. Elements of **markdown** are also supported. + Inline elements ---------------- +=============== Ordinary text may contain *inline elements*: @@ -27,71 +29,68 @@ Plain text Result ``\\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 are either direct URLs like ``https://nim-lang.org`` or written like +Links are either direct URLs like ``http://nimrod-lang.org`` or written like this:: - `Nim `_ + `Nimrod `_ Or like this:: - ``_ + ``_ Code blocks ------------ +=========== -The code blocks can be written in the same style as most common Markdown -flavours:: +are done this way:: - ```nim - if x == "abc": - echo "xyz" - ``` - -or using RST syntax:: - - .. code-block:: nim + .. code-block:: nimrod if x == "abc": 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": echo "xyz" -Apart from Nim, the programming languages C, C++, Java and C# also -have highlighting support. 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": echo "xyz" -The above is rendered as:: +Is rendered as:: if x == "abc": echo "xyz" @@ -99,9 +98,9 @@ The above is rendered as:: Bullet lists ------------- +============ -Bullet lists look like this:: +look like this:: * Item 1 * Item 2 that @@ -112,7 +111,7 @@ Bullet lists look like this:: - item 3b - valid bullet characters are ``+``, ``*`` and ``-`` -The above rendered as: +Is rendered as: * Item 1 * Item 2 that spans over multiple lines @@ -124,9 +123,9 @@ The above rendered as: Enumerated lists ----------------- +================ -Enumerated lists are written like this:: +are written like this:: 1. This is the first item 2. This is the second item @@ -134,17 +133,64 @@ Enumerated lists are written like this:: single letters, or roman numerals #. This item is auto-enumerated -They are rendered as: +Is rendered as: 1. This is the first item 2. This is the second item 3. Enumerators are arabic numbers, 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 ------- +====== Only *simple tables* are supported. They are of the form:: @@ -172,39 +218,3 @@ Cell 4 Cell 5; any Cell 6 multiple lines Cell 7 Cell 8 Cell 9 ================== =============== =================== - -Images ------- - -Image embedding is supported. This includes GIFs as well as mp4 (for which a -