~sirn/fanboi2

9cd49cc41534a4b8a7782984f028577279ef5429 — Kridsada Thanabulpong 5 years ago d914d8a
Implement theme selector.

User can now choose the default theme to display the website in.

The theme will be applied immediately without page reload once
selected in the theme selector. After selected, the theme identifier
will be stored in a cookie (`_theme`) and the app will use that cookie
to select the correct CSS class to render on the next page load.

Theme selection is done in both client side (via the theme selector)
and on the server side (via the `user_theme`) in order to prevent a
quick page flashing in large pages (e.g. full topic view) which is
caused by JavaScript components waiting for DOM to finished loading
before running.
M assets/app/javascripts/app.ts => assets/app/javascripts/app.ts +2 -0
@@ 1,9 1,11 @@
import domready = require('domready');
import {BoardSelector} from './components/board_selector';
import {ThemeSelector} from './components/theme_selector';
import {AnchorPopover} from './components/anchor_popover';


domready(function(): void {
    new BoardSelector('[data-board-selector]');
    new ThemeSelector('[data-theme-selector]');
    new AnchorPopover('[data-anchor]');
});

A assets/app/javascripts/components/theme_selector.ts => assets/app/javascripts/components/theme_selector.ts +98 -0
@@ 0,0 1,98 @@
import Cookies = require('js-cookie');
import {VNode, create, diff, patch, h} from 'virtual-dom';
import {SingletonComponent} from './base';


class Theme {
    className: string;
    constructor(public identifier: string, public name: string) {}
}


const themes = [
    new Theme('topaz', 'Topaz'),
    new Theme('obsidian', 'Obsidian'),
    new Theme('debug', 'Debug'),
];


class ThemeSelectorListView {
    themeListNode: VNode[];

    constructor(public themes: Theme[]) {}

    render(currentTheme?: string): VNode {
        return h('div', {className: 'js-theme-selector'}, [
            h('span', {
                className: 'js-theme-selector-title'
            }, [String('Theme')]),
            this.renderThemes(currentTheme),
        ]);
    }

    private renderThemes(currentTheme?: string): VNode {
        return h('ul',
            {className: 'js-theme-selector-list'},
            this.themes.map(function(theme: Theme): VNode {
                return h('li', {className: 'js-theme-selector-list-item'}, [
                    h('a', {
                        href: '#',
                        dataset: {themeSelectorItem: theme.identifier},
                        className: ThemeSelectorListView.getSelectorStateClass(
                            theme.identifier,
                            currentTheme
                        ),
                    }, [String(theme.name)])
                ]);
            })
        );
    }

    static getSelectorStateClass(
        identifier: string,
        currentTheme?: string
    ): string {
        return currentTheme && currentTheme == identifier ? 'current' : '';
    }
}


export class ThemeSelector extends SingletonComponent {
    selectorView: ThemeSelectorListView;
    selectorNode: VNode;
    selectorElement: Element;

    protected bindOne(element: Element) {
        this.selectorView = new ThemeSelectorListView(themes);
        this.selectorNode = this.selectorView.render(
            document.body.className.replace(/theme-/, '')
        );

        this.selectorElement = create(this.selectorNode);
        this.targetElement.appendChild(this.selectorElement);
        this.bindSelectorEvent();
    }

    private bindSelectorEvent(): void {
        let self = this;

        this.targetElement.addEventListener('click', function(e: Event) {
            let target = <Element>e.target;
            if (target.matches('[data-theme-selector-item]')) {
                e.preventDefault();

                let themeId = target.getAttribute('data-theme-selector-item');
                document.body.className = `theme-${themeId}`;
                Cookies.set('_theme', themeId, {
                    expires: 365,
                    path: '/'
                });

                let viewNode = self.selectorView.render(themeId);
                let patches = diff(self.selectorNode, viewNode);
                self.selectorElement = patch(self.selectorElement, patches);
                self.selectorNode = viewNode;
            }
        });
    }
}

M assets/app/stylesheets/js.scss => assets/app/stylesheets/js.scss +21 -0
@@ 41,6 41,27 @@
    visibility: hidden;
}

/* JS Theme Selector
 * ------------------------------------------------------------------------ */

.js-theme-selector {
}

.js-theme-selector-title {
}

.js-theme-selector-list {
    display: inline;
    list-style: none;
    margin: 0;
    padding: 0;
}

.js-theme-selector-list-item {
    display: inline;
    margin: 0 0 0 $spacing-horizontal-small;
}

/* JS Inline Quote
 * ------------------------------------------------------------------------ */


M assets/app/stylesheets/themes/debug.scss => assets/app/stylesheets/themes/debug.scss +9 -0
@@ 100,4 100,13 @@
    .post-header-item {
        background-color: rgba(#fff, 0.5);
    }

    /* js.scss
     * -------------------------------------------------------------------- */

    .js-theme-selector-list-item {
        .current {
            font-weight: bold;
        }
    }
}

M assets/app/stylesheets/themes/obsidian.scss => assets/app/stylesheets/themes/obsidian.scss +7 -0
@@ 367,6 367,13 @@ $color-text:             #cfcfcf;
        }
    }

    .js-theme-selector-list-item {
        .current {
            font-weight: bold;
            color: $color-gray-light;
        }
    }

    .js-anchor-popover {
    }


M assets/app/stylesheets/themes/topaz.scss => assets/app/stylesheets/themes/topaz.scss +7 -0
@@ 382,6 382,13 @@ $color-text:             #333;
    /* js.scss
     * -------------------------------------------------------------------- */

    .js-theme-selector-list-item {
        .current {
            font-weight: bold;
            color: $color-gray-dark;
        }
    }

    .js-anchor-popover {
    }


M fanboi2/formatters.py => fanboi2/formatters.py +27 -1
@@ 312,5 312,31 @@ def unquoted_path(context, request, *args, **kwargs):

    :type context: mako.runtime.Context or None
    :type request: pyramid.request.Request
    :rtype: str"""
    :rtype: str
    """
    return urllib.parse.unquote(request.route_path(*args, **kwargs))


THEMES = [
    'topaz',
    'obsidian',
    'debug',
]


def user_theme(context, request, cookie='_theme'):
    """Returns the current theme set by the user. If no theme was set
    in the :param:`cookie`, or one was set but invalid, the default
    theme will be returned.

    :param context: A :class:`mako.runtime.Context` object.
    :param request: A :class:`pyramid.request.Request` object.

    :type context: mako.runtime.Context or None
    :type request: pyramid.request.Request
    :rtype: str
    """
    user_theme = request.cookies.get(cookie)
    if user_theme is None or user_theme not in THEMES:
        user_theme = THEMES[0]
    return "theme-%s" % user_theme

M fanboi2/templates/partials/_layout.mako => fanboi2/templates/partials/_layout.mako +4 -4
@@ 1,3 1,4 @@
<%namespace name="formatters" module="fanboi2.formatters" />
<!DOCTYPE html>
<html>
<head>


@@ 19,7 20,7 @@
        ${self.header()}
    % endif
</head>
<body id="${request.route_name}" class="theme-topaz">
<body id="${request.route_name}" class="${formatters.user_theme(request)}">

<header class="header" data-board-selector="true">
    <div class="container">


@@ 31,13 32,12 @@ ${self.body()}

<footer class="footer">
    <div class="container">
        <div class="footer-lines">
        <div class="footer-lines" data-theme-selector="true">
            <p class="footer-lines-item">All contents are responsibility of its posters.</p>
            <p class="footer-lines-item">Fanboi2 is an <a href="https://github.com/pxfs/fanboi2">open-source</a> project.</p>
        </div>
        <ul class="footer-links">
            <li class="footer-links-item"><a href="${request.route_path('api_root')}">API documentation</a></li>
            <li class="footer-links-item"><a href="https://twitter.com/fanboich">Twitter</a></li>
            <li class="footer-links-item"><a href="https://github.com/pxfs/fanboi2">Source code</a></li>
        </ul>
    </div>
</footer>

M fanboi2/tests/test_formatters.py => fanboi2/tests/test_formatters.py +22 -2
@@ 5,13 5,13 @@ from pyramid import testing

class TestFormatters(unittest.TestCase):

    def _makeRequest(self):
    def _makeRequest(self, **kwargs):
        """:rtype: pyramid.request.Request"""
        from pyramid.registry import Registry
        registry = Registry()
        registry.settings = {'app.timezone': 'Asia/Bangkok'}
        testing.setUp(registry=registry)
        return testing.DummyRequest()
        return testing.DummyRequest(**kwargs)

    def test_url_fix(self):
        from fanboi2.formatters import url_fix


@@ 206,6 206,26 @@ class TestFormatters(unittest.TestCase):
        self.assertEqual(format_isotime(None, request, d2),
                         "2012-12-31T16:59:59Z")

    def test_user_theme(self):
        from fanboi2.formatters import user_theme
        request = self._makeRequest(cookies={'_theme': 'debug'})
        self.assertEqual(user_theme(None, request), 'theme-debug')

    def test_user_theme_empty(self):
        from fanboi2.formatters import user_theme
        request = self._makeRequest()
        self.assertEqual(user_theme(None, request), 'theme-topaz')

    def test_user_theme_invalid(self):
        from fanboi2.formatters import user_theme
        request = self._makeRequest(cookies={'_theme': 'bogus'})
        self.assertEqual(user_theme(None, request), 'theme-topaz')

    def test_user_theme_alternative(self):
        from fanboi2.formatters import user_theme
        request = self._makeRequest(cookies={'_foo': 'debug'})
        self.assertEqual(user_theme(None, request, '_foo'), 'theme-debug')


class TestFormattersWithRegistry(RegistryMixin):


M gulpfile.js => gulpfile.js +1 -0
@@ 119,6 119,7 @@ var externalDependencies = [
    'dom4',
    'domready',
    'es6-promise',
    'js-cookie',
    'virtual-dom'
];


M package.json => package.json +1 -0
@@ 21,6 21,7 @@
    "gulp-sequence": "^0.1.0",
    "gulp-sourcemaps": "^1.6.0",
    "gulp-uglify": "^1.4.2",
    "js-cookie": "^2.1.0",
    "postcss-round-subpixels": "^1.0.0",
    "postcss-urlrev": "^1.1.2",
    "tsify": "^0.12.2",

M typings.json => typings.json +1 -0
@@ 7,6 7,7 @@
  "ambientDependencies": {
    "dom4": "file:typings/vendor/dom4.d.ts",
    "es6-promise": "github:DefinitelyTyped/DefinitelyTyped/es6-promise/es6-promise.d.ts#830e8ebd9ef137d039d5c7ede24a421f08595f83",
    "js-cookie": "github:DefinitelyTyped/DefinitelyTyped/js-cookie/js-cookie.d.ts#bb370b898e6a1528c5959932b4e238a574f22869",
    "virtual-dom": "github:DefinitelyTyped/DefinitelyTyped/virtual-dom/virtual-dom.d.ts#df507c636cf0c799a8a20f35af89a3d0caae6c32"
  }
}