diff options
| author | rr- <rr-@sakuya.pl> | 2016-04-01 18:45:25 +0200 |
|---|---|---|
| committer | rr- <rr-@sakuya.pl> | 2016-04-01 18:48:16 +0200 |
| commit | e487adcc97b8d2bd111a34b7d3c72d66001deb8b (patch) | |
| tree | d684abf99bf176ead0a785bc237ae3b6a81417c1 /client | |
| parent | 1ad71585c4e95555566e8af068d5addf75b33274 (diff) | |
split files into client/ and server/
Diffstat (limited to 'client')
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… we won’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 “Dragon_Ball”</td> + </tr> + <tr> + <td><code>+Pirate</code></td> + <td>links to user “Pirate”</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 “access keys”. 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 }} (“Site”) 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’s database. The “Upload +anonymously” option allows you to post content without linking it to your +account – meaning your nickname will not be stored in the database +nor shown in the “Uploader” 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 Binary files differnew file mode 100644 index 0000000..22ce392 --- /dev/null +++ b/client/img/favicon.png 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 |