Compare commits

..

628 commits

Author SHA1 Message Date
Joey
88c2011323 Update submodules 2022-01-02 22:10:15 -07:00
Joey
4a181e9cb1 Install libsass with apt 2022-01-02 22:10:15 -07:00
Joey
4f00ea5942 Add mkdir 2022-01-02 22:10:15 -07:00
Joey
9994fc7840 test_devel -> test_stable 2022-01-02 22:10:15 -07:00
Joey
ceb04561cd Create main github actions file 2022-01-02 22:10:11 -07:00
Danil Yarantsev
40d7b1cf02
Fixes a few crashes and search functionality. (#307)
* Fixes a few crashes and search functionality.

* Use PostError
2021-11-21 23:40:04 +00:00
Dominik Picheta
c4684155f5 Run CI on all branches and every week. 2021-11-11 22:32:42 +00:00
Dominik Picheta
f940f81861 Lock CI Nim ver and update to Nim 1.6.0. 2021-11-11 22:32:42 +00:00
Juan Carlos
a1601b4600
Use input type search on search instead of text (#291) 2021-05-17 00:04:01 +01:00
zetashift
35e0de7b91
Tests for locking threads (#284)
* Initial try at locking threads tests

* Uncomment tests

* Consist casing

* Add correct query

* Remove redundant navigate call and add frontpage check

* Improve locked thread on frontpage test
2021-04-27 10:23:48 -06:00
zetashift
7954a38601
Pinned Threads (#278)
* Added isSticky field to `Thread` and in the sql query making a Thread

 - Modified indices in `data` and `selectUser` to support `isSticky`
 - Add backend procs for initial sticky logic, modeled after locking threads
 - Fix indices in selectThread
 - Fixup posts.json's threadquery to match Thread with sticky field

* Implement StickyButton for postbutton.nim and add it to postlist.nim

* Fix sticky routes

* Order sticky in a way that they actually appear at the top

* Add border for isSticky on genThread

* Rename stickies to pinned, so professional!

* Add pinned tests

 - Add an id to pin button, and add first attempt at useful tests
 - Improve pin tests, refactored it into adminTests and userTests
 - Add an id to pin button, and add first attempt at useful tests
 - Improve pin tests, refactored it into adminTests and userTests

* Make tests more reliable

Co-authored-by: Joey Yakimowich-Payne <jyapayne@gmail.com>
2021-04-25 18:39:03 -06:00
Joey
48c025ae78
Merge pull request #281 from nim-lang/github_actions
Add github actions
2021-04-24 16:23:05 -06:00
Joey Yakimowich-Payne
8782dff349 Use matrix nim version 2021-04-24 16:19:39 -06:00
Joey Yakimowich-Payne
0055a12fc1 Use choosenim instead 2021-04-24 16:17:17 -06:00
Joey Yakimowich-Payne
8cd5c45cda Remove travis 2021-04-22 16:42:32 -06:00
Joey
5b7b271627
Add github actions 2021-04-22 16:39:25 -06:00
j-james
6c6552176a Support dashes in usernames 2021-01-10 23:07:20 +00:00
digitalcraftsman
3ac9ec3ff6 Center view count of popular threads
Otherwise the counter is misaligned in the corresponding column.
2020-09-22 18:44:42 +01:00
Dominik Picheta
7d8417ff97 Revert "Replace webdriver with halonium" 2020-09-01 18:27:58 +01:00
Joey
b2932e9348
Merge pull request #263 from jyapayne/change-to-halonium
Replace webdriver with halonium
2020-09-01 07:14:00 -06:00
Joey
108aeda5b3
Merge pull request #262 from jyapayne/fix-cannot-edit-category
Fix cannot edit category
2020-09-01 06:04:18 -06:00
Joey Yakimowich-Payne
3d975e8386 Replace webdriver with halonium 2020-08-31 15:43:23 -06:00
Joey Yakimowich-Payne
4f8a585049 Remove unnecessary table identifier 2020-08-31 15:35:17 -06:00
narimiran
b27096ff75 allowed editing time is now much shorter
Other forums usually have allowed editing times measured in
*minutes*, we had it in weeks.

Two hours should be plenty of time to edit a post, but more importantly
it should prevent spamming mis-usages that sometimes happened before:
You read a perfectly normal post (usually copy-pasted from somewhere)
and then much later on (when most of us regular forum users don't notice
anymore because we frequently read new threads/posts) it is edited to
contain spammy links and content.

Admins must be able to always edit a post, no matter of its age.
2020-08-31 22:02:03 +01:00
Joey Yakimowich-Payne
ce3de27fb9 Fix user row index 2020-08-29 12:48:57 -06:00
Joey Yakimowich-Payne
9739c34abd Add test for category change 2020-08-29 11:49:00 -06:00
Joey Yakimowich-Payne
4821746c5d Add user id to the user object and fix thread user check 2020-08-29 11:31:24 -06:00
Miran
dc80ef022e
properly do #255 - moderators can change categories (#258) 2020-08-28 11:10:17 +02:00
Dominik Picheta
77fd9af1cd Version 2.1.0. 2020-08-24 21:02:44 +01:00
Dominik Picheta
e62ae672b3 Optimise threads.json SQL query. 2020-08-24 20:47:01 +01:00
Miran
6e32ec27b4
moderators should be able to edit categories (#255) 2020-08-24 13:48:23 +02:00
jiro
030c52020e
Add docker environment for local development (#257)
* Add docker environment

* Add document of docker

* Move docker files

* Change context

* Move git submodule command to entrypoint.sh

* Update README
2020-08-23 14:30:06 +01:00
Joey
abb770ebd1
Merge pull request #251 from andinus/update-readme-setup-info
Add additional setup information to README
2020-08-06 06:11:45 -06:00
Miran
16c9daea52
fix deprecated import (#254) 2020-08-06 14:07:31 +02:00
Andinus
8bad518e4b
Move setup fail information to Troubleshooting section
This also pastes the output instead of linking to pastebin.
2020-06-13 00:55:32 +05:30
Andinus
5c4d9b36d3
Add additional setup information to README
The build failed for me & was fixed after installing "karax" with
nimble.
2020-06-13 00:06:38 +05:30
Joey
14786d3d49
Merge pull request #250 from andinus/fix-email-change-update
Send confirmation email to updated address
2020-06-12 12:02:52 -06:00
Joey
48314432e9
Merge pull request #249 from hvnsweeting/only-v2-works
Gives note that only reCAPTCHA v2 works at the moment
2020-06-12 11:18:48 -06:00
Joey
d808749054
Merge pull request #248 from hvnsweeting/ssl-support
Add SSL support for sending email
2020-06-12 11:18:18 -06:00
Joey
03fd4fdc13
Merge pull request #246 from andinus/fix-email-date-header
Add Date header to emails
2020-06-12 11:17:51 -06:00
Andinus
ebbfa265d5
Send confirmation email to updated address
This will fix https://github.com/nim-lang/nimforum/issues/155.

Currently nimforum sends the confirmation email to the address in
database but it should've sent it to the new address.

Activity: User changes email
Issue: Confirmation email is sent to old address
Fix: Send the confirmation email to updated address
2020-06-12 22:21:45 +05:30
Viet Hung Nguyen
b933a9b2e8 Gives note that only v2 work at the moment 2020-06-06 21:36:14 +07:00
Viet Hung Nguyen
474fa63985 Add SSL support for sending email 2020-06-06 14:52:35 +07:00
Andinus
418bb3fe47
Add Date header to emails
UTC time is used because we cannot format time as "Fri, 22 May 2020
06:33:00 +0000" with the times package.

"zz" returns +00 & "zzz" returns +00:00, note that the former doesn't
return minutes value so it'll return +05 for systems in timezone
+0530 & "zzz" will return +05:30 for the same.

Instead of parsing it again & removing ':' manually we use UTC time &
add "+0000".
2020-05-22 12:12:11 +05:30
Joey
d372d9f980
Merge pull request #240 from hlaaftana/patch-1
Let users delete their own posts, fixes #208
2020-05-10 10:38:32 -06:00
hlaaftana
2987955e8a
fix delete thread test 2020-05-10 19:15:02 +03:00
hlaaftana
16abee0596
add user delete thread test 2020-05-10 17:54:32 +03:00
hlaaftana
f5e1a71e6e
add clear variables 2020-05-10 17:00:39 +03:00
Joey
f0bfd5516a
Merge pull request #244 from Yardanico/emailtls
Add ability to enable TLS for SMTP
2020-05-10 06:58:18 -06:00
Danil Yarantsev
fcbf22a64a Merge branch 'master' of github.com:nim-lang/nimforum into emailtls 2020-05-10 15:52:13 +03:00
Joey
53ad811bf8
Merge pull request #245 from Yardanico/updkarax
Update Karax and Jester dependencies
2020-05-10 06:31:13 -06:00
Danil Yarantsev
55c9476810 Karax to 1.1.2 2020-05-10 13:11:46 +03:00
Danil Yarantsev
e749512818 Update Karax and Jester dependencies 2020-05-10 13:09:12 +03:00
Danil Yarantsev
a3052efd78 Add smptTls to config loading 2020-05-10 12:59:31 +03:00
Danil Yarantsev
6c6ed08ec9 startTls should be after connection of course 2020-05-10 12:56:13 +03:00
Danil Yarantsev
3b6e7363a9 Add ability to enable TLS for SMTP 2020-05-10 12:52:14 +03:00
hlaaftana
cd565eabe0
Change to row 2020-04-17 14:40:18 +03:00
hlaaftana
f0d9a89167
Fix query for executeDeletePost 2020-04-17 14:30:58 +03:00
hlaaftana
d8661f62c7
Let users delete their own posts, fixes #208 2020-04-15 10:29:42 +03:00
Joey Yakimowich-Payne
8984074809 Fix tests 2020-03-26 22:58:48 +00:00
Joey Yakimowich-Payne
3adba32f1f Left align users 2020-03-26 22:58:48 +00:00
Joey Yakimowich-Payne
f35d6c4a32 Rename default category and change color to grey 2020-03-26 22:58:48 +00:00
Joey Yakimowich-Payne
3db01a1d44 Show category column if not on mobile 2020-03-07 01:21:15 +01:00
Joey Yakimowich-Payne
dcea4091f4 Fix tests 2020-03-06 11:27:41 +01:00
Joey Yakimowich-Payne
c2cc26ea77 Make mobile viewing more friendly 2020-03-06 11:27:41 +01:00
Dominik Picheta
b91bdeb450
Remove reloading and fix issues with state not being refreshed (#232)
* Remove reloading and fix issues with state not being refreshed

* Fix PR comments

* Remove unnecessary closure

* Change proc to anonymous proc

* categoryIdOption -> categoryId
2020-03-04 23:57:27 +01:00
Joey Yakimowich-Payne
7a7a7145ee Fix using magic number as default category id 2020-03-04 23:27:23 +01:00
Joey Yakimowich-Payne
0d63fef0f7 Add one more test for user in categories page 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
b26085cbd0 Update webdriver version 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
60ace9c65a Remove unnecessary check 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5f930c7f5a Ignore more generated files 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
3b092ae2d1 Change unnecessary templates to procs 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
31d3b2701d Add create category test 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
9127bc4c88 Add 'add category button' to categories page 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
95b21874db Separate out add category modal 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
7ec3ff9cac Minor cleanup 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5ce99a5a3d Add tests and improve testing experience 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5fc811e797 Only hide category on category page for testing purposes 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
91746924dc Remove rate limit check on compiler flag 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5e033d0356 Upgrade webdriver and run test backend separately 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
1f0f736915 Fix category clicking 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
433a21aa87 Fix compact and reloading 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
fd80f754d2 Make categories compact by default 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
6c70713afb Cleanup test imports 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
f3396777fb Add expanded version of category drop down and add it to categories page 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
38a21f34e6 Work around elements not refreshing on url operations 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
70ad45d8f6 Add number of topics to category list 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
da7045ecca Cleanup 2020-03-03 15:11:26 +01:00
Joey Payne
9d19d70558 Add categories page 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5a4f44b4ee Speedup and simplify tests
This drastically speeds up tests and simplifies test writing by making
it so that no explicit calls for waiting are needed. Elements that are
queried for now implicitly waits for them to be available.

On my machine, tests used to take 3-4 minutes to complete. Now they take
~1 minute to complete.
2020-02-17 08:53:30 +01:00
Joey Yakimowich-Payne
616c6eb100 Cleanup unused imports and compiler warnings 2020-02-16 14:07:00 +00:00
Joey Yakimowich-Payne
8d317ae0e3 Get rid of casts 2020-02-16 14:07:00 +00:00
Dominik Picheta
42985fd2bc
Merge pull request #194 from jyapayne/add_categories
Add preliminary category support
2020-02-15 17:08:26 +00:00
Joey Yakimowich-Payne
01d13aa0f3 Add todo about categories query 2020-02-15 09:36:50 -07:00
Joey Yakimowich-Payne
7337bceff3 Remove casting from formdata 2020-02-15 09:35:05 -07:00
Joey Yakimowich-Payne
ce9cde4a0d Refactor category picker 2020-02-15 09:02:00 -07:00
Joey Yakimowich-Payne
918cda96cf Minor cleanup 2020-02-15 08:53:56 -07:00
Joey Yakimowich-Payne
d9335ee0f0 Tighten updateThread fields and add todos 2020-02-15 08:52:13 -07:00
Joey Yakimowich-Payne
46d6a3b6bb Fix indentation 2020-02-15 08:37:28 -07:00
Joey Yakimowich-Payne
de7b391d11 Remove unnecessary readme line 2020-02-15 08:36:08 -07:00
Joey Yakimowich-Payne
fac828b1e9 Merge branch 'master' into add_categories 2020-02-14 09:48:08 -07:00
Joey Yakimowich-Payne
2717496bb5 Handle no replyingTo option better 2020-02-14 17:43:17 +01:00
Joey Yakimowich-Payne
14a0864d86 Version bump 2020-02-14 17:43:17 +01:00
Joey Yakimowich-Payne
64262978db Fix travis 2020-02-14 17:43:17 +01:00
Joey Yakimowich-Payne
f93bd87316 Update for Nim 1.0.6 2020-02-14 17:43:17 +01:00
Dominik Picheta
35baa815fd
Merge pull request #219 from lallulli/patch-1
Better way of updating nginx configuration
2019-11-01 21:46:22 +00:00
lallulli
f4af965a2e
Better way of updating nginx configuration
Using `nginx -s reload` is better than restarting the server on live systems, because if there is any problem with new config files, nginx will warn you, refuse to reload and continue to work with old configuration. Moreover, this command will minimize downtime.
2019-10-31 11:36:05 +01:00
Joey Payne
41a1a36dbf Fix issues compiling and testing 2019-01-24 20:37:03 -07:00
Dominik Picheta
b65fccd416
Merge pull request #201 from LemonBoy/hide-avatars
Hide all the avatars but the first on small screens
2018-08-22 21:01:17 +01:00
LemonBoy
30dc09f453 Hide all the avatars but the first on small screens 2018-08-22 17:43:24 +02:00
Dominik Picheta
bedaec3540
Merge pull request #199 from jyapayne/case_insensitive_usernames
Case insensitive usernames
2018-08-16 12:06:29 +01:00
Joey Yakimowich-Payne
2b88a54f54 Add test for case insensitive name check 2018-08-16 11:29:57 +09:00
Joey Yakimowich-Payne
7321ee6f61 Add case insensitive check to username validation 2018-08-16 11:10:49 +09:00
Joey
f8781ba5f3 Add better RST preview error (#196)
* Add better RST preview error

* Add exception information back
2018-08-10 08:51:22 +01:00
Joey Yakimowich-Payne
4e1b906b49 Rename square to category 2018-08-09 10:14:03 +09:00
Joey Yakimowich-Payne
416d2601fb Fix another test issue 2018-08-09 09:51:37 +09:00
Joey Yakimowich-Payne
796d8ee20c Fix tests 2018-08-08 21:41:20 +09:00
Joey Yakimowich-Payne
0af291dc10 Add test for category adding 2018-08-08 19:12:28 +09:00
Joey Yakimowich-Payne
d5df46823a Add category add button on new thread 2018-08-08 19:10:46 +09:00
Joey Yakimowich-Payne
82463ea423 Add server check for category adding 2018-08-08 19:09:54 +09:00
Joey Yakimowich-Payne
f1c5db2ced Add frontend for adding a category with the category picker 2018-08-08 18:47:47 +09:00
Joey Yakimowich-Payne
3b681e32f6 Add backend for creating a category 2018-08-08 18:22:41 +09:00
Joey Yakimowich-Payne
a05667ef78 Add server side checking for user permissions 2018-08-07 21:03:05 +09:00
Joey Yakimowich-Payne
cb7418f825 Add frontend for category picker when admin or thread author 2018-08-07 20:26:48 +09:00
Joey Yakimowich-Payne
b0639c4da2 Add backend for updating thread 2018-08-07 20:25:35 +09:00
Joey Yakimowich-Payne
2e42ede2ad Add basic categories test 2018-08-06 17:30:07 +09:00
Joey Yakimowich-Payne
d35f1e90cf Add name slug for categories and remove unused function 2018-08-06 17:21:18 +09:00
Joey Yakimowich-Payne
495ddf5d93 Add test data for categories 2018-08-06 16:54:55 +09:00
Joey Yakimowich-Payne
3df30386d9 Fix misc compiler warnings 2018-08-06 16:54:16 +09:00
Joey Yakimowich-Payne
5ed17333f9 Add category id to picker 2018-08-06 16:46:04 +09:00
Joey Yakimowich-Payne
f0bcb9abfd Add backend for categories 2018-08-06 16:32:20 +09:00
Joey Yakimowich-Payne
7cda14e9fe Add frontend category picker and enable categories in threadlist 2018-08-06 16:22:32 +09:00
Mr.Chun
7e42479228 make send mail from address configurable (#191)
* feat(email): make send mail from address configurable

* Exit sendMail early if there is no smtpFromAddr.

* Small adjustment to setup_nimforum
2018-07-29 16:04:36 +01:00
Dominik Picheta
ee3324cfb7
Merge pull request #187 from jyapayne/fix_tests
Fix tests
2018-07-26 11:53:23 +01:00
Joey Yakimowich-Payne
85f31aaf6b Update webdriver commit hash 2018-07-23 19:59:05 +09:00
Joey Yakimowich-Payne
7925c4b8b1 Remove navigate from login/logout and add clear command 2018-07-23 10:13:06 +09:00
Joey Yakimowich-Payne
465ba1e024 Fix tests and merge 2018-07-22 20:10:41 +09:00
Joey Yakimowich-Payne
a6c0fe691c Add test for banned user 2018-07-22 19:28:21 +09:00
Dominik Picheta
8c16a776b6 Bump version to 2.0.1. 2018-07-20 15:45:11 +01:00
Dominik Picheta
5714ad0c6a Adds attempt to test issue #181. Moves procs to common too.
Sadly the attempt wasn't a success. I decided to commit what I've
got so far and come back to it later. Out of time for this now.
2018-07-20 15:16:39 +01:00
Dominik Picheta
9f9d16467f Fixes #181. 2018-07-20 14:14:59 +01:00
Dominik Picheta
9ee0ddf176 Sessions now persist across different IP addresses. 2018-07-20 13:52:27 +01:00
Dominik Picheta
adaae54e7b
Merge pull request #185 from markprocess/master
Refactoring exception handling
2018-07-19 21:43:04 +01:00
markprocess
954fe7b05a Refactoring exception handling 2018-07-19 16:09:36 -04:00
Dominik Picheta
22ec94590c Update Jester to 0.4.0. 2018-07-19 19:37:46 +01:00
Dominik Picheta
93913d9b13
Merge pull request #184 from jyapayne/rework_tests
Make tests nicer to write
2018-07-17 16:31:58 +01:00
Joey Yakimowich-Payne
b405f63a32 Separate user and admin tests 2018-07-17 22:51:01 +09:00
Joey Yakimowich-Payne
80558b6bfb Make 5000 ms default wait 2018-07-17 22:32:38 +09:00
Joey Yakimowich-Payne
c361fda523 Replace len check with assert 2018-07-17 22:29:51 +09:00
Joey Yakimowich-Payne
0050ad42f5 Try to appease travis 2018-07-17 20:04:02 +09:00
Joey Yakimowich-Payne
b2fc4dfbe0 Add license id 2018-07-17 12:50:23 +09:00
Joey Yakimowich-Payne
f315be7361 Add tests for like/delete thread 2018-07-16 21:10:46 +09:00
Joey Yakimowich-Payne
3324f37faa Cleanup in scenario1 2018-07-16 21:09:55 +09:00
Joey Yakimowich-Payne
6fb5cfbfa2 Add more friendly classes 2018-07-16 21:09:24 +09:00
Joey Yakimowich-Payne
0d67eab626 Create test and dev users for testing 2018-07-16 19:06:00 +09:00
Joey Yakimowich-Payne
3c93224817 Fix user/password combo 2018-07-16 19:05:26 +09:00
Joey Yakimowich-Payne
d7f3a038a9 Rewrite threads tests with new macro 2018-07-16 14:51:21 +09:00
Joey Yakimowich-Payne
1b55aec5d2 Rewrite scenario1 with new macro 2018-07-16 14:50:33 +09:00
Joey Yakimowich-Payne
cb5923d9f8 Add helpful macro and procs for testing 2018-07-16 14:49:56 +09:00
Joey Yakimowich-Payne
d422b07394 Add more helpful classes and ids 2018-07-16 14:49:06 +09:00
Dominik Picheta
82ff2fb212
Merge pull request #179 from jyapayne/add_threads_test
Add threads tests
2018-07-13 11:50:42 +01:00
Joey Yakimowich-Payne
b650a9f401 Add basic test for creating a thread 2018-07-13 09:18:09 +09:00
Joey Yakimowich-Payne
122e279256 Add helpful ids and classes for testing 2018-07-13 09:16:48 +09:00
Dominik Picheta
8701aa92ac
Merge pull request #173 from jyapayne/patch-1
Update readme with required devdb call
2018-07-06 14:52:05 +01:00
Joey
34f5b3f80a
Update readme with required devdb call 2018-07-06 21:55:34 +09:00
Dominik Picheta
c3c3d91a0a Improves setup document. 2018-05-27 21:45:08 +01:00
Dominik Picheta
48d6480ea4 Version 2.0.0 2018-05-27 21:26:06 +01:00
Dominik Picheta
5c15dd3f11 Add setup file. Fixes #139. 2018-05-27 21:25:08 +01:00
Dominik Picheta
6e207327a5 Fixes post deletion for users. 2018-05-27 20:58:38 +01:00
Dominik Picheta
0e30f4f625 Add --setup option to setup_nimforum script. 2018-05-27 20:55:31 +01:00
Dominik Picheta
b958904b6a Fixes new thread style regression. 2018-05-25 22:59:58 +01:00
Dominik Picheta
8d991374f6 Improves .gitignore and removes outdated forum.json.example file. 2018-05-25 22:57:53 +01:00
Dominik Picheta
d0ec32321c Hotfix mentions in anchors. 2018-05-25 22:57:38 +01:00
Dominik Picheta
e515c168b2 Bump karax dependency to fix Edge issues. 2018-05-25 22:03:23 +01:00
Dominik Picheta
3054be9111 Fixes unicode profile names in backend. 2018-05-25 22:00:51 +01:00
Dominik Picheta
c32daefec7 Fixes profile UTF-8 decoding issue. 2018-05-25 21:43:41 +01:00
Dominik Picheta
a64121fcf9 Add forum hyperlink to readme. 2018-05-24 17:25:37 +01:00
Dominik Picheta
449d47108a Adjust padding for panel-body in #new-thread. 2018-05-24 17:07:29 +01:00
Dominik Picheta
571da55460 Fixes #135. 2018-05-24 17:01:40 +01:00
Dominik Picheta
919c09b4a9 Improve default stylesheet. 2018-05-24 16:06:41 +01:00
Dominik Picheta
d6a2af2217 Adds features to readme. 2018-05-24 16:06:34 +01:00
Dominik Picheta
32b81b94cf Fixes loading of posts/profiles multiple times unnecessarily. 2018-05-24 15:35:52 +01:00
Dominik Picheta
2d4608cb64 Bump version to 2.0. 2018-05-24 15:28:07 +01:00
Dominik Picheta
256bb5815d Improves readme. 2018-05-24 15:27:48 +01:00
Dominik Picheta
adeed9b5be Don't show the "last visit" line after last thread. 2018-05-24 14:52:17 +01:00
Dominik Picheta
5f198a35db Show usernames of people that liked a post on hover. 2018-05-24 14:46:59 +01:00
Dominik Picheta
2e3f8d4235 Adds rst cheatsheet. 2018-05-24 14:43:18 +01:00
Dominik Picheta
dbb3d5c7c1 Fixes Ctrl/Cmd not opening links in new tab. 2018-05-24 14:15:53 +01:00
Dominik Picheta
7baae0bde0 Spammers are now hidden from the thread list properly. 2018-05-24 14:07:52 +01:00
Dominik Picheta
c566b05347 Update Karax and small frontend fixes. 2018-05-24 12:00:06 +01:00
Dominik Picheta
32dbf30781 Fixes overflow for <pre> blocks without .code. 2018-05-23 14:27:44 +01:00
Dominik Picheta
934dbef44c Add recaptcha to reset password modal. 2018-05-23 14:00:49 +01:00
Dominik Picheta
6c4fd6f50b Pin sass package commit. 2018-05-23 12:56:07 +01:00
Dominik Picheta
b8c1ae1fe0 Implements ability to specify the HTTP port. 2018-05-23 12:48:31 +01:00
Dominik Picheta
b9df0ec895 Fixes newThread regression. 2018-05-22 23:04:08 +01:00
Dominik Picheta
3a87ecb0ab Adds titles to threads and profiles. 2018-05-22 22:57:28 +01:00
Dominik Picheta
2f2d83b86c Change 'scripts' to 'ga' for google analytics specific config. 2018-05-22 22:49:34 +01:00
Dominik Picheta
8ee75233d4 Fixes travis failure by creating test DB earlier. 2018-05-22 22:21:59 +01:00
Dominik Picheta
b895bf3d75 Adds ability to specify scripts in config to be added to HTML. 2018-05-22 22:15:42 +01:00
Dominik Picheta
d9b646cca1 Update Nim hash for travis. 2018-05-22 21:59:27 +01:00
Dominik Picheta
21a75483fe Fixes anchored post links spamming the browser history. 2018-05-22 20:44:27 +01:00
Dominik Picheta
5cc405ad69 Change link style in profile view. 2018-05-22 20:29:16 +01:00
Dominik Picheta
b30eba6859 Fixes search regression. 2018-05-22 20:26:13 +01:00
Dominik Picheta
e78c509940 Hide banned threads in the thread list for now using CSS. 2018-05-22 20:20:02 +01:00
Dominik Picheta
568e910040 Implements proper "last visit" line and time highlighting. 2018-05-22 20:06:58 +01:00
Dominik Picheta
e0802d4db0 Ensures old hyperlinks with page numbers are redirected properly. 2018-05-22 17:42:41 +01:00
Dominik Picheta
3e66655ee7 Cleanup old constants. 2018-05-22 17:39:52 +01:00
Dominik Picheta
6027a4fab0 Don't reload page when navigating to search result post. 2018-05-22 15:32:50 +01:00
Dominik Picheta
3e03ffe76d Implements search. 2018-05-22 14:58:29 +01:00
Dominik Picheta
705f212118 Adds ability to override CSS styles from a foreign repo. 2018-05-22 12:59:56 +01:00
Dominik Picheta
375391ae99 Move customisable styles to a custom-style.scss file. 2018-05-22 12:01:57 +01:00
Dominik Picheta
9420adb003 Ensure images don't overflow post container. 2018-05-22 00:13:32 +01:00
Dominik Picheta
34f2a15af1 Use :visited to colour visited links for now.
Until we get proper tracking :)
2018-05-21 23:26:04 +01:00
Dominik Picheta
407f03da77 Moves mockups to mockup folder. 2018-05-21 23:21:05 +01:00
Dominik Picheta
79c61613cd Hides more unimplemented features via CSS. 2018-05-21 23:19:02 +01:00
Dominik Picheta
3d860e9654 Implements importer for old DB. 2018-05-21 23:12:08 +01:00
Dominik Picheta
348a5df509 Hide unfinished features. 2018-05-21 20:49:49 +01:00
Dominik Picheta
318f3d7fa3 Initialise admin account with id of 1 not 0. 2018-05-21 20:40:26 +01:00
Dominik Picheta
c9538ec703 Implements ability to lock threads. 2018-05-21 19:43:16 +01:00
Dominik Picheta
0f58f5f7a0 Use % instead of multiReplace for string vars. 2018-05-21 18:38:50 +01:00
Dominik Picheta
c9901824e2 Fixes for better performance (caching, minifying, etc.) 2018-05-21 16:43:21 +01:00
Dominik Picheta
c0ecf782c8 Implements reset password modal under login modal. 2018-05-21 15:39:47 +01:00
Dominik Picheta
595b0ea086 Use HMAC for ident hashes and adds dedicated activateEmail page. 2018-05-21 15:00:24 +01:00
Dominik Picheta
6ed489ed5b Bump Jester commit to fix #79. 2018-05-21 13:56:29 +01:00
Dominik Picheta
649ba530cb Strip username in executeLogin. Fixes #123. 2018-05-21 00:07:32 +01:00
Dominik Picheta
2905b496fa Experiment with threads. 2018-05-20 23:37:29 +01:00
Dominik Picheta
1aadce71dc Fixes RSS regression. 2018-05-20 23:15:35 +01:00
Dominik Picheta
8e05dab331 Fixes #19. 2018-05-20 22:53:32 +01:00
Dominik Picheta
38f16606e8 Small style adjustments. 2018-05-20 22:25:12 +01:00
Dominik Picheta
d20d0e87ec Simple view incrementing on /posts request. 2018-05-20 22:02:58 +01:00
Dominik Picheta
40167ef1e4 Implements "can register" web driver test. 2018-05-20 21:43:09 +01:00
Dominik Picheta
d905a27f0c Disregard deleted accounts for duplicate checks. 2018-05-20 21:42:51 +01:00
Dominik Picheta
84a80ded03 Implements /activateEmail and changes how expiry is determined. 2018-05-20 21:21:19 +01:00
Dominik Picheta
2f5a6d888b Improves reset password page style. 2018-05-20 18:41:29 +01:00
Dominik Picheta
f8a9909278 Allow moderators to edit profiles, but don't show them the emails. 2018-05-20 18:25:51 +01:00
Dominik Picheta
93ae21cee6 Remove all sessions on password reset. 2018-05-20 17:36:39 +01:00
Dominik Picheta
b2ed4247df Implements password resets. 2018-05-20 17:30:39 +01:00
Dominik Picheta
249031b9a8 Implements /resetPassword and refactors email code. 2018-05-20 15:03:56 +01:00
Dominik Picheta
4c0ef19344 Removes a lot of unused code from backend. 2018-05-20 13:54:51 +01:00
Dominik Picheta
4f2f95efb5 Frontend now built with CSS. Improves about page. 2018-05-20 13:32:43 +01:00
Dominik Picheta
bfa5bfe4d9 Fixes editing for ordinary users and new thread field name. 2018-05-20 13:09:16 +01:00
Dominik Picheta
1592be0503 Implements /about/license 2018-05-19 23:43:10 +01:00
Dominik Picheta
ff70dce9e8 Attempt to build libsass manually for travis. 2018-05-19 23:18:29 +01:00
Dominik Picheta
c4431ebf2e Fixes editing box regression. 2018-05-19 20:57:45 +01:00
Dominik Picheta
0a53392258 Reset states properly when navigating to new url. 2018-05-19 20:27:29 +01:00
Dominik Picheta
2fbebfa3f9 Take deleted accounts into account. 2018-05-19 19:50:46 +01:00
Dominik Picheta
aa9f24e1d7 Switch permissions around. EmailUnconfirmed's are now visible but cannot post. 2018-05-19 19:05:34 +01:00
Dominik Picheta
2ab20bf7a5 Implements deleteUser and updateProfile in backend. 2018-05-19 18:47:03 +01:00
Dominik Picheta
dc72aeb677 Prevent stalls due to no -y flag. 2018-05-19 17:04:43 +01:00
Dominik Picheta
b4f96c8071 Attempt to run on xenial so that we can install libsass. 2018-05-19 15:47:18 +01:00
Dominik Picheta
54a7060dba Refactors and improves profile settings tab. 2018-05-19 15:41:11 +01:00
Dominik Picheta
dff0e89115 Pin more dependencies. 2018-05-19 14:05:55 +01:00
Dominik Picheta
8e7142420d Pin the Nim version inside the travis file. 2018-05-19 13:48:45 +01:00
Dominik Picheta
e3055920ea Adds travis.yml file. 2018-05-19 13:29:53 +01:00
Dominik Picheta
e5772b8579 Implements login test. 2018-05-19 13:18:57 +01:00
Dominik Picheta
c6ed9e80c9 Implements web driver test suite and simple test. 2018-05-18 22:33:56 +01:00
Dominik Picheta
770b5cddab Fix rare threadlist race condition properly. 2018-05-18 20:53:07 +01:00
Dominik Picheta
8518c70a66 Rearranges directories and files. 2018-05-18 20:48:01 +01:00
Dominik Picheta
41a6790fe8 Implements delete button fully in frontend and backend for posts and thread. 2018-05-18 19:54:33 +01:00
Dominik Picheta
dd9be8f639 Reset LikeButton error on mouse leave. 2018-05-18 17:11:22 +01:00
Dominik Picheta
a0655e049d Implements likes fully in frontend and backend. 2018-05-18 17:04:34 +01:00
Dominik Picheta
5c9f1bb85e Rearrange post buttons. 2018-05-18 14:16:19 +01:00
Dominik Picheta
d5f1c674c5 Implements edit history. 2018-05-18 14:03:35 +01:00
Dominik Picheta
8acaca298b Fixes bug with thread list loading twice on refresh. 2018-05-18 12:01:00 +01:00
Dominik Picheta
7f5e68331c Adds needsPasswordReseti DB field for future use. 2018-05-18 11:59:20 +01:00
Dominik Picheta
8d41060c54 Implements display of replyingTo in front end. 2018-05-17 22:44:30 +01:00
Dominik Picheta
1ecd8daa7c Implements categories in threads list. 2018-05-17 21:19:40 +01:00
Dominik Picheta
e34501a61a Implements replyingTo in the backend. 2018-05-17 20:52:59 +01:00
Dominik Picheta
c4df36d461 Fixes thread activity not updating. 2018-05-17 20:14:41 +01:00
Dominik Picheta
79c47d47f3 Implements proper 404s. 2018-05-17 20:09:35 +01:00
Dominik Picheta
416655764d Ensure deleted posts and threads are not accessible. 2018-05-17 19:54:15 +01:00
Dominik Picheta
e04403c7f1 Adds replyingTo field to post. 2018-05-17 19:40:14 +01:00
Dominik Picheta
3810220d37 Adds missing auth module. 2018-05-17 19:32:30 +01:00
Dominik Picheta
87952e8d4d Fixes category creation in setup script. 2018-05-17 19:30:00 +01:00
Dominik Picheta
7eb6b081ad Fixes forum to work in dev mode. Creates setup_nimforum script. 2018-05-17 19:22:46 +01:00
Dominik Picheta
3804faab3b Fixes issues caused by incorrect user comparisons. 2018-05-17 17:35:54 +01:00
Dominik Picheta
21b8165751 Hide moderated posts. Add indicators of user rank on posts. 2018-05-17 17:27:08 +01:00
Dominik Picheta
840d8164eb Moves the new design to /. 2018-05-17 17:13:02 +01:00
Dominik Picheta
8a8f99e198 Reintroduce spectre submodule. 2018-05-17 16:37:11 +01:00
Dominik Picheta
9f087be6e7 Minor cleanup of old useless files. 2018-05-17 16:33:04 +01:00
Dominik Picheta
825c1d6548 s/redesign/frontend/ 2018-05-17 16:16:45 +01:00
Dominik Picheta
37d9fb3bb7 Removes email from profile stats. 2018-05-17 16:11:01 +01:00
Dominik Picheta
581eba73e3 Implements post button for requesting a password reset. 2018-05-17 13:42:35 +01:00
Dominik Picheta
81e5bf5af0 Improvements to profile settings. 2018-05-17 12:54:50 +01:00
Dominik Picheta
87605e7d94 Some work on settings tab in profile page. 2018-05-17 00:10:36 +01:00
Dominik Picheta
5c86ae5d12 Add small TODO for edit box. 2018-05-16 23:06:21 +01:00
Dominik Picheta
765b43cf4a Fixes like button not showing up for admins. 2018-05-16 22:55:25 +01:00
Dominik Picheta
2b8c6d585f Show post edit buttons for admins too. 2018-05-16 22:50:55 +01:00
Dominik Picheta
fc7dabddda Finalises implementation of post editing. 2018-05-16 22:41:36 +01:00
Dominik Picheta
1be362259c Implements Cancel/Save buttons for edit box. 2018-05-16 19:14:28 +01:00
Dominik Picheta
c5fd70d275 Implements edit box and rearranges post buttons. 2018-05-16 18:45:42 +01:00
Dominik Picheta
84caff7c97 Adds global reply button to postlist. 2018-05-16 17:43:01 +01:00
Dominik Picheta
60694d9fbd Moderated posts are now shown to correct people in correct circumstances. 2018-05-16 15:13:41 +01:00
Dominik Picheta
c6b42c5979 Prevent registration with duplicate emails. 2018-05-16 14:49:59 +01:00
Dominik Picheta
c338d5e930 Fixes crash with profile view on new profile. 2018-05-16 14:39:14 +01:00
Dominik Picheta
3a394386d4 Implements registration. Refactors login backend code. 2018-05-16 14:29:01 +01:00
Dominik Picheta
fe7c39b538 Fixes user presence on threadlist. 2018-05-16 12:53:07 +01:00
Dominik Picheta
bc104ff41d Implements anchor awareness. 2018-05-15 22:13:53 +01:00
Dominik Picheta
bd150d04de Improve users list query and get thread authors as well. 2018-05-15 21:28:51 +01:00
Dominik Picheta
f6e6929c25 Show newthread error underneath subject. 2018-05-15 20:46:42 +01:00
Dominik Picheta
702967f624 Implements newthread logic on backend and marries it all together. 2018-05-15 20:40:59 +01:00
Dominik Picheta
76c7f43079 Create separate page for new thread modal. 2018-05-15 19:52:21 +01:00
Dominik Picheta
c966ec8f92 Implements new thread modal. 2018-05-15 19:40:07 +01:00
Dominik Picheta
35930799a9 Adds "New Thread" button. 2018-05-15 18:35:19 +01:00
Dominik Picheta
b30bedd65e Improves time passed messages. 2018-05-15 18:17:46 +01:00
Dominik Picheta
9ce1ad94c7 Fixes profile view not reloading on new username. 2018-05-15 16:30:08 +01:00
Dominik Picheta
091d21b50f Add links to all user avatars and refactor user things into user module. 2018-05-15 16:27:22 +01:00
Dominik Picheta
87d94397e5 Show user emails to Admins only under spoiler class. 2018-05-15 16:06:02 +01:00
Dominik Picheta
30721d53d6 Implements posts and threads list in profile page. 2018-05-15 15:52:01 +01:00
Dominik Picheta
2dd9dd52a2 Implements simple stats in profile view. 2018-05-15 14:14:37 +01:00
Dominik Picheta
d345bf76ae Implements naive /profile.json endpoint. 2018-05-14 22:45:27 +01:00
Dominik Picheta
6380ea699d Use big <div> to hide user menu in a more intuitive fashion. 2018-05-14 21:53:13 +01:00
Dominik Picheta
681c3ef19d Fixes user menu on larger screens. 2018-05-14 21:40:49 +01:00
Dominik Picheta
7f895123f9 Refactor on*Post procedures. 2018-05-14 21:23:44 +01:00
Dominik Picheta
7635478b34 Implements posting of replies. 2018-05-14 21:11:18 +01:00
Dominik Picheta
073f274e04 Small fix to blockquote parser.
Decided to leave the blockquote parser alone. It works well
enough and the small bugs it contains aren't critical.
2018-05-14 19:14:04 +01:00
Dominik Picheta
01315d2b34 Adds missing syntax.scss syntax highlighting styles file. 2018-05-14 18:29:12 +01:00
Dominik Picheta
b2c60f3995 Fixes "mention" transformation and parsing quirks. 2018-05-14 18:28:55 +01:00
Dominik Picheta
eeda927085 Implements mention highlighting in posts. 2018-05-14 14:41:12 +01:00
Dominik Picheta
53ed3717b8 Hide "Run" button when appropriate and "none" language caption. 2018-05-14 12:37:15 +01:00
Dominik Picheta
52f1e9c365 Implements syntax highlighting and <hr> style. 2018-05-14 12:28:45 +01:00
Dominik Picheta
e1b72ed566 Normalize and adjust <tt> and <code> styles. 2018-05-14 10:53:53 +01:00
Dominik Picheta
ed5f715ae5 Fixes styles so that content of post doesn't overflow its container. 2018-05-13 23:15:07 +01:00
Dominik Picheta
8b01e452b6 Improves post rendering to support multiple load more buttons. 2018-05-13 22:59:29 +01:00
Dominik Picheta
e13a4425f7 Show proper error when preview fails. 2018-05-13 19:26:36 +01:00
Dominik Picheta
67a5869c10 Implements rendering of RST using server and verbatim node in karax. 2018-05-12 18:36:51 +01:00
Dominik Picheta
4ff5df6be2 Working post preview! 2018-05-12 16:43:52 +01:00
Dominik Picheta
53c0bd89b9 Use old post anchors. Highlight anchored post. 2018-05-12 14:46:13 +01:00
Dominik Picheta
6fe8286509 Fixes reply box's border. 2018-05-12 14:16:23 +01:00
Dominik Picheta
9f059bfade Fixes misleading post time rendering. 2018-05-12 14:13:04 +01:00
Dominik Picheta
7faa8a8dae Adds margin to reply box panel. 2018-05-12 14:12:57 +01:00
Dominik Picheta
96e78a5b84 Show who you're replying to and scroll to reply box. 2018-05-12 14:08:08 +01:00
Dominik Picheta
fe49dd1e85 Fixes small style regression. 2018-05-11 22:41:11 +01:00
Dominik Picheta
aab10809a2 Implements reply box UI. 2018-05-11 20:52:07 +01:00
Dominik Picheta
dc7e9fda31 The plurals in the English language always get you 2018-05-11 20:24:53 +01:00
Dominik Picheta
ea6ced889c Implements rendering of time-passed divs. 2018-05-11 20:21:28 +01:00
Dominik Picheta
f1e2a68d86 Pass user info to post renderer. 2018-05-11 19:49:57 +01:00
Dominik Picheta
594f230480 Allow toggling of menu by clicking on the user icon. 2018-05-11 17:42:19 +01:00
Dominik Picheta
17436010a2 Show spinner on login. 2018-05-11 17:41:06 +01:00
Dominik Picheta
8406923915 Implements login via enter key. 2018-05-11 17:37:52 +01:00
Dominik Picheta
a7b616e63a Implements user dropdown and the ability to log out. 2018-05-11 17:33:10 +01:00
Dominik Picheta
4d115622d6 Small fix to genFormField. 2018-05-11 16:17:41 +01:00
Dominik Picheta
01253244f7 Reimplement sign up form. 2018-05-11 16:15:13 +01:00
Dominik Picheta
f9efbe04d3 Implements proper error handling for login form. 2018-05-11 15:49:22 +01:00
Dominik Picheta
c0bbce53e9 Implements logging in. 2018-05-11 13:53:26 +01:00
Dominik Picheta
29eb22cf9c Implements sign up form. 2018-05-11 00:32:01 +01:00
Dominik Picheta
4c0a88a131 Create login modal. 2018-05-10 23:33:48 +01:00
Dominik Picheta
e790e8ac57 Allow refreshes on other URLs too. 2018-05-10 19:13:42 +01:00
Dominik Picheta
19a9f24d3d Implements loading of more posts. 2018-05-10 19:07:53 +01:00
Dominik Picheta
4176bdee3c Use Jester's pattern matcher for simple routing. 2018-05-10 18:21:12 +01:00
Dominik Picheta
cf37fa34c4 Small style and other fixes. 2018-05-10 16:24:11 +01:00
Dominik Picheta
366bdadc90 Implements post list in front end and backend. 2018-05-10 16:05:42 +01:00
Dominik Picheta
b2225dec34 Implements navigation. 2018-05-10 00:22:05 +01:00
Dominik Picheta
40e948bcf8 Move karax redesign to /karax/ 2018-05-09 22:41:25 +01:00
Dominik Picheta
656b679a51 Load more button works. 2018-05-09 22:38:02 +01:00
Dominik Picheta
032d70a233 Move everything related to ThreadList to the threadlist module. 2018-05-09 22:10:03 +01:00
Dominik Picheta
2e95d078e1 Move genTopButtons to threadlist module. 2018-05-09 22:00:30 +01:00
Dominik Picheta
ca0de4d2ae Small fixes and adjustments to thread list. 2018-05-09 19:16:12 +01:00
Dominik Picheta
8910e55ad1 Implements thread list in Karax and backend. 2018-05-09 19:02:18 +01:00
Dominik Picheta
2efd694660 Fixes forum for 0.18.1. 2018-05-09 17:15:03 +01:00
Dominik Picheta
a88f879d36 Top buttons in Karax. 2018-05-09 15:01:22 +01:00
Dominik Picheta
9e61224b2c Beginnings with karax. 2018-05-09 14:54:32 +01:00
Dominik Picheta
07e8af644e Load More Posts in thread.html. 2018-05-09 13:38:50 +01:00
Dominik Picheta
5795235a47 Load More Threads button in thread list. 2018-05-09 13:23:14 +01:00
Dominik Picheta
00ac2332a5 Add simple "reply" info box. 2018-05-09 12:58:34 +01:00
Dominik Picheta
d27db4c8e4 Adds information to emphasise delay between posts. 2018-05-08 19:52:13 +01:00
Dominik Picheta
a032c6686c Add execution result. 2018-05-08 19:18:48 +01:00
Dominik Picheta
d2f3563d3f Abstract away nav buttons. Add "Run" button to Nim code. 2018-05-08 19:07:30 +01:00
Dominik Picheta
f4e91a9d0e Make posts div larger. 2018-05-08 17:22:56 +01:00
Dominik Picheta
3a44489fcc Use mentions in threads. 2018-05-08 17:12:42 +01:00
Dominik Picheta
f4a1a97ccf Adjustments to code and blockquote styles. 2018-05-08 16:54:35 +01:00
Dominik Picheta
514bcf28ed Small adjustments 2018-05-08 16:11:12 +01:00
Dominik Picheta
e51b74a017 Initial thread design. 2018-05-08 16:02:43 +01:00
Dominik Picheta
72a1863c29 Larger margin on top buttons. 2018-05-08 13:49:25 +01:00
Dominik Picheta
343a842fe0 Add icons to sign up/log in buttons. 2018-05-08 13:38:15 +01:00
Dominik Picheta
d2b065de7e Improve look of main buttons. 2018-05-08 13:34:56 +01:00
Dominik Picheta
a26964b158 Fix header to show background across whole page. 2018-05-08 13:19:56 +01:00
Dominik Picheta
14f08edeb7 Fixes button colours. 2018-05-07 23:37:27 +01:00
Dominik Picheta
088afbb9ac Fixes navbar colours. 2018-05-07 23:18:09 +01:00
Dominik Picheta
7a13c32ccf Triangles for category colours?
Being different for the sake of it probably isn't a good idea,
but I'll see how it goes.
2018-05-07 22:31:00 +01:00
Dominik Picheta
c62046d2a3 Add locked thread and solved thread icons. 2018-05-07 22:16:37 +01:00
Dominik Picheta
88b0bf8ae5 Create last visit separator. 2018-05-07 22:09:09 +01:00
Dominik Picheta
6e032045e6 Create unread count label. 2018-05-07 21:37:35 +01:00
Dominik Picheta
24b60f36a5 Some messing about with view/reply/activity colours. 2018-05-07 21:21:34 +01:00
Dominik Picheta
91207650cc Smaller table headings. 2018-05-07 21:06:21 +01:00
Dominik Picheta
591f665cef Better avatars. 2018-05-07 20:56:27 +01:00
Dominik Picheta
25b1b42f13 Create threads table. 2018-05-07 20:37:47 +01:00
Dominik Picheta
736ec8c09a Implement secondary "navbar" containing top buttons. 2018-05-07 20:26:17 +01:00
Dominik Picheta
c10d5f4e44 Simple navbar. Will need to fix colours later. 2018-05-07 19:58:42 +01:00
Dominik Picheta
86a7280585 Adds mockup skeleton HTML file. 2018-05-07 18:53:31 +01:00
Dominik Picheta
6200a30ccf Add Spectre skeleton with builder. 2018-05-07 18:45:03 +01:00
Dominik Picheta
c19bab30f4
Merge pull request #111 from stisa/improve-run
Improvements to `Run`
2018-02-18 21:54:14 +00:00
Dominik Picheta
fa7bc2ec39
Merge pull request #114 from stisa/postlink
Make date a link to the post
2018-02-18 21:52:57 +00:00
stisa
a44e17d03a restrict run to langNim 2017-11-07 23:35:59 +01:00
stisa
212f49623e merge css rules 2017-10-30 20:05:40 +01:00
stisa
f47449ea5c add post link to date; ref #113 2017-10-30 19:59:01 +01:00
stisa
0121f8bd9d loading; open in playground; improve compat (ie9+) 2017-10-24 23:12:19 +02:00
Dominik Picheta
42d38de17a Merge pull request #108 from stisa/fix-#102
Use rst.txt as help file - Fix #102
2017-10-23 19:29:16 +01:00
Dominik Picheta
bca6096b65 Merge pull request #110 from stisa/run-nim-code
Allow to run nim code in posts (via playground)
2017-10-23 19:28:44 +01:00
stisa
a956261ae8 change btn style to match sidebar 2017-10-23 20:11:04 +02:00
stisa
31f30a2fde use button 4; remove margin from result 2017-10-22 22:05:00 +02:00
stisa
1b0d39d706 overwrite result instead of appending multiple times 2017-10-22 22:01:30 +02:00
stisa
95280674e5 avoid innerHtml 2017-10-22 17:24:06 +02:00
stisa
50587abe9c tiny js script that runs listings in the playground 2017-10-22 17:08:50 +02:00
Dominik Picheta
ee0dc0a1c6 Merge pull request #109 from Yardanico/patch-1
Fix link for the Araq's blog
2017-10-19 19:08:08 +01:00
Daniil Yarancev
55d4790060 Fix link 2017-10-17 18:44:11 +03:00
Daniil Yarancev
2cc8eb9269 Fix link for the Araq's blog
Also make a new github link for nim :)
2017-10-17 15:10:15 +03:00
stisa
afe9b41249 proper h1/h2, add images 2017-10-07 22:30:50 +02:00
stisa
bd730df6c4 use relative path for rst help 2017-10-07 22:18:38 +02:00
stisa
25d4303c69 style pre blocks 2017-10-07 22:15:23 +02:00
Dominik Picheta
72d179b980 Merge pull request #107 from stisa/remove-cairo-dep
Remove cairo from .nimble deps
2017-10-07 16:38:59 +01:00
stisa
6c72d077e3 link to rst help - fixes #102 2017-10-07 17:33:21 +02:00
stisa
d441f9445d repurpose rst.txt as rst help 2017-10-07 17:32:34 +02:00
stisa
8bbe9356fe close a paren, fix a link 2017-10-07 17:17:08 +02:00
stisa
13f2de94a3 macos bcrypt may need a fixed version 2017-10-07 17:11:50 +02:00
stisa
957b3738f8 remove cairo dependency and update the readme 2017-10-07 16:54:13 +02:00
Dominik Picheta
238903ca3b Merge pull request #98 from euantorano/master
Adding reCAPTCHA rather than the custom captcha.
2017-04-23 17:16:10 +02:00
Dominik Picheta
ee7318966a Merge pull request #99 from stisa/patch-1
Fix compilation error: Move addr, port to connect in smtp
2017-04-21 20:27:00 +02:00
Silvio
57f5a81e48 Fix compile: Move addr, port to connect in smtp
Following fecad72e02 , moved address and port to `connect` from `newAsyncSmtp` , forum compiles again.
2017-04-19 15:03:33 +02:00
Euan Torano
e6c4c85bb1 Making TForumData a ref object
Signed-off-by: Euan Torano <euantorano@gmail.com>
2017-03-19 18:43:28 +00:00
Euan Torano
b780b970f4 Use captcha for reset password.
Signed-off-by: Euan Torano <euantorano@gmail.com>
2017-03-07 19:09:46 +00:00
Euan Torano
c1bd44b997 Adding reCAPTCHA rather than the custom captcha.
Signed-off-by: Euan Torano <euantorano@gmail.com>
2017-03-07 18:57:05 +00:00
Dominik Picheta
d5892db647 Merge pull request #96 from euantorano/feature-show-last-ip-on-profile
Show the user’s last IP on profile
2017-03-01 19:05:50 +00:00
Euan Torano
9200165c42 Show the user’s last IP on profile
Signed-off-by: Euan Torano <euantorano@gmail.com>
2017-03-01 18:55:58 +00:00
Dominik Picheta
c722234594 Merge pull request #95 from euantorano/patch-1
Update Gravatar URL to use HTTPS
2017-02-28 17:17:53 +00:00
Euan T
720f38b3d4 Update Gravatar URL to use HTTPS
Gravatars are currently loaded over HTTP, causing mixed content forums. Changing to HTTPS is a trivial change that makes sense in my opinion.
2017-02-28 13:14:51 +00:00
Dominik Picheta
040d1655aa Merge pull request #94 from trustable-code/patch-1
CSS: Add missing font color definition
2017-02-25 20:17:35 +01:00
Simon Krauter
5184c7c281 CSS: Add missing font color definition
Fixes: On my pc, text and background have the same color.
2017-02-25 20:03:07 +01:00
Andreas Rumpf
e65168db55 db model: introduce indexes for better performance 2017-01-05 11:49:13 +01:00
Andreas Rumpf
27a1305b2d Merge pull request #91 from nim-lang/first-post-needs-moderation
First post needs moderation
2017-01-02 23:20:50 +01:00
Andreas Rumpf
9788e93676 adjusted to dom's remarks 2017-01-02 20:49:27 +01:00
Andreas Rumpf
a2edc92fc5 posts from spammers are not visible 2017-01-02 10:55:54 +01:00
Andreas Rumpf
31e62f83bb everything works now 2017-01-02 02:45:27 +01:00
Araq
ca06f4e988 new version compiles; still unfinished 2017-01-01 23:24:58 +01:00
Araq
30ed9e2076 don't use admin field in db anymore 2017-01-01 21:00:37 +01:00
Araq
aff6e426eb new Rank enum; admins and moderators; sane data model 2017-01-01 20:34:25 +01:00
Araq
a6fdd16893 get rid of most deprecation warnings 2016-12-31 11:59:03 +01:00
Dominik Picheta
9d8afecc5c Merge pull request #90 from yglukhov/formatted-emails
Formatted emails
2016-08-09 22:41:15 +02:00
Yuriy Glukhov
82ce6a5ffc Formatted emails 2016-08-09 18:32:27 +03:00
Dominik Picheta
85f527ee4f Merge pull request #89 from avsej/fix-content-for-rss-readers
Allow to specify root HTML node for RST formatter
2016-07-25 21:36:38 +01:00
Sergey Avseyev
1ecca84b2e Allow to specify root HTML node for RST formatter
It turns out that many RSS readers (or at least two I'm using:
http://feedly.com and http://twentyfivesquares.com/press/) do not handle
non-HTML tags in Atom content tag and skipping the whole content of
it. This patch allows to specify different (in this case <div>) node to
make readers happy.
2016-07-25 23:30:05 +03:00
Dominik Picheta
c375e39983 Add rate limiting. 2016-06-19 20:28:39 +01:00
Dominik Picheta
840c7b173d Implement /deleteAll. 2016-06-19 19:58:34 +01:00
Dominik Picheta
e1d68792a4 Fix bans not working when user is already logged in. 2016-06-16 21:59:06 +01:00
Dominik Picheta
da6bc1658e Merge pull request #88 from avsej/fix-random-usage
Fix usage of 'random' after moving from 'math'
2016-06-10 15:12:14 +01:00
Sergey Avseyev
3b6c89c491 Fix usage of 'random' after moving from 'math' 2016-06-10 16:40:59 +03:00
Dominik Picheta
113dcf6def Fix utils.loadConfig 2016-05-16 13:48:38 +01:00
Dominik Picheta
908c14591d Increase registration field length limits in HTML 2016-05-13 23:47:06 +01:00
Dominik Picheta
4701c0ecee Merge pull request #81 from singularperturbation/feature/fix_stdtmpl
Make .tmpl files use stdtmpl | standard
2016-04-28 17:06:35 +01:00
singularperturbation
fa2aff26b4 Make .tmpl files use stdtmpl | standard
Wouldn't compile on 0.13.1 with `#! stdtmpl`, would with
`#? stdtmpl | standard`
2016-03-16 22:34:57 -05:00
Dominik Picheta
f6f4e5c888 Implemented quotes. 2016-01-30 15:05:28 +00:00
Dominik Picheta
bb494f0b65 Implemented an inline rst reference. Ref #75. 2016-01-30 12:09:08 +00:00
Dominik Picheta
b71a08fa21 Fixes #68. 2016-01-30 10:49:03 +00:00
Dominik Picheta
54993195b2 Fixes #74. 2016-01-30 10:46:24 +00:00
Dominik Picheta
6bded9791e Remove replyBtn on edit post page. Remove dead code. 2016-01-30 10:29:23 +00:00
Dominik Picheta
d75c11c5eb Fix crash when no mlist address is configured. 2016-01-30 10:21:25 +00:00
Dominik Picheta
0204aadddb Fixes @dom96/jester#53.
https://github.com/dom96/jester/issues/53
2016-01-30 10:14:17 +00:00
Dominik Picheta
00b01e5585 Get rid of post length limit, also decrease lower limit. Ref #71. 2015-11-17 11:24:24 +00:00
Dominik Picheta
852b3c13eb Fixed incorrect if condition. 2015-11-07 01:02:46 +00:00
Dominik Picheta
c2f70ce5ad Fix yet another [] crash. 2015-11-07 01:01:42 +00:00
Dominik Picheta
1008ab6476 Implement primitive spam checking. 2015-11-07 00:44:40 +00:00
Dominik Picheta
a5963c9164 Fixes for 0.12.0 (tables.[]), and added -d:dev. 2015-11-07 00:21:31 +00:00
Dominik Picheta
338518082c Merge pull request #71 from singularperturbation/feature/content_length
Enforce DB content length limit
2015-11-06 23:14:49 +00:00
Sloane Simmons
bf96ec645e Enforce DB content length limit
Content length limit of 1000 characters at DB level should be enforced
at the application level.
2015-10-31 20:03:46 -05:00
Dominik Picheta
35e01a6fcb Force users thumbnails to 20px
This fixes what appears to be a gravatar bug. See https://twitter.com/d0m96/status/647859715028975616
2015-09-27 20:47:38 +01:00
Dominik Picheta
f820838282 Merge pull request #65 from FedericoCeratto/master
Add From to every outgoing email
2015-09-05 00:19:16 +01:00
Federico Ceratto
520ec0fa02 Add From to every outgoing email 2015-09-05 00:00:12 +01:00
Dominik Picheta
eab5d985d0 Merge pull request #64 from FedericoCeratto/improve_mlist
Add author name and threading to emails to the mailing list
2015-09-02 20:53:34 +01:00
Federico Ceratto
409b40cffb Add author name, dates, threading to emails to the mailing list 2015-09-01 10:47:44 +01:00
Dominik Picheta
8f8d995e69 Merge pull request #62 from FedericoCeratto/resent-from
Add Resent-From header when syncing to a mailing list
2015-08-29 21:32:02 +01:00
Federico Ceratto
0e1062a692 Add Resent-From header when syncing to a mailing list 2015-08-27 23:17:08 +01:00
Dominik Picheta
8e17315282 Merge branch 'FedericoCeratto-master' 2015-08-23 22:39:34 +01:00
Dominik Picheta
633435a2fe Added asyncCheck to discarded future and moved out of template. 2015-08-23 22:38:57 +01:00
Federico Ceratto
087dbfd60a Implement basic mailing list mirroring #57 2015-06-21 17:13:00 +01:00
Dominik Picheta
8a22674852 Add allowed time before ident hash expires. 2015-05-29 23:40:06 +01:00
Dominik Picheta
5fc78ff831 Fix success reset password message. 2015-05-28 22:28:45 +01:00
Dominik Picheta
1390d2a22a Added missing utils.nim module. 2015-05-28 22:01:11 +01:00
Dominik Picheta
05a529df5b Email activation and password resets for users. 2015-05-28 01:36:31 +01:00
Dominik Picheta
5d0784c55b Merge pull request #56 from Nycto/master
Minor speed improvements
2015-04-24 14:27:18 +01:00
Nycto
3499711b60 Run pngcrush on all images 2015-04-23 20:10:58 -07:00
Nycto
aacd7639a3 Position glow arrow with CSS 2015-04-23 19:58:15 -07:00
Nycto
7e32c81358 Fix deprecated negative index warning 2015-04-23 19:47:10 -07:00
Dominik Picheta
b7584de440 Added a way for admins to reset passwords. 2015-03-16 20:01:19 +00:00
Dominik Picheta
5742a1d33d Merge pull request #49 from G4MR/master
Updated css to show which forum links were visited
2015-03-10 14:15:26 +00:00
Dominik Picheta
b2824ea68d Merge pull request #50 from btbytes/master
Remove text justifcation on `p#content`
2015-03-10 14:14:44 +00:00
Pradeep Gowda
4c64cbed14 Remove text-justify on p#content
Justified text is arguably harder to read than left-aligned text.
2015-03-01 19:33:41 -05:00
lamonte
05a4861212 Updated css to show which forum links were visited 2015-02-22 12:06:49 -06:00
Dominik Picheta
3d495e152c Merge pull request #48 from oderwat/fix-config
Updated the config file
2015-02-21 22:57:16 +00:00
Hans Raaf
17fd398c8c Updated the config file
I updated the config file to use the syntax of the nim.cfg file.
2015-02-21 23:40:11 +01:00
Dominik Picheta
aa70f5c420 Merge pull request #47 from flaviut/fix-bg
Improve background quality
2015-02-20 13:45:50 +00:00
Flaviu Tamas
4fa2d1a75b Improve background quality
See flaviut/Nim@e36011a5a1 for details
2015-02-20 07:45:20 -05:00
Dominik Picheta
db45ab463c Merge pull request #45 from oderwat/fix-gitignore
Added binaries to ignored files for convenience
2015-02-15 12:41:54 +00:00
Dominik Picheta
4860f35099 Merge pull request #44 from oderwat/fix-readme
I added some important info to the readme.
2015-02-15 12:41:33 +00:00
Hans Raaf
77b7812994 I added some important info to the readme.
There was no information about how to create the db. I also added
informations about compiling it on OS X (which can be removed after the
bcryptnim maintainer has updated his package. There is a PR for that
from me).
2015-02-15 12:58:45 +01:00
Hans Raaf
3455388197 Added binaries to ignored files for convenience
I often add recursive or by wildcard. This way the binaries will not end
up in the commits.
2015-02-15 12:58:15 +01:00
Dominik Picheta
5ab18cd953 More Nimrod -> Nim. Readme improvements. 2015-02-14 15:16:24 +00:00
Dominik Picheta
612fed9f0e Merge branch 'master' of github.com:nimrod-code/nimforum
Conflicts:
	forum.nim
	main.tmpl
2015-02-14 14:42:34 +00:00
Dominik Picheta
cc5d611f93 Babel -> Nimble 2015-02-14 14:39:32 +00:00
Dominik Picheta
6da5f369db Merge branch 'new_async'
Conflicts:
	forum.nim
	main.tmpl
2015-02-14 14:37:47 +00:00
Dominik Picheta
a12349427d Reverted to 42e74d230d 2015-02-14 14:33:21 +00:00
Dominik Picheta
a03e5a9bd3 Fixes #36 2015-02-14 14:22:25 +00:00
Dominik Picheta
e61f2462b7 Fixes navigation bar. Updates copyright year. 2015-02-14 14:19:44 +00:00
Dominik Picheta
8d800c753b Nimrod -> Nim 2015-02-14 14:14:44 +00:00
Dominik Picheta
57ee631735 Implement user bans and deactivations. Fix registration issues. 2015-02-14 14:13:36 +00:00
Dominik Picheta
72f8126d01 Modify the thread title if first post's title is edited. 2015-01-21 00:29:50 +00:00
Dominik Picheta
a472be41d8 Fix thread location not being updated when post is deleted.
A thread table has its own modified attribute which needed to be reset.
2015-01-21 00:12:24 +00:00
Dominik Picheta
9b309e4952 Merge pull request #39 from flaviut/patch-1
Update forms.tmpl
2015-01-18 13:45:39 +00:00
Flaviu Tamas
a1c5c47c2e Update forms.tmpl 2015-01-18 08:45:16 -05:00
Dominik Picheta
cb9ac61876 Ported fix to #17 2014-12-30 15:34:23 +00:00
Dominik Picheta
50cced23df Ported fix to #16. 2014-12-30 15:24:17 +00:00
Dominik Picheta
f678a31060 Implements #35. 2014-12-30 15:19:28 +00:00
Dominik Picheta
1ebaba2355 Fixes #34. 2014-12-08 22:28:59 +00:00
Dominik Picheta
5dc95b27cb Fixes #33 2014-12-08 22:15:32 +00:00
Dominik Picheta
dfac36593d Fixed more links at the bottom of the page. 2014-12-07 21:48:40 +00:00
Dominik Picheta
638aaa44c0 Fix top links. 2014-12-07 21:13:34 +00:00
Dominik Picheta
3611b42aed Fixed link to atom feed. 2014-12-07 20:05:49 +00:00
Dominik Picheta
ad02b4e710 Fixes silly var name mistake. 2014-12-07 19:11:29 +00:00
Dominik Picheta
4f059984ad Fixes bcrypt. 2014-12-07 19:09:36 +00:00
Dominik Picheta
0ebc9e1d0b Add salt param to hash(). 2014-12-07 18:35:37 +00:00
Dominik Picheta
bf80d4bf73 Enabled bcrypt on Linux. Closes #25. 2014-12-07 18:07:05 +00:00
Dominik Picheta
fd08340b8f Syntax highlighting improvements. 2014-12-07 14:54:03 +00:00
Dominik Picheta
98acb26f27 Better fonts. DejaVu Sans Mono and Helvetica. 2014-12-07 14:50:14 +00:00
Dominik Picheta
9e5c05fe63 Adds more space below "Edit Post". 2014-12-07 14:40:36 +00:00
Dominik Picheta
4ba9de012c Only show reply button if user is logged in. 2014-12-07 14:26:59 +00:00
Dominik Picheta
82843d5bc2 Increased effective clickable size of Reply button. 2014-12-07 14:21:03 +00:00
Dominik Picheta
90d7a70556 Inline code in posts now has a white background. 2014-12-07 14:04:50 +00:00
Dominik Picheta
98127731a2 Remove italics from num lit syntax highlighting. 2014-12-07 14:03:21 +00:00
Dominik Picheta
4b6d1d3a32 Increased margin on post content. 2014-12-07 14:01:58 +00:00
Dominik Picheta
8af27ae64d Improves search. 2014-12-07 02:53:10 +00:00
Dominik Picheta
3a97576160 Made syntax highlighting white slightly less white. 2014-12-07 02:17:16 +00:00
Dominik Picheta
38d386beb9 Increased activity font size. 2014-12-07 02:13:56 +00:00
Dominik Picheta
020d76d15e Add atom feed icons. 2014-12-07 02:09:33 +00:00
Dominik Picheta
53790a394c Better error feedback to user. 2014-12-07 01:55:59 +00:00
Dominik Picheta
32b9c27da9 Fixes Nim logo's incorrect <a> url. 2014-12-07 01:26:54 +00:00
Dominik Picheta
048180dd8b Larger subject field. 2014-12-07 01:05:16 +00:00
Dominik Picheta
e11f5d9345 Removes subject field when replying to thread. 2014-12-07 00:48:24 +00:00
Dominik Picheta
4002d121b4 Fixes recently introduced style issues. 2014-12-07 00:32:21 +00:00
Dominik Picheta
f1cf341d0d gitignore *.db 2014-12-06 23:32:29 +00:00
Dominik Picheta
5b30eddd16 Remove stupid Thumbs.db. 2014-12-06 23:31:55 +00:00
Dominik Picheta
cf6e99223a Adjusted top margin for post content. 2014-12-05 19:00:16 +00:00
Dominik Picheta
9e578522c1 Merge pull request #32 from Leu-Gim/new_async
highlighting current post via CSS :hover pseudo-element
2014-12-05 19:00:14 +00:00
Dominik Picheta
9b0d507dcf Better smiley positioning. Larger font size. 2014-12-05 18:50:09 +00:00
Dominik Picheta
99731e7bed Added smilieys. Fixed too many users overflow on front page. 2014-12-04 18:44:38 +00:00
Dominik Picheta
0ec7387e0c Restored server side activity calculations. Last reply info added. 2014-11-15 15:00:22 +00:00
Dominik Picheta
688a5503ac Improved style of threads: better links. 2014-11-15 14:33:46 +00:00
Miguel
e52d65c3a7 highlighting current post via CSS :hover pseudo-element 2014-11-15 17:05:36 +03:00
Dominik Picheta
eb769485e6 Fixes nav numbers. Removes jump page num. 2014-11-14 16:11:46 +00:00
Dominik Picheta
6e3c746664 Fixes editing posts. 2014-11-14 00:52:36 +00:00
Dominik Picheta
58dc6bfcec Remove caching. 2014-11-14 00:40:07 +00:00
Dominik Picheta
4dd8b0d8c7 Removed extraneous postId code. 2014-11-13 16:32:06 +00:00
Dominik Picheta
f0036a6ab2 Added createdb which creates the correct FTS tables. 2014-11-13 15:45:23 +00:00
Dominik Picheta
8569a8962f Merge branch 'new_async' of https://github.com/Leu-Gim/nimforum into Leu-Gim-new_async
Conflicts:
	forum.nim

Also fixes FTS table detection.
2014-11-13 15:26:33 +00:00
Miguel
a28e3539ce defaults restored for ThreadsPerPage, PostsPerPage 2014-11-11 23:55:53 +03:00
Dominik Picheta
374f6f96b4 Improved register and profile pages. 2014-11-11 20:18:29 +00:00
Dominik Picheta
2aaeaf9b9b Implemented activity timer in JS. 2014-11-11 17:59:01 +00:00
Miguel
3f08e94e21 Forum search added and some modifications done. 2014-11-11 08:24:46 +03:00
Miguel
4f7396d059 Forum search added and some modifications done. 2014-11-11 08:22:11 +03:00
Dominik Picheta
2ac0f934a7 Margin bottom for <pre> code. 2014-11-10 20:41:29 +00:00
Dominik Picheta
fb24365d9a Better form styles. Implemented new design for post preview. 2014-11-10 19:32:39 +00:00
Dominik Picheta
9e7b889e3d Syntax highlighting colors implemented. 2014-11-10 17:31:15 +00:00
Dominik Picheta
4d459819e1 Fixes sidebar style on smaller screens. 2014-11-10 17:16:18 +00:00
Dominik Picheta
5d90a9e651 Add spacing between page num links. 2014-11-10 00:20:05 +00:00
Dominik Picheta
707393a509 Added hr style. 2014-11-09 23:37:04 +00:00
Dominik Picheta
d1b6d0ad40 Design fixes for posts list. 2014-11-09 23:35:14 +00:00
Dominik Picheta
ab84e3249a Fix double scrollbar. 2014-11-09 22:41:37 +00:00
Dominik Picheta
114d322b50 Redesigned front page.
Made font smalled for the thread list. Gravatars are now shown for each
user that posted in each thread listed. Time since last reply is now
calculated and shown beside each thread.
2014-11-09 18:28:21 +00:00
Dominik Picheta
77bcb14327 Error handling for login in sidebar. 2014-11-09 00:49:14 +00:00
Dominik Picheta
c1cf06c747 Sidebar style adjustments. 2014-11-08 23:42:23 +00:00
Dominik Picheta
70b409e6e3 Design changes for thread view. 2014-11-08 20:41:00 +00:00
Dominik Picheta
dd61f5bdbf Implemented side bar. 2014-11-06 16:35:25 +00:00
Dominik Picheta
7b6e629bfc Small fixes for styling and reply button presence. 2014-11-04 13:52:48 +00:00
Dominik Picheta
b5f6d8fa8b Implemented new design for thread view. 2014-10-31 15:45:02 +00:00
Dominik Picheta
4de7e58511 First steps towards new forum design. 2014-10-29 22:12:05 +00:00
Dominik Picheta
e520df80da Remove debug code. 2014-10-17 18:39:09 +01:00
Dominik Picheta
630831f772 Fixed caching. 2014-10-17 18:35:49 +01:00
Dominik Picheta
3af3de2ea3 Fix createdb and config filename. 2014-10-14 17:22:02 +01:00
Dominik Picheta
da1876f7d0 Fixes CS problems. 2014-10-11 18:39:45 +01:00
Andreas Rumpf
a480485529 Merge pull request #26 from ClementJnc/master
fix #16, fix #17
2014-10-05 10:22:45 +02:00
Andreas Rumpf
bab6f67f10 Merge pull request #22 from gradha/pr_google_search
Adds form to query google search on the forum.
2014-10-05 10:21:22 +02:00
Clement
b242b1fce9 fix #17 2014-07-16 21:32:33 +02:00
Clement
d2942cc0c3 fix #16 2014-07-16 21:08:53 +02:00
Dominik Picheta
fbd8f057bb Fixes for new_async version of Jester. 2014-07-13 10:45:26 +01:00
Araq
42e74d230d compiles with the latest compiler version 2014-07-10 00:19:22 +02:00
Dominik Picheta
1740471100 Merge pull request #24 from gradha/pr_published_rss_tag
Adds published tag as duplicate of updated for old rss clients.
2014-04-06 11:57:03 +01:00
Grzegorz Adam Hankiewicz
aae5609bbb Adds published tag as duplicate of updated for old rss clients. 2014-04-06 12:19:55 +02:00
Grzegorz Adam Hankiewicz
507fc78a58 Adds form to query google search on the forum. 2014-03-25 18:38:43 +01:00
Dominik Picheta
8d94ef5690 Merge pull request #20 from gradha/pr_updates_domain
s/nimrod-code/nimrod-lang/
2013-12-24 14:12:33 -08:00
Grzegorz Adam Hankiewicz
9132db3ade s/nimrod-code/nimrod-lang/ 2013-12-24 19:26:28 +01:00
Dominik Picheta
7a29e748e7 Merge branch 'pr_html_rss' of git://github.com/gradha/nimforum into gradha-pr_html_rss 2013-08-06 20:19:49 +01:00
Dominik Picheta
341fac7288 Changed page title format. 2013-08-06 20:18:33 +01:00
Dominik Picheta
695f53f228 Merge branch 'pr_license_info' of git://github.com/gradha/nimforum into gradha-pr_license_info 2013-08-06 20:07:02 +01:00
Grzegorz Adam Hankiewicz
a11ee64dae Renders RSS entries in HTML. We are not from the past. 2013-07-28 22:47:17 +02:00
Grzegorz Adam Hankiewicz
6554985516 Adds to footer hyperlink to forum content license. Refs #10. 2013-07-28 22:10:39 +02:00
Grzegorz Adam Hankiewicz
1a8339bf42 Adds license page generated from static rst file. Refs #10. 2013-07-28 22:10:39 +02:00
Dominik Picheta
3368b5660c Merge pull request #12 from gradha/pr_cairo_notes
Adds info about libcairo runtime dependency.
2013-07-03 14:23:56 -07:00
Grzegorz Adam Hankiewicz
a9515d2a86 Adds info about libcairo runtime dependency. 2013-07-03 20:17:58 +02:00
Grzegorz Adam Hankiewicz
0c6eb83fe5 Fixes rss generated dates to conform to RFC-3339.
The timezone was missing, since all are UTC, adds a Z.
2013-06-04 19:23:58 +02:00
Dominik Picheta
32cacd2b99 Merge branch 'pr_improves_empty_input_checks' of git://github.com/gradha/nimforum into gradha-pr_improves_empty_input_checks 2013-05-30 12:55:40 +01:00
Dominik Picheta
91d4d473ce Merge branch 'pr_fixes_dev_docs_link' of git://github.com/gradha/nimforum into gradha-pr_fixes_dev_docs_link 2013-05-30 12:51:17 +01:00
Dominik Picheta
5cc7da6e59 Moved RSS feed links to the bottom of the page. 2013-05-30 12:45:21 +01:00
Dominik Picheta
264d062bae Merge branch 'pr_implements_rss' of git://github.com/gradha/nimforum into gradha-pr-implements-rss
Conflicts:
	forum.nim
	main.tmpl
2013-05-30 12:19:15 +01:00
Grzegorz Adam Hankiewicz
be6ab0cb4e Adds author tag to xml feed. 2013-04-15 20:03:51 +02:00
Grzegorz Adam Hankiewicz
2652be8944 Fixes self references of XML feeds, there can only be one. 2013-04-15 19:52:10 +02:00
Grzegorz Adam Hankiewicz
a1ab75e555 Checks user input length after removing whitespace. Refs #2. 2013-03-29 14:45:36 +01:00
Grzegorz Adam Hankiewicz
849a414206 Corrects developer documentation header link. 2013-03-29 12:37:41 +01:00
Grzegorz Adam Hankiewicz
9a917fe190 Adds visible links to rss feeds along with svg icon. 2013-03-29 01:27:20 +01:00
Grzegorz Adam Hankiewicz
ccd7b8c59e Implements post activity rss feed for main page. 2013-03-29 00:57:49 +01:00
Grzegorz Adam Hankiewicz
7d40153217 Removes unneeded stats calls during rss generation. 2013-03-28 23:03:50 +01:00
Dominik Picheta
f9fa9b4503 Reverted back some risky changes. 2013-03-27 23:44:57 +00:00
Dominik Picheta
82cc25bd62 Features to improve cooperation with webcrawlers.
Added proper titles to each page, page 1 of threads and posts lists now
redirects to /.
2013-03-27 23:28:00 +00:00
Grzegorz Adam Hankiewicz
ddd07883cb Implements hyperlinking to specific post from rss. 2013-03-28 00:04:51 +01:00
Dominik Picheta
9daa79425d Merge branch 'pr_implements_rss' of git://github.com/gradha/nimforum into gradha-pr_implements_rss 2013-03-27 22:05:08 +00:00
Dominik Picheta
1bd7975281 Merge branch 'pr_changes_header_links' of git://github.com/gradha/nimforum into gradha-pr_changes_header_links 2013-03-27 21:57:43 +00:00
Dominik Picheta
b5d190ed4e Merge branch 'pr_hyperlinks_user_profiles' of git://github.com/gradha/nimforum into gradha-pr_hyperlinks_user_profiles 2013-03-27 21:23:01 +00:00
Grzegorz Adam Hankiewicz
eab0673409 Implements rss for thread activity. 2013-03-27 00:43:47 +01:00
Grzegorz Adam Hankiewicz
9d9772e524 Changes forum's header links.
The forum link is now the first item, the homepage is now second along
with other useful links like documentation and github issues.
2013-03-24 21:51:09 +01:00
Grzegorz Adam Hankiewicz
01f480a791 Adds hyperlinks to profile pages. 2013-03-23 00:47:59 +01:00
Grzegorz Adam Hankiewicz
4c2b5d1ed2 Creates a .gitignore to avoid generated files. 2013-03-23 00:47:59 +01:00
Araq
d1d9c69489 updated nimrod.cfg 2013-03-17 20:44:42 +01:00
Dominik Picheta
5889ede99e Added license. 2013-01-13 22:55:53 +00:00
Dominik Picheta
0936542368 Added lastOnline field to Person table in createdb. 2013-01-13 22:52:53 +00:00
Dominik Picheta
ff22ee9243 Fixes layout problems in Chrome. 2012-10-30 18:23:03 +00:00
Dominik Picheta
2c8fbaf889 Fixes problems with utf8. Profile pages now show thread count. 2012-10-07 15:39:04 +01:00
Dominik Picheta
4e0e5c44f6 Implemented profiles. 2012-09-30 15:03:07 +01:00
Dominik Picheta
96da91fa3e Implements local pager. Fixes footer once and for all. 2012-09-30 13:19:03 +01:00
Dominik Picheta
d21f9217d5 Fixes footer, it is now properly stuck to the bottom of the page. 2012-09-29 17:27:03 +01:00
Dominik Picheta
0009e627f5 Compiles with 0.9.0 2012-09-28 18:43:21 +01:00
Dominik Picheta
9beee9848c Implemented paging. 2012-06-04 19:44:55 +01:00
77 changed files with 8933 additions and 1705 deletions

80
.github/workflows/main.yml vendored Normal file
View file

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

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# Wildcard patterns.
*.swp
nimcache/
*.db*
# Specific paths
/createdb
/forum
/nimforum.db
# Binaries
forum
createdb
editdb
.vscode
forum.json*
browsertester
setup_nimforum
buildcss
nimforum.css
/src/frontend/forum.js

6
.gitmodules vendored Normal file
View file

@ -0,0 +1,6 @@
[submodule "frontend/spectre"]
path = frontend/spectre
url = https://github.com/picturepan2/spectre
[submodule "public/css/spectre"]
path = public/css/spectre
url = https://github.com/picturepan2/spectre

130
README.md
View file

@ -1,11 +1,131 @@
nimforum
========
# nimforum
This is Nimrod's forum. The code is not nice and depends on the RST parser of
the Nimrod compiler.
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.
## Examples in the wild
[![forum.nim-lang.org](https://i.imgur.com/hdIF5Az.png)](https://forum.nim-lang.org)
<p align="center" margin="0"><a href="https://forum.nim-lang.org"><b>forum.nim-lang.org</b></a></p>
## Features
* Efficient, type safe and clean **single-page application** developed using the
[Karax](https://github.com/pragmagic/karax) and
[Jester](https://github.com/dom96/jester) frameworks.
* **Utilizes SQLite** making set up much easier.
* Endlessly **customizable** using SASS.
* Spam blocking via new user sandboxing with great tools for moderators.
* reStructuredText enriched by Markdown to make formatting your posts a breeze.
* Search powered by SQLite's full-text search.
* Context-aware replies.
* Last visit tracking.
* Gravatar support.
* And much more!
## Setup
[See this document.](https://github.com/nim-lang/nimforum/blob/master/setup.md)
## Dependencies
The following lists the dependencies which you may need to install manually
in order to get NimForum running, compiled*, or tested†.
* libsass
* SQLite
* pcre
* Nim (and the Nimble package manager)*
* [geckodriver](https://github.com/mozilla/geckodriver)†
* Firefox†
[*] Build time dependencies
[†] Test time dependencies
## Development
Check out the tasks defined by this project's ``nimforum.nimble`` file by
running ``nimble tasks``, as of writing they are:
```
backend Compiles and runs the forum backend
runbackend Runs the forum backend
frontend Builds the necessary JS frontend (with CSS)
minify Minifies the JS using Google's closure compiler
testdb Creates a test DB (with admin account!)
devdb Creates a test DB (with admin account!)
blankdb Creates a blank DB
test Runs tester
fasttest Runs tester without recompiling backend
```
To get up and running:
```bash
git clone https://github.com/nim-lang/nimforum
cd nimforum
git submodule update --init --recursive
# Setup the db with user: admin, pass: admin and some other users
nimble devdb
# Run this again if frontend code changes
nimble frontend
# Will start a server at localhost:5000
nimble backend
```
Development typically involves running `nimble devdb` which sets up the
database for development and testing, then `nimble backend`
which compiles and runs the forum's backend, and `nimble frontend`
separately to build the frontend. When making changes to the frontend it
should be enough to simply run `nimble frontend` again to rebuild. This command
will also build the SASS ``nimforum.scss`` file in the `public/css` directory.
### With docker
You can easily launch site on localhost if you have `docker` and `docker-compose`.
You don't have to setup dependencies (libsass, sglite, pcre, etc...) on you host PC.
To get up and running:
```bash
cd docker
docker-compose build
docker-compose up
```
And you can access local NimForum site.
Open http://localhost:5000 .
# Troubleshooting
You might have to run `nimble install karax@#5f21dcd`, if setup fails
with:
```
andinus@circinus ~/projects/forks/nimforum> nimble --verbose devdb
[...]
Installing karax@#5f21dcd
Tip: 24 messages have been suppressed, use --verbose to show them.
Error: No binaries built, did you specify a valid binary name?
[...]
Error: Exception raised during nimble script execution
```
The hash needs to be replaced with the one specified in output.
# Copyright
Copyright (c) 2012-2018 Andreas Rumpf, Dominik Picheta.
Copyright (c) 2012 Andreas Rumpf.
All rights reserved.
# License
NimForum is licensed under the MIT license.

View file

@ -1,39 +0,0 @@
#
#
# The Nimrod Forum
# (c) Copyright 2012 Andreas Rumpf
#
# All rights reserved.
#
import cairo, os, strutils, jester
proc getCaptchaFilename*(i: int): string {.inline.} =
result = "public/captchas/capture_" & $i & ".png"
proc getCaptchaUrl*(req: var TRequest, i: int): string =
result = req.makeUri("/captchas/capture_" & $i & ".png", absolute = false)
proc createCaptcha*(file, text: string) =
var surface = imageSurfaceCreate(FORMAT_ARGB32, 10*text.len, 10)
var cr = create(surface)
selectFontFace(cr, "serif", FONT_SLANT_NORMAL, FONT_WEIGHT_BOLD)
setFontSize(cr, 12.0)
setSourceRgb(cr, 1.0, 0.5, 0.0)
moveTo(cr, 0.0, 10.0)
showText(cr, repeatChar(text.len, 'O'))
setSourceRgb(cr, 0.0, 0.0, 1.0)
moveTo(cr, 0.0, 10.0)
showText(cr, text)
destroy(cr)
discard writeToPng(surface, file)
destroy(surface)
when isMainModule:
createCapture("test.png", "1+33")

View file

@ -1,92 +0,0 @@
#
#
# Nimrod Forum
# (c) Copyright 2012 Andreas Rumpf
#
# All rights reserved.
#
import strutils, db_sqlite
var db = Open(connection="nimforum.db", user="postgres", password="",
database="nimforum")
const
TUserName = "varchar(20)"
TPassword = "varchar(32)"
TEmail = "varchar(30)"
db.Exec(sql"""
create table if not exists thread(
id integer primary key,
name varchar(100) not null,
views integer not null,
modified timestamp not null default (DATETIME('now'))
);""", [])
db.Exec(sql"""
create unique index if not exists ThreadNameIx on thread (name);
""", [])
db.Exec(sql("""
create table if not exists person(
id integer primary key,
name $# not null,
password $# not null,
email $# not null,
creation timestamp not null default (DATETIME('now')),
salt varbin(128) not null,
status integer not null,
admin bool default false
);""" % [TUserName, TPassword, TEmail]), [])
# echo "person table already exists"
db.Exec(sql"""
create unique index if not exists UserNameIx on person (name);
""", [])
# ----------------------- Forum ------------------------------------------------
if not db.TryExec(sql"""
create table if not exists post(
id integer primary key,
author integer not null,
ip inet not null,
header varchar(100) not null,
content varchar(1000) not null,
thread integer not null,
creation timestamp not null default (DATETIME('now')),
foreign key (thread) references thread(id),
foreign key (author) references person(id)
);""", []):
echo "post table already exists"
# -------------------- Session -------------------------------------------------
if not db.TryExec(sql("""
create table if not exists session(
id integer primary key,
ip inet not null,
password $# not null,
userid integer not null,
lastModified timestamp not null default (DATETIME('now')),
foreign key (userid) references person(id)
);""" % [TPassword]), []):
echo "session table already exists"
if not db.TryExec(sql"""
create table if not exists antibot(
id integer primary key,
ip inet not null,
answer varchar(30) not null,
created timestamp not null default (DATETIME('now'))
);""", []):
echo "antibot table already exists"
#discard stdin.readline()
Close(db)

14
docker/Dockerfile Normal file
View file

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

12
docker/docker-compose.yml Normal file
View file

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

19
docker/entrypoint.sh Executable file
View file

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

View file

@ -1,11 +0,0 @@
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)

View file

@ -1,218 +0,0 @@
#! stdtmpl
#
#template `%`(idx: expr): expr {.immediate.} =
# row[idx]
#end template
#
#
#proc genThreadsList(c: var TForumData): string =
# const query = sql"select id, name, views, modified from thread order by modified desc"
# const threadId = 0
# const name = 1
# const views = 2
#
# result = ""
<table id="threads">
<tr>
<th>Topics</th>
<th>Author</th>
<th>Posts</th>
<th>Views</th>
<th>Last reply</th>
</tr>
# for row in Rows(db, query):
<tr>
<td class="topic">${UrlButton(c, XMLencode(%name), c.genThreadUrl(threadid = %threadid))}</td>
#let authorName = getValue(db, sql("select name from person where id = " &
# "(select author from post where id = " &
# "(select min(id) from post where thread = ?))"), %threadId)
<td class="author">${authorName}</td>
# let posts = GetValue(db, sql"select count(*) from post where thread = ?", %threadId)
<td class="posts">$posts</td>
<td class="views">${XMLencode(%views)}</td>
#let latestReplyAuthor = getValue(db, sql("select name from person where id = " &
# "(select author from post where id = " &
# "(select max(id) from post where thread = ?))"), %threadId)
#let latestReplyDate = getValue(db, sql("SELECT strftime('%s', " &
# "(select creation from post where id = (select max(id) from post where thread = ?)))"), %threadId)
<td class="lastreply">
<span>${formatTimestamp(latestReplyDate.parseInt())}</span><br/>
<span>${latestReplyAuthor}</span>
</td>
</tr>
# end for
</table>
#end proc
#
#
#proc genPostPreview(c: var TForumData,
# title, content, author, date: string): string =
# result = ""
<table class="post">
<tr>
<th colspan="2">
<span>${XMLEncode(title)}</span>
<span style="float:right;">${XMLencode(date)}</span>
</th>
</tr>
<tr>
<td class="left">
<span>${XMLencode(author)}</span>
</td>
<td class="content">
#try:
${content.rstToHtml}
#except EParseError:
# c.errorMsg = getCurrentExceptionMsg()
#end
</td>
</tr>
</table>
#end proc
#
#
#proc genPostsList(c: var TForumData, threadId: string): string =
# const query = sql"select p.id, u.name, p.header, p.content, p.creation, p.author, u.email from post p, person u where u.id = p.author and p.thread = ? order by p.id"
# const postId = 0
# const userName = 1
# const postHeader = 2
# const postContent = 3
# const postCreation = 4
# const postAuthor = 5
# const userEmail = 6
# result = ""
# for row in FastRows(db, query, threadId):
<table class="post">
<tr>
<th colspan="2">
<span>${XMLencode(%postHeader)}</span>
<span style="float:right;">${XMLencode(%postCreation)}</span>
</th>
</tr>
<tr>
<td class="left">
<span>${XMLencode(%userName)}</span>
<hr/>
${genGravatar(%userEmail)}
#if c.userId == %postAuthor and c.currentPost.subject.len == 0:
<hr/>${UrlButton(c, "Edit post", c.genThreadUrl(%postId, "edit"))}
#elif c.isAdmin and c.currentPost.subject.len == 0:
<hr/><span style="color:red">
${UrlButton(c, "Edit post", c.genThreadUrl(%postId, "edit"))}</span>
#end if
</td>
<td class="content">
#try:
${(%postContent).rstToHtml}
#except EParseError:
# c.errorMsg = getCurrentExceptionMsg()
#end
</td>
</tr>
</table>
# end for
#end proc
#
#
#proc genFormPost(c: var TForumData, action: string,
# topText, title, content: string, isEdit: bool): string =
# result = ""
<br />
<a name="reply"></a>
<div id="replywrapper">
<div id="replytop">
<span>${topText}</span>
</div>
<form action="${c.req.makeUri(action, false)}" method="POST">
${FieldValid(c, "subject", "Subject:")}
${TextWidget(c, "subject", title, maxlength=100)}
<br />
${FieldValid(c, "content", "Content:")}<br />
${TextAreaWidget(c, "content", content, width=100, height=20)}<br />
${FormSession(c, action)}
# if isEdit:
<input type="checkbox" name="delete" value="Delete">Delete Post<br />
# end if
<br/>
<input type="submit" name="previewBtn" value="Preview" />
<input type="submit" name="postBtn" value="Submit" />
<a href="http://nimrod-code.org/rst.html">Syntax Cheatsheet</a>
</form>
</div>
#end proc
#
#
#proc genFormRegister(c: var TForumData): string =
# result = ""
<form action="${c.req.makeUri("/doregister", false)}" method="POST">
<b>Register</b><br />
<table border="0">
<tr>
<td>${FieldValid(c, "name", "Username:")}</td>
<td>${TextWidget(c, "name", reuseText, maxlength=20)}</td>
</tr>
<tr>
<td>${FieldValid(c, "new_password", "Password:")}</td>
<td><input type="password" name="new_password" maxlength="20" /></td>
</tr>
<tr>
<td>${FieldValid(c, "email", "E-Mail:")}</td>
<td>${TextWidget(c, "email", reuseText, maxlength=30)}</td>
</tr>
<tr>
<td>${FieldValid(c, "antibot", "What is " & antibot(c) & "?")}</td>
<td>${TextWidget(c, "antibot", "", maxlength=4)}</td>
</tr>
</table>
<input type="submit" value="Register">
</form>
#end proc
#
#proc genFormLogin(c: var TForumData): string =
# result = ""
# if not c.loggedIn:
<form action="${c.req.makeUri("/dologin", false)}" method="POST">
<table border="0">
<tr><td>Username:</td><td>
<input type="text" name="name" maxlength="20"></td></tr>
<tr><td>Password:</td><td>
<input type="password" name="password" maxlength="20"></td></tr>
</table>
<input type="submit" value="Login">
</form>
<span style="color:red">$c.loginErrorMsg</span>
# else:
<span style="color:red">You're already logged in!</span>
# end if
#end proc
#
#
#proc genListOnline(c: var TForumData): string =
# result = ""
# let stats = c.getStats()
<div id="whoisonline">
<div class="wioHeader">
<span>Who is online?<span>
</div>
<div class="content">
<span>Out of ${stats.totalUsers} users ${stats.activeUsers.len} are online${if stats.activeUsers.len == 0: "." else: ":"}
#for index, usr in stats.activeUsers:
# if usr.isAdmin:
#if index != 0: result.add ','
#end if
#result.add("""<span class="user admin"> """ & usr.nick & """</span>""")
# else:
#if index != 0: result.add ','
#end if
#result.add("""<span class="user"> """ & usr.nick & """</span>""")
# end if
#end for
</span>
<hr/>
<span>Total threads: ${stats.totalThreads} | Total posts: ${stats.totalPosts} | Newest member: ${stats.newestMember.nick}</span>
</div>
</div>
#end proc

580
forum.nim
View file

@ -1,580 +0,0 @@
#
#
# The Nimrod Forum
# (c) Copyright 2012 Andreas Rumpf
#
# All rights reserved.
#
import
os, strutils, times, md5, strtabs, cgi, math, db_sqlite, matchers,
rst, rstgen, captchas, sockets, scgi, jester
const
unselectedThread = -1
transientThread = 0
type
TCrud = enum crCreate, crRead, crUpdate, crDelete
TSession = object of TObject
threadid: int
postid: int
userName, userPass, email: string
isAdmin: bool
TPost = tuple[subject, content: string]
TForumData = object of TSession
req: TRequest
userid: string
actionContent: string
errorMsg, loginErrorMsg: string
invalidField: string
currentPost: TPost
startTime: float
TStyledButton = tuple[text: string, link: string]
TForumStats = object
totalUsers: int
totalPosts: int
totalThreads: int
newestMember: tuple[nick: string, id: int, isAdmin: bool]
activeUsers: seq[tuple[nick: string, id: int, isAdmin: bool]]
var
db: TDbConn
docConfig: PStringTable
proc init(c: var TForumData) =
c.userPass = ""
c.userName = ""
c.threadId = unselectedThread
c.postId = -1
c.userid = ""
c.actionContent = ""
c.errorMsg = ""
c.loginErrorMsg = ""
c.invalidField = ""
c.currentPost = (subject: "", content: "")
proc loggedIn(c: TForumData): bool =
result = c.userName.len > 0
# --------------- HTML widgets ------------------------------------------------
# for widgets "" means the empty string as usual; should the old value be
# used again, pass `reuseText` instead:
const
reuseText = "\1"
proc TextWidget(c: TForumData, name, defaultText: string,
maxlength = 30, size = -1): string =
let x = if defaultText != reuseText: defaultText
else: XMLencode(c.req.params[name])
return """<input type="text" name="$1" maxlength="$2" value="$3" $4/>""" % [
name, $maxlength, x, if size != -1: "size=\"" & $size & "\"" else: ""]
proc TextAreaWidget(c: TForumData, name, defaultText: string,
width = 80, height = 20): string =
let x = if defaultText != reuseText: defaultText
else: XMLencode(c.req.params[name])
return """<textarea name="$1" cols="$2" rows="$3">$4</textarea>""" % [
name, $width, $height, x]
proc FieldValid(c: TForumData, name, text: string): string =
if name == c.invalidField:
result = """<span style="color:red">$1</span>""" % text
else:
result = text
proc genThreadUrl(c: TForumData, postId = "", action = "", threadid = ""): string =
result = "/t/" & (if threadid == "": $c.threadId else: threadid)
if action != "":
result.add("?action=" & action)
if postId != "":
result.add("&postid=" & postid)
result = c.req.makeUri(result, absolute = false)
proc FormSession(c: var TForumData, nextAction: string): string =
return """<input type="hidden" name="threadid" value="$1" />
<input type="hidden" name="postid" value="$2" />""" % [
$c.threadId, $c.postid]
proc UrlButton(c: var TForumData, text, url: string): string =
return ("""<a class="url_button" href="$1">$2</a>""") % [
url, text]
proc genButtons(c: var TForumData, btns: seq[TStyledButton]): string =
if btns.len == 1:
var anchor = ""
result = ("""<a class="active button" href="$1$3">$2</a>""") % [
btns[0].link, btns[0].text, anchor]
else:
result = ""
for i, btn in pairs(btns):
var anchor = ""
var class = ""
if i == 0: class = "left "
elif i == btns.len()-1: class = "right "
else: class = "middle "
result.add(("""<a class="$3active button" href="$1$4">$2</a>""") % [
btns[i].link, btns[i].text, class, anchor])
proc formatTimestamp(t: int): string =
let t2 = getGMTime(TTime(t))
return t2.format("ddd',' d MMM yyyy HH':'mm 'UTC'")
proc genGravatar(email: string, size: int = 80): string =
let emailMD5 = email.toLower.toMD5
result = "<img src=\"$1\" />" %
("http://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size &
"&d=identicon")
proc randomSalt(): string =
result = ""
for i in 0..127:
var r = random(225)
if r >= 32 and r <= 126:
result.add(chr(random(225)))
proc devRandomSalt(): string =
when defined(posix):
result = ""
var f = open("/dev/urandom")
var randomBytes: array[0..127, char]
discard f.readBuffer(addr(randomBytes), 128)
for i in 0..127:
if ord(randomBytes[i]) >= 32 and ord(randomBytes[i]) <= 126:
result.add(randomBytes[i])
f.close()
else:
result = randomSalt()
proc makeSalt(): string =
## Creates a salt using a cryptographically secure random number generator.
try:
result = devRandomSalt()
except EIO:
result = randomSalt()
proc makePassword(password, salt: string): string =
## Creates an MD5 hash by combining password and salt.
result = getMD5(salt & getMD5(password))
# -----------------------------------------------------------------------------
template `||`(x: expr): expr = (if not isNil(x): x else: "")
proc validThreadId(c: TForumData): bool =
result = GetValue(db, sql"select id from thread where id = ?",
$c.threadId).len > 0
proc antibot(c: var TForumData): string =
let a = math.random(10)+1
let b = math.random(1000)+1
let answer = $(a+b)
Exec(db, sql"delete from antibot where ip = ?", c.req.ip)
let CaptchaId = TryInsertID(db,
sql"insert into antibot(ip, answer) values (?, ?)", c.req.ip,
answer).int mod 10_000
let CaptchaFile = getCaptchaFilename(CaptchaId)
createCaptcha(CaptchaFile, $a & "+" & $b)
result = """<img src="$1" />""" % c.req.getCaptchaUrl(captchaId)
const
SecureChars = {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
proc setError(c: var TForumData, field, msg: string): bool {.inline.} =
c.invalidField = field
c.errorMsg = "Error: " & msg
return false
proc register(c: var TForumData, name, pass, antibot, email: string): bool =
# Username validation:
if name.len == 0 or not allCharsInSet(name, SecureChars):
return setError(c, "name", "Invalid username!")
if GetValue(db, sql"select name from person where name = ?", name).len > 0:
return setError(c, "name", "Username already exists!")
# Password validation:
if pass.len < 4:
return setError(c, "new_password", "Invalid password!")
# antibot validation:
let correctRes = GetValue(db,
sql"select answer from antibot where ip = ?", c.req.ip)
if antibot != correctRes:
return setError(c, "antibot", "You seem to be a bot!")
# email validation
if not validEmailAddress(email):
return setError(c, "email", "Invalid email address")
# perform registration:
var salt = makeSalt()
Exec(db, sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " &
"VALUES (?, ?, ?, ?, 'user', DATETIME('now'))"), name,
makePassword(pass, salt), email, salt)
# return setError(c, "", "Could not create your account!")
return true
proc checkLoggedIn(c: var TForumData) =
let pass = c.req.cookies["sid"]
if pass.len == 0: return
if ExecAffectedRows(db,
sql("update session set lastModified = DATETIME('now') " &
"where ip = ? and password = ?"),
c.req.ip, pass) > 0:
c.userpass = pass
c.userid = GetValue(db,
sql"select userid from session where ip = ? and password = ?",
c.req.ip, pass)
let row = getRow(db,
sql"select name, email, admin from person where id = ?", c.userid)
c.username = ||row[0]
c.email = ||row[1]
c.isAdmin = parseBool(||row[2])
# Update lastOnline
db.exec(sql"update person set lastOnline = DATETIME('now') where id = ?",
c.userid)
else:
echo("SID not found in sessions. Assuming logged out.")
proc logout(c: var TForumData) =
const query = sql"delete from session where ip = ? and password = ?"
c.username = ""
c.userpass = ""
Exec(db, query, c.req.ip, c.req.cookies["sid"])
proc incrementViews(c: var TForumData) =
const query = sql"update thread set views = views + 1 where id = ?"
Exec(db, query, $c.threadId)
proc isPreview(c: TForumData): bool =
result = c.req.params["previewBtn"].len > 0 # TODO: Could be wrong?
proc isDelete(c: TForumData): bool =
result = c.req.params["delete"].len > 0
proc rstToHtml(content: string): string =
result = rstgen.rstToHtml(content, {roSupportSmilies, roSupportMarkdown},
docConfig)
proc validateRst(c: var TForumData, content: string): bool =
result = true
try:
discard rstToHtml(content)
except EParseError:
result = setError(c, "", getCurrentExceptionMsg())
proc crud(c: TCrud, table: string, data: openArray[string]): TSqlQuery =
case c
of crCreate:
var fields = "insert into " & table & "("
var vals = ""
for i, d in data:
if i > 0:
fields.add(", ")
vals.add(", ")
fields.add(d)
vals.add('?')
result = sql(fields & ") values (" & vals & ")")
of crRead:
var res = "select "
for i, d in data:
if i > 0: res.add(", ")
res.add(d)
result = sql(res & " from " & table)
of crUpdate:
var res = "update " & table & " set "
for i, d in data:
if i > 0: res.add(", ")
res.add(d)
res.add(" = ?")
result = sql(res & " where id = ?")
of crDelete:
result = sql("delete from " & table & " where id = ?")
template retrSubject(c: expr) =
let subject = c.req.params["subject"]
if subject.len < 3: return setError(c, "subject", "Subject not long enough")
template retrContent(c: expr) =
let content = c.req.params["content"]
if not validateRst(c, content): return false
template retrPost(c: expr) =
retrSubject(c)
retrContent(c)
template checkLogin(c: expr) =
if not loggedIn(c): return setError(c, "", "User is not logged in")
template checkOwnership(c, postId: expr) =
if not c.isAdmin:
let x = getValue(db, sql"select author from post where id = ?",
postId)
if x != c.userId:
return setError(c, "", "You are not the owner of this post")
template setPreviewData(c: expr) =
c.currentPost.subject = subject
c.currentPost.content = content
template writeToDb(c, cr, postId: expr) =
exec(db, crud(cr, "post", "author", "ip", "header", "content", "thread"),
c.userId, c.req.ip, subject, content, $c.threadId, postId)
proc edit(c: var TForumData, postId: int): bool =
checkLogin(c)
if c.isPreview:
retrPost(c)
setPreviewData(c)
elif c.isDelete:
checkOwnership(c, $postId)
if not TryExec(db, crud(crDelete, "post"), $postId):
return setError(c, "", "database error")
# delete corresponding thread:
if ExecAffectedRows(db,
sql"delete from thread where id not in (select thread from post)") > 0:
# whole thread has been deleted, so:
c.threadId = unselectedThread
result = true
else:
checkOwnership(c, $postId)
retrPost(c)
exec(db, crud(crUpdate, "post", "header", "content"),
subject, content, $postId)
result = true
proc reply(c: var TForumData): bool =
checkLogin(c)
retrPost(c)
if c.isPreview:
setPreviewData(c)
else:
writeToDb(c, crCreate, "")
exec(db, sql"update thread set modified = DATETIME('now') where id = ?",
$c.threadId)
result = true
proc newThread(c: var TForumData): bool =
const query = sql"insert into thread(name, views, modified) values (?, 0, DATETIME('now'))"
checkLogin(c)
retrPost(c)
if c.isPreview:
setPreviewData(c)
c.threadID = transientThread
else:
c.threadID = TryInsertID(db, query, c.req.params["subject"]).int
if c.threadID < 0: return setError(c, "subject", "Subject already exists")
writeToDb(c, crCreate, "")
result = true
proc login(c: var TForumData, name, pass: string): bool =
# get form data:
const query =
sql"select id, name, password, email, salt, admin from person where name = ?"
if name.len == 0:
return c.setError("name", "Username cannot be nil.")
var success = false
for row in FastRows(db, query, name):
if row[2] == makePassword(pass, row[4]):
c.userid = row[0]
c.username = row[1]
c.userpass = row[2]
c.email = row[3]
c.isAdmin = row[5].parseBool
success = true
break
if success:
# create session:
Exec(db,
sql"insert into session (ip, password, userid) values (?, ?, ?)",
c.req.ip, c.userpass, c.userid)
return true
else:
return c.setError("password", "Login failed!")
proc genActionMenu(c: var TForumData): string =
result = ""
var btns: seq[TStyledButton] = @[]
# TODO: Make this detection better?
if c.req.pathInfo notin ["/", "/login", "/register", "/dologin", "/doregister"]:
btns.add(("Thread List", c.req.makeUri("/", false)))
if c.loggedIn:
let hasReplyBtn = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply"
if c.threadId >= 0 and hasReplyBtn:
let replyUrl = c.genThreadUrl("", "reply") & "#reply"
btns.add(("Reply", replyUrl))
btns.add(("New Thread", c.req.makeUri("/newthread", false)))
result = c.genButtons(btns)
proc getStats(c: var TForumData): TForumStats =
const totalUsersQuery =
sql"select count(*) from person"
result.totalUsers = getValue(db, totalUsersQuery).parseInt
const totalPostsQuery =
sql"select count(*) from post"
result.totalPosts = getValue(db, totalPostsQuery).parseInt
const totalThreadsQuery =
sql"select count(*) from thread"
result.totalThreads = getValue(db, totalThreadsQuery).parseInt
var newestMemberCreation = 0
result.activeUsers = @[]
const getUsersQuery =
sql"select id, name, admin, strftime('%s', lastOnline), strftime('%s', creation) from person"
for row in fastRows(db, getUsersQuery):
let secs = if row[3] == "": 0 else: row[3].parseint
let lastOnlineSeconds = getTime() - TTime(secs)
if lastOnlineSeconds < (60 * 5): # 5 minutes
result.activeUsers.add((row[1], row[0].parseInt, row[2].parseBool))
if row[4].parseInt > newestMemberCreation:
result.newestMember = (row[1], row[0].parseInt, row[2].parseBool)
newestMemberCreation = row[4].parseInt
include "forms.tmpl"
include "main.tmpl"
proc prependRe(s: string): string =
result = if s.len == 0:
""
elif s.startswith("Re:"): s
else: "Re: " & s
template createTFD(): stmt =
var c: TForumData
init(c)
c.req = request
c.startTime = epochTime()
if request.cookies.len > 0:
checkLoggedIn(c)
get "/":
createTFD()
resp genMain(c, genThreadsList(c), true)
get "/t/@threadid/?":
createTFD()
parseInt(@"threadid", c.threadId, -1..1000_000)
if (@"postid").len > 0:
parseInt(@"postid", c.postId, -1..1000_000)
if (@"action").len > 0:
case @"action"
of "reply":
let subject = GetValue(db,
sql"select header from post where id = (select max(id) from post where thread = ?)",
$c.threadId).prependRe
body = genPostsList(c, $c.threadId)
echo(c.threadId)
body.add genFormPost(c, "doreply", "Reply", subject, "", false)
of "edit":
cond c.postId != -1
const query = sql"select header, content from post where id = ?"
let row = getRow(db, query, $c.postId)
let header = ||row[0]
let content = ||row[1]
body = genFormPost(c, "doedit", "Edit", header, content, true)
resp c.genMain(body)
else:
cond validThreadId(c)
incrementViews(c)
resp genMain(c, genPostsList(c, $c.threadId))
get "/login/?":
createTFD()
resp genMain(c, genFormLogin(c))
get "/logout/?":
createTFD()
logout(c)
redirect(uri("/"))
get "/register/?":
createTFD()
resp genMain(c, genFormRegister(c))
template readIDs(): stmt =
# Retrieve the threadid and postid
if (@"threadid").len > 0:
parseInt(@"threadid", c.threadId, -1..1000_000)
if (@"postid").len > 0:
parseInt(@"postid", c.postId, -1..1000_000)
template finishLogin(): stmt =
setCookie("sid", c.userpass, daysForward(7))
redirect(uri("/"))
template handleError(action: string, topText: string, isEdit: bool): stmt =
if c.isPreview:
body.add genPostPreview(c, @"subject", @"content",
c.userName, $getGMTime(getTime()))
body.add genFormPost(c, action, topText, reuseText, reuseText, isEdit)
resp genMain(c, body)
post "/dologin":
createTFD()
if login(c, @"name", @"password"):
finishLogin()
else:
resp c.genMain(genFormLogin(c))
post "/doregister":
createTFD()
if c.register(@"name", @"new_password", @"antibot", @"email"):
discard c.login(@"name", @"new_password")
finishLogin()
else:
resp c.genMain(genFormRegister(c))
post "/donewthread":
createTFD()
if newThread(c):
redirect(uri("/"))
else:
body = ""
handleError("donewthread", "New thread", false)
post "/doreply":
createTFD()
readIDs()
if reply(c):
redirect(c.genThreadUrl())
else:
body = genPostsList(c, $c.threadId)
handleError("doreply", "Reply", false)
post "/doedit":
createTFD()
readIDs()
if edit(c, c.postId):
redirect(c.genThreadUrl())
else:
body = ""
handleError("doedit", "Edit", true)
get "/newthread/?":
createTFD()
resp genMain(c, genFormPost(c, "donewthread", "New thread", "", "", false))
when isMainModule:
docConfig = rstgen.defaultConfig()
math.randomize()
db = Open(connection="nimforum.db", user="postgres", password="",
database="nimforum")
var http = true
if paramCount() > 0:
if paramStr(1) == "scgi":
http = false
run("", port = TPort(9000), http = http)
db.close()

18
license.txt Normal file
View file

@ -0,0 +1,18 @@
Copyright (C) 2018 Andreas Rumpf, Dominik Picheta
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,16 @@
// Use this to customise the styles of your forum.
$primary-color: #6577ac;
$body-font-color: #292929;
$dark-color: #505050;
$label-color: #7cd2ff;
$secondary-btn-color: #f1f1f1;
// Define nav bar colours.
$body-bg: #ffffff;
$navbar-color: $body-bg;
$navbar-border-color-dark: $body-bg;
$navbar-primary-color: #e80080;
#main-navbar input#search-box {
border: 1px solid #e6e6e6;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,53 +0,0 @@
#! stdtmpl
#proc genMain(c: var TForumData, content: string, mainPage = false): string =
# result = ""
<!doctype html>
<html lang="en">
<head>
<title>Nimrod Forum</title>
<link rel="stylesheet" href="${c.req.makeUri("css/normalize.css", absolute = false)}">
<link rel="stylesheet" href="${c.req.makeUri("css/style.css", absolute = false)}">
</head>
<body>
<div id="wrapper">
<div id="nimbtn">
<a href="http://nimrod-code.org/">Homepage</a>
</div>
<div id="header">
#let frontQuery = c.req.makeUri("/")
<span><a href="${frontQuery}">Nimrod's Forum</a></span>
#if c.loggedIn:
<a href="${frontQuery}logout" class="right">Logout</a>
<span id="welcome">$c.username</span>
${genGravatar(c.email, 26)}
#else:
<a href="${frontQuery}register" class="right">Register</a>
<a href="${frontQuery}login" class="right">Login</a>
#end if
</div>
<div id="topbar">
${c.genActionMenu}
</div>
<div id="content">
$content
<span style="color:red">$c.errorMsg</span>
</div>
<div id="topbar">
${c.genActionMenu}
</div>
#if mainPage:
${c.genListOnline}
#end if
</div>
<div id="footer">
<span>Written in <a href="http://nimrod-code.org/">Nimrod</a> using <a href="https://github.com/dom96/jester">Jester</a></span>
<span> | <a href="https://github.com/nimrod-code/nimforum">Fork on Github</a></span>
<span style="float:right;">Generated in ${int((epochTime()-c.startTime)*1000.0)}ms</span>
</div>
</body>
</html>

151
mockup/index.html Normal file
View file

@ -0,0 +1,151 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Nim programming language forum</title>
<link rel="stylesheet" href="nimforum.css">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
<link rel="icon" href="images/favicon.png">
</head>
<body>
<header id="main-navbar">
<div class="navbar container grid-xl">
<section class="navbar-section">
<a href="#">
<img src="images/crown.png"
id="img-logo"/>
</a>
</section>
<section class="navbar-section">
<div class="input-group input-inline">
<input class="form-input input-sm" type="text" placeholder="search">
</div>
<button class="btn btn-primary btn-sm"><i class="fas fa-user-plus"></i> Sign up</button>
<button class="btn btn-primary btn-sm"><i class="fas fa-sign-in-alt"></i> Log in</button>
</section>
</div>
</header>
<section class="navbar container grid-xl" id="main-buttons">
<section class="navbar-section">
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" tabindex="0">
Filter <i class="fas fa-caret-down"></i>
</a>
<ul class="menu">
<li>community</li>
<li>dev</li>
</ul>
</div>
<button class="btn btn-primary">Latest</button>
<button class="btn btn-link">Most Active</button>
<button class="btn btn-link">Categories</button>
</section>
<section class="navbar-section">
</section>
</section>
<section class="container grid-xl">
<table class="table">
<thead>
<tr>
<th>Topic</th>
<th>Category</th>
<th>Users</th>
<th>Replies</th>
<th>Views</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
<tr>
<td><i class="fas fa-lock fa-xs" style="vertical-align: 0.05rem;"></i> Few mixed up questions</td>
<td><div class="triangle" style="border-bottom: 0.6rem solid #fc7c14;"></div> help</td>
<td>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/4831aedc3469e317e581e9e7348519a1?s=40&amp;d=identicon" title="DTxplorer">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/ed45a09ddafbe85bd1f3917f6ee57593?s=40&amp;d=identicon" title="mashingan">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/b3ed6848f575cc54c49f4916d15b65fd?s=40&amp;d=identicon" title="mratsim"></figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/bd68fd5a3c41111e89cc9c71d25d5a06?s=40&amp;d=identicon" title="Hlaaftana"></figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/aa81bb8117f158b6d9e6f3f174092573?s=40&amp;d=identicon" title="ErikCampobadal"><i class="avatar-presence online"></i></figure>
</td>
<td>5</td>
<td class="views-text">547</td>
<td class="text-success">45m</td>
</tr>
<tr>
<td><i class="fas fa-check-square fa-xs" style="vertical-align: 0.05rem;"></i> Lexers and parsers in Nim</td>
<td><div class="triangle"></div> community</td>
<td>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/aa81bb8117f158b6d9e6f3f174092573?s=40&amp;d=identicon" title="ErikCampobadal"><i class="avatar-presence online"></i></figure>
</td>
<td>0</td>
<td class="views-text">14</td>
<td class="text-success">44m</td>
</tr>
<tr class="no-border">
<td>I need help <span class="label label-custom">2</span></td>
<td><div class="triangle" style="border-bottom: 0.6rem solid #fc7c14;"></div> help</td>
<td>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/4831aedc3469e317e581e9e7348519a1?s=40&amp;d=identicon" title="DTxplorer">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/ed45a09ddafbe85bd1f3917f6ee57593?s=40&amp;d=identicon" title="mashingan">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/b3ed6848f575cc54c49f4916d15b65fd?s=40&amp;d=identicon" title="mratsim"></figure>
</td>
<td>4</td>
<td class="popular-text">1.4k</td>
<td>1d</td>
</tr>
<tr class="last-visit-separator">
<td colspan="6">
<span>
last visit
</span>
</td>
</tr>
<tr class="no-border">
<td>Nim v1.0 is here!</td>
<td><div class="triangle" style="border-bottom: 0.6rem solid #1f93f3;"></div> announcement</td>
<td>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/4831aedc3469e317e581e9e7348519a1?s=40&amp;d=identicon" title="DTxplorer">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/ed45a09ddafbe85bd1f3917f6ee57593?s=40&amp;d=identicon" title="mashingan">
</figure>
</td>
<td class="text-error">4</td>
<td class="super-popular-text">24.2k</td>
<td class="text-gray">4d</td>
</tr>
<tr class="load-more-separator">
<td colspan="6">
<span>
load more threads
</span>
</td>
</tr>
</tbody>
</table>
</section>
</body>
</html>

232
mockup/thread.html Normal file
View file

@ -0,0 +1,232 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Nim programming language forum</title>
<link rel="stylesheet" href="nimforum.css">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
<link rel="icon" href="images/favicon.png">
</head>
<body>
<header id="main-navbar">
<div class="navbar container grid-xl">
<section class="navbar-section">
<a href="#">
<img src="images/crown.png"
id="img-logo"/>
</a>
</section>
<section class="navbar-section">
<div class="input-group input-inline">
<input class="form-input input-sm" type="text" placeholder="search">
</div>
<button class="btn btn-primary btn-sm"><i class="fas fa-user-plus"></i> Sign up</button>
<button class="btn btn-primary btn-sm"><i class="fas fa-sign-in-alt"></i> Log in</button>
</section>
</div>
</header>
<section class="container grid-xl">
<div class="title">
<p>Lexers and parsers in nim</p>
<div class="triangle"></div> community
</div>
<div class="posts">
<div class="post">
<div class="post-icon">
<figure class="post-avatar">
<img src="https://www.gravatar.com/avatar/aa81bb8117f158b6d9e6f3f174092573?s=80&amp;d=identicon" alt="Avatar">
</figure>
</div>
<div class="post-main">
<div class="post-title">
<div class="post-username">
ErikCampobadal
</div>
<div class="post-time">Jan 2015</div>
</div>
<div class="post-content">
<p>Hey! I'm willing to create a programming language using nim.</p>
<p>It's an educational project. Been reading about compilers for weeks now and I started using tools like flex and bison for lexer and parser. I know nim have a parsing library but nowhere near that level.</p>
<p>There is an old post (2014) with a similar question so I'm bringing that back a few years later. Is there anything anyone know that could speed up the process of developing a programing language using nim? (I can have c code if needed ofc)</p>
</div>
<div class="post-buttons">
<div class="like-button">
<button class="btn">
<span class="like-count">2</span>
<i class="fas fa-heart"></i>
</button>
</div>
<div class="flag-button">
<button class="btn">
<i class="far fa-flag"></i>
</button>
</div>
<div class="reply-button">
<button class="btn">
<i class="fas fa-reply"></i>
Reply
</button>
</div>
</div>
</div>
</div>
<div class="post">
<div class="post-icon">
<figure class="post-avatar">
<img src="https://www.gravatar.com/avatar/bfa46cf3ac91fcf26792ff824983e07e?s=80&d=identicon" alt="Avatar">
</figure>
</div>
<div class="post-main">
<div class="post-title">
<div class="post-username">
twetzel59
</div>
<div class="post-time">Jan 2015</div>
</div>
<div class="post-content">
<p>Wow, I was just reading about the compilation pipeline today!</p>
<p>I suppose you could use at least the lexing part from a generator like <code>flex</code>, not so sure about using AST generators <b>easily</b> (it's possible).</p>
<p>Is your language complicated enough to warrant a parser generator or could you just use a custom parser?</p>
</div>
<div class="post-buttons">
<div class="like-button">
<button class="btn">
<i class="far fa-heart"></i>
</button>
</div>
<div class="flag-button">
<button class="btn">
<i class="far fa-flag"></i>
</button>
</div>
<div class="reply-button">
<button class="btn">
<i class="fas fa-reply"></i>
Reply
</button>
</div>
</div>
</div>
</div>
<div class="information time-passed">
<div class="information-icon">
<i class="fas fa-clock"></i>
</div>
<div class="information-main">
<div class="information-title">
3 years later
</div>
</div>
</div>
<div class="post">
<div class="post-icon">
<figure class="post-avatar">
<img src="https://www.gravatar.com/avatar/1d180d9e0368472144840049b3d79448?s=80&d=identicon" alt="Avatar">
</figure>
</div>
<div class="post-main">
<div class="post-title">
<div class="post-username">
dom96 <i class="fas fa-shield-alt"></i> <i class="fas fa-chess-king"></i><!-- Random idea, allow to specify any icon class -->
</div>
<div class="post-time">32m</div>
</div>
<div class="post-content">
<p>Let us test this new design a bit, <i>shall we?</i></p>
<pre class="code" data-lang="Nim"><code>proc hello(x: int) =
echo("Hello ", x)
42.hello()</code><div class="code-buttons"><button class="btn btn-primary btn-sm">Run</button></div></pre><pre class="execution-result execution-success"><button class="btn btn-clear float-right"></button><h6>Output</h6>Hello 42</pre>
<p>The greatest function ever written is <code>hello</code>.</p>
<blockquote>
<p>Designing websites is often a pain.</p>
<blockquote>Multi-level baby!</blockquote></blockquote>
<p>True that.</p>
<p>I also want to be able to support more detailed quoting:</p>
<blockquote>
<div class="detail">
<figure class="quote-avatar">
<img src="https://www.gravatar.com/avatar/ad1ada3bea74a6afab83d2e40da1dcf3?s=30&d=identicon" alt="Avatar">
</figure>
<span class="quote-username">Araq:</span>
<span class="quote-link"><i class="fas fa-arrow-up"></i></span>
</div>
Unix is a cancer.
</blockquote>
<p>We also want to be able to highlight user mentions:</p>
<p>Please let
<span class="user-mention">
@Araq
</span>
know that this forum is awesome.</p>
</div>
<div class="post-buttons">
<div class="like-button">
<button class="btn">
<i class="far fa-heart"></i>
</button>
</div>
<div class="flag-button">
<button class="btn">
<i class="far fa-flag"></i>
</button>
</div>
<div class="reply-button">
<button class="btn">
<i class="fas fa-reply"></i>
Reply
</button>
</div>
</div>
</div>
</div>
<div class="information load-more-posts">
<div class="information-icon">
<i class="fas fa-comment-dots"></i>
</div>
<div class="information-main">
<div class="information-title">
Load more posts
</div>
<div class="information-content">
</div>
</div>
</div>
<div class="information no-border">
<div class="information-icon">
<i class="fas fa-reply"></i>
</div>
<div class="information-main">
<div class="information-title">
Replying to "Lexers and parsers in nim"
</div>
<div class="information-content">
</div>
</div>
</div>
</div>
</section>
</body>
</html>

64
nimforum.nimble Normal file
View file

@ -0,0 +1,64 @@
# 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"

View file

@ -1,6 +0,0 @@
# we need the documentation generator of the compiler:
--path:"$nimrod/packages/docutils"
--path:"$nimrod"
--path:"/home/dominik/code/nimrod/jester"

View file

@ -1,2 +0,0 @@
*
!.gitignore

781
public/css/nimforum.scss Normal file
View file

@ -0,0 +1,781 @@
@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;
}
}

View file

@ -1,284 +0,0 @@
/*
* HTML5 Boilerplate
*
* What follows is the result of much research on cross-browser styling.
* Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal,
* Kroc Camen, and the H5BP dev community and team.
*
* Detailed information about this CSS: h5bp.com/css
*
* ==|== normalize ==========================================================
*/
/* =============================================================================
HTML5 display definitions
========================================================================== */
article, aside, details, figcaption, figure, footer, header, hgroup, nav, section { display: block; }
audio, canvas, video { display: inline-block; *display: inline; *zoom: 1; }
audio:not([controls]) { display: none; }
[hidden] { display: none; }
/* =============================================================================
Base
========================================================================== */
/*
* 1. Correct text resizing oddly in IE6/7 when body font-size is set using em units
* 2. Prevent iOS text size adjust on device orientation change, without disabling user zoom: h5bp.com/g
*/
html { font-size: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
html, button, input, select, textarea { font-family: sans-serif; color: #222; }
body { margin: 0; font-size: 1em; line-height: 1.4; }
/* =============================================================================
Links
========================================================================== */
a { color: #00e; }
a:visited { color: #551a8b; }
a:hover { color: #06e; }
a:focus { outline: thin dotted; }
/* Improve readability when focused and hovered in all browsers: h5bp.com/h */
a:hover, a:active { outline: 0; }
/* =============================================================================
Typography
========================================================================== */
abbr[title] { border-bottom: 1px dotted; }
b, strong { font-weight: bold; }
blockquote { margin: 1em 40px; }
dfn { font-style: italic; }
hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; }
ins { background: #ff9; color: #000; text-decoration: none; }
mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; }
/* Redeclare monospace font family: h5bp.com/j */
pre, code, kbd, samp { font-family: monospace, serif; _font-family: 'courier new', monospace; font-size: 1em; }
/* Improve readability of pre-formatted text in all browsers */
pre { white-space: pre; white-space: pre-wrap; word-wrap: break-word; }
q { quotes: none; }
q:before, q:after { content: ""; content: none; }
small { font-size: 85%; }
/* Position subscript and superscript content without affecting line-height: h5bp.com/k */
sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; }
sup { top: -0.5em; }
sub { bottom: -0.25em; }
/* =============================================================================
Lists
========================================================================== */
ul, ol { margin: 1em 0; padding: 0 0 0 40px; }
dd { margin: 0 0 0 40px; }
nav ul, nav ol { list-style: none; list-style-image: none; margin: 0; padding: 0; }
/* =============================================================================
Embedded content
========================================================================== */
/*
* 1. Improve image quality when scaled in IE7: h5bp.com/d
* 2. Remove the gap between images and borders on image containers: h5bp.com/i/440
*/
img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; }
/*
* Correct overflow not hidden in IE9
*/
svg:not(:root) { overflow: hidden; }
/* =============================================================================
Figures
========================================================================== */
figure { margin: 0; }
/* =============================================================================
Forms
========================================================================== */
form { margin: 0; }
fieldset { border: 0; margin: 0; padding: 0; }
/* Indicate that 'label' will shift focus to the associated form element */
label { cursor: pointer; }
/*
* 1. Correct color not inheriting in IE6/7/8/9
* 2. Correct alignment displayed oddly in IE6/7
*/
legend { border: 0; *margin-left: -7px; padding: 0; white-space: normal; }
/*
* 1. Correct font-size not inheriting in all browsers
* 2. Remove margins in FF3/4 S5 Chrome
* 3. Define consistent vertical alignment display in all browsers
*/
button, input, select, textarea { font-size: 100%; margin: 0; vertical-align: baseline; *vertical-align: middle; }
/*
* 1. Define line-height as normal to match FF3/4 (set using !important in the UA stylesheet)
*/
button, input { line-height: normal; }
/*
* 1. Display hand cursor for clickable form elements
* 2. Allow styling of clickable form elements in iOS
* 3. Correct inner spacing displayed oddly in IE7 (doesn't effect IE6)
*/
button, input[type="button"], input[type="reset"], input[type="submit"] { cursor: pointer; -webkit-appearance: button; *overflow: visible; }
/*
* Re-set default cursor for disabled elements
*/
button[disabled], input[disabled] { cursor: default; }
/*
* Consistent box sizing and appearance
*/
input[type="checkbox"], input[type="radio"] { box-sizing: border-box; padding: 0; *width: 13px; *height: 13px; }
input[type="search"] { -webkit-appearance: textfield; -moz-box-sizing: content-box; -webkit-box-sizing: content-box; box-sizing: content-box; }
input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }
/*
* Remove inner padding and border in FF3/4: h5bp.com/l
*/
button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; }
/*
* 1. Remove default vertical scrollbar in IE6/7/8/9
* 2. Allow only vertical resizing
*/
textarea { overflow: auto; vertical-align: top; resize: vertical; }
/* Colors for form validity */
input:valid, textarea:valid { }
input:invalid, textarea:invalid { background-color: #f0dddd; }
/* =============================================================================
Tables
========================================================================== */
table { border-collapse: collapse; border-spacing: 0; }
td { vertical-align: top; }
/* =============================================================================
Chrome Frame Prompt
========================================================================== */
.chromeframe { margin: 0.2em 0; background: #ccc; color: black; padding: 0.2em 0; }
/* ==|== primary styles =====================================================
Author:
========================================================================== */
/* ==|== media queries ======================================================
EXAMPLE Media Query for Responsive Design.
This example overrides the primary ('mobile first') styles
Modify as content requires.
========================================================================== */
@media only screen and (min-width: 35em) {
/* Style adjustments for viewports that meet the condition */
}
/* ==|== non-semantic helper classes ========================================
Please define your styles before this section.
========================================================================== */
/* For image replacement */
.ir { display: block; border: 0; text-indent: -999em; overflow: hidden; background-color: transparent; background-repeat: no-repeat; text-align: left; direction: ltr; *line-height: 0; }
.ir br { display: none; }
/* Hide from both screenreaders and browsers: h5bp.com/u */
.hidden { display: none !important; visibility: hidden; }
/* Hide only visually, but have it available for screenreaders: h5bp.com/v */
.visuallyhidden { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; }
/* Extends the .visuallyhidden class to allow the element to be focusable when navigated to via the keyboard: h5bp.com/p */
.visuallyhidden.focusable:active, .visuallyhidden.focusable:focus { clip: auto; height: auto; margin: 0; overflow: visible; position: static; width: auto; }
/* Hide visually and from screenreaders, but maintain layout */
.invisible { visibility: hidden; }
/* Contain floats: h5bp.com/q */
.clearfix:before, .clearfix:after { content: ""; display: table; }
.clearfix:after { clear: both; }
.clearfix { *zoom: 1; }
/* ==|== print styles =======================================================
Print styles.
Inlined to avoid required HTTP connection: h5bp.com/r
========================================================================== */
@media print {
* { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } /* Black prints faster: h5bp.com/s */
a, a:visited { text-decoration: underline; }
a[href]:after { content: " (" attr(href) ")"; }
abbr[title]:after { content: " (" attr(title) ")"; }
.ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; } /* Don't show links for images, or javascript/internal links */
pre, blockquote { border: 1px solid #999; page-break-inside: avoid; }
thead { display: table-header-group; } /* h5bp.com/t */
tr, img { page-break-inside: avoid; }
img { max-width: 100% !important; }
@page { margin: 0.5cm; }
p, h2, h3 { orphans: 3; widows: 3; }
h2, h3 { page-break-after: avoid; }
}

1
public/css/spectre Submodule

@ -0,0 +1 @@
Subproject commit 7a6af53bca72b549b2cbf1948763348bd5b30dcd

View file

@ -1,308 +0,0 @@
html, body {
height: 100%;
}
#wrapper {
min-height: 100%;
}
div#header {
background-color: #3d3d3d;
color: #ffffff;
padding-top: 3pt;
padding-bottom: 3pt;
}
div#header span#welcome {
float: right;
padding: 0;
padding-right: 7pt;
}
div#header img {
float: right;
margin-top: -2pt;
padding-right: 7pt;
}
div#header span, #nimbtn span {
margin: 0;
padding: 5pt;
}
div#header a.right, #nimbtn a {
float: right;
color: #ffffff;
margin-right: 6pt;
}
div#header a {
color: #ffffff;
}
div#header a:visited, #nimbtn a:visited {
color: #ffffff;
}
div#header a:hover, #nimbtn a:hover {
text-decoration: none;
}
#nimbtn a {
margin-left: 6pt;
}
#nimbtn {
float: left;
background-color: #2a2a2a;
color: #ffffff;
padding-top: 3pt;
padding-bottom: 3pt;
}
#content {
margin: 5pt;
}
#content table#threads {
width: 100%;
border-collapse: separate;
text-align: center;
border: #ffffff solid 1px;
font-size: 10pt;
}
#content table#threads th {
background-color: #5D5D5D;
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
color: #ffffff;
border-bottom: #2d2d2d solid 2px;
border-right: #2d2d2d solid 1px;
}
#content table#threads tr:nth-child(even) {
background-color: #eee;
}
#content table#threads td {
vertical-align: middle;
border-right: #9d9d9d solid 1px;
border-bottom: #9d9d9d solid 1px;
}
#content table#threads>tbody>tr>td:first-child {
border-left: #9d9d9d solid 1px;
}
#content table#threads>tbody>tr>td:last-child {
border-right: #9d9d9d solid 1px;
}
#content table#threads td:hover {
border-right-color: #9d9d9d;
}
#content table#threads td.topic {
text-align: left;
padding: 5pt;
}
#content table#threads td.author {
width: 10%;
}
#content table#threads td.posts {
width: 10%;
}
#content table#threads td.views {
width: 10%;
}
#content table#threads td.lastreply {
width: 15%;
}
#whoisonline {
margin: 5pt;
font-size: 9pt;
}
#whoisonline .wioHeader {
background-color: #5D5D5D;
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
color: #ffffff;
border-bottom: #2d2d2d solid 1px;
padding: 3px;
padding-left: 5pt;
}
#whoisonline .content {
border: #9d9d9d solid 1px;
border-top: #2D2D2D solid 1px;
padding: 5pt;
}
#whoisonline .content hr {
margin-top: 5pt;
margin-bottom: 5pt;
}
#footer {
background-color: #5D5D5D;
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
color: #ffffff;
padding-left: 5px;
padding-right: 5px;
}
#footer a:link, #footer a:visited {
color: #ffffff;
}
#footer a:hover {
text-decoration: none;
}
#topbar {
margin: 5pt;
}
#content .post {
border: #4d4d4d solid 2px;
width: 100%;
margin-bottom: 5pt;
}
#content .post th {
background-color: #5D5D5D;
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
color: #ffffff;
padding-left: 5pt;
padding-right: 5pt;
padding-top: 3pt;
padding-bottom: 3pt;
text-align: left;
font-size: 9pt;
}
#content .post .left {
border-left: #4d4d4d solid 2px;
background-color: #eee;
padding: 7pt;
width: 15%;
height: auto;
}
#content .post .left hr {
margin: 0;
margin-bottom: 5pt;
margin-top: 2pt;
}
#content .post .content {
padding: 6pt;
}
div#replywrapper {
width: 70%;
margin-left: auto;
margin-right: auto;
border: #4d4d4d solid 2px;
}
div#replywrapper div#replytop {
background-color: #5D5D5D;
background: -moz-linear-gradient(top, #5D5D5D, #4D4D4D);
background: -webkit-linear-gradient(top, #5D5D5D, #4d4d4d);
background: -o-linear-gradient(top, #5D5D5D, #4d4d4d);
font-size: 9pt;
color: #ffffff;
padding: 5pt;
}
div#replywrapper form textarea {
width: 99%;
}
div#replywrapper form > input:first-child {
width: 80%;
}
div#replywrapper form {
padding: 8pt;
}
/* For RST nimrod syntax highlighter */
span.DecNumber {color: blue}
span.BinNumber {color: blue}
span.HexNumber {color: blue}
span.OctNumber {color: blue}
span.FloatNumber {color: blue}
span.Identifier {color: black}
span.Keyword {font-weight: bold}
span.StringLit {color: blue}
span.LongStringLit {color: blue}
span.CharLit {color: blue}
span.EscapeSequence {color: black}
span.Operator {color: black}
span.Punctation {color: black}
span.Comment, span.LongComment {font-style:italic; color: green}
span.RegularExpression {color: DarkViolet}
span.TagStart {color: DarkViolet}
span.TagEnd {color: DarkViolet}
span.Key {color: blue}
span.Value {color: black}
span.RawData {color: blue}
span.Assembler {color: blue}
span.Preprocessor {color: DarkViolet}
span.Directive {color: DarkViolet}
span.Command, span.Rule, span.Hyperlink, span.Label, span.Reference,
span.Other {color: black}
/* Buttons */
a.button {
border-radius: 2px 2px 2px 2px;
background: -moz-linear-gradient(top, #f7f7f7, #ebebeb);
background: -webkit-linear-gradient(top, #f7f7f7, #ebebeb);
background: -o-linear-gradient(top, #f7f7f7, #ebebeb);
text-decoration: none;
color: #3d3d3d;
padding: 5px;
border: solid 1px #9d9d9d;
display: inline-block;
position: relative;
text-align: center;
font-size: small;
}
a.button.left {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
a.button.middle {
border-radius: 0;
border-left: 0;
}
a.button.right {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 0;
}
a.button:hover {
background: -moz-linear-gradient(top, #0099c7, #0294C1);
background: -webkit-linear-gradient(top, #0099c7, #0294C1);
background: -o-linear-gradient(top, #0099c7, #0294C1);
border: solid 1px #077A9C;
color: #ffffff;
}

13
public/css/syntax.scss Normal file
View file

@ -0,0 +1,13 @@
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;
}

31
public/karax.html Normal file
View file

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$title</title>
<link rel="stylesheet" href="/css/nimforum.css">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
<link rel="icon" href="/images/favicon.png">
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=$ga"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '$ga');
</script>
</head>
<body>
<div id="ROOT"></div>
<script type="text/javascript" src="/js/forum.js?t=$timestamp"></script>
</body>
</html>

38
public/license.rst Normal file
View file

@ -0,0 +1,38 @@
Content license
===============
All the content contributed to $hostname is `cc-wiki (aka cc-by-sa)
<http://creativecommons.org/licenses/by-sa/3.0/>`_ licensed, intended to be
**shared and remixed**.
The cc-wiki licensing, while intentionally permissive, does require
attribution:
**Attribution** — You must attribute the work in the manner specified by
the author or licensor (but not in any way that suggests that they endorse
you or your use of the work).
This means that if you republish this content, you are
required to:
* **Visually indicate that the content is from the $name**. It doesnt
have to be obnoxious; a discreet text blurb is fine.
* **Hyperlink directly to the original post** (e.g.,
https://$hostname/t/186/1#908)
* **Show the author names** for every post.
* **Hyperlink each author name** directly back to their user profile page
(e.g., http://$hostname/profile/Araq)
To be more specific, each hyperlink must
point directly to the $hostname domain in
standard HTML visible even with JavaScript disabled, and not use a tinyurl or
any other form of obfuscation or redirection. Furthermore, the links must not
be `nofollowed
<http://googleblog.blogspot.com.es/2005/01/preventing-comment-spam.html>`_.
This is about the spirit of fair **attribution**. Attribution to the website,
and more importantly, to the individuals who so generously contributed their
time to create that content in the first place!
Feel free to remix and reuse to your hearts content, as long as a good faith
effort is made to attribute the content!

View file

@ -1,9 +1,8 @@
===========================================================================
reStructuredText cheat sheet
===========================================================================
Markdown and RST supported by this forum
========================================
This is a cheat sheet for the *reStructuredText* dialect as implemented by
Nimrod's documentation generator which has been reused for this forum. :-)
Nim's documentation generator which has been reused for this forum.
See also the
`official RST cheat sheet <http://docutils.sourceforge.net/docs/user/rst/cheatsheet.txt>`_
@ -12,9 +11,8 @@ for further information.
Elements of **markdown** are also supported.
Inline elements
===============
---------------
Ordinary text may contain *inline elements*:
@ -29,68 +27,71 @@ Plain text Result
``\\escape`` \\escape
=============================== ============================================
Links
=====
Quoting other users can be done by prefixing their message with ``>``::
Links are either direct URLs like ``http://nimrod-code.org`` or written like
> Hello World
Hi!
Which will result in:
> Hello World
Hi!
Links
-----
Links are either direct URLs like ``https://nim-lang.org`` or written like
this::
`Nimrod <http://nimrod-code.org>`_
`Nim <https://nim-lang.org>`_
Or like this::
`<http://nimrod-code.org>`_
`<https://nim-lang.org>`_
Code blocks
===========
-----------
are done this way::
The code blocks can be written in the same style as most common Markdown
flavours::
.. code-block:: nimrod
```nim
if x == "abc":
echo "xyz"
```
or using RST syntax::
.. code-block:: nim
if x == "abc":
echo "xyz"
Both are rendered as:
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
.. code-block:: nim
if x == "abc":
echo "xyz"
Apart from Nim, the programming languages C, C++, Java and C# also
have highlighting support.
Literal blocks
==============
--------------
Are introduced by '::' and a newline. The block is indicated by indentation:
These are introduced by '::' and a newline. The block is indicated by indentation:
::
::
if x == "abc":
echo "xyz"
Is rendered as::
The above is rendered as::
if x == "abc":
echo "xyz"
@ -98,9 +99,9 @@ Is rendered as::
Bullet lists
============
------------
look like this::
Bullet lists look like this::
* Item 1
* Item 2 that
@ -111,7 +112,7 @@ look like this::
- item 3b
- valid bullet characters are ``+``, ``*`` and ``-``
Is rendered as:
The above rendered as:
* Item 1
* Item 2 that
spans over multiple lines
@ -123,9 +124,9 @@ Is rendered as:
Enumerated lists
================
----------------
are written like this::
Enumerated lists are written like this::
1. This is the first item
2. This is the second item
@ -133,64 +134,17 @@ are written like this::
single letters, or roman numerals
#. This item is auto-enumerated
Is rendered as:
They are 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
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.
#. This item is auto-enumerated
Tables
======
------
Only *simple tables* are supported. They are of the form::
@ -218,3 +172,39 @@ 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
<video> tag will be automatically generated).
For example:
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
```
Will render as:
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
And a GIF example:
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif
```
Will render as:
.. image:: https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif
You can also specify the size of the image:
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
:width: 40%
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
:width: 40%

35
public/search-help.rst Normal file
View file

@ -0,0 +1,35 @@
Full-text search for Nim forum
==============================
Syntax (using *SQLite* dll compiled without *Enhanced Query Syntax* support):
-----------------------------------------------------------------------------
- Only alphanumeric characters are searched.
- Only full words and words beginnings (e.g. ``Nim*`` for both ``Nimrod`` and ``Nim``) are searched
- All words are joined with implicit **AND** operator; there's no explicit one
- There's explicit **OR** operator (upper-case) and it has higher priority
- Words can be prepended with **-** to be excluded from search
- No parentheses support
- Quotes for phrases search, e.g. ``"programming language"``
- Distances between words/phrases can be specified putting ``NEAR`` or ``NEAR/some_number`` between them
Syntax - differences in *Enhanced Query Syntax* (should be enabled in *SQLite* dll):
------------------------------------------------------------------------------------
- **AND** and **NOT** logical operators available
- Precedence of operators is, from highest to lowest: **NOT**, **AND**, **OR**
- Parentheses for grouping are supported
Where search is performed:
--------------------------
- **Threads' titles** - these results are outputed first
- **Posts' titles** - middle precedence
- **Posts' contents** - the latest
How results are shown:
----------------------
- All results are ordered by date (posts' edits don't affect)
- Matched tokens in text are marked (bold or dotted underline)
- Threads title is the link to the thread and posts title is the link to the post

171
setup.md Normal file
View file

@ -0,0 +1,171 @@
# NimForum setup
This document describes the steps needed to setup a working NimForum instance.
## Requirements
* Ubuntu 16.04+
* Some Linux knowledge
## Installation
Begin by downloading the latest NimForum release from
[here](https://github.com/nim-lang/nimforum/releases). The macOS releases are
mainly provided for testing.
Extract the downloaded tarball on your server. These steps can be done using
the following commands:
```
wget https://github.com/nim-lang/nimforum/releases/download/v2.0.0/nimforum_2.0.0_linux.tar.xz
tar -xf nimforum_2.0.0_linux.tar.xz
```
Then ``cd`` into the forum's directory:
```
cd nimforum_2.0.0_linux
```
### Dependencies
The following may need to be installed on your server:
```
sudo apt install libsass-dev sqlite3
```
## Configuration and DB creation
The NimForum release comes with a handy ``setup_nimforum`` program. Run
it to begin the setup process:
```
./setup_nimforum --setup
```
The program will ask you multiple questions which will require some
additional setup, including mail server info and recaptcha keys. You can
just specify dummy values if you want to play around with the forum as
quickly as possible and set these up later.
This program will create a ``nimforum.db`` file, this contains your forum's
database. It will also create a ``forum.json`` file, you can modify this
file after running the ``setup_nimforum`` script if you've made any mistakes
or just want to change things.
## Running the forum
Executing the forum is simple, just run the ``forum`` binary:
```
./forum
```
The forum will start listening to HTTP requests on port 5000 (by default, this
can be changed in ``forum.json``).
On your server you should set up a separate HTTP server. The recommended choice
is nginx. You can then use it as a reverse proxy for NimForum.
### HTTP server
#### nginx
Once you have nginx installed on your server, you will need to configure it.
Create a ``forum.hostname.com`` file (replace the hostname with your forum's
hostname) inside ``/etc/nginx/sites-available/``.
Place the following inside it:
```
server {
server_name forum.hostname.com;
autoindex off;
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real_IP $remote_addr;
}
}
```
Again, be sure to replace ``forum.hostname.com`` with your forum's
hostname.
You should then create a symlink to this file inside ``/etc/nginx/sites-enabled/``:
```
ln -s /etc/nginx/sites-available/<forum.hostname.com> /etc/nginx/sites-enabled/<forum.hostname.com>
```
Then reload nginx configuration by running ``sudo nginx -s reload``.
### Supervisor
#### systemd
In order to ensure the forum is always running, even after a crash or a server
reboot, you should create a systemd service file.
Create a new file called ``nimforum.service`` inside ``/lib/systemd/system/nimforum.service``.
Place the following inside it:
```
[Unit]
Description=nimforum
After=network.target httpd.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/<user>/nimforum-2.0.0/ # MODIFY THIS
ExecStart=/usr/bin/stdbuf -oL /home/<user>/nimforum-2.0.0/forum # MODIFY THIS
# Restart when crashes.
Restart=always
RestartSec=1
User=dom
StandardOutput=syslog+console
StandardError=syslog+console
[Install]
WantedBy=multi-user.target
```
**Be sure to specify the correct ``WorkingDirectory`` and ``ExecStart``!**
You can then enable and start the service by running the following:
```
sudo systemctl enable nimforum
sudo systemctl start nimforum
```
To check that everything is in order, run this:
```
systemctl status nimforum
```
You should see something like this:
```
● nimforum.service - nimforum
Loaded: loaded (/lib/systemd/system/nimforum.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2018-05-25 22:09:59 UTC; 1 day 22h ago
Main PID: 21474 (forum)
Tasks: 1
Memory: 55.2M
CPU: 1h 15min 31.905s
CGroup: /system.slice/nimforum.service
└─21474 /home/dom/nimforum/src/forum
```
## Conclusion
That should be all you need to get started. Your forum should now be accessible
via your hostname, assuming that it points to your VPS' IP address.

90
src/auth.nim Normal file
View file

@ -0,0 +1,90 @@
import random, md5
import bcrypt, hmac
proc randomSalt(): string =
result = ""
for i in 0..127:
var r = rand(225)
if r >= 32 and r <= 126:
result.add(chr(rand(225)))
proc devRandomSalt(): string =
when defined(posix):
result = ""
var f = open("/dev/urandom")
var randomBytes: array[0..127, char]
discard f.readBuffer(addr(randomBytes), 128)
for i in 0..127:
if ord(randomBytes[i]) >= 32 and ord(randomBytes[i]) <= 126:
result.add(randomBytes[i])
f.close()
else:
result = randomSalt()
proc makeSalt*(): string =
## Creates a salt using a cryptographically secure random number generator.
##
## Ensures that the resulting salt contains no ``\0``.
try:
result = devRandomSalt()
except IOError:
result = randomSalt()
var newResult = ""
for i in 0 ..< result.len:
if result[i] != '\0':
newResult.add result[i]
return newResult
proc makeSessionKey*(): string =
## Creates a random key to be used to authorize a session.
let random = makeSalt()
return bcrypt.hash(random, genSalt(8))
proc makePassword*(password, salt: string, comparingTo = ""): string =
## Creates an MD5 hash by combining password and salt.
let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8)
result = hash(getMD5(salt & getMD5(password)), bcryptSalt)
proc makeIdentHash*(user, password: string, epoch: int64,
secret: string): string =
## Creates a hash verifying the identity of a user. Used for password reset
## links and email activation links.
## The ``epoch`` determines the creation time of this hash, it will be checked
## during verification to ensure the hash hasn't expired.
## The ``secret`` is the 'salt' field in the ``person`` table.
result = hmac_sha256(secret, user & password & $epoch).toHex()
when isMainModule:
block:
let ident = makeIdentHash("test", "pass", 1526908753, "randomtext")
let ident2 = makeIdentHash("test", "pass", 1526908753, "randomtext")
doAssert ident == ident2
let invalid = makeIdentHash("test", "pass", 1526908754, "randomtext")
doAssert ident != invalid
block:
let ident = makeIdentHash(
"test",
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
1526908753,
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um"
)
let ident2 = makeIdentHash(
"test",
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
1526908753,
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um"
)
doAssert ident == ident2
let invalid = makeIdentHash(
"test",
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
1526908754,
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um"
)
doAssert ident != invalid

35
src/buildcss.nim Normal file
View file

@ -0,0 +1,35 @@
import os
import sass
import utils
proc buildCSS*(config: Config) =
let publicLoc = "public"
var includePaths: seq[string] = @[]
# Check for a styles override.
var hostname = config.hostname
if not existsDir(hostname):
hostname = "localhost.local"
let dir = getCurrentDir() / hostname / "public"
includePaths.add(dir / "css")
createDir(publicLoc / "images")
let logo = publicLoc / "images" / "logo.png"
removeFile(logo)
createSymlink(
dir / "images" / "logo.png",
logo
)
let cssLoc = publicLoc / "css"
sass.compileFile(
cssLoc / "nimforum.scss",
cssLoc / "nimforum.css",
includePaths=includePaths
)
when isMainModule:
let config = loadConfig()
buildCSS(config)
echo("CSS Built successfully")

151
src/email.nim Normal file
View file

@ -0,0 +1,151 @@
import asyncdispatch, smtp, strutils, times, cgi, tables, logging
from jester import Request, makeUri
import utils, auth
type
Mailer* = ref object
config: Config
lastReset: Time
emailsSent: CountTable[string]
proc newMailer*(config: Config): Mailer =
Mailer(
config: config,
lastReset: getTime(),
emailsSent: initCountTable[string]()
)
proc rateCheck(mailer: Mailer, address: string): bool =
## Returns true if we've emailed the address too much.
let diff = getTime() - mailer.lastReset
if diff.inHours >= 1:
mailer.lastReset = getTime()
mailer.emailsSent.clear()
result = address in mailer.emailsSent and mailer.emailsSent[address] >= 2
mailer.emailsSent.inc(address)
proc sendMail(
mailer: Mailer,
subject, message, recipient: string,
otherHeaders:seq[(string, string)] = @[]
) {.async.} =
# Ensure we aren't emailing this address too much.
if rateCheck(mailer, recipient):
let msg = "Too many messages have been sent to this email address recently."
raise newForumError(msg)
if mailer.config.smtpAddress.len == 0:
warn("Cannot send mail: no smtp server configured (smtpAddress).")
return
if mailer.config.smtpFromAddr.len == 0:
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
return
var client: AsyncSmtp
if mailer.config.smtpTls:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
await client.startTls()
elif mailer.config.smtpSsl:
client = newAsyncSmtp(useSsl=true)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
else:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
if mailer.config.smtpUser.len > 0:
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
let toList = @[recipient]
var headers = otherHeaders
headers.add(("From", mailer.config.smtpFromAddr))
let dateHeader = now().utc().format("ddd, dd MMM yyyy hh:mm:ss") & " +0000"
headers.add(("Date", dateHeader))
let encoded = createMessage(subject, message,
toList, @[], headers)
await client.sendMail(mailer.config.smtpFromAddr, toList, $encoded)
proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} =
let message = """Hello $1,
A password reset has been requested for your account on the $3.
If you did not make this request, you can safely ignore this email.
A password reset request can be made by anyone, and it does not indicate
that your account is in any danger of being accessed by someone else.
If you do actually want to reset your password, visit this link:
$2
Thank you for being a part of our community!
""" % [user, resetUrl, mailer.config.name]
let subject = mailer.config.name & " Password Recovery"
await sendMail(mailer, subject, message, email)
proc sendEmailActivation(
mailer: Mailer,
email, user, activateUrl: string
) {.async.} =
let message = """Hello $1,
You have recently registered an account on the $3.
As the final step in your registration, we require that you confirm your email
via the following link:
$2
Thank you for registering and becoming a part of our community!
""" % [user, activateUrl, mailer.config.name]
let subject = mailer.config.name & " Account Email Confirmation"
await sendMail(mailer, subject, message, email)
type
SecureEmailKind* = enum
ActivateEmail, ResetPassword
proc sendSecureEmail*(
mailer: Mailer,
kind: SecureEmailKind, req: Request,
name, password, email, salt: string
) {.async.} =
let epoch = int(epochTime())
let path =
case kind
of ActivateEmail:
"activateEmail"
of ResetPassword:
"resetPassword"
let url = req.makeUri(
"/$#?nick=$#&epoch=$#&ident=$#" %
[
path,
encodeUrl(name),
encodeUrl($epoch),
encodeUrl(makeIdentHash(name, password, epoch, salt))
]
)
debug(url)
let emailSentFut =
case kind
of ActivateEmail:
sendEmailActivation(mailer, email, name, url)
of ResetPassword:
sendPassReset(mailer, email, name, url)
yield emailSentFut
if emailSentFut.failed:
warn("Couldn't send email: ", emailSentFut.error.msg)
if emailSentFut.error of ForumError:
raise emailSentFut.error
else:
raise newForumError("Couldn't send email", @["email"])

1627
src/forum.nim Normal file

File diff suppressed because it is too large Load diff

44
src/frontend/about.nim Normal file
View file

@ -0,0 +1,44 @@
when defined(js):
import sugar, httpcore
import dom except Event
include karax/prelude
import karax / [kajax]
import error
import karaxutils
type
About* = ref object
loading: bool
status: HttpCode
content: kstring
page: string
proc newAbout*(): About =
About(
status: Http200
)
proc onContent(status: int, response: kstring, state: About) =
state.status = status.HttpCode
state.content = response
proc render*(state: About, page: string): VNode =
if state.status != Http200:
return renderError($state.content, state.status)
if page != state.page:
if not state.loading:
state.page = page
state.loading = true
state.status = Http200
let uri = makeUri("/about/" & page & ".html")
ajaxGet(uri, @[], (s: int, r: kstring) => onContent(s, r, state))
return buildHtml(tdiv(class="loading"))
result = buildHtml():
section(class="container grid-xl"):
tdiv(class="about"):
verbatim(state.content)

View file

@ -0,0 +1,53 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
include karax/prelude
import karax / [kajax, kdom]
import error
import karaxutils
type
ActivateEmail* = ref object
loading: bool
status: HttpCode
error: Option[PostError]
proc newActivateEmail*(): ActivateEmail =
ActivateEmail(
status: Http200
)
proc onPost(httpStatus: int, response: kstring, state: ActivateEmail) =
postFinished:
navigateTo(makeUri("/activateEmail/success"))
proc onSetClick(
ev: Event, n: VNode,
state: ActivateEmail
) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("activateEmail", search = $kdom.window.location.search)
ajaxPost(uri, @[], "",
(s: int, r: kstring) => onPost(s, r, state))
proc render*(state: ActivateEmail): VNode =
result = buildHtml():
section(class="container grid-xl"):
tdiv(id="activateemail"):
tdiv(class="title"):
p(): text "Activate Email"
tdiv(class="content"):
button(class=class(
{"loading": state.loading},
"btn btn-primary"
),
onClick=(ev: Event, n: VNode) =>
(onSetClick(ev, n, state))):
text "Activate"
if state.error.isSome():
p(class="text-error"):
text state.error.get().message

View file

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

48
src/frontend/category.nim Normal file
View file

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

View file

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

View file

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

133
src/frontend/delete.nim Normal file
View file

@ -0,0 +1,133 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error, post, threadlist, user
import karaxutils
type
DeleteKind* = enum
DeleteUser, DeletePost, DeleteThread
DeleteModal* = ref object
shown: bool
loading: bool
onDeletePost: proc (post: Post)
onDeleteThread: proc (thread: Thread)
onDeleteUser: proc (user: User)
error: Option[PostError]
case kind: DeleteKind
of DeleteUser:
user: User
of DeletePost:
post: Post
of DeleteThread:
thread: Thread
proc onDeletePost(httpStatus: int, response: kstring, state: DeleteModal) =
postFinished:
state.shown = false
case state.kind
of DeleteUser:
state.onDeleteUser(state.user)
of DeletePost:
state.onDeletePost(state.post)
of DeleteThread:
state.onDeleteThread(state.thread)
proc onDelete(ev: Event, n: VNode, state: DeleteModal) =
state.loading = true
state.error = none[PostError]()
let uri =
case state.kind
of DeleteUser:
makeUri("/deleteUser")
of DeleteThread:
makeUri("/deleteThread")
of DeletePost:
makeUri("/deletePost")
# TODO: This is a hack, karax should support this.
let formData = newFormData()
case state.kind
of DeleteUser:
formData.append("username", state.user.name)
of DeletePost:
formData.append("id", $state.post.id)
of DeleteThread:
formData.append("id", $state.thread.id)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onDeletePost(s, r, state))
proc onClose(ev: Event, n: VNode, state: DeleteModal) =
state.shown = false
ev.preventDefault()
proc newDeleteModal*(
onDeletePost: proc (post: Post),
onDeleteThread: proc (thread: Thread),
onDeleteUser: proc (user: User),
): DeleteModal =
DeleteModal(
shown: false,
onDeletePost: onDeletePost,
onDeleteThread: onDeleteThread,
onDeleteUser: onDeleteUser,
)
proc show*(state: DeleteModal, thing: User | Post | Thread) =
state.shown = true
state.error = none[PostError]()
when thing is User:
state.kind = DeleteUser
state.user = thing
when thing is Post:
state.kind = DeletePost
state.post = thing
when thing is Thread:
state.kind = DeleteThread
state.thread = thing
proc render*(state: DeleteModal): VNode =
result = buildHtml():
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
id="delete-modal"):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
a(href="", class="btn btn-clear float-right",
"aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-title h5"):
text "Delete"
tdiv(class="modal-body"):
tdiv(class="content"):
p():
text "Are you sure you want to delete this "
case state.kind
of DeleteUser:
text "user account?"
of DeleteThread:
text "thread?"
of DeletePost:
text "post?"
tdiv(class="modal-footer"):
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
button(class=class(
{"loading": state.loading},
"btn btn-primary delete-btn"
),
onClick=(ev: Event, n: VNode) => onDelete(ev, n, state)):
italic(class="fas fa-trash-alt")
text " Delete"
button(class="btn cancel-btn",
onClick=(ev: Event, n: VNode) => (state.shown = false)):
text "Cancel"

99
src/frontend/editbox.nim Normal file
View file

@ -0,0 +1,99 @@
when defined(js):
import httpcore, options, sugar, json
import jsffi except `&`
include karax/prelude
import karax/kajax
import replybox, post, karaxutils, threadlist, error
type
OnEditPosted* = proc (id: int, content: string, subject: Option[string])
EditBox* = ref object
box: ReplyBox
post: Post
rawContent: Option[kstring] ## The raw rst for a post (needs to be loaded)
loading: bool
status: HttpCode
error: Option[PostError]
onEditPosted: OnEditPosted
onEditCancel: proc ()
proc newEditBox*(onEditPosted: OnEditPosted, onEditCancel: proc ()): EditBox =
EditBox(
box: newReplyBox(nil),
onEditPosted: onEditPosted,
onEditCancel: onEditCancel,
status: Http200
)
proc onRawContent(httpStatus: int, response: kstring, state: EditBox) =
state.status = httpStatus.HttpCode
if state.status != Http200: return
state.rawContent = some(response)
state.box.setText(state.rawContent.get())
proc onEditPost(httpStatus: int, response: kstring, state: EditBox) =
postFinished:
state.onEditPosted(
state.post.id,
$response,
none[string]()
)
proc save(state: EditBox) =
if state.loading:
# TODO: Weird behaviour: onClick handler gets called 80+ times.
return
state.loading = true
state.error = none[PostError]()
let formData = newFormData()
formData.append("msg", state.box.getText())
formData.append("postId", $state.post.id)
# TODO: Subject
let uri = makeUri("/updatePost")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onEditPost(s, r, state))
proc render*(state: EditBox, post: Post): VNode =
if (not state.post.isNil) and state.post.id != post.id:
state.rawContent = none[kstring]()
state.status = Http200
if state.status != Http200:
return renderError("Couldn't retrieve raw post", state.status)
if state.rawContent.isNone():
state.post = post
state.rawContent = none[kstring]()
var params = @[("id", $post.id)]
let uri = makeUri("post.rst", params)
ajaxGet(uri, @[], (s: int, r: kstring) => onRawContent(s, r, state))
return buildHtml(tdiv(class="loading"))
result = buildHtml():
tdiv(class="edit-box"):
renderContent(
state.box,
none[Thread](),
none[Post]()
)
if state.error.isSome():
span(class="text-error"):
text state.error.get().message
tdiv(class="edit-buttons"):
tdiv(class="cancel-button"):
button(class="btn btn-link",
onClick=(e: Event, n: VNode) => (state.onEditCancel())):
text " Cancel"
tdiv(class="save-button"):
button(class=class({"loading": state.loading}, "btn btn-primary"),
onClick=(e: Event, n: VNode) => state.save()):
italic(class="fas fa-check")
text " Save"

92
src/frontend/error.nim Normal file
View file

@ -0,0 +1,92 @@
import httpcore
type
PostError* = object
errorFields*: seq[string] ## IDs of the fields with an error.
message*: string
when defined(js):
import json, options
include karax/prelude
import karaxutils
proc render404*(): VNode =
result = buildHtml():
tdiv(class="empty error"):
tdiv(class="empty icon"):
italic(class="fas fa-bug fa-5x")
p(class="empty-title h5"):
text "404 Not Found"
p(class="empty-subtitle"):
text "Cannot find what you are looking for, it might have been " &
"deleted. Sorry!"
tdiv(class="empty-action"):
a(href="/", onClick=anchorCB):
button(class="btn btn-primary"):
text "Go back home"
proc renderError*(message: string, status: HttpCode): VNode =
if status == Http404:
return render404()
result = buildHtml():
tdiv(class="empty error"):
tdiv(class="empty icon"):
italic(class="fas fa-bug fa-5x")
p(class="empty-title h5"):
text message
p(class="empty-subtitle"):
text "Please report this issue to us so we can fix it!"
tdiv(class="empty-action"):
a(href="https://github.com/nim-lang/nimforum/issues", target="_blank"):
button(class="btn btn-primary"):
text "Report issue"
proc renderMessage*(message, submessage, icon: string): VNode =
result = buildHtml():
tdiv(class="empty error"):
tdiv(class="empty icon"):
italic(class="fas " & icon & " fa-5x")
p(class="empty-title h5"):
text message
p(class="empty-subtitle"):
text submessage
proc genFormField*(error: Option[PostError], name, label, typ: string,
isLast: bool, placeholder=""): VNode =
let hasError =
not error.isNone and (
name in error.get().errorFields or
error.get().errorFields.len == 0)
result = buildHtml():
tdiv(class=class({"has-error": hasError}, "form-group")):
label(class="form-label", `for`=name):
text label
input(class="form-input", `type`=typ, name=name,
placeholder=placeholder)
if not error.isNone:
let e = error.get()
if (e.errorFields.len == 1 and e.errorFields[0] == name) or
(isLast and e.errorFields.len == 0):
span(class="form-input-hint"):
text e.message
template postFinished*(onSuccess: untyped): untyped =
state.loading = false
let status = httpStatus.HttpCode
if status == Http200:
onSuccess
else:
# TODO: Karax should pass the content-type...
try:
let parsed = parseJson($response)
let error = to(parsed, PostError)
state.error = some(error)
except:
echo getCurrentExceptionMsg()
state.error = some(PostError(
errorFields: @[],
message: "Unknown error occurred."
))

162
src/frontend/forum.nim Normal file
View file

@ -0,0 +1,162 @@
import options, tables, sugar, httpcore
from dom import window, Location, document, decodeURI
include karax/prelude
import karax/[kdom]
import jester/[patterns]
import threadlist, postlist, header, profile, newthread, error, about
import categorylist
import resetpassword, activateemail, search
import karaxutils
type
State = ref object
originalTitle: cstring
url: Location
profile: ProfileState
newThread: NewThread
about: About
resetPassword: ResetPassword
activateEmail: ActivateEmail
search: Search
proc copyLocation(loc: Location): Location =
# TODO: It sucks that I had to do this. We need a nice way to deep copy in JS.
Location(
hash: loc.hash,
host: loc.host,
hostname: loc.hostname,
href: loc.href,
pathname: loc.pathname,
port: loc.port,
protocol: loc.protocol,
search: loc.search
)
proc newState(): State =
State(
originalTitle: document.title,
url: copyLocation(window.location),
profile: newProfileState(),
newThread: newNewThread(),
about: newAbout(),
resetPassword: newResetPassword(),
activateEmail: newActivateEmail(),
search: newSearch()
)
var state = newState()
proc onPopState(event: dom.Event) =
# This event is usually only called when the user moves back in their
# history. I fire it in karaxutils.anchorCB as well to ensure the URL is
# always updated. This should be moved into Karax in the future.
echo "New URL: ", window.location.href, " ", state.url.href
document.title = state.originalTitle
if state.url.href != window.location.href:
state = newState() # Reload the state to remove stale data.
state.url = copyLocation(window.location)
redraw()
type Params = Table[string, string]
type
Route = object
n: string
p: proc (params: Params): VNode
proc r(n: string, p: proc (params: Params): VNode): Route = Route(n: n, p: p)
proc route(routes: openarray[Route]): VNode =
let path =
if state.url.pathname.len == 0: "/" else: $state.url.pathname
let prefix = if appName == "/": "" else: appName
for route in routes:
let pattern = (prefix & route.n).parsePattern()
var (matched, params) = pattern.match(path)
parseUrlQuery($state.url.search, params)
if matched:
return route.p(params)
return renderError("Unmatched route: " & path, Http500)
proc render(): VNode =
result = buildHtml(tdiv()):
renderHeader()
route([
r("/categories",
(params: Params) =>
(renderCategoryList(getLoggedInUser()))
),
r("/c/@id",
(params: Params) =>
(renderThreadList(getLoggedInUser(), some(params["id"].parseInt)))
),
r("/newthread",
(params: Params) =>
(render(state.newThread, getLoggedInUser()))
),
r("/profile/@username",
(params: Params) =>
(
render(
state.profile,
decodeURI(params["username"]),
getLoggedInUser()
)
)
),
r("/t/@id",
(params: Params) =>
(
let postId = getInt(($state.url.hash).substr(1), 0);
renderPostList(
params["id"].parseInt(),
if postId == 0: none[int]() else: some[int](postId),
getLoggedInUser()
)
)
),
r("/about/?@page?",
(params: Params) => (render(state.about, params["page"]))
),
r("/activateEmail/success",
(params: Params) => (
renderMessage(
"Email activated",
"You can now create new posts!",
"fa-check"
)
)
),
r("/activateEmail",
(params: Params) => (
render(state.activateEmail)
)
),
r("/resetPassword/success",
(params: Params) => (
renderMessage(
"Password changed",
"You can now login using your new password!",
"fa-check"
)
)
),
r("/resetPassword",
(params: Params) => (
render(state.resetPassword)
)
),
r("/search",
(params: Params) => (
render(state.search, params["q"], getLoggedInUser())
)
),
r("/404",
(params: Params) => render404()
),
r("/", (params: Params) => renderThreadList(getLoggedInUser()))
])
window.onPopState = onPopState
setRenderer render

View file

@ -0,0 +1 @@
-d:js

119
src/frontend/header.nim Normal file
View file

@ -0,0 +1,119 @@
import options, httpcore
import user
type
UserStatus* = object
user*: Option[User]
recaptchaSiteKey*: Option[string]
when defined(js):
import times, json, sugar
include karax/prelude
import karax / [kajax, kdom]
import login, signup, usermenu
import karaxutils
from dom import
setTimeout, window, document, getElementById, focus
type
State = ref object
data: Option[UserStatus]
loading: bool
status: HttpCode
lastUpdate: Time
loginModal: LoginModal
signupModal: SignupModal
userMenu: UserMenu
proc newState(): State
var
state = newState()
proc getStatus(logout=false)
proc newState(): State =
State(
data: none[UserStatus](),
loading: false,
status: Http200,
loginModal: newLoginModal(
() => (state.lastUpdate = fromUnix(0); getStatus()),
() => state.signupModal.show()
),
signupModal: newSignupModal(
() => (state.lastUpdate = fromUnix(0); getStatus()),
() => state.loginModal.show()
),
userMenu: newUserMenu(
() => (state.lastUpdate = fromUnix(0); getStatus(logout=true))
)
)
proc onStatus(httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
state.data = some(to(parsed, UserStatus))
state.lastUpdate = getTime()
proc getStatus(logout=false) =
if state.loading: return
let diff = getTime() - state.lastUpdate
if diff.inMinutes < 5:
return
state.loading = true
let uri = makeUri("status.json", [("logout", $logout)])
ajaxGet(uri, @[], onStatus)
proc getLoggedInUser*(): Option[User] =
state.data.map(x => x.user).flatten
proc isLoggedIn*(): bool =
not getLoggedInUser().isNone
proc onKeyDown(e: Event, n: VNode) =
let event = cast[KeyboardEvent](e)
if event.key == "Enter":
navigateTo(makeUri("/search", ("q", $n.value), reuseSearch=false))
proc renderHeader*(): VNode =
if state.data.isNone and state.status == Http200:
getStatus()
let user = state.data.map(x => x.user).flatten
result = buildHtml(tdiv()):
header(id="main-navbar"):
tdiv(class="navbar container grid-xl"):
section(class="navbar-section"):
a(href=makeUri("/")):
img(src="/images/logo.png", id="img-logo")
section(class="navbar-section"):
tdiv(class="input-group input-inline"):
input(class="search-input input-sm",
`type`="search", placeholder="Search",
id="search-box", required="required",
onKeyDown=onKeyDown)
if state.loading:
tdiv(class="loading")
elif user.isNone:
button(id="signup-btn", class="btn btn-primary btn-sm",
onClick=(e: Event, n: VNode) => state.signupModal.show()):
italic(class="fas fa-user-plus")
text " Sign up"
button(id="login-btn", class="btn btn-primary btn-sm",
onClick=(e: Event, n: VNode) => state.loginModal.show()):
italic(class="fas fa-sign-in-alt")
text " Log in"
else:
render(state.userMenu, user.get())
# Modals
if state.data.isSome():
render(state.loginModal, state.data.get().recaptchaSiteKey)
render(state.signupModal, state.data.get().recaptchaSiteKey)

128
src/frontend/karaxutils.nim Normal file
View file

@ -0,0 +1,128 @@
import strutils, strformat, parseutils, tables
proc limit*(str: string, n: int): string =
## Limit the number of characters in a string. Ends with a elipsis
if str.len > n:
return str[0..<n-3] & "..."
else:
return str
proc slug*(name: string): string =
## Transforms text into a url slug
name.strip().replace(" ", "-").toLowerAscii
proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} =
## parses `s` into an integer in the range `validRange`. If successful,
## `value` is modified to contain the result. Otherwise no exception is
## raised and `value` is not touched; this way a reasonable default value
## won't be overwritten.
try:
discard parseutils.parseInt(s, value, 0)
except OverflowError:
discard
proc getInt*(s: string, default = 0): int =
## Safely parses an int and returns it.
result = default
parseIntSafe(s, result)
proc getInt64*(s: string, default = 0): int64 =
## Safely parses an int and returns it.
result = default
try:
discard parseutils.parseBiggestInt(s, result, 0)
except OverflowError:
discard
when defined(js):
include karax/prelude
import karax / [kdom, kajax]
from dom import nil
const appName* = "/"
proc class*(classes: varargs[tuple[name: string, present: bool]],
defaultClasses: string = ""): string =
result = defaultClasses & " "
for class in classes:
if class.present: result.add(class.name & " ")
proc makeUri*(relative: string, appName=appName, includeHash=false,
search: string=""): string =
## Concatenates ``relative`` to the current URL in a way that is
## (possibly) sane.
var relative = relative
assert appName in $window.location.pathname
if relative[0] == '/': relative = relative[1..^1]
return $window.location.protocol & "//" &
$window.location.host &
appName &
relative &
search &
(if includeHash: $window.location.hash else: "")
proc makeUri*(relative: string, params: varargs[(string, string)],
appName=appName, includeHash=false,
reuseSearch=true): string =
var query = ""
for i in 0 ..< params.len:
let param = params[i]
if i != 0: query.add("&")
query.add(param[0] & "=" & param[1])
if query.len > 0:
var search = if reuseSearch: $window.location.search else: ""
if search.len != 0: search.add("&")
search.add(query)
if search[0] != '?': search = "?" & search
makeUri(relative, appName, search=search)
else:
makeUri(relative, appName)
proc navigateTo*(uri: cstring) =
# TODO: This was annoying. Karax also shouldn't have its own `window`.
dom.pushState(dom.window.history, 0, cstring"", uri)
# Fire the popState event.
dom.dispatchEvent(dom.window, dom.newEvent("popstate"))
proc anchorCB*(e: Event, n: VNode) =
let mE = e.MouseEvent
if not (mE.metaKey or mE.ctrlKey):
e.preventDefault()
# TODO: Why does Karax have it's own Node type? That's just silly.
let url = n.getAttr("href")
navigateTo(url)
proc newFormData*(form: dom.Element): FormData
{.importcpp: "new FormData(@)", constructor.}
proc get*(form: FormData, key: cstring): cstring
{.importcpp: "#.get(@)".}
proc renderProfileUrl*(username: string): string =
makeUri(fmt"/profile/{username}")
proc renderPostUrl*(threadId, postId: int): string =
makeUri(fmt"/t/{threadId}#{postId}")
proc parseUrlQuery*(query: string, result: var Table[string, string])
{.deprecated: "use stdlib".} =
## Based on copy from Jester. Use stdlib when
## https://github.com/nim-lang/Nim/pull/7761 is merged.
var i = 0
i = query.skip("?")
while i < query.len()-1:
var key = ""
var val = ""
i += query.parseUntil(key, '=', i)
if query[i] != '=':
raise newException(ValueError, "Expected '=' at " & $i &
" but got: " & $query[i])
inc(i) # Skip =
i += query.parseUntil(val, '&', i)
inc(i) # Skip &
result[$decodeUri(key)] = $decodeUri(val)

97
src/frontend/login.nim Normal file
View file

@ -0,0 +1,97 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event, KeyboardEvent
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error, resetpassword
import karaxutils
type
LoginModal* = ref object
shown: bool
loading: bool
onLogIn: proc ()
onSignUp: proc ()
error: Option[PostError]
resetPasswordModal: ResetPasswordModal
proc onLogInPost(httpStatus: int, response: kstring, state: LoginModal) =
postFinished:
state.shown = false
state.onLogIn()
proc onLogInClick(ev: Event, n: VNode, state: LoginModal) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("login")
let form = dom.document.getElementById("login-form")
# TODO: This is a hack, karax should support this.
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onLogInPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: LoginModal) =
state.shown = false
ev.preventDefault()
proc newLoginModal*(onLogIn, onSignUp: proc ()): LoginModal =
LoginModal(
shown: false,
onLogIn: onLogIn,
onSignUp: onSignUp,
resetPasswordModal: newResetPasswordModal()
)
proc show*(state: LoginModal) =
state.shown = true
proc onKeyDown(e: Event, n: VNode, state: LoginModal) =
let event = cast[KeyboardEvent](e)
if event.key == "Enter":
onLogInClick(e, n, state)
proc render*(state: LoginModal, recaptchaSiteKey: Option[string]): VNode =
result = buildHtml(tdiv()):
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
id="login-modal"):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
a(href="", class="btn btn-clear float-right",
"aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-title h5"):
text "Log in"
tdiv(class="modal-body"):
tdiv(class="content"):
form(id="login-form",
onKeyDown=(ev: Event, n: VNode) => onKeyDown(ev, n, state)):
genFormField(state.error, "username", "Username", "text", false)
genFormField(
state.error,
"password",
"Password",
"password",
true
)
a(href="", onClick=(e: Event, n: VNode) =>
(state.resetPasswordModal.show(); e.preventDefault())):
text "Reset your password"
tdiv(class="modal-footer"):
button(class=class(
{"loading": state.loading},
"btn btn-primary"
),
onClick=(ev: Event, n: VNode) => onLogInClick(ev, n, state)):
text "Log in"
button(class="btn",
onClick=(ev: Event, n: VNode) =>
(state.onSignUp(); state.shown = false)):
text "Create account"
render(state.resetPasswordModal, recaptchaSiteKey)

View file

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

View file

@ -0,0 +1,79 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error, replybox, threadlist, post, user
import karaxutils, categorypicker
type
NewThread* = ref object
loading: bool
error: Option[PostError]
replyBox: ReplyBox
subject: kstring
categoryPicker: CategoryPicker
proc newNewThread*(): NewThread =
NewThread(
replyBox: newReplyBox(nil),
subject: "",
categoryPicker: newCategoryPicker()
)
proc onSubjectChange(e: Event, n: VNode, state: NewThread) =
state.subject = n.value
proc onCreatePost(httpStatus: int, response: kstring, state: NewThread) =
postFinished:
let j = parseJson($response)
let response = to(j, array[2, int])
navigateTo(renderPostUrl(response[0], response[1]))
proc onCreateClick(ev: Event, n: VNode, state: NewThread) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("newthread")
# TODO: This is a hack, karax should support this.
let formData = newFormData()
let categoryID = state.categoryPicker.selectedCategoryID
formData.append("subject", state.subject)
formData.append("msg", state.replyBox.getText())
formData.append("categoryId", $categoryID)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCreatePost(s, r, state))
proc render*(state: NewThread, currentUser: Option[User]): VNode =
result = buildHtml():
section(class="container grid-xl"):
tdiv(id="new-thread"):
tdiv(class="title"):
p(): text "New Thread"
tdiv(class="content"):
input(id="thread-title", class="form-input", `type`="text", name="subject",
placeholder="Type the title here",
oninput=(e: Event, n: VNode) => onSubjectChange(e, n, state))
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
tdiv():
label(class="d-inline-block form-label"):
text "Category"
render(state.categoryPicker, currentUser, compact=false)
renderContent(state.replyBox, none[Thread](), none[Post]())
tdiv(class="footer"):
button(id="create-thread-btn",
class=class(
{"loading": state.loading},
"btn btn-primary"
),
onClick=(ev: Event, n: VNode) =>
(onCreateClick(ev, n, state))):
text "Create thread"

67
src/frontend/post.nim Normal file
View file

@ -0,0 +1,67 @@
import options
import user
type
PostInfo* = object
creation*: int64
content*: string
Post* = ref object
id*: int
author*: User
likes*: seq[User] ## Users that liked this post.
seen*: bool ## Determines whether the current user saw this post.
## I considered using a simple timestamp for each thread,
## but that wouldn't work when a user navigates to the last
## post in a thread for example.
history*: seq[PostInfo] ## If the post was edited this will contain the
## older versions of the post.
info*: PostInfo
moreBefore*: seq[int]
replyingTo*: Option[PostLink]
PostLink* = object ## Used by profile
creation*: int64
topic*: string
threadId*: int
postId*: int
author*: Option[User] ## Only used for `replyingTo`.
proc lastEdit*(post: Post): PostInfo =
post.history[^1]
proc isModerated*(post: Post): bool =
## Determines whether the specified post is under moderation
## (i.e. whether the post is invisible to ordinary users).
post.author.rank <= Moderated
proc isLikedBy*(post: Post, user: Option[User]): bool =
## Determines whether the specified user has liked the post.
if user.isNone(): return false
for u in post.likes:
if u.name == user.get().name:
return true
return false
type
Profile* = object
user*: User
joinTime*: int64
threads*: seq[PostLink]
posts*: seq[PostLink]
postCount*: int
threadCount*: int
# Information that only admins should see.
email*: Option[string]
when defined(js):
import karaxutils, threadlist
proc renderPostUrl*(post: Post, thread: Thread): string =
renderPostUrl(thread.id, post.id)
proc renderPostUrl*(link: PostLink): string =
renderPostUrl(link.threadId, link.postId)

261
src/frontend/postbutton.nim Normal file
View file

@ -0,0 +1,261 @@
## Simple generic button that can be clicked to make a post request.
## The button will show a loading indicator and a tick on success.
##
## Used for password reset emails.
import options, httpcore, json, sugar, sequtils, strutils
when defined(js):
include karax/prelude
import karax/[kajax, kdom]
import jsffi except `&`
import error, karaxutils, post, user, threadlist
type
PostButton* = ref object
uri, title, icon: string
formData: FormData
error: Option[PostError]
loading: bool
posted: bool
proc newPostButton*(uri: string, formData: FormData,
title: string, icon: string): PostButton =
PostButton(
uri: uri,
formData: formData,
title: title,
icon: icon
)
proc newResetPasswordButton*(username: string): PostButton =
var formData = newFormData()
formData.append("email", username)
result = newPostButton(
makeUri("/sendResetPassword"),
formData,
"Send password reset email",
"fas fa-envelope",
)
proc onPost(httpStatus: int, response: kstring, state: PostButton) =
postFinished:
discard
proc onClick(ev: Event, n: VNode, state: PostButton) =
if state.loading or state.posted: return
state.loading = true
state.posted = true
state.error = none[PostError]()
# TODO: This is a hack, karax should support this.
ajaxPost(state.uri, @[], cast[cstring](state.formData),
(s: int, r: kstring) => onPost(s, r, state))
ev.preventDefault()
proc render*(state: PostButton, disabled: bool): VNode =
result = buildHtml(tdiv()):
button(class=class({
"loading": state.loading,
"disabled": disabled
},
"btn btn-secondary"
),
`type`="button",
onClick=(e: Event, n: VNode) => (onClick(e, n, state))):
if state.posted:
if state.error.isNone():
italic(class="fas fa-check")
else:
italic(class="fas fa-times")
else:
italic(class=state.icon)
text " " & state.title
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
type
LikeButton* = ref object
error: Option[PostError]
loading: bool
proc newLikeButton*(): LikeButton =
LikeButton()
proc onPost(httpStatus: int, response: kstring, state: LikeButton,
post: Post, user: User) =
postFinished:
if post.isLikedBy(some(user)):
var newLikes: seq[User] = @[]
for like in post.likes:
if like.name != user.name:
newLikes.add(like)
post.likes = newLikes
else:
post.likes.add(user)
proc onClick(ev: Event, n: VNode, state: LikeButton, post: Post,
currentUser: Option[User]) =
if state.loading: return
if currentUser.isNone():
state.error = some[PostError](PostError(message: "Not logged in."))
return
state.loading = true
state.error = none[PostError]()
# TODO: This is a hack, karax should support this.
var formData = newFormData()
formData.append("id", $post.id)
let uri =
if post.isLikedBy(currentUser):
makeUri("/unlike")
else:
makeUri("/like")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) =>
onPost(s, r, state, post, currentUser.get()))
ev.preventDefault()
proc render*(state: LikeButton, post: Post,
currentUser: Option[User]): VNode =
let liked = isLikedBy(post, currentUser)
let tooltip =
if state.error.isSome(): state.error.get().message
else: ""
result = buildHtml():
tdiv(class="like-button"):
button(class=class({"tooltip": state.error.isSome()}, "btn"),
onClick=(e: Event, n: VNode) =>
(onClick(e, n, state, post, currentUser)),
"data-tooltip"=tooltip,
onmouseleave=(e: Event, n: VNode) =>
(state.error = none[PostError]())):
if post.likes.len > 0:
let names = post.likes.map(x => x.name).join(", ")
span(class="like-count tooltip", "data-tooltip"=names):
text $post.likes.len
italic(class=class({"far": not liked, "fas": liked}, "fa-heart"))
type
LockButton* = ref object
error: Option[PostError]
loading: bool
proc newLockButton*(): LockButton =
LockButton()
proc onPost(httpStatus: int, response: kstring, state: LockButton,
thread: var Thread) =
postFinished:
thread.isLocked = not thread.isLocked
proc onLockClick(ev: Event, n: VNode, state: LockButton, thread: var Thread) =
if state.loading: return
state.loading = true
state.error = none[PostError]()
# TODO: This is a hack, karax should support this.
var formData = newFormData()
formData.append("id", $thread.id)
let uri =
if thread.isLocked:
makeUri("/unlock")
else:
makeUri("/lock")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) =>
onPost(s, r, state, thread))
ev.preventDefault()
proc render*(state: LockButton, thread: var Thread,
currentUser: Option[User]): VNode =
if currentUser.isNone() or
currentUser.get().rank < Moderator:
return buildHtml(tdiv())
let tooltip =
if state.error.isSome(): state.error.get().message
else: ""
result = buildHtml():
button(class="btn btn-secondary", id="lock-btn",
onClick=(e: Event, n: VNode) =>
onLockClick(e, n, state, thread),
"data-tooltip"=tooltip,
onmouseleave=(e: Event, n: VNode) =>
(state.error = none[PostError]())):
if thread.isLocked:
italic(class="fas fa-unlock-alt")
text " Unlock Thread"
else:
italic(class="fas fa-lock")
text " Lock Thread"
type
PinButton* = ref object
error: Option[PostError]
loading: bool
proc newPinButton*(): PinButton =
PinButton()
proc onPost(httpStatus: int, response: kstring, state: PinButton,
thread: var Thread) =
postFinished:
thread.isPinned = not thread.isPinned
proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) =
if state.loading: return
state.loading = true
state.error = none[PostError]()
# Same as LockButton so the following is still a hack and karax should support this.
var formData = newFormData()
formData.append("id", $thread.id)
let uri =
if thread.isPinned:
makeUri("/unpin")
else:
makeUri("/pin")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPost(s, r, state, thread))
ev.preventDefault()
proc render*(state: PinButton, thread: var Thread,
currentUser: Option[User]): VNode =
if currentUser.isNone() or
currentUser.get().rank < Moderator:
return buildHtml(tdiv())
let tooltip =
if state.error.isSome(): state.error.get().message
else: ""
result = buildHtml():
button(class="btn btn-secondary", id="pin-btn",
onClick=(e: Event, n: VNode) =>
onPinClick(e, n, state, thread),
"data-tooltip"=tooltip,
onmouseleave=(e: Event, n: VNode) =>
(state.error = none[PostError]())):
if thread.isPinned:
italic(class="fas fa-thumbtack")
text " Unpin Thread"
else:
italic(class="fas fa-thumbtack")
text " Pin Thread"

420
src/frontend/postlist.nim Normal file
View file

@ -0,0 +1,420 @@
import system except Thread
import options, json, times, httpcore, sugar, strutils
import sequtils
import threadlist, category, post, user
type
PostList* = ref object
thread*: Thread
history*: seq[Thread] ## If the thread was edited this will contain the
## older versions of the thread (title/category
## changes). TODO
posts*: seq[Post]
when defined(js):
from dom import document
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import karaxutils, error, replybox, editbox, postbutton, delete
import categorypicker
type
State = ref object
list: Option[PostList]
loading: bool
status: HttpCode
error: Option[PostError]
replyingTo: Option[Post]
replyBox: ReplyBox
editing: Option[Post] ## If in edit mode, this contains the post.
editBox: EditBox
likeButton: LikeButton
deleteModal: DeleteModal
lockButton: LockButton
pinButton: PinButton
categoryPicker: CategoryPicker
proc onReplyPosted(id: int)
proc onCategoryChanged(oldCategory: Category, newCategory: Category)
proc onEditPosted(id: int, content: string, subject: Option[string])
proc onEditCancelled()
proc onDeletePost(post: Post)
proc onDeleteThread(thread: Thread)
proc newState(): State =
State(
list: none[PostList](),
loading: false,
status: Http200,
error: none[PostError](),
replyingTo: none[Post](),
replyBox: newReplyBox(onReplyPosted),
editBox: newEditBox(onEditPosted, onEditCancelled),
likeButton: newLikeButton(),
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
lockButton: newLockButton(),
pinButton: newPinButton(),
categoryPicker: newCategoryPicker(onCategoryChanged)
)
var
state = newState()
proc onCategoryPost(httpStatus: int, response: kstring, state: State) =
state.loading = false
postFinished:
discard
# TODO: show success message
proc onCategoryChanged(oldCategory: Category, newCategory: Category) =
let uri = makeUri("/updateThread")
let formData = newFormData()
formData.append("threadId", $state.list.get().thread.id)
formData.append("category", $newCategory.id)
state.loading = true
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCategoryPost(s, r, state))
proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = to(parsed, PostList)
state.list = some(list)
dom.document.title = list.thread.topic & " - " & dom.document.title
state.categoryPicker.select(list.thread.category.id)
# The anchor should be jumped to once all the posts have been loaded.
if postId.isSome():
discard setTimeout(
() => (
# Would have used scrollIntoView but then the `:target` selector
# isn't activated.
getVNodeById($postId.get()).dom.scrollIntoView()
),
100
)
proc onMorePosts(httpStatus: int, response: kstring, start: int) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
var list = to(parsed, seq[Post])
var idsLoaded: seq[int] = @[]
for i in 0..<list.len:
state.list.get().posts.insert(list[i], i+start)
idsLoaded.add(list[i].id)
# Save a list of the IDs which have not yet been loaded into the top-most
# post.
let postIndex = start+list.len
# The following check is necessary because we reuse this proc to load
# a newly created post.
if postIndex < state.list.get().posts.len:
let post = state.list.get().posts[postIndex]
var newPostIds: seq[int] = @[]
for id in post.moreBefore:
if id notin idsLoaded:
newPostIds.add(id)
post.moreBefore = newPostIds
proc loadMore(start: int, ids: seq[int]) =
if state.loading: return
state.loading = true
let uri = makeUri(
"specific_posts.json",
[("ids", $(%ids))]
)
ajaxGet(
uri,
@[],
(s: int, r: kstring) => onMorePosts(s, r, start)
)
proc onReplyPosted(id: int) =
## Executed when a reply has been successfully posted.
loadMore(state.list.get().posts.len, @[id])
proc onEditCancelled() = state.editing = none[Post]()
proc onEditPosted(id: int, content: string, subject: Option[string]) =
## Executed when an edit has been successfully posted.
state.editing = none[Post]()
let list = state.list.get()
for i in 0 ..< list.posts.len:
if list.posts[i].id == id:
list.posts[i].history.add(PostInfo(
creation: getTime().toUnix(),
content: content
))
break
proc onReplyClick(e: Event, n: VNode, p: Option[Post]) =
state.replyingTo = p
state.replyBox.show()
proc onEditClick(e: Event, n: VNode, p: Post) =
state.editing = some(p)
# TODO: Ensure the edit box is as big as its content. Auto resize the
# text area.
proc onDeletePost(post: Post) =
state.list.get().posts.keepIf(
x => x.id != post.id
)
proc onDeleteThread(thread: Thread) =
window.location.href = makeUri("/")
proc onDeleteClick(e: Event, n: VNode, p: Post) =
let list = state.list.get()
if list.posts[0].id == p.id:
state.deleteModal.show(list.thread)
else:
state.deleteModal.show(p)
proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) =
loadMore(start, post.moreBefore) # TODO: Don't load all!
proc genLoadMore(post: Post, start: int): VNode =
result = buildHtml():
tdiv(class="information load-more-posts",
onClick=(e: Event, n: VNode) => onLoadMore(e, n, start, post)):
tdiv(class="information-icon"):
italic(class="fas fa-comment-dots")
tdiv(class="information-main"):
if state.loading:
tdiv(class="loading loading-lg")
else:
tdiv(class="information-title"):
text "Load more posts "
span(class="more-post-count"):
text "(" & $post.moreBefore.len & ")"
proc genCategories(thread: Thread, currentUser: Option[User]): VNode =
let loggedIn = currentUser.isSome()
let authoredByUser =
loggedIn and currentUser.get().name == thread.author.name
let canChangeCategory =
loggedIn and currentUser.get().rank in {Admin, Moderator}
result = buildHtml():
tdiv():
if authoredByUser or canChangeCategory:
render(state.categoryPicker, currentUser, compact=false)
else:
render(thread.category)
proc genPostButtons(post: Post, currentUser: Option[User]): Vnode =
let loggedIn = currentUser.isSome()
let authoredByUser =
loggedIn and currentUser.get().name == post.author.name
let currentAdmin =
currentUser.isSome() and currentUser.get().rank == Admin
# Don't show buttons if the post is being edited.
if state.editing.isSome() and state.editing.get() == post:
return buildHtml(tdiv())
result = buildHtml():
tdiv(class="post-buttons"):
if authoredByUser or currentAdmin:
tdiv(class="edit-button", onClick=(e: Event, n: VNode) =>
onEditClick(e, n, post)):
button(class="btn"):
italic(class="far fa-edit")
tdiv(class="delete-button",
onClick=(e: Event, n: VNode) => onDeleteClick(e, n, post)):
button(class="btn"):
italic(class="far fa-trash-alt")
render(state.likeButton, post, currentUser)
if loggedIn:
tdiv(class="flag-button"):
button(class="btn"):
italic(class="far fa-flag")
tdiv(class="reply-button"):
button(class="btn", onClick=(e: Event, n: VNode) =>
onReplyClick(e, n, some(post))):
italic(class="fas fa-reply")
text " Reply"
proc genPost(
post: Post, thread: Thread, currentUser: Option[User], highlight: bool
): VNode =
let postCopy = post # TODO: Another workaround here, closure capture :(
let originalPost = thread.author == post.author
result = buildHtml():
tdiv(class=class({"highlight": highlight, "original-post": originalPost}, "post"),
id = $post.id):
tdiv(class="post-icon"):
render(post.author, "post-avatar")
tdiv(class="post-main"):
tdiv(class="post-title"):
tdiv(class="post-username"):
text post.author.name
renderUserRank(post.author)
tdiv(class="post-metadata"):
if post.replyingTo.isSome():
let replyingTo = post.replyingTo.get()
tdiv(class="post-replyingTo"):
a(href=renderPostUrl(replyingTo)):
italic(class="fas fa-reply")
renderUserMention(replyingTo.author.get())
if post.history.len > 0:
let title = post.lastEdit.creation.fromUnix().local.
format("'Last modified' MMM d, yyyy HH:mm")
tdiv(class="post-history", title=title):
span(class="edit-count"):
text $post.history.len
italic(class="fas fa-pencil-alt")
let title = post.info.creation.fromUnix().local.
format("MMM d, yyyy HH:mm")
a(href=renderPostUrl(post, thread), title=title):
text renderActivity(post.info.creation)
tdiv(class="post-content"):
if state.editing.isSome() and state.editing.get() == post:
render(state.editBox, postCopy)
else:
let content =
if post.history.len > 0:
post.lastEdit.content
else:
post.info.content
verbatim(content)
genPostButtons(postCopy, currentUser)
proc genTimePassed(prevPost: Post, post: Option[Post], last: bool): VNode =
var latestTime =
if post.isSome: post.get().info.creation.fromUnix()
else: getTime()
# TODO: Use `between` once it's merged into stdlib.
let
tmpl =
if last: [
"A long time since last reply",
"$1 year since last reply",
"$1 years since last reply",
"$1 month since last reply",
"$1 months since last reply",
]
else: [
"Some time later",
"$1 year later", "$1 years later",
"$1 month later", "$1 months later"
]
var diffStr = tmpl[0]
let diff = latestTime - prevPost.info.creation.fromUnix()
if diff.inWeeks > 48:
let years = diff.inWeeks div 48
diffStr =
(if years == 1: tmpl[1] else: tmpl[2]) % $years
elif diff.inWeeks > 4:
let months = diff.inWeeks div 4
diffStr =
(if months == 1: tmpl[3] else: tmpl[4]) % $months
else:
return buildHtml(tdiv())
# PROTIP: Good thread ID to test this with is: 1267.
result = buildHtml():
tdiv(class="information time-passed"):
tdiv(class="information-icon"):
italic(class="fas fa-clock")
tdiv(class="information-main"):
tdiv(class="information-title"):
text diffStr
proc renderPostList*(threadId: int, postId: Option[int],
currentUser: Option[User]): VNode =
if state.list.isSome() and state.list.get().thread.id != threadId:
state.list = none[PostList]()
state.status = Http200
if state.status != Http200:
return renderError("Couldn't retrieve posts.", state.status)
if state.list.isNone:
var params = @[("id", $threadId)]
if postId.isSome():
params.add(("anchor", $postId.get()))
let uri = makeUri("posts.json", params)
if not state.loading:
state.loading = true
ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, postId))
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get()
result = buildHtml():
section(class="container grid-xl"):
tdiv(id="thread-title", class="title"):
if state.error.isSome():
span(class="text-error"):
text state.error.get().message
p(class="title-text"): text list.thread.topic
if list.thread.isLocked:
italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to")
text "Locked"
if list.thread.isModerated:
italic(class="fas fa-eye-slash fa-xs",
title="Thread is moderated")
text "Moderated"
if list.thread.isSolved:
italic(class="fas fa-check-square fa-xs",
title="Thread has a solution")
text "Solved"
genCategories(list.thread, currentUser)
tdiv(class="posts"):
var prevPost: Option[Post] = none[Post]()
for i, post in list.posts:
if not post.visibleTo(currentUser): continue
if prevPost.isSome:
genTimePassed(prevPost.get(), some(post), false)
if post.moreBefore.len > 0:
genLoadMore(post, i)
let highlight = postId.isSome() and postId.get() == post.id
genPost(post, list.thread, currentUser, highlight)
prevPost = some(post)
if prevPost.isSome:
genTimePassed(prevPost.get(), none[Post](), true)
tdiv(id="thread-buttons"):
button(class="btn btn-secondary",
onClick=(e: Event, n: VNode) =>
onReplyClick(e, n, none[Post]())):
italic(class="fas fa-reply")
text " Reply"
render(state.lockButton, list.thread, currentUser)
render(state.pinButton, list.thread, currentUser)
render(state.replyBox, list.thread, state.replyingTo, false)
render(state.deleteModal)

150
src/frontend/profile.nim Normal file
View file

@ -0,0 +1,150 @@
import options, httpcore, json, sugar, times, strutils
import threadlist, post, error, user
when defined(js):
from dom import document
include karax/prelude
import karax/[kajax, kdom]
import karaxutils, profilesettings
type
ProfileTab* = enum
Overview, Settings
ProfileState* = ref object
profile: Option[Profile]
settings: Option[ProfileSettings]
currentTab: ProfileTab
loading: bool
status: HttpCode
proc newProfileState*(): ProfileState =
ProfileState(
loading: false,
status: Http200,
currentTab: Overview
)
proc onProfile(httpStatus: int, response: kstring, state: ProfileState) =
# TODO: Try to abstract these.
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let profile = to(parsed, Profile)
state.profile = some(profile)
state.settings = some(newProfileSettings(profile))
dom.document.title = profile.user.name & " - " & dom.document.title
proc genPostLink(link: PostLink): VNode =
let url = renderPostUrl(link)
result = buildHtml():
tdiv(class="profile-post"):
tdiv(class="profile-post-main"):
tdiv(class="profile-post-title"):
a(href=url):
text link.topic
tdiv(class="profile-post-time"):
let title = link.creation.fromUnix().local.
format("MMM d, yyyy HH:mm")
p(title=title):
text renderActivity(link.creation)
proc render*(
state: ProfileState,
username: kstring,
currentUser: Option[User]
): VNode =
if state.profile.isSome() and state.profile.get().user.name != username:
state.profile = none[Profile]()
state.status = Http200
if state.status != Http200:
return renderError("Couldn't retrieve profile.", state.status)
if state.profile.isNone:
if not state.loading:
state.loading = true
let uri = makeUri("profile.json", ("username", $username))
ajaxGet(uri, @[], (s: int, r: kstring) => onProfile(s, r, state))
return buildHtml(tdiv(class="loading loading-lg"))
let profile = state.profile.get()
result = buildHtml():
section(class="container grid-xl"):
tdiv(class="profile"):
tdiv(class="profile-icon"):
render(profile.user, "profile-avatar")
tdiv(class="profile-content"):
h2(class="profile-title"):
text profile.user.name
tdiv(class="profile-stats"):
dl():
dt(text "Joined")
dd(text threadlist.renderActivity(profile.joinTime))
if profile.posts.len > 0:
dt(text "Last Post")
dd(text renderActivity(profile.posts[0].creation))
dt(text "Last Online")
dd(text renderActivity(profile.user.lastOnline))
dt(text "Posts")
dd():
if profile.postCount > 999:
text $(profile.postCount / 1000) & "k"
else:
text $profile.postCount
dt(text "Threads")
dd():
if profile.threadCount > 999:
text $(profile.threadCount / 1000) & "k"
else:
text $profile.threadCount
dt(text "Rank")
dd(text $profile.user.rank)
if currentUser.isSome():
let user = currentUser.get()
if user.name == profile.user.name or user.rank >= Moderator:
ul(class="tab profile-tabs"):
li(class=class(
{"active": state.currentTab == Overview},
"tab-item"
),
onClick=(e: Event, n: VNode) => (state.currentTab = Overview)
):
a(id="overview-tab", class="c-hand"):
text "Overview"
li(class=class(
{"active": state.currentTab == Settings},
"tab-item"
),
onClick=(e: Event, n: VNode) => (state.currentTab = Settings)
):
a(id="settings-tab", class="c-hand"):
italic(class="fas fa-cog")
text " Settings"
case state.currentTab
of Overview:
if profile.posts.len > 0 or profile.threads.len > 0:
tdiv(class="columns"):
tdiv(class="column col-6"):
h4(text "Latest Posts")
tdiv(class="posts"):
for post in profile.posts:
genPostLink(post)
tdiv(class="column col-6"):
h4(text "Latest Threads")
tdiv(class="posts"):
for thread in profile.threads:
genPostLink(thread)
of Settings:
if state.settings.isSome():
render(state.settings.get(), currentUser)

View file

@ -0,0 +1,206 @@
when defined(js):
import httpcore, options, sugar, json, strutils, strformat
import jsffi except `&`
include karax/prelude
import karax/[kajax, kdom]
import post, karaxutils, postbutton, error, delete, user
type
ProfileSettings* = ref object
loading: bool
status: HttpCode
error: Option[PostError]
email: kstring
rank: Rank
deleteModal: DeleteModal
resetPassword: PostButton
profile: Profile
proc onUserDelete(user: User) =
window.location.href = makeUri("/")
proc resetSettings(state: ProfileSettings) =
let profile = state.profile
if profile.email.isSome():
state.email = profile.email.get()
else:
state.email = ""
state.rank = profile.user.rank
state.error = none[PostError]()
proc newProfileSettings*(profile: Profile): ProfileSettings =
result = ProfileSettings(
status: Http200,
deleteModal: newDeleteModal(nil, nil, onUserDelete),
resetPassword: newResetPasswordButton(profile.user.name),
profile: profile
)
resetSettings(result)
proc onProfilePost(httpStatus: int, response: kstring,
state: ProfileSettings) =
postFinished:
state.profile.email = some($state.email)
state.profile.user.rank = state.rank
proc onEmailChange(event: Event, node: VNode, state: ProfileSettings) =
state.email = node.value
if state.profile.user.rank != Admin:
if state.email != state.profile.email.get():
state.rank = EmailUnconfirmed
else:
state.rank = state.profile.user.rank
proc onRankChange(event: Event, node: VNode, state: ProfileSettings) =
state.rank = parseEnum[Rank]($node.value)
proc save(state: ProfileSettings) =
if state.loading:
return
state.loading = true
state.error = none[PostError]()
let formData = newFormData()
formData.append("email", state.email)
formData.append("rank", $state.rank)
formData.append("username", $state.profile.user.name)
let uri = makeUri("/saveProfile")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onProfilePost(s, r, state))
proc needsSave(state: ProfileSettings): bool =
if state.profile.email.isSome():
result = state.email != state.profile.email.get()
result = result or state.rank != state.profile.user.rank
proc render*(state: ProfileSettings,
currentUser: Option[User]): VNode =
let canEditRank = currentUser.isSome() and
currentUser.get().rank > state.profile.user.rank
let canResetPassword = state.profile.user.rank > EmailUnconfirmed
let rankSelect = buildHtml(tdiv()):
if canEditRank:
select(id="rank-field",
class="form-select", value = $state.rank,
onchange=(e: Event, n: VNode) => onRankChange(e, n, state)):
for r in Rank:
option(text $r, id="rank-" & toLowerAscii($r))
p(class="form-input-hint text-warning"):
text "You can modify anyone's rank. Remember: with " &
"great power comes great responsibility."
else:
input(id="rank-field", class="form-input",
`type`="text", disabled="", value = $state.rank)
p(class="form-input-hint"):
text "Your rank determines the actions you can perform " &
"on the forum."
case state.rank:
of Spammer, Troll:
p(class="form-input-hint text-warning"):
text "Your account was banned."
of EmailUnconfirmed:
p(class="form-input-hint text-warning"):
text "You cannot post until you confirm your email."
of Moderated:
p(class="form-input-hint text-warning"):
text "Your account is under moderation. This is a spam prevention "&
"measure. You can write posts but only moderators and admins "&
"will see them until your account is verified by them."
else:
discard
result = buildHtml():
tdiv(class="columns"):
tdiv(class="column col-6"):
form(class="form-horizontal"):
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Username"
tdiv(class="col-9 col-sm-12"):
input(class="form-input",
`type`="text",
value=state.profile.user.name,
disabled="")
p(class="form-input-hint"):
text fmt("Users can refer to you by writing" &
" @{state.profile.user.name} in their posts.")
if state.profile.email.isSome():
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Email"
tdiv(class="col-9 col-sm-12"):
input(id="email-input", class="form-input",
`type`="text", value=state.email,
oninput=(e: Event, n: VNode) =>
onEmailChange(e, n, state)
)
p(class="form-input-hint"):
text "Your avatar is linked to this email and can be " &
"changed at "
a(href="https://gravatar.com/emails"):
text "gravatar.com"
text ". Note that any changes to your email will " &
"require email verification."
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Rank"
tdiv(class="col-9 col-sm-12"):
rankSelect
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Password"
tdiv(class="col-9 col-sm-12"):
render(state.resetPassword,
disabled=not canResetPassword)
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Account"
tdiv(class="col-9 col-sm-12"):
button(id="delete-account-btn",
class="btn btn-secondary", `type`="button",
onClick=(e: Event, n: VNode) =>
(state.deleteModal.show(state.profile.user))):
italic(class="fas fa-times")
text " Delete account"
tdiv(class="float-right"):
if state.error.isSome():
span(class="text-error"):
text state.error.get().message
button(id="cancel-btn",
class=class(
{"disabled": not needsSave(state)}, "btn btn-link"
),
onClick=(e: Event, n: VNode) => (resetSettings(state))):
text "Cancel"
button(id="save-btn",
class=class(
{"disabled": not needsSave(state)}, "btn btn-primary"
),
onClick=(e: Event, n: VNode) => save(state),
id="save-btn"):
italic(class="fas fa-save")
text " Save"
render(state.deleteModal)
# TODO: I really should just be able to set the `value` attr.
# TODO: This doesn't work when settings are reset for some reason.
let rankField = getVNodeById("rank-field")
if not rankField.isNil:
rankField.setInputText($state.rank)
let emailField = getVNodeById("email-field")
if not emailField.isNil:
emailField.setInputText($state.email)

167
src/frontend/replybox.nim Normal file
View file

@ -0,0 +1,167 @@
when defined(js):
import strformat, options, httpcore, json, sugar
import jsffi except `&`
from dom import getElementById, scrollIntoView, setTimeout
include karax/prelude
import karax / [vstyles, kajax, kdom]
import karaxutils, threadlist, post, error, user
type
ReplyBox* = ref object
shown: bool
text: kstring
preview: bool
loading: bool
error: Option[PostError]
rendering: Option[kstring]
onPost: proc (id: int)
proc newReplyBox*(onPost: proc (id: int)): ReplyBox =
ReplyBox(
text: "",
onPost: onPost
)
proc performScroll() =
let replyBox = dom.document.getElementById("reply-box")
replyBox.scrollIntoView()
proc show*(state: ReplyBox) =
# Scroll to the reply box.
if not state.shown:
# TODO: It would be nice for Karax to give us an event when it renders
# things. That way we can remove this crappy hack.
discard dom.window.setTimeout(performScroll, 50)
else:
performScroll()
state.shown = true
proc getText*(state: ReplyBox): kstring = state.text
proc setText*(state: ReplyBox, text: kstring) = state.text = text
proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) =
postFinished:
echo response
state.rendering = some[kstring](response)
proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) =
state.preview = true
state.loading = true
state.error = none[PostError]()
state.rendering = none[kstring]()
let formData = newFormData()
formData.append("msg", state.text)
let uri = makeUri("/preview")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPreviewPost(s, r, state))
proc onMessageClick(e: Event, n: VNode, state: ReplyBox) =
state.preview = false
state.error = none[PostError]()
proc onReplyPost(httpStatus: int, response: kstring, state: ReplyBox) =
postFinished:
state.text = ""
state.shown = false
state.onPost(parseJson($response).getInt())
proc onReplyClick(e: Event, n: VNode, state: ReplyBox,
thread: Thread, replyingTo: Option[Post]) =
state.loading = true
state.error = none[PostError]()
let formData = newFormData()
formData.append("msg", state.text)
formData.append("threadId", $thread.id)
if replyingTo.isSome:
formData.append("replyingTo", $replyingTo.get().id)
let uri = makeUri("/createPost")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onReplyPost(s, r, state))
proc onCancelClick(e: Event, n: VNode, state: ReplyBox) =
# TODO: Double check reply box contents and ask user whether to discard.
state.shown = false
proc onChange(e: Event, n: VNode, state: ReplyBox) =
# TODO: Please document this better in Karax.
state.text = n.value
proc renderContent*(state: ReplyBox, thread: Option[Thread],
post: Option[Post]): VNode =
result = buildHtml():
tdiv(class="panel"):
tdiv(class="panel-nav"):
ul(class="tab tab-block"):
li(class=class({"active": not state.preview}, "tab-item"),
onClick=(e: Event, n: VNode) =>
onMessageClick(e, n, state)):
a(class="c-hand"):
text "Message"
li(class=class({"active": state.preview}, "tab-item"),
onClick=(e: Event, n: VNode) =>
onPreviewClick(e, n, state)):
a(class="c-hand"):
text "Preview"
tdiv(class="panel-body"):
if state.preview:
if state.loading:
tdiv(class="loading")
elif state.rendering.isSome():
verbatim(state.rendering.get())
else:
textarea(id="reply-textarea",
class="form-input post-text-area", rows="5",
onChange=(e: Event, n: VNode) =>
onChange(e, n, state),
value=state.text)
a(href=makeUri("/about/rst"), target="blank_"):
text "Styling with RST is supported"
if state.error.isSome():
span(class="text-error",
style=style(StyleAttr.marginTop, "0.4rem")):
text state.error.get().message
if thread.isSome:
tdiv(class="panel-footer"):
button(class=class(
{"loading": state.loading},
"btn btn-primary float-right"
),
onClick=(e: Event, n: VNode) =>
onReplyClick(e, n, state, thread.get(), post)):
text "Reply"
button(class="btn btn-link float-right",
onClick=(e: Event, n: VNode) =>
onCancelClick(e, n, state)):
text "Cancel"
proc render*(state: ReplyBox, thread: Thread, post: Option[Post],
hasMore: bool): VNode =
if not state.shown:
return buildHtml(tdiv(id="reply-box"))
result = buildHtml():
tdiv(class=class({"no-border": hasMore}, "information"), id="reply-box"):
tdiv(class="information-icon"):
italic(class="fas fa-reply")
tdiv(class="information-main", style=style(StyleAttr.width, "100%")):
tdiv(class="information-title"):
if post.isNone:
text fmt("Replying to \"{thread.topic}\"")
else:
text "Replying to "
renderUserMention(post.get().author)
tdiv(class="post-buttons",
style=style(StyleAttr.marginTop, "-0.3rem")):
a(href=renderPostUrl(post.get(), thread)):
button(class="btn"):
italic(class="fas fa-arrow-up")
tdiv(class="information-content"):
renderContent(state, some(thread), post)

View file

@ -0,0 +1,156 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event, KeyboardEvent
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error
import karaxutils
type
ResetPassword* = ref object
loading: bool
status: HttpCode
error: Option[PostError]
newPassword: kstring
proc newResetPassword*(): ResetPassword =
ResetPassword(
status: Http200,
newPassword: ""
)
proc onPassChange(e: Event, n: VNode, state: ResetPassword) =
state.newPassword = n.value
proc onPost(httpStatus: int, response: kstring, state: ResetPassword) =
postFinished:
navigateTo(makeUri("/resetPassword/success"))
proc onSetClick(
ev: Event, n: VNode,
state: ResetPassword
) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("resetPassword", ("newPassword", $state.newPassword))
ajaxPost(uri, @[], "",
(s: int, r: kstring) => onPost(s, r, state))
proc render*(state: ResetPassword): VNode =
if state.loading:
return buildHtml(tdiv(class="loading"))
result = buildHtml():
section(class="container grid-xl"):
tdiv(id="resetpassword"):
tdiv(class="title"):
p(): text "Reset Password"
tdiv(class="content"):
label(class="form-label", `for`="password"):
text "Password"
input(class="form-input", `type`="password", name="password",
placeholder="Type your new password here",
oninput=(e: Event, n: VNode) => onPassChange(e, n, state))
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
tdiv(class="footer"):
button(class=class(
{"loading": state.loading},
"btn btn-primary"
),
onClick=(ev: Event, n: VNode) =>
(onSetClick(ev, n, state))):
text "Set password"
type
ResetPasswordModal* = ref object
shown: bool
loading: bool
error: Option[PostError]
sent: bool
proc onPost(httpStatus: int, response: kstring, state: ResetPasswordModal) =
postFinished:
state.sent = true
proc onClick(ev: Event, n: VNode, state: ResetPasswordModal) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("sendResetPassword")
let form = dom.document.getElementById("resetpassword-form")
# TODO: This is a hack, karax should support this.
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPost(s, r, state))
ev.preventDefault()
proc onClose(ev: Event, n: VNode, state: ResetPasswordModal) =
state.shown = false
ev.preventDefault()
proc newResetPasswordModal*(): ResetPasswordModal =
ResetPasswordModal(
shown: false
)
proc show*(state: ResetPasswordModal) =
state.shown = true
proc onKeyDown(e: Event, n: VNode, state: ResetPasswordModal) =
let event = cast[KeyboardEvent](e)
if event.key == "Enter":
onClick(e, n, state)
proc render*(state: ResetPasswordModal,
recaptchaSiteKey: Option[string]): VNode =
result = buildHtml():
tdiv(class=class({"active": state.shown}, "modal"),
id="resetpassword-modal"):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
a(href="", class="btn btn-clear float-right",
"aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-title h5"):
text "Reset your password"
tdiv(class="modal-body"):
tdiv(class="content"):
form(id="resetpassword-form",
onKeyDown=(ev: Event, n: VNode) => onKeyDown(ev, n, state)):
genFormField(
state.error,
"email",
"Enter your email or username and we will send you a " &
"password reset email.",
"text",
true,
placeholder="Username or email"
)
if recaptchaSiteKey.isSome:
tdiv(id="recaptcha"):
tdiv(class="g-recaptcha",
"data-sitekey"=recaptchaSiteKey.get())
script(src="https://www.google.com/recaptcha/api.js")
tdiv(class="modal-footer"):
if state.sent:
span(class="text-success"):
italic(class="fas fa-check-circle")
text " Sent"
else:
button(class=class(
{"loading": state.loading},
"btn btn-primary"
),
`type`="button",
onClick=(ev: Event, n: VNode) => onClick(ev, n, state)):
text "Reset password"

102
src/frontend/search.nim Normal file
View file

@ -0,0 +1,102 @@
import user, options, httpcore, json, times
type
SearchResultKind* = enum
ThreadMatch, PostMatch
SearchResult* = object
kind*: SearchResultKind
threadId*: int
postId*: int
threadTitle*: string
postContent*: string
author*: User
creation*: int64
proc isModerated*(searchResult: SearchResult): bool =
return searchResult.author.rank <= Moderated
when defined(js):
from dom import nil
include karax/prelude
import karax / [kajax]
import karaxutils, error, threadlist, sugar
type
Search* = ref object
list: Option[seq[SearchResult]]
loading: bool
status: HttpCode
query: string
proc newSearch*(): Search =
Search(
list: none[seq[SearchResult]](),
loading: false,
status: Http200,
query: ""
)
proc onList(httpStatus: int, response: kstring, state: Search) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = to(parsed, seq[SearchResult])
state.list = some(list)
proc genSearchResult(searchResult: SearchResult): VNode =
let url = renderPostUrl(searchResult.threadId, searchResult.postId)
result = buildHtml():
tdiv(class="post", id = $searchResult.postId):
tdiv(class="post-icon"):
render(searchResult.author, "post-avatar")
tdiv(class="post-main"):
tdiv(class="post-title"):
tdiv(class="thread-title"):
a(href=url, onClick=anchorCB):
verbatim(searchResult.threadTitle)
tdiv(class="post-username"):
text searchResult.author.name
renderUserRank(searchResult.author)
tdiv(class="post-metadata"):
# TODO: History and replying to.
let title = searchResult.creation.fromUnix().local.
format("MMM d, yyyy HH:mm")
a(href=url, title=title, onClick=anchorCB):
text renderActivity(searchResult.creation)
tdiv(class="post-content"):
verbatim(searchResult.postContent)
proc render*(state: Search, query: string, currentUser: Option[User]): VNode =
if state.query != query:
state.list = none[seq[SearchResult]]()
state.status = Http200
state.query = query
if state.status != Http200:
return renderError("Couldn't retrieve search results.", state.status)
if state.list.isNone:
var params = @[("q", state.query)]
let uri = makeUri("search.json", params)
ajaxGet(uri, @[], (s: int, r: kstring) => onList(s, r, state))
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get()
result = buildHtml():
section(class="container grid-xl"):
tdiv(class="title"):
p(): text "Search results"
tdiv(class="searchresults"):
if list.len == 0:
renderMessage("No results found", "", "fa-exclamation")
else:
for searchResult in list:
if not searchResult.visibleTo(currentUser): continue
genSearchResult(searchResult)

97
src/frontend/signup.nim Normal file
View file

@ -0,0 +1,97 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error
import karaxutils
type
SignupModal* = ref object
shown: bool
loading: bool
onSignUp, onLogIn: proc ()
error: Option[PostError]
proc onSignUpPost(httpStatus: int, response: kstring, state: SignupModal) =
postFinished:
state.shown = false
state.onSignUp()
proc onSignUpClick(ev: Event, n: VNode, state: SignupModal) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("signup")
let form = dom.document.getElementById("signup-form")
# TODO: This is a hack, karax should support this.
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onSignUpPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: SignupModal) =
state.shown = false
ev.preventDefault()
proc newSignupModal*(onSignUp, onLogIn: proc ()): SignupModal =
SignupModal(
shown: false,
onLogIn: onLogIn,
onSignUp: onSignUp
)
proc show*(state: SignupModal) =
state.shown = true
proc render*(state: SignupModal, recaptchaSiteKey: Option[string]): VNode =
setForeignNodeId("recaptcha")
result = buildHtml():
tdiv(class=class({"active": state.shown}, "modal"),
id="signup-modal"):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
a(href="", class="btn btn-clear float-right",
"aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-title h5"):
text "Create a new account"
tdiv(class="modal-body"):
tdiv(class="content"):
form(id="signup-form"):
genFormField(state.error, "email", "Email", "email", false)
genFormField(state.error, "username", "Username", "text", false)
genFormField(
state.error,
"password",
"Password",
"password",
true
)
if recaptchaSiteKey.isSome:
tdiv(id="recaptcha"):
tdiv(class="g-recaptcha",
"data-sitekey"=recaptchaSiteKey.get())
script(src="https://www.google.com/recaptcha/api.js")
tdiv(class="modal-footer"):
button(class=class({"loading": state.loading},
"btn btn-primary create-account-btn"),
onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)):
text "Create account"
button(class="btn login-btn",
onClick=(ev: Event, n: VNode) =>
(state.onLogIn(); state.shown = false)):
text "Log in"
p(class="license-text text-gray"):
text "By registering, you agree to the "
a(id="license", href=makeUri("/about/license"),
onClick=(ev: Event, n: VNode) =>
(state.shown = false; anchorCB(ev, n))):
text "content license"
text "."

251
src/frontend/threadlist.nim Normal file
View file

@ -0,0 +1,251 @@
import strformat, times, options, json, httpcore, sugar
import category, user
type
Thread* = object
id*: int
topic*: string
category*: Category
author*: User
users*: seq[User]
replies*: int
views*: int
activity*: int64 ## Unix timestamp
creation*: int64 ## Unix timestamp
isLocked*: bool
isSolved*: bool
isPinned*: bool
ThreadList* = ref object
threads*: seq[Thread]
moreCount*: int ## How many more threads are left
proc isModerated*(thread: Thread): bool =
## Determines whether the specified thread is under moderation.
## (i.e. whether the specified thread is invisible to ordinary users).
thread.author.rank <= Moderated
when defined(js):
import sugar
include karax/prelude
import karax / [vstyles, kajax, kdom]
import karaxutils, error, user, mainbuttons
type
State = ref object
list: Option[ThreadList]
refreshList: bool
loading: bool
status: HttpCode
mainButtons: MainButtons
var state: State
proc newState(): State =
State(
list: none[ThreadList](),
loading: false,
status: Http200,
mainButtons: newMainButtons(
onCategoryChange =
(oldCategory: Category, newCategory: Category) => (state.list = none[ThreadList]())
)
)
state = newState()
proc visibleTo*[T](thread: T, user: Option[User]): bool =
## Determines whether the specified thread (or post) should be
## shown to the user. This procedure is generic and works on any
## object with a `isModerated` proc.
##
## The rules for this are determined by the rank of the user, their
## settings (TODO), and whether the thread's creator is moderated or not.
##
## The ``user`` argument refers to the currently logged in user.
mixin isModerated
if user.isNone(): return not thread.isModerated
let rank = user.get().rank
if rank < Rank.Moderator and thread.isModerated:
return thread.author == user.get()
return true
proc genUserAvatars(users: seq[User]): VNode =
result = buildHtml(td(class="thread-users")):
for user in users:
render(user, "avatar avatar-sm", showStatus=true)
text " "
proc renderActivity*(activity: int64): string =
let currentTime = getTime()
let activityTime = fromUnix(activity)
let duration = currentTime - activityTime
if currentTime.local().year != activityTime.local().year:
return activityTime.local().format("MMM yyyy")
elif duration.inDays > 30 and duration.inDays < 300:
return activityTime.local().format("MMM dd")
elif duration.inDays != 0:
return $duration.inDays & "d"
elif duration.inHours != 0:
return $duration.inHours & "h"
elif duration.inMinutes != 0:
return $duration.inMinutes & "m"
else:
return $duration.inSeconds & "s"
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
let isBanned = thread.author.rank.isBanned()
result = buildHtml():
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
td(class="thread-title"):
if thread.isLocked:
italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to")
if thread.isPinned:
italic(class="fas fa-thumbtack fa-xs",
title="Pinned post")
if isBanned:
italic(class="fas fa-ban fa-xs",
title="Thread author is banned")
if thread.isModerated:
italic(class="fas fa-eye-slash fa-xs",
title="Thread is moderated")
if thread.isSolved:
italic(class="fas fa-check-square fa-xs",
title="Thread has a solution")
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
text thread.topic
tdiv(class="show-sm" & class({"d-none": not displayCategory})):
render(thread.category)
td(class="hide-sm" & class({"d-none": not displayCategory})):
render(thread.category)
genUserAvatars(thread.users)
td(class="thread-replies"): text $thread.replies
td(class="hide-sm" & class({
"views-text": thread.views < 999,
"popular-text": thread.views > 999 and thread.views < 5000,
"super-popular-text": thread.views > 5000
})):
if thread.views > 999:
text fmt"{thread.views/1000:.1f}k"
else:
text $thread.views
let friendlyCreation = thread.creation.fromUnix.local.format(
"'First post:' MMM d, yyyy HH:mm'\n'"
)
let friendlyActivity = thread.activity.fromUnix.local.format(
"'Last reply:' MMM d, yyyy HH:mm"
)
td(class=class({"is-new": isNew, "is-old": isOld}, "thread-time"),
title=friendlyCreation & friendlyActivity):
text renderActivity(thread.activity)
proc onThreadList(httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = to(parsed, ThreadList)
if state.list.isSome:
state.list.get().threads.add(list.threads)
state.list.get().moreCount = list.moreCount
else:
state.list = some(list)
proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) =
state.loading = true
let start = state.list.get().threads.len
if categoryId.isSome:
ajaxGet(makeUri("threads.json?start=" & $start & "&categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
proc getInfo(
list: seq[Thread], i: int, currentUser: Option[User]
): tuple[isLastUnseen, isNew: bool] =
## Determines two properties about a thread.
##
## * isLastUnseen - Whether this is the last thread that had new
## activity since the last time the user visited the forum.
## * isNew - Whether this thread was created during the time that the
# user was absent from the forum.
let previousVisitAt =
if currentUser.isSome(): currentUser.get().previousVisitAt
else: getTime().toUnix
assert previousVisitAt != 0
let thread = list[i]
let isUnseen = thread.activity > previousVisitAt
let isNextUnseen = i+1 < list.len and list[i+1].activity > previousVisitAt
return (
isLastUnseen: isUnseen and (not isNextUnseen),
isNew: thread.creation > previousVisitAt
)
proc genThreadList(currentUser: Option[User], categoryId: Option[int]): VNode =
if state.status != Http200:
return renderError("Couldn't retrieve threads.", state.status)
if state.list.isNone:
if not state.loading:
state.loading = true
if categoryId.isSome:
ajaxGet(makeUri("threads.json?categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json"), @[], onThreadList)
return buildHtml(tdiv(class="loading loading-lg"))
let displayCategory = categoryId.isNone
let list = state.list.get()
result = buildHtml():
section(class="thread-list"):
table(class="table", id="threads-list"):
thead():
tr:
th(text "Topic")
th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category"
th(class="thread-users"): text "Users"
th(class="centered-header"): text "Replies"
th(class="hide-sm centered-header"): text "Views"
th(class="centered-header"): text "Activity"
tbody():
for i in 0 ..< list.threads.len:
let thread = list.threads[i]
if not visibleTo(thread, currentUser): continue
let isLastThread = i+1 == list.threads.len
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
genThread(i+1, thread, isNew,
noBorder=isLastUnseen or isLastThread,
displayCategory=displayCategory)
if isLastUnseen and (not isLastThread):
tr(class="last-visit-separator"):
td(colspan="6"):
span(text "last visit")
if list.moreCount > 0:
tr(class="load-more-separator"):
if state.loading:
td(colspan="6"):
tdiv(class="loading loading-lg")
else:
td(colspan="6",
onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))):
span(text "load more threads")
proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode =
result = buildHtml(tdiv):
state.mainButtons.render(currentUser, categoryId=categoryId)
genThreadList(currentUser, categoryId)

79
src/frontend/user.nim Normal file
View file

@ -0,0 +1,79 @@
import times, options
type
# If you add more "Banned" states, be sure to modify forum's threadsQuery too.
Rank* {.pure.} = enum ## serialized as 'status'
Spammer ## spammer: every post is invisible
Moderated ## new member: posts manually reviewed before everybody
## can see them
Troll ## troll: cannot write new posts
Banned ## A non-specific ban
EmailUnconfirmed ## member with unconfirmed email address. Their posts
## are visible, but cannot make new posts. This is so that
## when a user with existing posts changes their email,
## their posts don't disappear.
User ## Ordinary user
Moderator ## Moderator: can change a user's rank
Admin ## Admin: can do everything
User* = object
id*: string
name*: string
avatarUrl*: string
lastOnline*: int64
previousVisitAt*: int64 ## Tracks the "last visit" line position
rank*: Rank
isDeleted*: bool
proc isOnline*(user: User): bool =
return getTime().toUnix() - user.lastOnline < (60*5)
proc isAdmin*(user: Option[User]): bool =
return user.isSome and user.get().rank == Admin
proc `==`*(u1, u2: User): bool =
u1.name == u2.name
proc canPost*(rank: Rank): bool =
## Determines whether the specified rank can make new posts.
rank >= Rank.User or rank == Moderated
proc isBanned*(rank: Rank): bool =
rank in {Spammer, Troll, Banned}
when defined(js):
include karax/prelude
import karaxutils
proc render*(user: User, class: string, showStatus=false): VNode =
result = buildHtml():
a(href=renderProfileUrl(user.name), onClick=anchorCB):
figure(class=class):
img(src=user.avatarUrl, title=user.name)
if user.isOnline and showStatus:
italic(class="avatar-presence online")
proc renderUserMention*(user: User): VNode =
result = buildHtml():
a(class="user-mention",
href=makeUri("/profile/" & user.name),
onClick=anchorCB):
text "@" & user.name
proc renderUserRank*(user: User): VNode =
result = buildHtml():
case user.rank
of Spammer, Troll, Banned:
italic(class="fas fa-eye-ban",
title="User is banned")
of Rank.User, EmailUnconfirmed:
span()
of Moderated:
italic(class="fas fa-eye-slash",
title="User is moderated")
of Moderator:
italic(class="fas fa-shield-alt",
title="User is a moderator")
of Admin:
italic(class="fas fa-chess-knight",
title="User is an admin")

66
src/frontend/usermenu.nim Normal file
View file

@ -0,0 +1,66 @@
when defined(js):
import sugar
include karax/prelude
import karax/[vstyles]
import karaxutils
import user
type
UserMenu* = ref object
shown: bool
user: User
onLogout: proc ()
proc newUserMenu*(onLogout: proc ()): UserMenu =
UserMenu(
shown: false,
onLogout: onLogout
)
proc onClick(e: Event, n: VNode, state: UserMenu) =
state.shown = not state.shown
proc render*(state: UserMenu, user: User): VNode =
result = buildHtml():
tdiv(id="profile-btn"):
figure(class="avatar c-hand",
onClick=(e: Event, n: VNode) => onClick(e, n, state)):
img(src=user.avatarUrl, title=user.name)
if user.isOnline:
italic(class="avatar-presense online")
tdiv(style=style([
(StyleAttr.width, kstring"999999px"),
(StyleAttr.height, kstring"999999px"),
(StyleAttr.position, kstring"absolute"),
(StyleAttr.left, kstring"0"),
(StyleAttr.top, kstring"0"),
(
StyleAttr.display,
if state.shown: kstring"block" else: kstring"none"
)
]),
onClick=(e: Event, n: VNode) => (state.shown = false))
ul(class="menu menu-right", style=style(
StyleAttr.display, if state.shown: "inherit" else: "none"
)):
li(class="menu-item"):
tdiv(class="tile tile-centered"):
tdiv(class="tile-icon"):
img(class="avatar", src=user.avatarUrl,
title=user.name)
tdiv(id="profile-name", class="tile-content"):
text user.name
li(class="divider")
li(class="menu-item"):
a(id="myprofile-btn",
href=makeUri("/profile/" & user.name)):
text "My profile"
li(class="menu-item c-hand"):
a(id="logout-btn",
onClick = (e: Event, n: VNode) =>
(state.shown=false; state.onLogout())):
text "Logout"

73
src/fts.sql Normal file
View file

@ -0,0 +1,73 @@
-- selects just threads,
-- those where title doesn't coinside with some of its posts' titles
-- by now selects only the threads title (no post snippet)
SELECT
thread_id,
snippet(thread_fts, '<b>', '</b>', '<b>...</b>') AS thread,
post_id,
post_content,
cdate,
person.id,
person.name AS author,
person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,
strftime('%s', person.previousVisitAt) AS previousVisitAt,
person.status AS status,
person.isDeleted as person_isDeleted,
0 AS what
FROM (
SELECT
thread_fts.id AS thread_id,
post.id AS post_id,
post.content AS post_content,
strftime('%s', post.creation) AS cdate,
MIN(post.creation) AS cdate,
post.author AS author_id
FROM thread_fts
JOIN post ON post.thread=thread_id
WHERE thread_fts MATCH ?
GROUP BY thread_id, post_id
HAVING thread_id NOT IN (
SELECT thread
FROM post_fts JOIN post USING(id)
WHERE post_fts MATCH ?
)
LIMIT ? OFFSET ?
)
JOIN thread_fts ON thread_fts.id=thread_id
JOIN person ON person.id=author_id
WHERE thread_fts MATCH ?
UNION
-- the main query, selects posts
SELECT
thread.id AS thread_id,
thread.name AS thread,
post.id AS post_id,
CASE what WHEN 1
THEN snippet(post_fts, '**', '**', '...', what, -45)
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
cdate,
person.id,
person.name AS author,
person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,
strftime('%s', person.previousVisitAt) AS previousVisitAt,
person.status AS status,
person.isDeleted as person_isDeleted,
what
FROM post_fts JOIN (
-- inner query, selects ids of matching posts, orders and limits them,
-- so snippets only for limited count of posts are created (in outer query)
SELECT id, strftime('%s', post.creation) AS cdate, thread, 1 AS what, post.author AS author
FROM post_fts JOIN post USING(id)
WHERE post_fts.content MATCH ?
ORDER BY what, cdate DESC
LIMIT ? OFFSET ?
) AS post USING(id)
JOIN thread ON thread.id=thread
JOIN person ON person.id=author
WHERE post_fts MATCH ?
ORDER BY what ASC, cdate DESC
LIMIT 300 -- hardcoded limit just in case
;

66
src/importer.nim Normal file
View file

@ -0,0 +1,66 @@
import db_sqlite, strutils
let origFile = "nimforum.db-21-05-18"
let targetFile = "nimforum-blank.db"
let orig = open(connection=origFile, user="", password="",
database="nimforum")
let target = open(connection=targetFile, user="", password="",
database="nimforum")
block:
let fields = "id, name, views, modified"
for thread in getAllRows(orig, sql("select $1 from thread;" % fields)):
target.exec(
sql("""
insert into thread($1)
values (?, ?, ?, ?)
""" % fields),
thread
)
block:
let fields = "id, name, password, email, creation, salt, status, lastOnline"
for person in getAllRows(orig, sql("select $1 from person;" % fields)):
target.exec(
sql("""
insert into person($1)
values (?, ?, ?, ?, ?, ?, ?, ?)
""" % fields),
person
)
block:
let fields = "id, author, ip, content, thread, creation"
for post in getAllRows(orig, sql("select $1 from post;" % fields)):
target.exec(
sql("""
insert into post($1)
values (?, ?, ?, ?, ?, ?)
""" % fields),
post
)
block:
let fields = "id, name"
for t in getAllRows(orig, sql("select $1 from thread_fts;" % fields)):
target.exec(
sql("""
insert into thread_fts($1)
values (?, ?)
""" % fields),
t
)
block:
let fields = "id, content"
for p in getAllRows(orig, sql("select $1 from post_fts;" % fields)):
target.exec(
sql("""
insert into post_fts($1)
values (?, ?)
""" % fields),
p
)
echo("Imported!")

107
src/main.tmpl Normal file
View file

@ -0,0 +1,107 @@
#? stdtmpl | standard
#template `!`(idx: untyped): untyped =
# row[idx]
#end template
#proc genRSSHeaders(c: TForumData): string =
# result = ""
<link href="${c.req.makeUri("/threadActivity.xml")}" title="Thread activity"
type="application/atom+xml" rel="alternate">
<link href="${c.req.makeUri("/postActivity.xml")}" title="Post activity"
type="application/atom+xml" rel="alternate">
#end proc
#
#proc genThreadsRSS(c: TForumData): string =
# result = ""
# const query = sql"""SELECT A.id, A.name,
# strftime('%Y-%m-%dT%H:%M:%SZ', (A.modified)),
# COUNT(B.id), C.name, B.content, B.id
# FROM thread AS A, post AS B, person AS C
# WHERE A.id = b.thread AND B.author = C.id
# GROUP BY B.thread
# ORDER BY modified DESC LIMIT ?"""
# const threadId = 0
# const name = 1
# const threadDate = 2
# const postCount = 3
# const postAuthor = 4
# const postContent = 5
# const postId = 6
# let frontQuery = c.req.makeUri("/")
# let recent = getValue(db, sql"""SELECT
# strftime('%Y-%m-%dT%H:%M:%SZ', (modified)) FROM thread
# ORDER BY modified DESC LIMIT 1""")
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${config.name} thread activity</title>
<link href="${c.req.makeUri("/threadActivity.xml")}" rel="self" />
<link href="${frontQuery}" />
<id>${frontQuery}</id>
<updated>${recent}</updated>
# for row in rows(db, query, 10):
<entry>
<title>${xmlEncode(!name)}</title>
<id>urn:entry:${!threadid}</id>
# let url = c.genThreadUrl(threadid = !threadid) &
# "#" & !postId
<link rel="alternate" type="text/html"
href="${c.req.makeUri(url)}"/>
<published>${!threadDate}</published>
<updated>${!threadDate}</updated>
<author><name>${xmlEncode(!postAuthor)}</name></author>
<content type="html"
>Posts ${!postCount}, ${xmlEncode(!postAuthor)} said:
&lt;p&gt;
${xmlEncode(rstToHtml(!postContent))}</content>
</entry>
# end for
</feed>
#end proc
#
#proc genPostsRSS(c: TForumData): string =
# result = ""
# const query = sql"""SELECT A.id, B.name, A.content, A.thread, T.name,
# strftime('%Y-%m-%dT%H:%M:%SZ', A.creation),
# A.creation, COUNT(C.id)
# FROM post AS A, person AS B, post AS C, thread AS T
# WHERE A.author = B.id AND A.thread = C.thread AND C.id <= A.id
# AND T.id = A.thread
# GROUP BY A.id
# ORDER BY A.creation DESC LIMIT 10"""
# const postId = 0
# const postAuthor = 1
# const postContent = 2
# const postThread = 3
# const postHeader = 4
# const postRssDate = 5
# const postHumanDate = 6
# const postPosition = 7
# let frontQuery = c.req.makeUri("/")
# let recent = getValue(db, sql"""SELECT
# strftime('%Y-%m-%dT%H:%M:%SZ', creation) FROM post
# ORDER BY creation DESC LIMIT 1""")
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${config.name} post activity</title>
<link href="${c.req.makeUri("/postActivity.xml")}" rel="self" />
<link href="${frontQuery}" />
<id>${frontQuery}</id>
<updated>${recent}</updated>
# for row in rows(db, query):
<entry>
<title>${xmlEncode(!postHeader)}</title>
<id>urn:entry:${!postId}</id>
# let url = c.genThreadUrl(threadid = !postThread) &
# "#" & !postId
<link rel="alternate" type="text/html"
href="${c.req.makeUri(url)}"/>
<published>${!postRssDate}</published>
<updated>${!postRssDate}</updated>
<author><name>${xmlEncode(!postAuthor)}</name></author>
<content type="html"
>On ${xmlEncode(!postHumanDate)}, ${xmlEncode(!postAuthor)} said:
&lt;p&gt;
${xmlEncode(rstToHtml(!postContent))}</content>
</entry>
# end for
</feed>
#end proc

9
src/nim.cfg Normal file
View file

@ -0,0 +1,9 @@
# we need the documentation generator of the compiler:
path="$lib/packages/docutils"
path="$nim"
-d:ssl
# --threads:on
# --threadAnalysis:off

384
src/setup_nimforum.nim Normal file
View file

@ -0,0 +1,384 @@
#
#
# The Nim Forum
# (c) Copyright 2018 Andreas Rumpf, Dominik Picheta
# Look at license.txt for more info.
# All rights reserved.
#
# Script to initialise the nimforum.
import strutils, db_sqlite, os, times, json, options, terminal
import auth, frontend/user
proc backup(path: string, contents: Option[string]=none[string]()) =
if existsFile(path):
if contents.isSome() and readFile(path) == contents.get():
# Don't backup if the files are equivalent.
echo("Not backing up because new file is the same.")
return
let backupPath = path & "." & $getTime().toUnix()
echo(path, " already exists. Moving to ", backupPath)
moveFile(path, backupPath)
proc createUser(db: DbConn, user: tuple[username, password, email: string],
rank: Rank) =
assert user.username.len != 0
let salt = makeSalt()
let password = makePassword(user.password, salt)
exec(db, sql"""
INSERT INTO person(name, password, email, salt, status, lastOnline)
VALUES (?, ?, ?, ?, ?, DATETIME('now'))
""", user.username, password, user.email, salt, $rank)
proc initialiseDb(admin: tuple[username, password, email: string],
filename="nimforum.db") =
let
path = getCurrentDir() / filename
isTest = "-test" in filename
isDev = "-dev" in filename
if not isDev and not isTest:
backup(path)
removeFile(path)
var db = open(connection=path, user="", password="",
database="nimforum")
const
userNameType = "varchar(20)"
passwordType = "varchar(300)"
emailType = "varchar(254)" # https://stackoverflow.com/a/574698/492186
# -- Category
db.exec(sql"""
create table category(
id integer primary key,
name varchar(100) not null,
description varchar(500) not null,
color varchar(10) not null
);
""")
db.exec(sql"""
insert into category (id, name, description, color)
values (0, 'Unsorted', 'No category has been chosen yet.', '');
""")
# -- Thread
db.exec(sql"""
create table thread(
id integer primary key,
name varchar(100) not null,
views integer not null,
modified timestamp not null default (DATETIME('now')),
category integer not null default 0,
isLocked boolean not null default 0,
solution integer,
isDeleted boolean not null default 0,
isPinned boolean not null default 0,
foreign key (category) references category(id),
foreign key (solution) references post(id)
);""", [])
db.exec(sql"""
create unique index ThreadNameIx on thread (name);
""", [])
# -- Person
db.exec(sql("""
create table person(
id integer primary key,
name $# not null,
password $# not null,
email $# not null,
creation timestamp not null default (DATETIME('now')),
salt varbin(128) not null,
status varchar(30) not null,
lastOnline timestamp not null default (DATETIME('now')),
previousVisitAt timestamp not null default (DATETIME('now')),
isDeleted boolean not null default 0,
needsPasswordReset boolean not null default 0
);""" % [userNameType, passwordType, emailType]), [])
db.exec(sql"""
create unique index UserNameIx on person (name);
""", [])
db.exec sql"create index PersonStatusIdx on person(status);"
# Create default user.
db.createUser(admin, Admin)
# Create some test data for development
if isTest or isDev:
for rank in Spammer..Moderator:
let rankLower = toLowerAscii($rank)
let user = (username: $rankLower,
password: $rankLower,
email: $rankLower & "@localhost.local")
db.createUser(user, rank)
db.exec(sql"""
insert into category (name, description, color)
values ('Libraries', 'Libraries and library development', '0198E1'),
('Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
('Fun', 'Posts that are just for fun', '00897B'),
('Potential Issues', 'Potential Nim compiler issues', 'E53935');
""")
# -- Post
db.exec(sql"""
create table post(
id integer primary key,
author integer not null,
ip inet not null,
content varchar(1000) not null,
thread integer not null,
creation timestamp not null default (DATETIME('now')),
isDeleted boolean not null default 0,
replyingTo integer,
foreign key (thread) references thread(id),
foreign key (author) references person(id),
foreign key (replyingTo) references post(id)
);""", [])
db.exec sql"create index PostByAuthorIdx on post(thread, author);"
db.exec(sql"""
create table postRevision(
id integer primary key,
creation timestamp not null default (DATETIME('now')),
original integer not null,
content varchar(1000) not null,
foreign key (original) references post(id)
)
""")
# -- Session
db.exec(sql("""
create table session(
id integer primary key,
ip inet not null,
key $# not null,
userid integer not null,
lastModified timestamp not null default (DATETIME('now')),
foreign key (userid) references person(id)
);""" % [passwordType]), [])
# -- Likes
db.exec(sql("""
create table like(
id integer primary key,
author integer not null,
post integer not null,
creation timestamp not null default (DATETIME('now')),
foreign key (author) references person(id),
foreign key (post) references post(id)
)
"""))
# -- Report
db.exec(sql("""
create table report(
id integer primary key,
author integer not null,
post integer not null,
kind varchar(30) not null,
content varchar(500) not null default '',
foreign key (author) references person(id),
foreign key (post) references post(id)
)
"""))
# -- FTS
if not db.tryExec(sql"""
CREATE VIRTUAL TABLE thread_fts USING fts4 (
id INTEGER PRIMARY KEY,
name VARCHAR(100) NOT NULL
);""", []):
echo "thread_fts table already exists or fts4 not supported"
else:
db.exec(sql"""
INSERT INTO thread_fts
SELECT id, name FROM thread;
""", [])
if not db.tryExec(sql"""
CREATE VIRTUAL TABLE post_fts USING fts4 (
id INTEGER PRIMARY KEY,
content VARCHAR(1000) NOT NULL
);""", []):
echo "post_fts table already exists or fts4 not supported"
else:
db.exec(sql"""
INSERT INTO post_fts
SELECT id, content FROM post;
""", [])
close(db)
proc initialiseConfig(
name, title, hostname: string,
recaptcha: tuple[siteKey, secretKey: string],
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
isDev: bool,
dbPath: string,
ga: string=""
) =
let path = getCurrentDir() / "forum.json"
var j = %{
"name": %name,
"title": %title,
"hostname": %hostname,
"recaptchaSiteKey": %recaptcha.siteKey,
"recaptchaSecretKey": %recaptcha.secretKey,
"smtpAddress": %smtp.address,
"smtpUser": %smtp.user,
"smtpPassword": %smtp.password,
"smtpFromAddr": %smtp.fromAddr,
"smtpTls": %smtp.tls,
"isDev": %isDev,
"dbPath": %dbPath
}
if ga.len > 0:
j["ga"] = %ga
backup(path, some(pretty(j)))
writeFile(path, pretty(j))
proc question(q: string): string =
while result.len == 0:
stdout.write(q)
result = stdin.readLine()
proc setup() =
echo("""
Welcome to the NimForum setup script. Please answer the following questions.
These can be changed later in the generated forum.json file.
""")
let name = question("Forum full name: ")
let title = question("Forum short name: ")
let hostname = question("Forum hostname: ")
let adminUser = question("Admin username: ")
let adminPass = readPasswordFromStdin("Admin password: ")
let adminEmail = question("Admin email: ")
echo("")
echo("The following question are related to recaptcha. \nYou must set up a " &
"recaptcha v2 for your forum before answering them. \nPlease do so now " &
"and then answer these questions: https://www.google.com/recaptcha/admin")
let recaptchaSiteKey = question("Recaptcha site key: ")
let recaptchaSecretKey = question("Recaptcha secret key: ")
echo("The following questions are related to smtp. You must set up a \n" &
"mailing server for your forum or use an external service.")
let smtpAddress = question("SMTP address (eg: mail.hostname.com): ")
let smtpUser = question("SMTP user: ")
let smtpPassword = readPasswordFromStdin("SMTP pass: ")
let smtpFromAddr = question("SMTP sending email address (eg: mail@mail.hostname.com): ")
let smtpTls = parseBool(question("Enable TLS for SMTP: "))
echo("The following is optional. You can specify your Google Analytics ID " &
"if you wish. Otherwise just leave it blank.")
stdout.write("Google Analytics (eg: UA-12345678-1): ")
let ga = stdin.readLine().strip()
let dbPath = "nimforum.db"
initialiseConfig(
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
dbPath, ga
)
initialiseDb(
admin=(adminUser, adminPass, adminEmail),
dbPath
)
echo("Setup complete!")
proc echoHelp() =
quit("""
Usage: setup_nimforum opts
Options:
--setup Performs first time setup for end users.
Development options:
--dev Creates a new development DB and config.
--test Creates a new test DB and config.
--blank Creates a new blank DB.
""")
when isMainModule:
if paramCount() > 0:
case paramStr(1)
of "--dev":
let dbPath = "nimforum-dev.db"
echo("Initialising nimforum for development...")
initialiseConfig(
"Development Forum",
"Development Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", "", "", false),
isDev=true,
dbPath
)
initialiseDb(
admin=("admin", "admin", "admin@localhost.local"),
dbPath
)
of "--test":
let dbPath = "nimforum-test.db"
echo("Initialising nimforum for testing...")
initialiseConfig(
"Test Forum",
"Test Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", "", "", false),
isDev=true,
dbPath
)
initialiseDb(
admin=("admin", "admin", "admin@localhost.local"),
dbPath
)
of "--blank":
let dbPath = "nimforum-blank.db"
echo("Initialising blank DB...")
initialiseDb(
admin=("", "", ""),
dbPath
)
of "--setup":
setup()
else:
echoHelp()
else:
echoHelp()

187
src/utils.nim Normal file
View file

@ -0,0 +1,187 @@
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
htmlparser, streams, parseutils, options, logging
from times import getTime, utc, format
# Used to be:
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
let
UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this.
import frontend/[karaxutils, error]
export parseInt
type
Config* = object
smtpAddress*: string
smtpPort*: int
smtpUser*: string
smtpPassword*: string
smtpFromAddr*: string
smtpTls*: bool
smtpSsl*: bool
mlistAddress*: string
recaptchaSecretKey*: string
recaptchaSiteKey*: string
isDev*: bool
dbPath*: string
hostname*: string
name*, title*: string
ga*: string
port*: int
ForumError* = object of Exception
data*: PostError
proc newForumError*(message: string,
fields: seq[string] = @[]): ref ForumError =
new(result)
result.msg = message
result.data =
PostError(
errorFields: fields,
message: message
)
var docConfig: StringTableRef
docConfig = rstgen.defaultConfig()
docConfig["doc.listing_start"] = "<pre class='code' data-lang='$2'><code>"
docConfig["doc.listing_end"] = "</code><div class='code-buttons'><button class='btn btn-primary btn-sm'>Run</button></div></pre>"
proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
result = Config(smtpAddress: "", smtpPort: 25, smtpUser: "",
smtpPassword: "", mlistAddress: "")
let root = parseFile(filename)
result.smtpAddress = root{"smtpAddress"}.getStr("")
result.smtpPort = root{"smtpPort"}.getInt(25)
result.smtpUser = root{"smtpUser"}.getStr("")
result.smtpPassword = root{"smtpPassword"}.getStr("")
result.smtpFromAddr = root{"smtpFromAddr"}.getStr("")
result.smtpTls = root{"smtpTls"}.getBool(false)
result.smtpSsl = root{"smtpSsl"}.getBool(false)
result.mlistAddress = root{"mlistAddress"}.getStr("")
result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("")
result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("")
result.isDev = root{"isDev"}.getBool()
result.dbPath = root{"dbPath"}.getStr("nimforum.db")
result.hostname = root["hostname"].getStr()
result.name = root["name"].getStr()
result.title = root["title"].getStr()
result.ga = root{"ga"}.getStr()
result.port = root{"port"}.getInt(5000)
proc processGT(n: XmlNode, tag: string): (int, XmlNode, string) =
result = (0, newElement(tag), tag)
if n.kind == xnElement and len(n) == 1 and n[0].kind == xnElement:
return processGT(n[0], if n[0].kind == xnElement: n[0].tag else: tag)
var countGT = true
for c in items(n):
case c.kind
of xnText:
if c.text == ">" and countGT:
result[0].inc()
else:
countGT = false
result[1].add(newText(c.text))
else:
result[1].add(c)
proc blockquoteFinish(currentBlockquote, newNode: var XmlNode, n: XmlNode) =
if currentBlockquote.len > 0:
#echo(currentBlockquote.repr)
newNode.add(currentBlockquote)
currentBlockquote = newElement("blockquote")
newNode.add(n)
proc processQuotes(node: XmlNode): XmlNode =
# Bolt on quotes.
# TODO: Yes, this is ugly. I wrote it quickly. PRs welcome ;)
result = newElement("div")
var currentBlockquote = newElement("blockquote")
for n in items(node):
case n.kind
of xnElement:
case n.tag
of "p":
let (nesting, contentNode, _) = processGT(n, "p")
if nesting > 0:
var bq = currentBlockquote
for i in 1 ..< nesting:
var newBq = bq.child("blockquote")
if newBq.isNil:
newBq = newElement("blockquote")
bq.add(newBq)
bq = newBq
bq.add(contentNode)
else:
blockquoteFinish(currentBlockquote, result, n)
else:
blockquoteFinish(currentBlockquote, result, n)
of xnText:
if n.text[0] == '\10':
result.add(n)
else:
blockquoteFinish(currentBlockquote, result, n)
else:
blockquoteFinish(currentBlockquote, result, n)
proc replaceMentions(node: XmlNode): seq[XmlNode] =
assert node.kind == xnText
result = @[]
var current = ""
var i = 0
while i < len(node.text):
i += parseUntil(node.text, current, {'@'}, i)
if i >= len(node.text): break
if node.text[i] == '@':
i.inc # Skip @
var username = ""
i += parseWhile(node.text, username, UsernameIdent, i)
if username.len == 0:
result.add(newText(current & "@"))
else:
let el = <>span(
class="user-mention",
data-username=username,
newText("@" & username)
)
result.add(newText(current))
current = ""
result.add(el)
result.add(newText(current))
proc processMentions(node: XmlNode): XmlNode =
case node.kind
of xnText:
result = newElement("span")
for child in replaceMentions(node):
result.add(child)
of xnElement:
case node.tag
of "pre", "code", "tt", "a":
return node
else:
result = newElement(node.tag)
result.attrs = node.attrs
for n in items(node):
result.add(processMentions(n))
else:
return node
proc rstToHtml*(content: string): string =
result = rstgen.rstToHtml(content, {roSupportMarkdown},
docConfig)
try:
var node = parseHtml(newStringStream(result))
if node.kind == xnElement:
node = processQuotes(node)
node = processMentions(node)
result = ""
add(result, node, indWidth=0, addNewLines=false)
except:
warn("Could not parse rst html.")

78
tests/browsertester.nim Normal file
View file

@ -0,0 +1,78 @@
import options, osproc, streams, threadpool, os, strformat, httpclient
import webdriver
proc runProcess(cmd: string) =
let p = startProcess(
cmd,
options={
poStdErrToStdOut,
poEvalCommand
}
)
let o = p.outputStream
while p.running and (not o.atEnd):
echo cmd.substr(0, 10), ": ", o.readLine()
p.close()
const backend = "forum"
const port = 5000
const baseUrl = "http://localhost:" & $port & "/"
template withBackend(body: untyped): untyped =
## Starts a new backend instance.
spawn runProcess("nimble -y testbackend")
defer:
discard execCmd("killall " & backend)
echo("Waiting for server...")
var success = false
for i in 0..5:
sleep(5000)
try:
let client = newHttpClient()
doAssert client.getContent(baseUrl).len > 0
success = true
break
except:
echo("Failed to getContent")
doAssert success
body
import browsertests/[scenario1, threads, issue181, categories]
proc main() =
# Kill any already running instances
discard execCmd("killall geckodriver")
spawn runProcess("geckodriver -p 4444 --log config")
defer:
discard execCmd("killall geckodriver")
# Create a fresh DB for the tester.
doAssert(execCmd("nimble testdb") == QuitSuccess)
doAssert(execCmd("nimble -y frontend") == QuitSuccess)
echo("Waiting for geckodriver to startup...")
sleep(5000)
try:
let driver = newWebDriver()
let session = driver.createSession()
withBackend:
scenario1.test(session, baseUrl)
threads.test(session, baseUrl)
categories.test(session, baseUrl)
issue181.test(session, baseUrl)
session.close()
except:
sleep(10000) # See if we can grab any more output.
raise
when isMainModule:
main()

2
tests/browsertester.nims Normal file
View file

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

View file

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

View file

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

View file

@ -0,0 +1,36 @@
import unittest, common
import webdriver
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
test "can see banned posts":
with session:
register("issue181", "issue181")
logout()
# Change rank to `user` so they can post.
login("admin", "admin")
setUserRank(baseUrl, "issue181", "user")
logout()
login("issue181", "issue181")
navigate(baseUrl)
const title = "Testing issue 181."
createThread(title, "Test for issue #181")
logout()
login("admin", "admin")
# Ban our user.
setUserRank(baseUrl, "issue181", "banned")
# Make sure the banned user's thread is still visible.
navigate(baseUrl)
ensureExists("tr.banned")
checkText("tr.banned .thread-title > a", title)
logout()
checkText("tr.banned .thread-title > a", title)

View file

@ -0,0 +1,43 @@
import unittest, common
import webdriver
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
# Sanity checks
test "shows sign up":
session.checkText("#signup-btn", "Sign up")
test "shows log in":
session.checkText("#login-btn", "Log in")
test "is empty":
session.checkIsNone("tr > td.thread-title")
# Logging in
test "can login/logout":
with session:
login("admin", "admin")
# Check whether we can log out.
logout()
# Verify we have logged out by looking for the log in button.
ensureExists "#login-btn"
test "can register":
with session:
register("test", "test")
logout()
test "can't register same username with different case":
with session:
register "test1", "test1", verify = false
logout()
navigate baseUrl
register "TEst1", "test1", verify = false
ensureExists "#signup-form .has-error"
navigate baseUrl

View file

@ -0,0 +1,267 @@
import unittest, common
import webdriver
let
userTitleStr = "This is a user thread!"
userContentStr = "A user has filled this out"
adminTitleStr = "This is a thread title!"
adminContentStr = "This is content"
proc banUser(session: Session, baseUrl: string) =
with session:
login "admin", "admin"
setUserRank baseUrl, "user", "banned"
logout()
proc unBanUser(session: Session, baseUrl: string) =
with session:
login "admin", "admin"
setUserRank baseUrl, "user", "user"
logout()
proc userTests(session: Session, baseUrl: string) =
suite "user thread tests":
session.login("user", "user")
setup:
session.navigate(baseUrl)
test "can create thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", userTitleStr
sendKeys "#reply-textarea", userContentStr
click "#create-thread-btn"
checkText "#thread-title .title-text", userTitleStr
checkText ".original-post div.post-content", userContentStr
test "can delete thread":
with session:
# create thread to be deleted
click "#new-thread-btn"
sendKeys "#thread-title", "To be deleted"
sendKeys "#reply-textarea", "This thread is to be deleted"
click "#create-thread-btn"
click ".post-buttons .delete-button"
# click delete confirmation
click "#delete-modal .delete-btn"
# Make sure the forum post is gone
checkIsNone "To be deleted", LinkTextSelector
test "cannot (un)pin thread":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Unpinnable"
sendKeys "#reply-textarea", "Cannot (un)pin as an user"
click "#create-thread-btn"
checkIsNone "#pin-btn"
test "cannot lock threads":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Locking"
sendkeys "#reply-textarea", "Cannot lock as an user"
click "#create-thread-btn"
checkIsNone "#lock-btn"
session.logout()
proc anonymousTests(session: Session, baseUrl: string) =
suite "anonymous user tests":
with session:
navigate baseUrl
test "can view banned thread":
with session:
ensureExists userTitleStr, LinkTextSelector
with session:
navigate baseUrl
proc bannedTests(session: Session, baseUrl: string) =
suite "banned user thread tests":
with session:
navigate baseUrl
login "banned", "banned"
test "can't start thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "test"
sendKeys "#reply-textarea", "test"
click "#create-thread-btn"
ensureExists "#new-thread p.text-error"
session.logout()
proc adminTests(session: Session, baseUrl: string) =
suite "admin thread tests":
session.login("admin", "admin")
setup:
session.navigate(baseUrl)
test "can view banned thread":
with session:
ensureExists userTitleStr, LinkTextSelector
test "can create thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", adminTitleStr
sendKeys "#reply-textarea", adminContentStr
click "#create-thread-btn"
checkText "#thread-title .title-text", adminTitleStr
checkText ".original-post div.post-content", adminContentStr
test "try create duplicate thread":
with session:
click "#new-thread-btn"
ensureExists "#new-thread"
sendKeys "#thread-title", adminTitleStr
sendKeys "#reply-textarea", adminContentStr
click "#create-thread-btn"
ensureExists "#new-thread p.text-error"
test "can edit post":
let modificationText = " and I edited it!"
with session:
click adminTitleStr, LinkTextSelector
click ".post-buttons .edit-button"
sendKeys ".original-post #reply-textarea", modificationText
click ".edit-buttons .save-button"
checkText ".original-post div.post-content", adminContentStr & modificationText
test "can like thread":
# Try to like the user thread above
with session:
click userTitleStr, LinkTextSelector
click ".post-buttons .like-button"
checkText ".post-buttons .like-button .like-count", "1"
test "can delete thread":
with session:
click adminTitleStr, LinkTextSelector
click ".post-buttons .delete-button"
# click delete confirmation
click "#delete-modal .delete-btn"
# Make sure the forum post is gone
checkIsNone adminTitleStr, LinkTextSelector
test "can pin a thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "Pinned post"
sendKeys "#reply-textarea", "A pinned post"
click "#create-thread-btn"
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Normal post"
sendKeys "#reply-textarea", "A normal post"
click "#create-thread-btn"
navigate(baseUrl)
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Unpin Thread"
navigate(baseUrl)
# Make sure pin exists
ensureExists "#threads-list .thread-1 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Pinned post"
checkText "#threads-list .thread-2 .thread-title a", "Normal post"
test "can unpin a thread":
with session:
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Pin Thread"
navigate(baseUrl)
checkIsNone "#threads-list .thread-2 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Normal post"
checkText "#threads-list .thread-2 .thread-title a", "Pinned post"
test "can lock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
ensureExists "#thread-title i.fas.fa-lock.fa-xs"
test "locked thread appears on frontpage":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "A new locked thread"
sendKeys "#reply-textarea", "This thread should appear locked on the frontpage"
click "#create-thread-btn"
click "#lock-btn"
navigate(baseUrl)
ensureExists "#threads-list .thread-1 .thread-title i.fas.fa-lock.fa-xs"
test "can unlock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
checkIsNone "#thread-title i.fas.fa-lock.fa-xs"
session.logout()
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
userTests(session, baseUrl)
banUser(session, baseUrl)
bannedTests(session, baseUrl)
anonymousTests(session, baseUrl)
adminTests(session, baseUrl)
unBanUser(session, baseUrl)
session.navigate(baseUrl)

View file

@ -1,14 +0,0 @@
Version 1.0
-----------
- even better spam avoidance
- Implement automated testing.
- Implement an "edit account" feature.
- Implement a "who is online" view.
Version 2.0
-----------
- Some email notification/RSS feed system would be nice.