summaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
authorrr- <rr-@sakuya.pl>2016-04-01 18:45:25 +0200
committerrr- <rr-@sakuya.pl>2016-04-01 18:48:16 +0200
commite487adcc97b8d2bd111a34b7d3c72d66001deb8b (patch)
treed684abf99bf176ead0a785bc237ae3b6a81417c1 /client
parent1ad71585c4e95555566e8af068d5addf75b33274 (diff)
split files into client/ and server/
Diffstat (limited to 'client')
-rw-r--r--client/.jscsrc5
-rw-r--r--client/build.js119
-rw-r--r--client/css/forms.css167
-rw-r--r--client/css/help.css12
-rw-r--r--client/css/home.css9
-rw-r--r--client/css/main.css128
-rw-r--r--client/css/users.css38
-rw-r--r--client/html/help-about.hbs13
-rw-r--r--client/html/help-comments.hbs30
-rw-r--r--client/html/help-keyboard.hbs42
-rw-r--r--client/html/help-search.hbs265
-rw-r--r--client/html/help-tos.hbs51
-rw-r--r--client/html/help.hbs13
-rw-r--r--client/html/home.hbs5
-rw-r--r--client/html/index.htm16
-rw-r--r--client/html/login.hbs26
-rw-r--r--client/html/top_nav.hbs11
-rw-r--r--client/html/user_registration.hbs37
-rw-r--r--client/img/favicon.pngbin0 -> 1997 bytes
-rw-r--r--client/js/.gitignore1
-rw-r--r--client/js/api.js96
-rw-r--r--client/js/config.js4
-rw-r--r--client/js/controllers/auth_controller.js56
-rw-r--r--client/js/controllers/comments_controller.js11
-rw-r--r--client/js/controllers/help_controller.js17
-rw-r--r--client/js/controllers/history_controller.js11
-rw-r--r--client/js/controllers/home_controller.js21
-rw-r--r--client/js/controllers/posts_controller.js23
-rw-r--r--client/js/controllers/tags_controller.js11
-rw-r--r--client/js/controllers/top_nav_controller.js80
-rw-r--r--client/js/controllers/users_controller.js61
-rw-r--r--client/js/event_listener.js24
-rw-r--r--client/js/main.js57
-rw-r--r--client/js/util.js44
-rw-r--r--client/js/views/base_view.js66
-rw-r--r--client/js/views/help_view.js44
-rw-r--r--client/js/views/home_view.js22
-rw-r--r--client/js/views/login_view.js43
-rw-r--r--client/js/views/registration_view.js43
-rw-r--r--client/js/views/top_nav_view.js37
-rw-r--r--client/package.json25
-rw-r--r--client/public/.gitignore2
42 files changed, 1786 insertions, 0 deletions
diff --git a/client/.jscsrc b/client/.jscsrc
new file mode 100644
index 0000000..c12b28d
--- /dev/null
+++ b/client/.jscsrc
@@ -0,0 +1,5 @@
+{
+ "preset": "google",
+ "fileExtensions": [".js", "jscs"],
+ "validateIndentation": 4,
+}
diff --git a/client/build.js b/client/build.js
new file mode 100644
index 0000000..eb32e9b
--- /dev/null
+++ b/client/build.js
@@ -0,0 +1,119 @@
+'use strict';
+
+const fs = require('fs');
+const glob = require('glob');
+const path = require('path');
+const util = require('util');
+const execSync = require('child_process').execSync;
+
+function getVersion() {
+ return execSync('git describe --always --dirty --long --tags').toString();
+}
+
+function getConfig() {
+ const ini = require('ini');
+ const merge = require('merge');
+ const camelcaseKeys = require('camelcase-keys');
+
+ function parseIniFile(path) {
+ let result = ini.parse(fs.readFileSync(path, 'utf-8')
+ .replace(/#.+$/gm, '')
+ .replace(/\s+$/gm, ''));
+ Object.keys(result).map((key, _) => {
+ result[key] = camelcaseKeys(result[key]);
+ });
+ return result;
+ }
+
+ let config = parseIniFile('../config.ini.dist');
+
+ try {
+ const localConfig = parseIniFile('../config.ini');
+ config = merge.recursive(config, localConfig);
+ } catch (e) {
+ console.warn('Local config does not exist, ignoring');
+ }
+
+ delete config.basic.secret;
+ delete config.smtp;
+ delete config.database;
+ config.service.userRanks = config.service.userRanks.split(/,\s*/);
+ config.service.tagCategories = config.service.tagCategories.split(/,\s*/);
+ config.meta = {
+ version: getVersion(),
+ buildDate: new Date().toUTCString(),
+ };
+
+ return config;
+}
+
+function bundleHtml(config) {
+ const minify = require('html-minifier').minify;
+ const baseHtml = fs.readFileSync('./html/index.htm', 'utf-8');
+ glob('./html/**/*.hbs', {}, (er, files) => {
+ let templatesHtml = '';
+ for (const file of files) {
+ templatesHtml += util.format(
+ '<template id=\'%s-template\'>%s</template>',
+ path.basename(file, '.hbs').replace('_', '-'),
+ fs.readFileSync(file));
+ }
+
+ const finalHtml = baseHtml
+ .replace(/(<\/head>)/, templatesHtml + '$1')
+ .replace(
+ /(<title>)(.*)(<\/title>)/,
+ util.format('$1%s$3', config.basic.name));
+
+ fs.writeFileSync(
+ './public/index.htm',
+ minify(
+ finalHtml, {
+ removeComments: true,
+ collapseWhitespace: true,
+ conservativeCollapse: true}));
+ console.info('Bundled HTML');
+ });
+}
+
+function bundleCss() {
+ const minify = require('csso').minify;
+ glob('./css/**/*.css', {}, (er, files) => {
+ let css = '';
+ for (const file of files) {
+ css += fs.readFileSync(file);
+ }
+ fs.writeFileSync('./public/bundle.min.css', minify(css));
+ console.info('Bundled CSS');
+ });
+}
+
+function bundleJs() {
+ const browserify = require('browserify');
+ const uglifyjs = require('uglify-js');
+ glob('./js/**/*.js', {}, function(er, files) {
+ const outputFile = fs.createWriteStream('./public/bundle.min.js');
+ browserify().add(files).bundle().pipe(outputFile);
+ outputFile.on('finish', function() {
+ const result = uglifyjs.minify('./public/bundle.min.js');
+ fs.writeFileSync('./public/bundle.min.js', result.code);
+ console.info('Bundled JS');
+ });
+ });
+}
+
+function bundleConfig(config) {
+ fs.writeFileSync(
+ './js/.config.autogen.json', JSON.stringify(config));
+}
+
+function copyFile(source, target) {
+ fs.createReadStream(source).pipe(fs.createWriteStream(target));
+}
+
+const config = getConfig();
+bundleConfig(config);
+bundleHtml(config);
+bundleCss();
+bundleJs();
+copyFile('./img/favicon.png', './public/favicon.png');
diff --git a/client/css/forms.css b/client/css/forms.css
new file mode 100644
index 0000000..4635b00
--- /dev/null
+++ b/client/css/forms.css
@@ -0,0 +1,167 @@
+form {
+ display: block;
+ width: 20em;
+}
+form fieldset {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+form fieldset legend {
+ display: block;
+ text-align: center;
+ width: 100%;
+ font-size: 17pt;
+}
+form ul {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+form ul li {
+ margin-top: 0.5em;
+}
+form .buttons {
+ margin-top: 1em;
+}
+form .input li:first-child label,
+form .input li:first-child {
+ padding-top: 0;
+ margin-top: 0;
+}
+
+form.tabular ul {
+ display: table;
+ border-spacing: 0.5em;
+ margin: 0.5em -0.5em;
+ width: 100%;
+}
+form.tabular ul li {
+ display: table-row;
+}
+form.tabular ul li label {
+ display: table-cell;
+ width: 33%;
+ padding: 0;
+}
+form.tabular .buttons {
+ margin-left: 33%;
+}
+
+form:not(.tabular) ul li label {
+ display: block;
+ padding: 0.5em 0;
+}
+
+input[type=radio], input[type=checkbox] {
+ float: left;
+ display: none;
+}
+
+.radio, .checkbox {
+ box-sizing: border-box;
+ position: relative;
+ display: inline-block;
+ padding-left: calc(20px + 0.5em) !important;
+ vertical-align: middle;
+ cursor: pointer;
+}
+.radio:hover:before, .checkbox:hover:before {
+ border-color: var(--main-color);
+}
+.radio:before, .checkbox:before {
+ transition: border-color 0.1s linear;
+ position: absolute;
+ top: 50%;
+ left: 0;
+ display: block;
+ margin-top: -10px;
+ width: 16px;
+ height: 16px;
+ border: 2px solid var(--input-background-color);
+ background: var(--input-border-color);
+ content: '';
+}
+input[type=radio]:checked + .radio:before,
+input[type=checkbox]:checked + .checkbox:before {
+ border-color: var(--main-color);
+}
+input[type=radio]:checked + .radio:after,
+input[type=checkbox]:checked + .checkbox:after {
+ opacity: 1;
+}
+
+.radio:after {
+ transition: opacity 0.1s linear;
+ position: absolute;
+ top: 50%;
+ left: 5px;
+ display: block;
+ margin-top: -5px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ content: '';
+ opacity: 0;
+}
+.checkbox:after {
+ transition: opacity 0.1s linear;
+ position: absolute;
+ top: 50%;
+ left: 6px;
+ display: block;
+ margin-top: -7px;
+ width: 5px;
+ height: 9px;
+ border-right: 3px solid var(--main-color);
+ border-bottom: 3px solid var(--main-color);
+ content: '';
+ opacity: 0;
+ transform: rotate(45deg);
+}
+
+textarea,
+input[type=text],
+input[type=email],
+input[type=password] {
+ transition: border-color 0.1s linear, background-color 0.1s linear;
+ font-size: 100%;
+ font-family: 'Inconsolata', monospace;
+ padding: 0.3em;
+ border: 2px solid var(--input-background-color);
+ background: var(--input-border-color);
+ text-overflow: ellipsis;
+ width: 100%;
+ box-sizing: border-box;
+ box-shadow: none; /* :-moz-submit-invalid on FF */
+}
+
+textarea:focus,
+input[type=text]:focus,
+input[type=email]:focus,
+input[type=password]:focus {
+ border-color: var(--main-color);
+}
+
+form.show-validation fieldset.input input:invalid {
+ outline: none;
+ border: 2px solid var(--input-bad-border-color);
+ background: var(--input-bad-background-color);
+}
+form.show-validation fieldset.input input:valid {
+ outline: none;
+ border: 2px solid var(--input-good-border-color);
+ background: var(--input-good-background-color);
+}
+
+button,
+input[type=button],
+input[type=submit] {
+ cursor: pointer;
+ font-size: 100%;
+ line-height: 100%;
+ padding: 0.3em 0.7em;
+ border: 1px solid var(--main-color);
+ background: var(--main-color);
+ color: white;
+}
diff --git a/client/css/help.css b/client/css/help.css
new file mode 100644
index 0000000..4548a8a
--- /dev/null
+++ b/client/css/help.css
@@ -0,0 +1,12 @@
+#help {
+ width: 40em;
+}
+#help nav {
+ margin-bottom: 1.5em;
+}
+#help td {
+ padding-right: 1em;
+}
+#help .section {
+ margin-top: 2em;
+}
diff --git a/client/css/home.css b/client/css/home.css
new file mode 100644
index 0000000..d4ee6d7
--- /dev/null
+++ b/client/css/home.css
@@ -0,0 +1,9 @@
+#home {
+ text-align: center !important;
+}
+#home h1 {
+ margin-top: 0;
+}
+#home .message {
+ margin-bottom: 2em;
+}
diff --git a/client/css/main.css b/client/css/main.css
new file mode 100644
index 0000000..c161a6a
--- /dev/null
+++ b/client/css/main.css
@@ -0,0 +1,128 @@
+* {
+ --main-color: #24AADD;
+ --top-nav-color: #F5F5F5;
+ --text-color: #111;
+ --inactive-link-color: #888;
+ --line-color: #DDD;
+ --message-error-border-color: #FCC;
+ --message-error-background-color: #FFF5F5;
+ --message-success-border-color: #D3E3D3;
+ --message-success-background-color: #F5FFF5;
+ --input-bad-border-color: #FCC;
+ --input-bad-background-color: #FFF5F5;
+ --input-good-border-color: #D3E3D3;
+ --input-good-background-color: #F5FFF5;
+ --input-background-color: #EEE;
+ --input-border-color: #FAFAFA;
+}
+
+body {
+ margin: 0;
+ color: var(--text-color);
+ font-family: 'Droid Sans' !important;
+ font-size: 12pt;
+ line-height: 18pt;
+}
+
+h1, h2, h3 {
+ font-weight: normal;
+}
+
+a {
+ color: var(--main-color);
+ text-decoration: none;
+}
+
+#content-holder {
+ margin-top: 2em;
+ text-align: center;
+}
+#content-holder>.content-wrapper {
+ text-align: left;
+ display: inline-block;
+ margin: 0 auto;
+}
+#content-holder>.content-wrapper:not(.transparent) {
+ background: var(--top-nav-color);
+ padding: 2em;
+}
+#content-holder>.content-wrapper>*:first-child {
+ margin-top: 0;
+}
+hr {
+ border: 0;
+ border-top: 1px solid var(--line-color);
+ margin: 1em 0;
+ padding: 0;
+}
+
+nav ul {
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+ display: inline-block;
+}
+nav ul li {
+ display: inline-block;
+ padding: 0;
+ margin: 0;
+}
+nav ul li a {
+ display: inline-block;
+}
+nav ul li img {
+ margin: 0;
+ vertical-align: top; /* fix ghost margin under the image */
+}
+
+nav.text-nav {
+ margin: 1em 0;
+}
+nav.text-nav ul li a {
+ padding: 0.3em 1.2em;
+}
+nav.text-nav ul li:not(.active) a {
+ color: var(--inactive-link-color);
+}
+nav.text-nav ul li.active a {
+ background: var(--main-color);
+ color: white;
+}
+
+#top-nav {
+ background: var(--top-nav-color);
+ margin: 0;
+}
+#top-nav ul {
+ display: block;
+ text-align: right;
+}
+#top-nav ul li {
+ float: left;
+}
+#top-nav ul li[data-name=register],
+#top-nav ul li[data-name=login],
+#top-nav ul li[data-name=logout],
+#top-nav ul li[data-name=help] {
+ float: none;
+}
+#top-nav .access-key {
+ text-decoration: underline;
+}
+
+.messages {
+ width: 30em;
+}
+.messages .message {
+ display: inline-block;
+ text-align: left;
+ padding: 0.5em 1em;
+}
+.message.error {
+ border: 1px solid var(--message-error-border-color);
+ background: var(--message-error-background-color);
+}
+.message.success {
+ border: 1px solid var(--message-success-border-color);
+ background: var(--message-success-background-color);
+}
diff --git a/client/css/users.css b/client/css/users.css
new file mode 100644
index 0000000..c28f5dc
--- /dev/null
+++ b/client/css/users.css
@@ -0,0 +1,38 @@
+#user-registration form {
+ float: left;
+}
+#user-registration .info {
+ float: left;
+ margin-left: 3em;
+ border-radius: 0.2em;
+ width: 20em;
+}
+#user-registration .info ul {
+ line-height: 1.8em;
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+#user-registration .info li {
+ margin: 0;
+ padding: 0;
+}
+#user-registration .info i {
+ margin-right: 0.5em;
+}
+#user-registration .info i.fa {
+ color: var(--main-color);
+}
+#user-registration .info p:first-child {
+ margin: 0 0 0.5em 0;
+}
+#user-registration p.hint {
+ margin-top: 0.5em;
+ color: var(--inactive-link-color);
+ font-size: 80%;
+ line-height: 120%;
+}
+
+#login .buttons a {
+ margin-left: 1em;
+}
diff --git a/client/html/help-about.hbs b/client/html/help-about.hbs
new file mode 100644
index 0000000..1a1c377
--- /dev/null
+++ b/client/html/help-about.hbs
@@ -0,0 +1,13 @@
+<p>Szurubooru is an image board engine inspired by services such as Danbooru,
+Gelbooru and Moebooru. Its name <a href='http://sjp.pwn.pl/sjp/;2527372'>has
+its roots in Polish language and has onomatopeic meaning of scraping or
+scrubbing</a>. It is pronounced as <em>shoorubooru</em>.</p>
+
+<p class='section'><strong>Registration</strong></p>
+
+<p>The e-mail you enter during account creation is only used to retrieve your
+Gravatar and for password reminders. Only you can see it (well, except the
+database staff&hellip; we won&rsquo;t spam your mailbox anyway).</p>
+
+<p>Oh, and you can delete your account at any time. Posts you uploaded will
+stay, unless some angry admin removes them.</p>
diff --git a/client/html/help-comments.hbs b/client/html/help-comments.hbs
new file mode 100644
index 0000000..a1a7b3d
--- /dev/null
+++ b/client/html/help-comments.hbs
@@ -0,0 +1,30 @@
+<p>Comments support Markdown syntax, extended by some handy tags:</p>
+
+<table>
+ <tbody>
+ <tr>
+ <td><code>@426</code></td>
+ <td>links to post number 426</td>
+ </tr>
+ <tr>
+ <td><code>#Dragon_Ball</code></td>
+ <td>links to tag &ldquo;Dragon_Ball&rdquo;</td>
+ </tr>
+ <tr>
+ <td><code>+Pirate</code></td>
+ <td>links to user &ldquo;Pirate&rdquo;</td>
+ </tr>
+ <tr>
+ <td><code>~~new~~</code></td>
+ <td>adds strike-through</td>
+ </tr>
+ <tr>
+ <td><code>[spoiler]Lelouch survives[/spoiler]</td>
+ <td>marks text as spoiler and hides it</td>
+ </tr>
+ <tr>
+ <td><code>[sjis](´・ω・`)[/sjis]</td>
+ <td>adds SJIS art</td>
+ </tr>
+ </tbody>
+</table>
diff --git a/client/html/help-keyboard.hbs b/client/html/help-keyboard.hbs
new file mode 100644
index 0000000..8b04682
--- /dev/null
+++ b/client/html/help-keyboard.hbs
@@ -0,0 +1,42 @@
+<p>You can use your keyboard to navigate around the site. There are a few
+shortcuts:</p>
+
+<table>
+ <thead>
+ <tr>
+ <th>Hotkey</th>
+ <th>Description</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td><code>[Q]</code></td>
+ <td>Focus search field, if available</td>
+ </tr>
+
+ <tr>
+ <td><code>[A]</code> and <code>[D]</code></td>
+ <td>Go to newer/older page or post</td>
+ </tr>
+
+ <tr>
+ <td><code>[F]</code></td>
+ <td>Cycle post fit mode</td>
+ </tr>
+
+ <tr>
+ <td><code>[E]</code></td>
+ <td>Edit post</td>
+ </tr>
+
+ <tr>
+ <td><code>[P]</code></td>
+ <td>Focus first post in post list</td>
+ </tr>
+ </tbody>
+</table>
+
+<p>Additionally, each item in top navigation can be accessed using feature
+called &ldquo;access keys&rdquo;. Pressing underlined letter while holding
+Shfit or Alt+Shift (depending on your browser) will go to the desired page
+(most browsers) or focus the link (IE).</p>
diff --git a/client/html/help-search.hbs b/client/html/help-search.hbs
new file mode 100644
index 0000000..c340f3a
--- /dev/null
+++ b/client/html/help-search.hbs
@@ -0,0 +1,265 @@
+<table>
+ <thead>
+ <tr>
+ <th>Command</th>
+ <th>Description</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr>
+ <td><a href='/posts/query=Haruhi'><code>Haruhi</code></a></td>
+ <td>containing tag “Haruhi”</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=-Kyon'><code>-Kyon</code></a></td>
+ <td>not containing tag “Kyon”</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=uploader:David'><code>uploader:David</code></a></td>
+ <td>uploaded by user David</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=comment:David'><code>comment:David</code></a></td>
+ <td>commented by David</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=fav:David'><code>fav:David</code></a></td>
+ <td>favorited by David</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=fav_count:4'><code>fav_count:4</code></a></td>
+ <td>favorited by exactly four users</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=fav_count:4,5'><code>fav_count:4,5</code></a></td>
+ <td>favorited by four or five users</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=fav_count:4..'><code>fav_count:4..</code></a></td>
+ <td>favorited by at least four users</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=fav_count:..4'><code>fav_count:..4</code></a></td>
+ <td>favorited by at most four users</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=fav_count:4..6'><code>fav_count:4..6</code></a></td>
+ <td>favorited by at least four, but no more than six users</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=comment_count:3'><code>comment_count:3</code></a></td>
+ <td>having exactly three comments</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=score:4'><code>score:4</code></a></td>
+ <td>having score of 4</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=tag_count:7'><code>tag_count:7</code></a></td>
+ <td>tagged with exactly seven tags</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=note_count:1..'><code>note_count:1..</code></a></td>
+ <td>having at least one post note</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=feature_count:1..'><code>feature_count:1..</code></a></td>
+ <td>having been featured at least once</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=date:today'><code>date:today</code></a></td>
+ <td>posted today</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=date:yesterday'><code>date:yesterday</code></a></td>
+ <td>posted yesterday</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=date:2000'><code>date:2000</code></a></td>
+ <td>posted in year 2000</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=date:2000-01'><code>date:2000-01</code></a></td>
+ <td>posted in January, 2000</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=date:2000-01-01'><code>date:2000-01-01</code></a></td>
+ <td>posted on January 1st, 2000</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=id:1'><code>id:1</code></a></td>
+ <td>having specific post ID</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=name:hash'><code>name:<em>hash</em></code></a></td>
+ <td>having specific post name (hash in full URLs)</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=file_size:100..'><code>file_size:100..</code></a></td>
+ <td>having at least 100 bytes</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=image_width:100..'><code>image_width:100..</code></a></td>
+ <td>being at least 100 pixels wide</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=image_height:100..'><code>image_height:100..</code></a></td>
+ <td>being at least 100 pixels tall</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=image_area:10000..'><code>image_area:10000..</code></a></td>
+ <td>having at least 10000 pixels</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=type:image'><code>type:image</code></a></td>
+ <td>only image posts</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=type:flash'><code>type:flash</code></a></td>
+ <td>only Flash posts</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=type:youtube'><code>type:youtube</code></a></td>
+ <td>only Youtube posts</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=type:video'><code>type:video</code></a></td>
+ <td>only video posts</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=special:liked'><code>special:liked</code></a></td>
+ <td>posts liked by currently logged in user</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=special:disliked'><code>special:disliked</code></a></td>
+ <td>posts disliked by currently logged in user</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=special:fav'><code>special:fav</code></a></td>
+ <td>posts added to favorites by currently logged in user</td>
+ </tr>
+ <tr>
+ <td><a href='/posts/query=special:tumbleweed'><code>special:tumbleweed</code></a></td>
+ <td>posts with score of 0, without comments and without favorites</td>
+ </tr>
+ </tbody>
+</table>
+
+<p>Most of the commands support ranged and composites values, e.g.
+<code>id:<em>number</em></code> operator supports respectively
+<a href='/posts/query=id:5..7'><code>id:5..7</code></a> and
+<a href='/posts/query=id:5,10,15'><code>id:5,10,15</code></a>.
+You can combine tags and negate any of them for interesting results.
+<a href='/posts/query=sea -fav_count:..8 type:flash uploader:Pirate'><code>sea -fav_count:8.. type:swf uploader:Pirate</code></a>
+will show you flash files tagged as sea, that were liked by seven people at
+most, uploaded by user Pirate.</p>
+
+<p>All of the above can be sorted using additional tag in form of
+<code>order:<em>keyword</em></code>:</p>
+
+<table>
+ <thead>
+ <tr>
+ <th>Command</th>
+ <th>Description</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr>
+ <td><a href='/posts/query=order:random'><code>order:random</code></a></td>
+ <td>as random as it can get</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:id'><code>order:id</code></a></td>
+ <td>highest to lowest post ID (default browse view)</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:creation_date'><code>order:creation_date</code></a></td>
+ <td>newest to oldest (pretty much same as above)</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=-order:creation_date'><code>-order:creation_date</code></a></td>
+ <td>oldest to newest</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:creation_date,asc'><code>order:creation_date,asc</code></a></td>
+ <td>oldest to newest (ascending order, default = descending)</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:edit_date'><code>order:edit_date</code></a></td>
+ <td>like <code>creation_date</code>, only looks at last edit time</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:score'><code>order:score</code></a></td>
+ <td>highest scored</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:file_size'><code>order:file_size</code></a></td>
+ <td>largest files first</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:image_width'><code>order:image_width</code></a></td>
+ <td>widest images first</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:image_height'><code>order:image_height</code></a></td>
+ <td>tallest images first</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:image_area'><code>order:image_area</code></a></td>
+ <td>largest images first</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:tag_count'><code>order:tag_count</code></a></td>
+ <td>with most tags</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:fav_count'><code>order:fav_count</code></a></td>
+ <td>loved by most</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:comment_count'><code>order:comment_count</code></a></td>
+ <td>most commented first</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:fav_date'><code>order:fav_date</code></a></td>
+ <td>recently added to favorites</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:comment_date'><code>order:comment_date</code></a></td>
+ <td>recently commented</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:feature_date'><code>order:feature_date</code></a></td>
+ <td>recently featured</td>
+ </tr>
+
+ <tr>
+ <td><a href='/posts/query=order:feature_count'><code>order:feature_count</code></a></td>
+ <td>most often featured</td>
+ </tr>
+ </tbody>
+</table>
+
+<p>As shown with <a
+href='/posts/query=-order:creation_date'><code>-order:creation_date</code></a>,
+any of them can be reversed in the same way as negating other tags: by placing
+a dash before the tag.</p>
diff --git a/client/html/help-tos.hbs b/client/html/help-tos.hbs
new file mode 100644
index 0000000..2b35f8f
--- /dev/null
+++ b/client/html/help-tos.hbs
@@ -0,0 +1,51 @@
+<p>By accessing {{ name }} (&ldquo;Site&rdquo;) you agree to the following
+Terms of Service. If you do not agree to these terms, then please do not access
+the Site.</p>
+
+<ul>
+ <li>The Site is presented to you AS IS, without any warranty, express or
+ implied. You will not hold the Site or its staff members liable for damages
+ caused by the use of the site.</li>
+ <li>The Site reserves the right to delete or modify your account, or any
+ content you have posted to the site.</li>
+ <li>The Site reserves the right to change these Terms of Service without
+ prior notice.</li>
+ <li>If you are a minor, then you will not use the Site.</li>
+ <li>You are using the Site only for personal use.</li>
+ <li>You will not spam, troll or offend anyone.</li>
+ <li>You accept that the Site is not liable for any content that you may
+ stumble upon.</li>
+</ul>
+
+<p class='section' id='section-prohibited-content'><strong>Prohibited content</strong></p>
+
+<ul>
+ <li>Child pornography: any photograph or photorealistic drawing or movie
+ that depicts children in a sexual manner. This includes nudity, explicit
+ sex, implied sex, or sexually persuasive positions.</li>
+
+ <li>Bestiality: any photograph or photorealistic drawing or movie that
+ depicts humans having sex (either explicit or implied) with other non-human
+ animals.</li>
+
+ <li>Any depiction of extreme mutilation, extreme bodily distension,
+ feces.</li>
+
+ <li>Personal images: any image that is suspected to be uploaded for
+ personal use. This includes, but is not limited to, avatars and forum
+ signatures.</li>
+</ul>
+
+<p class='section' id='section-privacy-policy'><strong>Privacy policy</strong></p>
+
+<p>The Site will not disclose the IP address or email address of any user
+except to the staff.</p>
+
+<p>Posts, comments, favorites, ratings and other actions linked to your account
+will be stored in the Site&rsquo;s database. The &ldquo;Upload
+anonymously&rdquo; option allows you to post content without linking it to your
+account&nbsp;&ndash; meaning your nickname will not be stored in the database
+nor shown in the &ldquo;Uploader&rdquo; field.</p>
+
+<p>Cookies are used to store your session data in order to keep you logged in
+and personalize your web experience.</p>
diff --git a/client/html/help.hbs b/client/html/help.hbs
new file mode 100644
index 0000000..5a2fccd
--- /dev/null
+++ b/client/html/help.hbs
@@ -0,0 +1,13 @@
+<div class='content-wrapper' id='help'>
+ <nav class='text-nav'><!--
+ --><ul><!--
+ --><li data-name='about'><a href='/help/about'>About</a></li><!--
+ --><li data-name='keyboard'><a href='/help/keyboard'>Keyboard</a></li><!--
+ --><li data-name='search'><a href='/help/search'>Search syntax</a></li><!--
+ --><li data-name='comments'><a href='/help/comments'>Comments</a></li><!--
+ --><li data-name='tos'><a href='/help/tos'>Terms of service</a></li><!--
+ --></ul><!--
+ --></nav>
+
+ <div class='content'>{{{ this.content }}}</div>
+</div>
diff --git a/client/html/home.hbs b/client/html/home.hbs
new file mode 100644
index 0000000..fc476b5
--- /dev/null
+++ b/client/html/home.hbs
@@ -0,0 +1,5 @@
+<div class='content-wrapper transparent' id='home'>
+ <div class='messages'></div>
+ <h1>{{name}}</h1>
+ <footer>Version: <span class='version'>{{version}}</span> (built {{buildDate}})</footer>
+</div>
diff --git a/client/html/index.htm b/client/html/index.htm
new file mode 100644
index 0000000..2dbceab
--- /dev/null
+++ b/client/html/index.htm
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset='utf-8'/>
+ <title><!-- change via config.ini --></title>
+ <link href='/bundle.min.css' rel='stylesheet' type='text/css'/>
+ <link href='//maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css' rel='stylesheet' type='text/css'/>
+ <link href='//fonts.googleapis.com/css?family=Inconsolata|Droid+Sans' rel='stylesheet' type='text/css'/>
+ <link rel='shortcut icon' type='image/png' href='/favicon.png'/>
+</head>
+<body>
+ <div id='top-nav-holder'></div>
+ <div id='content-holder'></div>
+ <script type='text/javascript' src='/bundle.min.js'></script>
+</body>
+</html>
diff --git a/client/html/login.hbs b/client/html/login.hbs
new file mode 100644
index 0000000..651ac92
--- /dev/null
+++ b/client/html/login.hbs
@@ -0,0 +1,26 @@
+<div class='content-wrapper' id='login'>
+ <h1>Log in</h1>
+ <form>
+ <fieldset class='input'>
+ <ul>
+ <li>
+ <label for='user-name'>User name</label>
+ <input id='user-name' name='name' type='text' required/>
+ </li>
+ <li>
+ <label for='user-password'>Password</label>
+ <input id='user-password' name='password' type='password' required/>
+ </li>
+ <li>
+ <input id='remember-user' name='remember-user' type='checkbox'/>
+ <label for='remember-user' class='checkbox'>Remember me</label>
+ </li>
+ </ul>
+ </fieldset>
+ <fieldset class='messages'></fieldset>
+ <fieldset class='buttons'>
+ <input type='submit' value='Log in'/>
+ <a>Forgot the password?</a>
+ </fieldset>
+ </form>
+</div>
diff --git a/client/html/top_nav.hbs b/client/html/top_nav.hbs
new file mode 100644
index 0000000..fb693c2
--- /dev/null
+++ b/client/html/top_nav.hbs
@@ -0,0 +1,11 @@
+<nav id='top-nav' class='text-nav'>
+ <ul><!--
+ -->{{#each items}}<!--
+ -->{{#if this.available}}<!--
+ --><li data-name='{{@key}}'><!--
+ --><a href='{{this.url}}' accesskey='{{this.accessKey}}'>{{this.name}}</a><!--
+ --></li><!--
+ -->{{/if}}<!--
+ -->{{/each}}<!--
+ --></ul>
+</nav>
diff --git a/client/html/user_registration.hbs b/client/html/user_registration.hbs
new file mode 100644
index 0000000..a17997f
--- /dev/null
+++ b/client/html/user_registration.hbs
@@ -0,0 +1,37 @@
+<div class='content-wrapper' id='user-registration'>
+ <h1>Registration</h1>
+ <form autocomplete='off'>
+ <fieldset class='input'>
+ <ul>
+ <li>
+ <label for='user-name'>User name</label>
+ <input id='user-name' name='user-name' type='text' autocomplete='off' placeholder='letters, digits, _, -' required/>
+ </li>
+ <li>
+ <label for='user-password'>Password</label>
+ <input id='user-password' name='user-password' type='password' autocomplete='off' placeholder='5+ characters' required/>
+ </li>
+ <li>
+ <label for='user-email'>Email</label>
+ <input id='user-email' name='user-email' type='email' autocomplete='off' placeholder='optional'/>
+ <p class='hint'>Used for password reminder and to show a <a href='http://gravatar.com/'>Gravatar</a>. Leave blank for random Gravatar.</p>
+ </li>
+ </ul>
+ </fieldset>
+ <fieldset class='messages'></fieldset>
+ <fieldset class='buttons'>
+ <input type='submit' value='Create an account'/>
+ </fieldset>
+ </form>
+ <div class='info'>
+ <p>Registered users can:</p>
+ <ul>
+ <li><i class='fa fa-upload'></i> upload new posts</li>
+ <li><i class='fa fa-heart'></i> mark them as favorite</li>
+ <li><i class='fa fa-commenting-o'></i> add comments</li>
+ <li><i class='fa fa-star-half-o'></i> vote up/down on posts and comments</li>
+ </ul>
+ <hr/>
+ <p>By creating an account, you are agreeing to the <a href='/help/tos'>Terms of Service</a>.</p>
+ </div>
+</div>
diff --git a/client/img/favicon.png b/client/img/favicon.png
new file mode 100644
index 0000000..22ce392
--- /dev/null
+++ b/client/img/favicon.png
Binary files differ
diff --git a/client/js/.gitignore b/client/js/.gitignore
new file mode 100644
index 0000000..16e3bbe
--- /dev/null
+++ b/client/js/.gitignore
@@ -0,0 +1 @@
+.config.autogen.json
diff --git a/client/js/api.js b/client/js/api.js
new file mode 100644
index 0000000..b950b68
--- /dev/null
+++ b/client/js/api.js
@@ -0,0 +1,96 @@
+'use strict';
+
+const request = require('superagent');
+const config = require('./config.js');
+const EventListener = require('./event_listener.js');
+
+class Api {
+ constructor() {
+ this.user = null;
+ this.userName = null;
+ this.userPassword = null;
+ this.authenticated = new EventListener();
+ }
+
+ get(url) {
+ const fullUrl = this.getFullUrl(url);
+ return this._process(fullUrl, () => request.get(fullUrl));
+ }
+
+ post(url, data) {
+ const fullUrl = this.getFullUrl(url);
+ return this._process(fullUrl, () => request.post(fullUrl).send(data));
+ }
+
+ _process(url, requestFactory) {
+ return new Promise((resolve, reject) => {
+ let req = requestFactory();
+ if (this.userName && this.userPassword) {
+ req.auth(this.userName, this.userPassword);
+ }
+ req.set('Accept', 'application/json')
+ .end((error, response) => {
+ if (error) {
+ reject(response.body);
+ } else {
+ resolve(response.body);
+ }
+ });
+ });
+ }
+
+ hasPrivilege(lookup) {
+ let minViableRank = null;
+ for (let privilege of Object.keys(config.privileges)) {
+ if (!privilege.startsWith(lookup)) {
+ continue;
+ }
+ const rankName = config.privileges[privilege];
+ const rankIndex = config.service.userRanks.indexOf(rankName);
+ if (minViableRank === null || rankIndex < minViableRank) {
+ minViableRank = rankIndex;
+ }
+ }
+ if (minViableRank === null) {
+ console.error('Bad privilege name: ' + lookup);
+ }
+ let myRank = this.user !== null ?
+ config.service.userRanks.indexOf(this.user.accessRank) :
+ 0;
+ return myRank >= minViableRank;
+ }
+
+ login(userName, userPassword) {
+ return new Promise((resolve, reject) => {
+ this.userName = userName;
+ this.userPassword = userPassword;
+ this.get('/user/' + userName)
+ .then(response => {
+ this.user = response.user;
+ resolve();
+ this.authenticated.fire();
+ }).catch(response => {
+ reject(response.description);
+ this.logout();
+ this.authenticated.fire();
+ });
+ });
+ }
+
+ logout() {
+ this.user = null;
+ this.userName = null;
+ this.userPassword = null;
+ this.authenticated.fire();
+ }
+
+ isLoggedIn() {
+ return this.userName !== null;
+ }
+
+ getFullUrl(url) {
+ return (config.basic.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
+ }
+}
+
+module.exports = new Api();
diff --git a/client/js/config.js b/client/js/config.js
new file mode 100644
index 0000000..4524be6
--- /dev/null
+++ b/client/js/config.js
@@ -0,0 +1,4 @@
+'use strict';
+
+const config = require('./.config.autogen.json');
+module.exports = config;
diff --git a/client/js/controllers/auth_controller.js b/client/js/controllers/auth_controller.js
new file mode 100644
index 0000000..39a0c43
--- /dev/null
+++ b/client/js/controllers/auth_controller.js
@@ -0,0 +1,56 @@
+'use strict';
+
+const cookies = require('js-cookie');
+const page = require('page');
+const api = require('../api.js');
+const topNavController = require('../controllers/top_nav_controller.js');
+const LoginView = require('../views/login_view.js');
+
+class AuthController {
+ constructor() {
+ this.loginView = new LoginView();
+
+ const auth = cookies.getJSON('auth');
+ if (auth && auth.user && auth.password) {
+ api.login(auth.user, auth.password).catch(errorMessage => {
+ cookies.remove('auth');
+ page('/');
+ this.loginView.notifyError(
+ 'An error happened while trying to log you in: ' +
+ errorMessage);
+ });
+ }
+ }
+
+ loginRoute() {
+ topNavController.activate('login');
+ this.loginView.render({
+ login: (name, password, doRemember) => {
+ return new Promise((resolve, reject) => {
+ api.login(name, password)
+ .then(() => {
+ const options = {};
+ if (doRemember) {
+ options.expires = 365;
+ }
+ cookies.set(
+ 'auth',
+ {'user': name, 'password': password},
+ options);
+ resolve();
+ page('/');
+ this.loginView.notifySuccess('Logged in');
+ }).catch(errorMessage => { reject(errorMessage); });
+ });
+ }});
+ }
+
+ logoutRoute() {
+ api.logout();
+ cookies.remove('auth');
+ page('/');
+ this.loginView.notifySuccess('Logged out');
+ }
+}
+
+module.exports = new AuthController();
diff --git a/client/js/controllers/comments_controller.js b/client/js/controllers/comments_controller.js
new file mode 100644
index 0000000..ff934df
--- /dev/null
+++ b/client/js/controllers/comments_controller.js
@@ -0,0 +1,11 @@
+'use strict';
+
+const topNavController = require('../controllers/top_nav_controller.js');
+
+class CommentsController {
+ listCommentsRoute() {
+ topNavController.activate('comments');
+ }
+}
+
+module.exports = new CommentsController();
diff --git a/client/js/controllers/help_controller.js b/client/js/controllers/help_controller.js
new file mode 100644
index 0000000..86788f6
--- /dev/null
+++ b/client/js/controllers/help_controller.js
@@ -0,0 +1,17 @@
+'use strict';
+
+const topNavController = require('../controllers/top_nav_controller.js');
+const HelpView = require('../views/help_view.js');
+
+class HelpController {
+ constructor() {
+ this.helpView = new HelpView();
+ }
+
+ showHelpRoute(section) {
+ topNavController.activate('help');
+ this.helpView.render(section);
+ }
+}
+
+module.exports = new HelpController();
diff --git a/client/js/controllers/history_controller.js b/client/js/controllers/history_controller.js
new file mode 100644
index 0000000..4ecad22
--- /dev/null
+++ b/client/js/controllers/history_controller.js
@@ -0,0 +1,11 @@
+'use strict';
+
+const topNavController = require('../controllers/top_nav_controller.js');
+
+class HistoryController {
+ listHistoryRoute() {
+ topNavController.activate('');
+ }
+}
+
+module.exports = new HistoryController();
diff --git a/client/js/controllers/home_controller.js b/client/js/controllers/home_controller.js
new file mode 100644
index 0000000..1eede35
--- /dev/null
+++ b/client/js/controllers/home_controller.js
@@ -0,0 +1,21 @@
+'use strict';
+
+const topNavController = require('../controllers/top_nav_controller.js');
+const HomeView = require('../views/home_view.js');
+
+class HomeController {
+ constructor() {
+ this.homeView = new HomeView();
+ }
+
+ indexRoute() {
+ topNavController.activate('home');
+ this.homeView.render();
+ }
+
+ notFoundRoute() {
+ topNavController.activate('');
+ }
+}
+
+module.exports = new HomeController();
diff --git a/client/js/controllers/posts_controller.js b/client/js/controllers/posts_controller.js
new file mode 100644
index 0000000..08cd2d2
--- /dev/null
+++ b/client/js/controllers/posts_controller.js
@@ -0,0 +1,23 @@
+'use strict';
+
+const topNavController = require('../controllers/top_nav_controller.js');
+
+class PostsController {
+ uploadPostsRoute() {
+ topNavController.activate('upload');
+ }
+
+ listPostsRoute() {
+ topNavController.activate('posts');
+ }
+
+ showPostRoute(id) {
+ topNavController.activate('posts');
+ }
+
+ editPostRoute(id) {
+ topNavController.activate('posts');
+ }
+}
+
+module.exports = new PostsController();
diff --git a/client/js/controllers/tags_controller.js b/client/js/controllers/tags_controller.js
new file mode 100644
index 0000000..f16123b
--- /dev/null
+++ b/client/js/controllers/tags_controller.js
@@ -0,0 +1,11 @@
+'use strict';
+
+const topNavController = require('../controllers/top_nav_controller.js');
+
+class TagsController {
+ listTagsRoute() {
+ topNavController.activate('tags');
+ }
+}
+
+module.exports = new TagsController();
diff --git a/client/js/controllers/top_nav_controller.js b/client/js/controllers/top_nav_controller.js
new file mode 100644
index 0000000..8fcafcf
--- /dev/null
+++ b/client/js/controllers/top_nav_controller.js
@@ -0,0 +1,80 @@
+'use strict';
+
+const api = require('../api.js');
+const TopNavView = require('../views/top_nav_view.js');
+
+class NavigationItem {
+ constructor(accessKey, name, url) {
+ this.accessKey = accessKey;
+ this.name = name;
+ this.url = url;
+ this.available = true;
+ }
+}
+
+class TopNavController {
+ constructor() {
+ this.topNavView = new TopNavView();
+ this.activeItem = null;
+
+ this.items = {
+ 'home': new NavigationItem('H', 'Home', '/'),
+ 'posts': new NavigationItem('P', 'Posts', '/posts'),
+ 'upload': new NavigationItem('U', 'Upload', '/upload'),
+ 'comments': new NavigationItem('C', 'Comments', '/comments'),
+ 'tags': new NavigationItem('T', 'Tags', '/tags'),
+ 'users': new NavigationItem('S', 'Users', '/users'),
+ 'account': new NavigationItem('A', 'Account', '/user/{me}'),
+ 'register': new NavigationItem('R', 'Register', '/register'),
+ 'login': new NavigationItem('L', 'Log in', '/login'),
+ 'logout': new NavigationItem('O', 'Logout', '/logout'),
+ 'help': new NavigationItem('E', 'Help', '/help'),
+ };
+
+ api.authenticated.listen(() => {
+ this.updateVisibility();
+ this.topNavView.render(this.items, this.activeItem);
+ this.topNavView.activate(this.activeItem);
+ });
+
+ this.updateVisibility();
+ this.topNavView.render(this.items, this.activeItem);
+ this.topNavView.activate(this.activeItem);
+ }
+
+ updateVisibility() {
+ const b = Object.keys(this.items);
+ for (let key of b) {
+ this.items[key].available = true;
+ }
+ if (!api.hasPrivilege('posts:list')) {
+ this.items.posts.available = false;
+ }
+ if (!api.hasPrivilege('posts:create')) {
+ this.items.upload.available = false;
+ }
+ if (!api.hasPrivilege('comments:list')) {
+ this.items.comments.available = false;
+ }
+ if (!api.hasPrivilege('tags:list')) {
+ this.items.tags.available = false;
+ }
+ if (!api.hasPrivilege('users:list')) {
+ this.items.users.available = false;
+ }
+ if (api.isLoggedIn()) {
+ this.items.register.available = false;
+ this.items.login.available = false;
+ } else {
+ this.items.account.available = false;
+ this.items.logout.available = false;
+ }
+ }
+
+ activate(itemName) {
+ this.activeItem = itemName;
+ this.topNavView.activate(this.activeItem);
+ }
+}
+
+module.exports = new TopNavController();
diff --git a/client/js/controllers/users_controller.js b/client/js/controllers/users_controller.js
new file mode 100644
index 0000000..5bd78f8
--- /dev/null
+++ b/client/js/controllers/users_controller.js
@@ -0,0 +1,61 @@
+'use strict';
+
+const cookies = require('js-cookie');
+const page = require('page');
+const api = require('../api.js');
+const topNavController = require('../controllers/top_nav_controller.js');
+const RegistrationView = require('../views/registration_view.js');
+
+class UsersController {
+ constructor() {
+ this.registrationView = new RegistrationView();
+ }
+
+ listUsersRoute() {
+ topNavController.activate('users');
+ }
+
+ createUserRoute() {
+ topNavController.activate('register');
+ this.registrationView.render({register: (...args) => {
+ return this._register(...args);
+ }});
+ }
+
+ _register(name, password, email) {
+ const data = {
+ 'name': name,
+ 'password': password,
+ 'email': email
+ };
+ // TODO: reduce callback hell
+ return new Promise((resolve, reject) => {
+ api.post('/users/', data).then(() => {
+ api.login(name, password).then(() => {
+ cookies.set('auth', {'user': name, 'password': password});
+ resolve();
+ page('/');
+ this.registrationView.notifySuccess('Welcome aboard!');
+ }).catch(response => {
+ reject(response.description);
+ });
+ }).catch(response => {
+ reject(response.description);
+ });
+ });
+ }
+
+ showUserRoute(user) {
+ if (api.isLoggedIn() && user == api.userName) {
+ topNavController.activate('account');
+ } else {
+ topNavController.activate('users');
+ }
+ }
+
+ editUserRoute(user) {
+ topNavController.activate('users');
+ }
+}
+
+module.exports = new UsersController();
diff --git a/client/js/event_listener.js b/client/js/event_listener.js
new file mode 100644
index 0000000..e7eebff
--- /dev/null
+++ b/client/js/event_listener.js
@@ -0,0 +1,24 @@
+class EventListener {
+ constructor() {
+ this.listeners = [];
+ }
+
+ listen(callback) {
+ this.listeners.push(callback);
+ }
+
+ unlisten(callback) {
+ const index = this.listeners.indexOf(callback);
+ if (index !== -1) {
+ this.listeners.splice(index, 1);
+ }
+ }
+
+ fire(data) {
+ for (let listener of this.listeners) {
+ listener(data);
+ }
+ }
+}
+
+module.exports = EventListener;
diff --git a/client/js/main.js b/client/js/main.js
new file mode 100644
index 0000000..c031edf
--- /dev/null
+++ b/client/js/main.js
@@ -0,0 +1,57 @@
+'use strict';
+
+// ----------------------
+// - import controllers -
+// ----------------------
+const homeController = require('./controllers/home_controller.js');
+const postsController = require('./controllers/posts_controller.js');
+const usersController = require('./controllers/users_controller.js');
+const helpController = require('./controllers/help_controller.js');
+const authController = require('./controllers/auth_controller.js');
+const commentsController = require('./controllers/comments_controller.js');
+const historyController = require('./controllers/history_controller.js');
+const tagsController = require('./controllers/tags_controller.js');
+
+// -----------------
+// - setup routing -
+// -----------------
+const page = require('page');
+
+page('/', () => { homeController.indexRoute(); });
+
+page('/upload', () => { postsController.uploadPostsRoute(); });
+page('/posts', () => { postsController.listPostsRoute(); });
+page('/post/:id', id => { postsController.showPostRoute(id); });
+page('/post/:id/edit', id => { postsController.editPostRoute(id); });
+
+page('/register', () => { usersController.createUserRoute(); });
+page('/users', () => { usersController.listUsersRoute(); });
+
+page(
+ '/user/:name',
+ (ctx, next) => {
+ usersController.showUserRoute(ctx.params.name);
+ });
+
+page(
+ '/user/:name/edit',
+ (ctx, next) => {
+ usersController.editUserRoute(ctx.params.name);
+ });
+
+page('/history', () => { historyController.showHistoryRoute(); });
+page('/tags', () => { tagsController.listTagsRoute(); });
+page('/comments', () => { commentsController.listCommentsRoute(); });
+page('/login', () => { authController.loginRoute(); });
+page('/logout', () => { authController.logoutRoute(); });
+
+page(
+ '/help/:section',
+ (ctx, next) => {
+ helpController.showHelpRoute(ctx.params.section);
+ });
+page('/help', () => { helpController.showHelpRoute(); });
+
+page('*', () => { homeController.notFoundRoute(); });
+
+page();
diff --git a/client/js/util.js b/client/js/util.js
new file mode 100644
index 0000000..0933410
--- /dev/null
+++ b/client/js/util.js
@@ -0,0 +1,44 @@
+'use strict';
+
+function formatRelativeTime(timeString) {
+ if (!timeString) {
+ return 'never';
+ }
+
+ const then = Date.parse(timeString);
+ const now = Date.now();
+ const difference = Math.abs(now - then) / 1000.0;
+ const future = now < then;
+
+ const descriptions = [
+ [60, 'a few seconds', null],
+ [60*2, 'a minute', null],
+ [60*60, '% minutes', 60],
+ [60*60*2, 'an hour', null],
+ [60*60*24, '% hours', 60*60],
+ [60*60*24*2, 'a day', null],
+ [60*60*24*30.42, '% days', 60*60*24],
+ [60*60*24*30.42*2, 'a month', null],
+ [60*60*24*30.42*12, '% months', 60*60*24*30.42],
+ [60*60*24*30.42*12*2, 'a year', null],
+ [8640000000000000/*max*/, '% years', 60*60*24*30.42*12],
+ ];
+
+ let text = null;
+ for (let kv of descriptions) {
+ const multiplier = kv[0];
+ const template = kv[1];
+ const divider = kv[2];
+ if (difference < multiplier) {
+ text = template.replace(/%/, Math.round(difference / divider));
+ break;
+ }
+ }
+
+ if (text === 'a day') {
+ return future ? 'tomorrow' : 'yesterday';
+ }
+ return future ? 'in ' + text : text + ' ago';
+}
+
+module.exports = {formatRelativeTime: formatRelativeTime};
diff --git a/client/js/views/base_view.js b/client/js/views/base_view.js
new file mode 100644
index 0000000..1a0f7d0
--- /dev/null
+++ b/client/js/views/base_view.js
@@ -0,0 +1,66 @@
+'use strict';
+
+const handlebars = require('handlebars');
+
+// fix iterating over NodeList in Chrome and Opera
+NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
+
+class BaseView {
+ constructor() {
+ this.contentHolder = document.getElementById('content-holder');
+ }
+
+ getTemplate(templatePath) {
+ const templateElement = document.getElementById(templatePath);
+ if (!templateElement) {
+ console.log('Missing template: ' + templatePath);
+ return null;
+ }
+ const templateText = templateElement.innerHTML;
+ return handlebars.compile(templateText);
+ }
+
+ notifyError(message) {
+ this.notify(message, 'error');
+ }
+
+ notifySuccess(message) {
+ this.notify(message, 'success');
+ }
+
+ notify(message, className) {
+ const messagesHolder = this.contentHolder.querySelector('.messages');
+ /* TODO: animate this */
+ const node = document.createElement('div');
+ node.innerHTML = message;
+ node.classList.add('message');
+ node.classList.add(className);
+ messagesHolder.appendChild(node);
+ }
+
+ clearMessages() {
+ const messagesHolder = this.contentHolder.querySelector('.messages');
+ /* TODO: animate that */
+ while (messagesHolder.lastChild) {
+ messagesHolder.removeChild(messagesHolder.lastChild);
+ }
+ }
+
+ decorateValidator(form) {
+ // postpone showing form fields validity until user actually tries
+ // to submit it (seeing red/green form w/o doing anything breaks POLA)
+ const submitButton = form.querySelector('.buttons input');
+ submitButton.addEventListener('click', e => {
+ form.classList.add('show-validation');
+ });
+ form.addEventListener('submit', e => {
+ form.classList.remove('show-validation');
+ });
+ }
+
+ showView(html) {
+ this.contentHolder.innerHTML = html;
+ }
+}
+
+module.exports = BaseView;
diff --git a/client/js/views/help_view.js b/client/js/views/help_view.js
new file mode 100644
index 0000000..4f68a7d
--- /dev/null
+++ b/client/js/views/help_view.js
@@ -0,0 +1,44 @@
+'use strict';
+
+const config = require('../config.js');
+const BaseView = require('./base_view.js');
+
+class HelpView extends BaseView {
+ constructor() {
+ super();
+ this.template = this.getTemplate('help-template');
+ this.sectionTemplates = {};
+ const sectionKeys = ['about', 'keyboard', 'search', 'comments', 'tos'];
+ for (let section of sectionKeys) {
+ const templateName = 'help-' + section + '-template';
+ this.sectionTemplates[section] = this.getTemplate(templateName);
+ }
+ }
+
+ render(section) {
+ if (!section) {
+ section = 'about';
+ }
+ if (!(section in this.sectionTemplates)) {
+ this.showView('');
+ return;
+ }
+
+ const content = this.sectionTemplates[section]({
+ 'name': config.basic.name,
+ });
+
+ this.showView(this.template({'content': content}));
+
+ const allItemsSelector = '#content-holder [data-name]';
+ for (let item of document.querySelectorAll(allItemsSelector)) {
+ if (item.getAttribute('data-name') === section) {
+ item.className = 'active';
+ } else {
+ item.className = '';
+ }
+ }
+ }
+}
+
+module.exports = HelpView;
diff --git a/client/js/views/home_view.js b/client/js/views/home_view.js
new file mode 100644
index 0000000..4beca60
--- /dev/null
+++ b/client/js/views/home_view.js
@@ -0,0 +1,22 @@
+'use strict';
+
+const util = require('../util.js');
+const config = require('../config.js');
+const BaseView = require('./base_view.js');
+
+class HomeView extends BaseView {
+ constructor() {
+ super();
+ this.template = this.getTemplate('home-template');
+ }
+
+ render(section) {
+ this.showView(this.template({
+ name: config.basic.name,
+ version: config.meta.version,
+ buildDate: util.formatRelativeTime(config.meta.buildDate),
+ }));
+ }
+}
+
+module.exports = HomeView;
diff --git a/client/js/views/login_view.js b/client/js/views/login_view.js
new file mode 100644
index 0000000..6ea29fe
--- /dev/null
+++ b/client/js/views/login_view.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const config = require('../config.js');
+const BaseView = require('./base_view.js');
+
+class LoginView extends BaseView {
+ constructor() {
+ super();
+ this.template = this.getTemplate('login-template');
+ }
+
+ render(options) {
+ this.showView(this.template());
+ const form = this.contentHolder.querySelector('form');
+ this.decorateValidator(form);
+
+ const userNameField = document.getElementById('user-name');
+ const passwordField = document.getElementById('user-password');
+ const rememberUserField = document.getElementById('remember-user');
+ userNameField.setAttribute('pattern', config.service.userNameRegex);
+ passwordField.setAttribute('pattern', config.service.passwordRegex);
+
+ form.addEventListener('submit', e => {
+ e.preventDefault();
+ this.clearMessages();
+ form.setAttribute('disabled', true);
+ options
+ .login(
+ userNameField.value,
+ passwordField.value,
+ rememberUserField.checked)
+ .then(() => {
+ form.setAttribute('disabled', false);
+ })
+ .catch(errorMessage => {
+ form.setAttribute('disabled', false);
+ this.notifyError(errorMessage);
+ });
+ });
+ }
+}
+
+module.exports = LoginView;
diff --git a/client/js/views/registration_view.js b/client/js/views/registration_view.js
new file mode 100644
index 0000000..ad08e60
--- /dev/null
+++ b/client/js/views/registration_view.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const config = require('../config.js');
+const BaseView = require('./base_view.js');
+
+class RegistrationView extends BaseView {
+ constructor() {
+ super();
+ this.template = this.getTemplate('user-registration-template');
+ }
+
+ render(options) {
+ this.showView(this.template());
+ const form = document.querySelector('#content-holder form');
+ this.decorateValidator(form);
+
+ const userNameField = document.getElementById('user-name');
+ const passwordField = document.getElementById('user-password');
+ const emailField = document.getElementById('user-email');
+ userNameField.setAttribute('pattern', config.service.userNameRegex);
+ passwordField.setAttribute('pattern', config.service.passwordRegex);
+
+ form.addEventListener('submit', e => {
+ e.preventDefault();
+ this.clearMessages();
+ form.setAttribute('disabled', true);
+ options
+ .register(
+ userNameField.value,
+ passwordField.value,
+ emailField.value)
+ .then(() => {
+ form.setAttribute('disabled', false);
+ })
+ .catch(errorMessage => {
+ form.setAttribute('disabled', false);
+ this.notifyError(errorMessage);
+ });
+ });
+ }
+}
+
+module.exports = RegistrationView;
diff --git a/client/js/views/top_nav_view.js b/client/js/views/top_nav_view.js
new file mode 100644
index 0000000..6864c63
--- /dev/null
+++ b/client/js/views/top_nav_view.js
@@ -0,0 +1,37 @@
+'use strict';
+
+const BaseView = require('./base_view.js');
+
+class TopNavView extends BaseView {
+ constructor() {
+ super();
+ this.template = this.getTemplate('top-nav-template');
+ this.navHolder = document.getElementById('top-nav-holder');
+ }
+
+ render(items) {
+ this.navHolder.innerHTML = this.template({items: items});
+ for (let link of this.navHolder.querySelectorAll('a')) {
+ const regex = new RegExp(
+ '(' + link.getAttribute('accesskey') + ')', 'i');
+ link.innerHTML = link.textContent.replace(
+ regex,
+ '<span class="access-key" data-accesskey="$1">$1</span>');
+ }
+ }
+
+ activate(itemName) {
+ const allItemsSelector = '#top-nav-holder [data-name]';
+ const currentItemSelector =
+ '#top-nav-holder [data-name="' + itemName + '"]';
+ for (let item of document.querySelectorAll(allItemsSelector)) {
+ item.className = '';
+ }
+ const currentItem = document.querySelectorAll(currentItemSelector);
+ if (currentItem.length > 0) {
+ currentItem[0].className = 'active';
+ }
+ }
+}
+
+module.exports = TopNavView;
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..da59222
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "szurubooru",
+ "private": true,
+ "scripts": {
+ "build": "node build.js",
+ "watch": "watch 'npm run build' html js css img --wait=0 --ignoreDotFiles"
+ },
+ "dependencies": {
+ "browserify": "^13.0.0",
+ "camelcase-keys": "^2.1.0",
+ "csso": "^1.8.0",
+ "glob": "^7.0.3",
+ "handlebars": "^4.0.5",
+ "html-minifier": "^1.3.1",
+ "ini": "^1.3.4",
+ "js-cookie": "^2.1.0",
+ "merge": "^1.2.0",
+ "page": "^1.7.1",
+ "superagent": "^1.8.3",
+ "uglify-js": "git://github.com/mishoo/UglifyJS2.git#harmony"
+ },
+ "devDependencies": {
+ "watch": "latest"
+ }
+}
diff --git a/client/public/.gitignore b/client/public/.gitignore
new file mode 100644
index 0000000..67c04e4
--- /dev/null
+++ b/client/public/.gitignore
@@ -0,0 +1,2 @@
+*.*
+!.gitignore