diff --git a/client/css/main.styl b/client/css/main.styl index cbfda7f..07aa31a 100644 --- a/client/css/main.styl +++ b/client/css/main.styl @@ -46,6 +46,9 @@ a opacity: .5 &:focus outline: 2px solid $main-color + .vim-nav-hint + position: absolute + visibility: hidden a.append margin-left: 1em diff --git a/client/css/posts.styl b/client/css/posts.styl index 5146bac..56cea57 100644 --- a/client/css/posts.styl +++ b/client/css/posts.styl @@ -1,4 +1,98 @@ @import colors +$safety-safe = #88D488 +$safety-sketchy = #F3D75F +$safety-unsafe = #F3985F + +.post-view + width: 100% + display: flex !important + flex-direction: row + >.sidebar + margin-right: 1em + width: 20em + min-width: 15em + line-height: 160% + + a:active + border: 0 + outline: 0 + + nav.buttons + margin-top: 0 + display: flex + flex-wrap: wrap + article + flex: 1 0 33% + a + display: inline-block + width: 100% + padding: 0.3em 0 + text-align: center + vertical-align: middle + transition: background 0.2s linear + a:hover + background: lighten($main-color, 90%) + i + font-size: 140% + text-align: center + + .details + i + margin-right: 0.6em + display: inline-block + width: 1em + text-align: center + + .safety-safe + color: $safety-safe + .safety-sketchy + color: $safety-sketchy + .safety-unsafe + color: $safety-unsafe + + .upload-info + .thumbnail + width: 1em + height: 1em + margin: -0.1em 0.6em 0 0 + + .zoom + margin-top: 1em + a + display: inline-block + .active + text-decoration: underline + + .social + margin-top: 1em + .score + float: left + margin-right: 3em + .downvote i + text-align: right + i + text-align: left + margin: 0 + .value + text-align: center + display: inline-block + width: 2em + + .tags + margin-top: 2em + line-height: 130% + word-break: break-all + h1 + margin-bottom: 0.5em + i + padding-right: 0.4em + .count + color: $inactive-link-color + padding-left: 0.7em + font-size: 90% + + .post-container .post-content + margin: 0 .post-list ul @@ -82,17 +176,17 @@ .safety &.safety-safe - background-color: #88D488 + background-color: $safety-safe border-color: @background-color &.disabled background-color: alpha(@background-color, 0.15) &.safety-sketchy - background-color: #F3D75F + background-color: $safety-sketchy border-color: @background-color &.disabled background-color: alpha(@background-color, 0.15) &.safety-unsafe - background-color: #F3985F + background-color: $safety-unsafe border-color: @background-color &.disabled background-color: alpha(@background-color, 0.15) @@ -116,6 +210,9 @@ top: 0 bottom: 0 + .post-overlay + pointer-events: none + .post-overlay>* position: absolute left: 0 @@ -130,6 +227,7 @@ polygon fill: $note-overlay-background-color stroke: $note-overlay-border-color + pointer-events: auto .note-text position: absolute diff --git a/client/html/post.tpl b/client/html/post.tpl new file mode 100644 index 0000000..d767fa8 --- /dev/null +++ b/client/html/post.tpl @@ -0,0 +1,49 @@ +<div class='content-wrapper transparent post-view'> + <aside class='sidebar'> + <nav class='buttons'> + <article class='next-post'> + <% if (ctx.nextPostId) { %> + <a href='/post/<%= ctx.nextPostId %>'> + <% } else { %> + <a class='inactive'> + <% } %> + <i class='fa fa-chevron-left'></i> + <span class='vim-nav-hint'>Next post</span> + </a> + </article> + <article class='previous-post'> + <% if (ctx.prevPostId) { %> + <a href='/post/<%= ctx.prevPostId %>'> + <% } else { %> + <a class='inactive'> + <% } %> + <i class='fa fa-chevron-right'></i> + <span class='vim-nav-hint'>Previous post</span> + </a> + </article> + <article class='edit-post'> + <% if (ctx.editMode) { %> + <a href='/post/<%= ctx.post.id %>'> + <i class='fa fa-eye'></i> + <span class='vim-nav-hint'>Back to view mode</span> + </a> + <% } else { %> + <a href='/post/<%= ctx.post.id %>/edit'> + <i class='fa fa-pencil'></i> + <span class='vim-nav-hint'>Edit post</span> + </a> + <% } %> + </article> + </nav> + + <div class='sidebar-container'></div> + </aside> + + <div class='content'> + <div class='post-container'></div> + + <section class='comments'> + <!-- TODO: comments --> + </section> + </div> +</div> diff --git a/client/html/post_content.tpl b/client/html/post_content.tpl index 41be1bd..255f076 100644 --- a/client/html/post_content.tpl +++ b/client/html/post_content.tpl @@ -12,7 +12,7 @@ <% } else if (ctx.post.type === 'video') { %> - <% if (ctx.post.flags.includes('loop')) { %> + <% if ((ctx.post.flags || []).includes('loop')) { %> <video id='video' controls loop='loop'> <% } else { %> <video id='video' controls> diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl new file mode 100644 index 0000000..29a5d6a --- /dev/null +++ b/client/html/post_edit_sidebar.tpl @@ -0,0 +1 @@ +<h1>Editing</h1> diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl new file mode 100644 index 0000000..fabfedc --- /dev/null +++ b/client/html/post_readonly_sidebar.tpl @@ -0,0 +1,87 @@ +<article class='details'> + <section class='download'> + <a rel='external' href='<%= ctx.post.contentUrl %>'> + <i class='fa fa-download'></i><!-- + --><%= ctx.makeFileSize(ctx.post.fileSize) %> <!-- + --><%= { + 'image/gif': 'GIF', + 'image/jpeg': 'JPEG', + 'image/png': 'PNG', + 'video/webm': 'WEBM', + 'application/x-shockwave-flash': 'SWF', + }[ctx.post.mimeType] %> + </a> + (<%= ctx.post.canvasWidth %>x<%= ctx.post.canvasHeight %>) + </section> + + <section class='upload-info'> + <%= ctx.makeUserLink(ctx.post.user) %>, + <%= ctx.makeRelativeTime(ctx.post.creationTime) %> + </section> + + <section class='safety'> + <i class='fa fa-circle safety-<%= ctx.post.safety %>'></i><!-- + --><%= ctx.post.safety[0].toUpperCase() + ctx.post.safety.slice(1) %> + </section> + + <section class='zoom'> + <a class='fit-original' href='#'>Original zoom</a> · + <a class='fit-width' href='#'>fit width</a> · + <a class='fit-height' href='#'>height</a> · + <a class='fit-both' href='#'>both</a> + </section> + + <section class='search'> + Search on + <a href='http://iqdb.org/?url=<%= ctx.post.contentUrl %>'>IQDB</a> · + <a href='https://www.google.com/searchbyimage?&image_url=<%= ctx.post.contentUrl %>'>Google Images</a> + </section> + + <section class='social'> + <div class='score'> + <a class='upvote' href='#'> + <% if (ctx.post.ownScore == 1) { %> + <i class='fa fa-thumbs-up'></i> + <% } else { %> + <i class='fa fa-thumbs-o-up'></i> + <% } %> + <span class='hint'></span> + </a> + <span class='value'><%= ctx.post.score %></span> + <a class='downvote' href='#'> + <% if (ctx.post.ownScore == -1) { %> + <i class='fa fa-thumbs-down'></i> + <% } else { %> + <i class='fa fa-thumbs-o-down'></i> + <% } %> + <span class='hint'></span> + </a> + </div> + + <div class='fav'> + <% if (ctx.post.ownFavorite) { %> + <a class='remove-favorite' href='#'><i class='fa fa-heart'></i></a> + <% } else { %> + <a class='add-favorite' href='#'><i class='fa fa-heart-o'></i></a> + <% } %> + <span class='value'><%= ctx.post.favoriteCount %></span> + </div> + </section> +</article> + +<nav class='tags'> + <h1>Tags (<%= ctx.post.tags.length %>)</h1> + <ul><!-- + --><% for (let tag of ctx.post.tags) { %><!-- + --><li><!-- + --><a href='/tag/<%= tag %>' class='tag-<%= ctx.getTagCategory(tag) %>'><!-- + --><i class='fa fa-tag'></i><!-- + --></a><!-- + --><a href='/posts/text=<%= tag %>' class='tag-<%= ctx.getTagCategory(tag) %>'><!-- + --><%= tag %><!-- + --></a><!-- + --><span class='count'><%= ctx.getTagUsages(tag) %></span><!-- + --></li><!-- + --><% } %><!-- + --></ul> +</nav> diff --git a/client/js/controllers/posts_controller.js b/client/js/controllers/posts_controller.js index 32a0f05..e4da227 100644 --- a/client/js/controllers/posts_controller.js +++ b/client/js/controllers/posts_controller.js @@ -3,17 +3,20 @@ const page = require('page'); const api = require('../api.js'); const settings = require('../settings.js'); +const events = require('../events.js'); const misc = require('../util/misc.js'); const topNavController = require('../controllers/top_nav_controller.js'); const pageController = require('../controllers/page_controller.js'); const PostsHeaderView = require('../views/posts_header_view.js'); const PostsPageView = require('../views/posts_page_view.js'); +const PostView = require('../views/post_view.js'); const EmptyView = require('../views/empty_view.js'); class PostsController { constructor() { this._postsHeaderView = new PostsHeaderView(); this._postsPageView = new PostsPageView(); + this._postView = new PostView(); } registerRoutes() { @@ -23,10 +26,10 @@ class PostsController { (ctx, next) => { this._listPostsRoute(ctx); }); page( '/post/:id', - (ctx, next) => { this._showPostRoute(ctx.params.id); }); + (ctx, next) => { this._showPostRoute(ctx.params.id, false); }); page( '/post/:id/edit', - (ctx, next) => { this._editPostRoute(ctx.params.id); }); + (ctx, next) => { this._showPostRoute(ctx.params.id, true); }); this._emptyView = new EmptyView(); } @@ -41,18 +44,7 @@ class PostsController { pageController.run({ state: ctx.state, requestPage: page => { - const browsingSettings = settings.getSettings(); - let text = ctx.searchQuery.text; - let disabledSafety = []; - for (let key of Object.keys(browsingSettings.listPosts)) { - if (browsingSettings.listPosts[key] === false) { - disabledSafety.push(key); - } - } - if (disabledSafety.length) { - text = `-rating:${disabledSafety.join(',')} ${text}`; - } - text = text.trim(); + const text = this._decorateSearchQuery(ctx.searchQuery.text); return api.get( `/posts/?query=${text}&page=${page}&pageSize=40&_fields=` + `id,type,tags,score,favoriteCount,` + @@ -66,14 +58,38 @@ class PostsController { }); } - _showPostRoute(id) { + _showPostRoute(id, editMode) { topNavController.activate('posts'); - this._emptyView.render(); + Promise.all([ + api.get('/post/' + id), + api.get(`/post/${id}/around?_fields=id&query=` + + this._decorateSearchQuery('')), + ]).then(responses => { + const [postResponse, aroundResponse] = responses; + this._postView.render({ + post: postResponse, + editMode: editMode, + nextPostId: aroundResponse.next ? aroundResponse.next.id : null, + prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null, + }); + }, response => { + this._emptyView.render(); + events.notify(events.Error, response.description); + }); } - _editPostRoute(id) { - topNavController.activate('posts'); - this._emptyView.render(); + _decorateSearchQuery(text) { + const browsingSettings = settings.getSettings(); + let disabledSafety = []; + for (let key of Object.keys(browsingSettings.listPosts)) { + if (browsingSettings.listPosts[key] === false) { + disabledSafety.push(key); + } + } + if (disabledSafety.length) { + text = `-rating:${disabledSafety.join(',')} ${text}`; + } + return text.trim(); } } diff --git a/client/js/controls/post_content_control.js b/client/js/controls/post_content_control.js index ad4f018..db6cf2c 100644 --- a/client/js/controls/post_content_control.js +++ b/client/js/controls/post_content_control.js @@ -44,12 +44,25 @@ class PostContentControl { this._currentFitFunction = this.fitBoth; let mul = this._post.canvasHeight / this._post.canvasWidth; if (this._viewportWidth * mul < this._viewportHeight) { - this.fitWidth(); + let width = this._viewportWidth; + if (!settings.getSettings().upscaleSmallPosts) { + width = Math.min(this._post.canvasWidth, width); + } + this._resize(width, width * mul); } else { - this.fitHeight(); + let height = this._viewportHeight; + if (!settings.getSettings().upscaleSmallPosts) { + height = Math.min(this._post.canvasHeight, height); + } + this._resize(height / mul, height); } } + fitOriginal() { + this._currentFitFunction = this.fitOriginal; + this._resize(this._post.canvasWidth, this._post.canvasHeight); + } + get _viewportWidth() { return this._viewportSizeCalculator()[0]; } diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js new file mode 100644 index 0000000..9393c0b --- /dev/null +++ b/client/js/controls/post_edit_sidebar_control.js @@ -0,0 +1,23 @@ +'use strict'; + +const views = require('../util/views.js'); + +class PostEditSidebarControl { + constructor(hostNode, post, postContentControl) { + this._hostNode = hostNode; + this._post = post; + this._postContentControl = postContentControl; + this._template = views.getTemplate('post-edit-sidebar'); + + this.install(); + } + + install() { + const sourceNode = this._template({ + post: this._post, + }); + views.showView(this._hostNode, sourceNode); + } +}; + +module.exports = PostEditSidebarControl; diff --git a/client/js/controls/post_readonly_sidebar_control.js b/client/js/controls/post_readonly_sidebar_control.js new file mode 100644 index 0000000..ff49af2 --- /dev/null +++ b/client/js/controls/post_readonly_sidebar_control.js @@ -0,0 +1,148 @@ +'use strict'; + +const api = require('../api.js'); +const tags = require('../tags.js'); +const views = require('../util/views.js'); + +class PostReadonlySidebarControl { + constructor(hostNode, post, postContentControl) { + this._hostNode = hostNode; + this._post = post; + this._postContentControl = postContentControl; + this._template = views.getTemplate('post-readonly-sidebar'); + + this.install(); + } + + install() { + const sourceNode = this._template({ + post: this._post, + getTagCategory: this._getTagCategory, + getTagUsages: this._getTagUsages, + }); + const upvoteButton = sourceNode.querySelector('.upvote'); + const downvoteButton = sourceNode.querySelector('.downvote') + const addFavButton = sourceNode.querySelector('.add-favorite') + const remFavButton = sourceNode.querySelector('.remove-favorite'); + const fitBothButton = sourceNode.querySelector('.fit-both') + const fitOriginalButton = sourceNode.querySelector('.fit-original'); + const fitWidthButton = sourceNode.querySelector('.fit-width') + const fitHeightButton = sourceNode.querySelector('.fit-height'); + + upvoteButton.addEventListener( + 'click', this._eventRequestProxy( + () => this._setScore(this._post.ownScore === 1 ? 0 : 1))); + downvoteButton.addEventListener( + 'click', this._eventRequestProxy( + () => this._setScore(this._post.ownScore === -1 ? 0 : -1))); + + if (addFavButton) { + addFavButton.addEventListener( + 'click', this._eventRequestProxy( + () => this._addToFavorites())); + } + if (remFavButton) { + remFavButton.addEventListener( + 'click', this._eventRequestProxy( + () => this._removeFromFavorites())); + } + + fitBothButton.addEventListener( + 'click', this._eventZoomProxy( + () => this._postContentControl.fitBoth())); + fitOriginalButton.addEventListener( + 'click', this._eventZoomProxy( + () => this._postContentControl.fitOriginal())); + fitWidthButton.addEventListener( + 'click', this._eventZoomProxy( + () => this._postContentControl.fitWidth())); + fitHeightButton.addEventListener( + 'click', this._eventZoomProxy( + () => this._postContentControl.fitHeight())); + + views.showView(this._hostNode, sourceNode); + + this._syncFitButton(); + } + + _eventZoomProxy(func) { + return e => { + e.preventDefault(); + e.target.blur(); + func(); + this._syncFitButton(); + }; + } + + _eventRequestProxy(promise) { + return e => { + e.preventDefault(); + promise().then(() => { + this.install(); + }); + } + } + + _syncFitButton() { + const funcToClassName = {}; + funcToClassName[this._postContentControl.fitBoth] = 'fit-both'; + funcToClassName[this._postContentControl.fitOriginal] = 'fit-original'; + funcToClassName[this._postContentControl.fitWidth] = 'fit-width'; + funcToClassName[this._postContentControl.fitHeight] = 'fit-height'; + const className = funcToClassName[ + this._postContentControl._currentFitFunction]; + const oldNode = this._hostNode.querySelector('.zoom a.active'); + const newNode = this._hostNode.querySelector(`.zoom a.${className}`); + if (oldNode) { + oldNode.classList.remove('active'); + } + newNode.classList.add('active'); + } + + _getTagUsages(name) { + const tag = tags.getTagByName(name); + return tag ? tag.usages : 0; + } + + _getTagCategory(name) { + const tag = tags.getTagByName(name); + return tag ? tag.category : 'unknown'; + } + + _setScore(score) { + return this._requestAndRefresh( + () => api.put('/post/' + this._post.id + '/score', {score: score})); + } + + _addToFavorites() { + return this._requestAndRefresh( + () => api.post('/post/' + this._post.id + '/favorite')); + } + + _removeFromFavorites() { + return this._requestAndRefresh( + () => api.delete('/post/' + this._post.id + '/favorite')); + } + + _requestAndRefresh(requestPromise) { + return new Promise((resolve, reject) => { + requestPromise() + .then( + response => { return api.get('/post/' + this._post.id) }, + response => { + return Promise.reject(response.description); + }) + .then( + response => { + this._post = response; + resolve(); + }, + response => { + reject(); + events.notify(events.Error, errorMessage); + }); + }); + } +}; + +module.exports = PostReadonlySidebarControl; diff --git a/client/js/util/views.js b/client/js/util/views.js index 823edb3..0389616 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -166,7 +166,7 @@ function makeUserLink(user) { return makeNonVoidElement('span', {class: 'user'}, makeThumbnail(user.avatarUrl) + makeNonVoidElement( - 'a', {'href': '/user/' + user.name}, '+' + user.name)); + 'a', {'href': '/user/' + user.name}, user.name)); } function makeFlexboxAlign(options) { diff --git a/client/js/views/post_view.js b/client/js/views/post_view.js new file mode 100644 index 0000000..14e2459 --- /dev/null +++ b/client/js/views/post_view.js @@ -0,0 +1,67 @@ +'use strict'; + +const views = require('../util/views.js'); +const PostContentControl = require('../controls/post_content_control.js'); +const PostNotesOverlayControl + = require('../controls/post_notes_overlay_control.js'); +const PostReadonlySidebarControl + = require('../controls/post_readonly_sidebar_control.js'); +const PostEditSidebarControl + = require('../controls/post_edit_sidebar_control.js'); + +class PostView { + constructor() { + this._template = views.getTemplate('post'); + } + + render(ctx) { + const target = document.getElementById('content-holder'); + const source = this._template(ctx); + + const postContainerNode = source.querySelector('.post-container'); + const sidebarNode = source.querySelector('.sidebar'); + + views.listenToMessages(source); + views.showView(target, source); + + const topNavNode = document.body.querySelector('#top-nav'); + const postViewNode = document.body.querySelector('.content-wrapper'); + + const margin = ( + postViewNode.getBoundingClientRect().top + - topNavNode.getBoundingClientRect().height); + + this._postContentControl = new PostContentControl( + postContainerNode, + ctx.post, + () => { + return [ + window.innerWidth + - postContainerNode.getBoundingClientRect().left + - margin, + window.innerHeight + - topNavNode.getBoundingClientRect().height + - margin * 2, + ]; + }); + + new PostNotesOverlayControl( + postContainerNode.querySelector('.post-overlay'), + ctx.post); + + if (ctx.editMode) { + new PostEditSidebarControl( + postViewNode.querySelector('.sidebar-container'), + ctx.post, + this._postContentControl); + } else { + new PostReadonlySidebarControl( + postViewNode.querySelector('.sidebar-container'), + ctx.post, + this._postContentControl); + } + } + +} + +module.exports = PostView;