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> &middot;
+        <a class='fit-width' href='#'>fit width</a> &middot;
+        <a class='fit-height' href='#'>height</a> &middot;
+        <a class='fit-both' href='#'>both</a>
+    </section>
+
+    <section class='search'>
+        Search on
+        <a href='http://iqdb.org/?url=<%= ctx.post.contentUrl %>'>IQDB</a> &middot;
+        <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;