~sirn/fanboi2

a68873a108d20a6a49a1b6caf374bfb2cf6a94fe — Kridsada Thanabulpong 2 years ago 5b482cf
Coding style cleanups and setup pre-commit hooks (#42)

121 files changed, 13342 insertions(+), 11225 deletions(-)

M .gitignore
A .pre-commit-config.yaml
A .prettierrc
M .travis.yml
A Makefile
A Pipfile
A Pipfile.lock
A Procfile.dev
M README.rst
M Vagrantfile
A assets/Makefile
M assets/app/javascripts/app.ts
M assets/app/javascripts/components/anchor_popover.ts
M assets/app/javascripts/components/base.ts
M assets/app/javascripts/components/board_selector.ts
M assets/app/javascripts/components/theme_selector.ts
M assets/app/javascripts/components/topic/base.ts
M assets/app/javascripts/components/topic/topic_load_posts.ts
M assets/app/javascripts/components/topic/topic_new_post.ts
M assets/app/javascripts/components/topic/topic_state.ts
M assets/app/javascripts/components/topic_inline_reply.ts
M assets/app/javascripts/components/topic_manager.ts
M assets/app/javascripts/components/topic_quick_reply.ts
M assets/app/javascripts/components/topic_reloader.ts
M assets/app/javascripts/components/topic_state_tracker.ts
M assets/app/javascripts/models/base.ts
M assets/app/javascripts/models/board.ts
M assets/app/javascripts/models/post.ts
M assets/app/javascripts/models/task.ts
M assets/app/javascripts/models/topic.ts
M assets/app/javascripts/utils/cancellable.ts
M assets/app/javascripts/utils/elements.ts
M assets/app/javascripts/utils/errors.ts
M assets/app/javascripts/utils/formatters.ts
M assets/app/javascripts/utils/forms.ts
M assets/app/javascripts/utils/loading.ts
M assets/app/javascripts/utils/request.ts
M assets/app/javascripts/views/board_selector_view.ts
M assets/app/javascripts/views/board_view.ts
M assets/app/javascripts/views/popover_view.ts
M assets/app/javascripts/views/post_collection_view.ts
M assets/app/javascripts/views/post_form.ts
M assets/app/javascripts/views/theme_selector_view.ts
M assets/app/javascripts/views/topic_view.ts
M assets/app/stylesheets/_mixins.scss
M assets/app/stylesheets/_variables.scss
M assets/app/stylesheets/api.scss
M assets/app/stylesheets/app.scss
M assets/app/stylesheets/themes/_variables_obsidian.scss
M assets/app/stylesheets/themes/_variables_topaz.scss
M assets/app/stylesheets/themes/debug.scss
M assets/app/stylesheets/themes/obsidian.scss
M assets/app/stylesheets/themes/topaz.scss
M fanboi2/__init__.py
M fanboi2/auth.py
M fanboi2/cache.py
M fanboi2/cmd/ctl.py
M fanboi2/errors.py
M fanboi2/filters/__init__.py
M fanboi2/filters/akismet.py
M fanboi2/filters/dnsbl.py
M fanboi2/filters/proxy.py
M fanboi2/forms.py
M fanboi2/geoip.py
M fanboi2/helpers/formatters.py
M fanboi2/helpers/partials.py
M fanboi2/interfaces.py
M fanboi2/models/__init__.py
M fanboi2/models/_base.py
M fanboi2/models/_type.py
M fanboi2/models/_versioned.py
M fanboi2/models/board.py
M fanboi2/models/group.py
M fanboi2/models/page.py
M fanboi2/models/post.py
M fanboi2/models/rule.py
M fanboi2/models/rule_ban.py
M fanboi2/models/setting.py
M fanboi2/models/topic.py
M fanboi2/models/topic_meta.py
M fanboi2/models/user.py
M fanboi2/models/user_session.py
M fanboi2/redis.py
M fanboi2/serializers.py
M fanboi2/services/__init__.py
M fanboi2/services/board.py
M fanboi2/services/filter_.py
M fanboi2/services/identity.py
M fanboi2/services/page.py
M fanboi2/services/post.py
M fanboi2/services/rate_limiter.py
M fanboi2/services/rule.py
M fanboi2/services/setting.py
M fanboi2/services/topic.py
M fanboi2/services/user.py
M fanboi2/tasks/__init__.py
M fanboi2/tasks/_base.py
M fanboi2/tasks/_result_proxy.py
M fanboi2/tasks/post.py
M fanboi2/tasks/topic.py
M fanboi2/templates/admin/setup.mako
M fanboi2/tests/__init__.py
M fanboi2/tests/test_app.py
M fanboi2/tests/test_auth.py
M fanboi2/tests/test_cache.py
M fanboi2/tests/test_errors.py
M fanboi2/tests/test_filters.py
M fanboi2/tests/test_forms.py
M fanboi2/tests/test_helpers.py
M fanboi2/tests/test_integrations.py
M fanboi2/tests/test_models.py
M fanboi2/tests/test_serializers.py
M fanboi2/tests/test_services.py
M fanboi2/tests/test_tasks.py
M fanboi2/version.py
M fanboi2/views/admin.py
M fanboi2/views/api.py
M fanboi2/views/boards.py
M fanboi2/views/pages.py
M setup.cfg
M setup.py
M .gitignore => .gitignore +1 -0
@@ 12,6 12,7 @@ tmp/
*.log
*.tmp
*.dump
.env

# Assets
/assets/*/

A .pre-commit-config.yaml => .pre-commit-config.yaml +22 -0
@@ 0,0 1,22 @@
repos:
  - repo: https://github.com/ambv/black
    rev: stable
    hooks:
      - id: black
        args: [--line-length=88, --safe]
        language_version: python3.6
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v1.2.3
    hooks:
      - id: flake8
        name: flake8
        additional_dependencies: ['flake8-bugbear']
        args: [--config=setup.cfg]
  - repo: local
    hooks:
      - id: prettier
        name: prettier
        entry: prettier --write
        language: node
        files: \.(scss|ts|js)$
        additional_dependencies: ['prettier']

A .prettierrc => .prettierrc +3 -0
@@ 0,0 1,3 @@
printWidth: 88
tabWidth: 4
trailingComma: all
\ No newline at end of file

M .travis.yml => .travis.yml +5 -3
@@ 1,14 1,16 @@
language: python
script: nosetests

python:
  - "3.6"

install:
  - 'pip install -e .'
  - make devinit

before_script:
  - 'psql -c "create database fanboi2;" -U postgres'
  - psql -c "create database fanboi2;" -U postgres

script:
  - make test

env:
  - POSTGRESQL_TEST_DATABASE=postgresql://postgres@localhost:5432/fanboi2

A Makefile => Makefile +65 -0
@@ 0,0 1,65 @@
LDFLAGS := "-L/usr/local/lib"
CFLAGS  := "-I/usr/local/include"

# ----------------------------------------------------------------------
# Prod
# ----------------------------------------------------------------------

.PHONY: prod init

prod: init assets

init:
	pip install pipenv --upgrade
	env \
		LDFLAGS=$(LDFLAGS) \
		CFLAGS=$(CFLAGS) \
	pipenv install

# ----------------------------------------------------------------------
# Development
# ----------------------------------------------------------------------

.PHONY: develop devinit devhook devserver

develop: devinit assets

devinit:
	pip install pipenv --upgrade
	env \
		LDFLAGS=$(LDFLAGS) \
		CFLAGS=$(CFLAGS) \
	pipenv install --dev

devhook:
	pipenv run pre-commit install

devserver:
	pipenv run honcho start -f Procfile.dev

# ----------------------------------------------------------------------
# Assets
# ----------------------------------------------------------------------

.PHONY: assets

assets:
	cd assets && make

# ----------------------------------------------------------------------
# Testing
# ----------------------------------------------------------------------

.PHONY: test

test:
	pipenv run nosetests

# ----------------------------------------------------------------------
# Misc
# ----------------------------------------------------------------------

.PHONY: migrate

migrate:
	pipenv run alembic upgrade head

A Pipfile => Pipfile +23 -0
@@ 0,0 1,23 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
"e1839a8" = {path = ".", editable = true}

[dev-packages]
coverage = "*"
nose = "*"
rednose = "*"
"flake8" = "*"
"flake8-bugbear" = "*"
pre-commit = "*"
black = "*"
honcho = "*"

[requires]
python_version = "3.6"

[pipenv]
allow_prereleases = true

A Pipfile.lock => Pipfile.lock +703 -0
@@ 0,0 1,703 @@
{
    "_meta": {
        "hash": {
            "sha256": "c55be80914159148c81d269e47c447bcae27e2f55d06a460ca7cef5060433278"
        },
        "pipfile-spec": 6,
        "requires": {
            "python_version": "3.6"
        },
        "sources": [
            {
                "name": "pypi",
                "url": "https://pypi.org/simple",
                "verify_ssl": true
            }
        ]
    },
    "default": {
        "alembic": {
            "hashes": [
                "sha256:85bd3ea7633024e4930900bc64fb58f9742dedbc6ebb6ecf25be2ea9a3c1b32e"
            ],
            "version": "==0.9.9"
        },
        "amqp": {
            "hashes": [
                "sha256:4e28d3ea61a64ae61830000c909662cb053642efddbe96503db0e7783a6ee85b",
                "sha256:cba1ace9d4ff6049b190d8b7991f9c1006b443a5238021aca96dd6ad2ac9da22"
            ],
            "version": "==2.2.2"
        },
        "argon2-cffi": {
            "hashes": [
                "sha256:05dd15949be3a7d9f65807fe58fad70526023a319747054bb89da209c4071a33",
                "sha256:07480018d77f4c7447924e6c44c5ba1789a918413fe3efaa391a097958bbd9f6",
                "sha256:10e702dbd98a2148d22de9524a605021bdc55d05304beb90ea801ba58c4a4f1e",
                "sha256:131effd5eabbe08649bc672b5d602fd6e2772b03cfec2ddb2795f9d9babe3fba",
                "sha256:3f3b48b4802e98bb9692d72108ecad2fecea969c254c17660b70ce5730bbe4a6",
                "sha256:4c510232a96e991079a743a9310d3c9a014856cdbca644fccc496db2a1ff0e17",
                "sha256:5f1099b0f5ee4a7148bbd323503983aa4387ab16769ff9b5c51d26f6b0f1719e",
                "sha256:67452b1f10e873ececcea657c25d063e4bb4007e115227a53157369de5848992",
                "sha256:77a3d50e6325df79499e1220b7c38adbd30588c2f6d7c2d764fddb2d3b02e650",
                "sha256:7e4b75611b73f53012117ad21cdde7a17b32d1e99ff6799f22d827eb83a2a59b",
                "sha256:7f4b6d7c38258e76c1db293a6cf55b7e31701927fc773c5108e57578c7f8e09a",
                "sha256:82db759b8a495aaed51aec4762b0f44e5e7ad80256e8baf512ae70cdb3b28c50",
                "sha256:92b3f8f93b19081d520d911f1ce5902693edeeab2181c08aa0bb4130adba51aa",
                "sha256:93f631fa567dbf948f26874476c9e9afb51e0a835372bf1a319df0c5aa071bfb",
                "sha256:9befaa6d9798d9771b8176174ba82160beaf1dcdbcc63cd2dc5212f723e5e2a3",
                "sha256:a14e6d99787a2972d3802615911770fcba9c904401fb0dfb60bdeb250b4c5110",
                "sha256:c60764fe7f62cc52a74f326e366c60f7aa33a1586c8d02107394a01ae9db6e91",
                "sha256:cba2c8c539bed691513ae1bcd5a7da632d2aa2410d8b8ebdf56026eac7e2193f",
                "sha256:d79c918cf8bf981cd23b43a1a547cd1eececb77f3607ba9fa7c0ec01bf1f05a5",
                "sha256:dc3028ec541146924e3c45973b458a7acf390b9e9ee0b64a13ac0853109a69bc",
                "sha256:eb3fcb55224a47b8d50830561977c64761eaad9e349af0b2241eab089af44a14",
                "sha256:f732ca584e81491cc11e3d12e18cbd8c63e137b3f461f378426a6fdaaef47fb0",
                "sha256:fcd5681388d1f18e4a7ee3ff7a9b68650bc04db044b5a0a832728cbce182806d"
            ],
            "version": "==18.1.0"
        },
        "billiard": {
            "hashes": [
                "sha256:1d7b22bdc47aa52841120fcd22a74ae4fc8c13e9d3935643098184f5788c3ce6",
                "sha256:abd9ce008c9a71ccde2c816f8daa36246e92a21e6a799831b887d88277187ecd"
            ],
            "version": "==3.5.0.3"
        },
        "celery": {
            "hashes": [
                "sha256:6fc4678d1692af97e137b2a9f1c04efd8e7e2fb7134c5c5ad60738cdd927762f",
                "sha256:d1f2a3359bdbdfb344edce98b8e891f5fe64f8a11c5a45538ec20ac237c971f5"
            ],
            "version": "==4.1.1"
        },
        "certifi": {
            "hashes": [
                "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
                "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
            ],
            "version": "==2018.4.16"
        },
        "cffi": {
            "hashes": [
                "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743",
                "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
                "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
                "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
                "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
                "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
                "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
                "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
                "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
                "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
                "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359",
                "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596",
                "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b",
                "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd",
                "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95",
                "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e",
                "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6",
                "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca",
                "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31",
                "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1",
                "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085",
                "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801",
                "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4",
                "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
                "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
                "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
                "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
            ],
            "version": "==1.11.5"
        },
        "chardet": {
            "hashes": [
                "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
                "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
            ],
            "version": "==3.0.4"
        },
        "dogpile.cache": {
            "hashes": [
                "sha256:631197e78b4471bb0e93d0a86264c45736bc9ae43b4205d581dcc34fbe9b5f31"
            ],
            "version": "==0.6.5"
        },
        "e1839a8": {
            "editable": true,
            "path": "."
        },
        "geoip2": {
            "hashes": [
                "sha256:a37ddac2d200ffb97c736da8b8ba9d5d8dc47da6ec0f162a461b681ecac53a14",
                "sha256:f7ffe9d258e71a42cf622ce6350d976de1d0312b9f2fbce3975c7d838b57ecf0"
            ],
            "version": "==2.9.0"
        },
        "hiredis": {
            "hashes": [
                "sha256:ca958e13128e49674aa4a96f02746f5de5973f39b57297b84d59fd44d314d5b5"
            ],
            "version": "==0.2.0"
        },
        "hupper": {
            "hashes": [
                "sha256:20387760e4d32bd4813c2cabc8e51d92b2c22c546102a0af182c33c152cd7ede",
                "sha256:6b8133e9c5cc0a8ec422a29ef3b38aea2c49a809a0af73f419a78a7015b32615"
            ],
            "version": "==1.3"
        },
        "idna": {
            "hashes": [
                "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f",
                "sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4"
            ],
            "version": "==2.6"
        },
        "isodate": {
            "hashes": [
                "sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8",
                "sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81"
            ],
            "version": "==0.6.0"
        },
        "kombu": {
            "hashes": [
                "sha256:416aa6fb7b22125a3b65322eb26f5c4479d4830b3e7bad55191ac46438ef2b2b",
                "sha256:d601c47312833c0f6f4aaf037f293b2627398d4cf8526e6ba0360287294ee1fb"
            ],
            "version": "==4.2.0"
        },
        "mako": {
            "hashes": [
                "sha256:4e02fde57bd4abb5ec400181e4c314f56ac3e49ba4fb8b0d50bba18cb27d25ae"
            ],
            "version": "==1.0.7"
        },
        "markupsafe": {
            "hashes": [
                "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
            ],
            "version": "==1.0"
        },
        "maxminddb": {
            "hashes": [
                "sha256:5c10bc71b4a4d76ec90fa8587684f480bbd88a853b9d5e8abbee7683da0791eb"
            ],
            "version": "==1.4.0"
        },
        "misaka": {
            "hashes": [
                "sha256:87637d90f5f52595d07ed1be93d0576d32632d125694b96b8e4ce55cd4c019fb"
            ],
            "version": "==2.1.0"
        },
        "passlib": {
            "hashes": [
                "sha256:3d948f64138c25633613f303bcc471126eae67c04d5e3f6b7b8ce6242f8653e0",
                "sha256:43526aea08fa32c6b6dbbbe9963c4c767285b78147b7437597f992812f69d280"
            ],
            "version": "==1.7.1"
        },
        "pastedeploy": {
            "hashes": [
                "sha256:39973e73f391335fac8bc8a8a95f7d34a9f42e2775600ce2dc518d93b37ef943",
                "sha256:d5858f89a255e6294e63ed46b73613c56e3b9a2d82a42f1df4d06c8421a9e3cb"
            ],
            "version": "==1.5.2"
        },
        "plaster": {
            "hashes": [
                "sha256:215c921a438b5349931fd7df9a5a11a3572947f20f4bc6dd622ac08f1c3ba249",
                "sha256:8351c7c7efdf33084c1de88dd0f422cbe7342534537b553c49b857b12d98c8c3"
            ],
            "version": "==1.0"
        },
        "plaster-pastedeploy": {
            "hashes": [
                "sha256:25cc239d767c5fab0afa44b1ed3c1a33a3d7ec6302ff2e599aec674b77ff6667",
                "sha256:70a3185b2a3336996a26e9987968cf35e84cf13390b7e8a0a9a91eb8f6f85ba9"
            ],
            "version": "==0.5"
        },
        "psycopg2": {
            "hashes": [
                "sha256:027ae518d0e3b8fff41990e598bc7774c3d08a3a20e9ecc0b59fb2aaaf152f7f",
                "sha256:092a80da1b052a181b6e6c765849c9b32d46c5dac3b81bf8c9b83e697f3cdbe8",
                "sha256:0b9851e798bae024ed1a2a6377a8dab4b8a128a56ed406f572f9f06194e4b275",
                "sha256:179c52eb870110a8c1b460c86d4f696d58510ea025602cd3f81453746fccb94f",
                "sha256:19983b77ec1fc2a210092aa0333ee48811fd9fb5f194c6cd5b927ed409aea5f8",
                "sha256:1d90379d01d0dc50ae9b40c863933d87ff82d51dd7d52cea5d1cb7019afd72cd",
                "sha256:27467fd5af1dcc0a82d72927113b8f92da8f44b2efbdb8906bd76face95b596d",
                "sha256:32702e3bd8bfe12b36226ba9846ed9e22336fc4bd710039d594b36bd432ae255",
                "sha256:33f9e1032095e1436fa9ec424abcbd4c170da934fb70e391c5d78275d0307c75",
                "sha256:36030ca7f4b4519ee4f52a74edc4ec73c75abfb6ea1d80ac7480953d1c0aa3c3",
                "sha256:363fbbf4189722fc46779be1fad2597e2c40b3f577dc618f353a46391cf5d235",
                "sha256:6f302c486132f8dd11f143e919e236ea4467d53bf18c451cac577e6988ecbd05",
                "sha256:733166464598c239323142c071fa4c9b91c14359176e5ae7e202db6bcc1d2eb5",
                "sha256:7cbc3b21ce2f681ca9ad2d8c0901090b23a30c955e980ebf1006d41f37068a95",
                "sha256:888bba7841116e529f407f15c6d28fe3ef0760df8c45257442ec2f14f161c871",
                "sha256:8966829cb0d21a08a3c5ac971a2eb67c3927ae27c247300a8476554cc0ce2ae8",
                "sha256:8bf51191d60f6987482ef0cfe8511bbf4877a5aa7f313d7b488b53189cf26209",
                "sha256:8eb94c0625c529215b53c08fb4e461546e2f3fc96a49c13d5474b5ad7aeab6cf",
                "sha256:8ebba5314c609a05c6955e5773c7e0e57b8dd817e4f751f30de729be58fa5e78",
                "sha256:932a4c101af007cb3132b1f8a9ffef23386acc53dad46536dc5ba43a3235ae02",
                "sha256:ad75fe10bea19ad2188c5cb5fc4cdf53ee808d9b44578c94a3cd1e9fc2beb656",
                "sha256:aeaba399254ca79c299d9fe6aa811d3c3eac61458dee10270de7f4e71c624998",
                "sha256:b178e0923c93393e16646155794521e063ec17b7cc9f943f15b7d4b39776ea2c",
                "sha256:b68e89bb086a9476fa85298caab43f92d0a6af135a5f433d1f6b6d82cafa7b55",
                "sha256:d74cf9234ba76426add5e123449be08993a9b13ff434c6efa3a07caa305a619f",
                "sha256:f3d3a88128f0c219bdc5b2d9ccd496517199660cea021c560a3252116df91cbd",
                "sha256:fe6a7f87356116f5ea840c65b032af17deef0e1a5c34013a2962dd6f99b860dd"
            ],
            "version": "==2.7.4"
        },
        "pycparser": {
            "hashes": [
                "sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226"
            ],
            "version": "==2.18"
        },
        "pygments": {
            "hashes": [
                "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
                "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"
            ],
            "version": "==2.2.0"
        },
        "pynacl": {
            "hashes": [
                "sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca",
                "sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512",
                "sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6",
                "sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776",
                "sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac",
                "sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b",
                "sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb",
                "sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98",
                "sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2",
                "sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef",
                "sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f",
                "sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988",
                "sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b",
                "sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101",
                "sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a",
                "sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975",
                "sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb",
                "sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45",
                "sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9",
                "sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752",
                "sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0",
                "sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053",
                "sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4"
            ],
            "version": "==1.2.1"
        },
        "pyramid": {
            "hashes": [
                "sha256:600f12e0d11211a55c2da970120af33214f77607ed45caba6af6c891afeaa771",
                "sha256:cf89a48cb899291639686bf3d4a883b39e496151fa4871fb83cc1a3200d5b925"
            ],
            "version": "==1.9.2"
        },
        "pyramid-debugtoolbar": {
            "hashes": [
                "sha256:5f779aa242009c4aace848f67807da44af2970b303aa1c9682c2efab76b7e79e",
                "sha256:9d7bd8161d18122473a94d991dedf989ef8dafdf44c112a5042d19346f2324a0"
            ],
            "version": "==4.4"
        },
        "pyramid-mako": {
            "hashes": [
                "sha256:6da0987b9874cf53e72139624665a73965bbd7fbde504d1753e4231ce916f3a1"
            ],
            "version": "==1.0.2"
        },
        "pyramid-nacl-session": {
            "hashes": [
                "sha256:12f3486361e6df284d261be22783278805184beadf629ac85c7c58aeba5e609d",
                "sha256:b7dca62622df5d21cd2ee52574d16d4b76c514298ad127f95bb2315e27573a1d"
            ],
            "version": "==0.3"
        },
        "pyramid-services": {
            "hashes": [
                "sha256:533e67659224c2064ed4ffa803c30adb99e6288c2a2c31d7e7447a8a4f19d7b4",
                "sha256:adff1063ddf86e16c9c3d32e160c7b8b44ad28a0831333446fdf7bba8aa7146c"
            ],
            "version": "==1.1"
        },
        "pyramid-tm": {
            "hashes": [
                "sha256:07d03bab7bdd265c3920db4e68dbaa8cbaff27da828700f404b1424244ad617f",
                "sha256:11b0f31482339d655358081bc1366d39679d02588782b5c8019bfb41ae02ba3d"
            ],
            "version": "==2.2"
        },
        "python-dateutil": {
            "hashes": [
                "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0",
                "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8"
            ],
            "version": "==2.7.3"
        },
        "python-editor": {
            "hashes": [
                "sha256:a3c066acee22a1c94f63938341d4fb374e3fdd69366ed6603d7b24bed1efc565"
            ],
            "version": "==1.0.3"
        },
        "python3-memcached": {
            "hashes": [
                "sha256:7cbe5951d68eef69d948b7a7ed7decfbd101e15e7f5be007dcd1219ccc584859"
            ],
            "version": "==1.51"
        },
        "pytz": {
            "hashes": [
                "sha256:65ae0c8101309c45772196b21b74c46b2e5d11b6275c45d251b150d5da334555",
                "sha256:c06425302f2cf668f1bba7a0a03f3c1d34d4ebeef2c72003da308b3947c7f749"
            ],
            "version": "==2018.4"
        },
        "redis": {
            "hashes": [
                "sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb",
                "sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f"
            ],
            "version": "==2.10.6"
        },
        "repoze.lru": {
            "hashes": [
                "sha256:0429a75e19380e4ed50c0694e26ac8819b4ea7851ee1fc7583c8572db80aff77",
                "sha256:f77bf0e1096ea445beadd35f3479c5cff2aa1efe604a133e67150bc8630a62ea"
            ],
            "version": "==0.7"
        },
        "requests": {
            "hashes": [
                "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b",
                "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e"
            ],
            "version": "==2.18.4"
        },
        "six": {
            "hashes": [
                "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
                "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
            ],
            "version": "==1.11.0"
        },
        "sqlalchemy": {
            "hashes": [
                "sha256:d6cda03b0187d6ed796ff70e87c9a7dce2c2c9650a7bc3c022cd331416853c31"
            ],
            "version": "==1.2.7"
        },
        "transaction": {
            "hashes": [
                "sha256:269601a3493cd3eddeb869419ceadfc5e6d2bc931e9970d11fc4649dab189c3c",
                "sha256:9de0f93f833713270fbceaf6092194313c1de0afb660e66dea8e089855eb281c",
                "sha256:f2242070e437e5d555ea3df809cb517860513254c828f33847df1c5e4b776c7a"
            ],
            "version": "==2.2.1"
        },
        "translationstring": {
            "hashes": [
                "sha256:4ee44cfa58c52ade8910ea0ebc3d2d84bdcad9fa0422405b1801ec9b9a65b72d",
                "sha256:e26c7bf383413234ed442e0980a2ebe192b95e3745288a8fd2805156d27515b4"
            ],
            "version": "==1.3"
        },
        "urllib3": {
            "hashes": [
                "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b",
                "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"
            ],
            "version": "==1.22"
        },
        "venusian": {
            "hashes": [
                "sha256:757162c5f907e18571b6ab41b7673e5bf18cc8715abf8164292eaef4f1610668",
                "sha256:9902e492c71a89a241a18b2f9950bea7e41d025cc8f3af1ea8d8201346f8577d"
            ],
            "version": "==1.1.0"
        },
        "vine": {
            "hashes": [
                "sha256:52116d59bc45392af9fdd3b75ed98ae48a93e822cee21e5fda249105c59a7a72",
                "sha256:6849544be74ec3638e84d90bc1cf2e1e9224cc10d96cd4383ec3f69e9bce077b"
            ],
            "version": "==1.1.4"
        },
        "waitress": {
            "hashes": [
                "sha256:40b0f297a7f3af61fbfbdc67e59090c70dc150a1601c39ecc9f5f1d283fb931b",
                "sha256:d33cd3d62426c0f1b3cd84ee3d65779c7003aae3fc060dee60524d10a57f05a9"
            ],
            "version": "==1.1.0"
        },
        "webob": {
            "hashes": [
                "sha256:1771899117c8851153f6f91e8b8a86236972aa8a1b6bd69ad0a36a9879ea2cd7",
                "sha256:54f35073d2fdcddd7a98c2a1dedeede49739150737164a787220f30283139ba6"
            ],
            "version": "==1.8.1"
        },
        "wtforms": {
            "hashes": [
                "sha256:ffdf10bd1fa565b8233380cb77a304cd36fd55c73023e91d4b803c96bc11d46f"
            ],
            "version": "==2.1"
        },
        "zope.deprecation": {
            "hashes": [
                "sha256:7d52e134bbaaa0d72e1e2bc90f0587f1adc116c4bdf15912afaf2f1e8856b224",
                "sha256:c83cfef3085d10dcb07de5a59a2d95713865befa46e0e88784c5648610fba789"
            ],
            "version": "==4.3.0"
        },
        "zope.interface": {
            "hashes": [
                "sha256:21506674d30c009271fe68a242d330c83b1b9d76d62d03d87e1e9528c61beea6",
                "sha256:3d184aff0756c44fff7de69eb4cd5b5311b6f452d4de28cb08343b3f21993763",
                "sha256:467d364b24cb398f76ad5e90398d71b9325eb4232be9e8a50d6a3b3c7a1c8789",
                "sha256:57c38470d9f57e37afb460c399eb254e7193ac7fb8042bd09bdc001981a9c74c",
                "sha256:9ada83f4384bbb12dedc152bcdd46a3ac9f5f7720d43ac3ce3e8e8b91d733c10",
                "sha256:a1daf9c5120f3cc6f2b5fef8e1d2a3fb7bbbb20ed4bfdc25bc8364bc62dcf54b",
                "sha256:e6b77ae84f2b8502d99a7855fa33334a1eb6159de45626905cb3e454c023f339",
                "sha256:e881ef610ff48aece2f4ee2af03d2db1a146dc7c705561bd6089b2356f61641f",
                "sha256:f41037260deaacb875db250021fe883bf536bf6414a4fd25b25059b02e31b120"
            ],
            "version": "==4.5.0"
        },
        "zope.sqlalchemy": {
            "hashes": [
                "sha256:9316a1a8bb9e4f9f59332acf1ad2cc8b664f19a4bde5f68be7f61f3e11f80514"
            ],
            "version": "==1.0"
        }
    },
    "develop": {
        "appdirs": {
            "hashes": [
                "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
                "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
            ],
            "version": "==1.4.3"
        },
        "aspy.yaml": {
            "hashes": [
                "sha256:04d26279513618f1024e1aba46471db870b3b33aef204c2d09bcf93bea9ba13f",
                "sha256:0a77e23fafe7b242068ffc0252cee130d3e509040908fc678d9d1060e7494baa"
            ],
            "version": "==1.1.1"
        },
        "attrs": {
            "hashes": [
                "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265",
                "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b"
            ],
            "version": "==18.1.0"
        },
        "black": {
            "hashes": [
                "sha256:4fec2566f9fbbd4a58de50a168cbe3ab952713530410d227e82e4c65d1fad946",
                "sha256:5fec0f25486046b9edb97961c946412ced96021247dd1a60ecd9f0567b68b030"
            ],
            "index": "pypi",
            "version": "==18.5b0"
        },
        "cached-property": {
            "hashes": [
                "sha256:67acb3ee8234245e8aea3784a492272239d9c4b487eba2fdcce9d75460d34520",
                "sha256:bf093e640b7294303c7cc7ba3212f00b7a07d0416c1d923465995c9ef860a139"
            ],
            "version": "==1.4.2"
        },
        "cfgv": {
            "hashes": [
                "sha256:2fbaf8d082456d8fff5a68163ff59c1025a52e906914fbc738be7d8ea5b7aa4b",
                "sha256:733aa2f66b5106af32d271336a571610b9808e868de0ad5690d9d5155e5960c5"
            ],
            "version": "==1.0.0"
        },
        "click": {
            "hashes": [
                "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
                "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
            ],
            "version": "==6.7"
        },
        "colorama": {
            "hashes": [
                "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda",
                "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"
            ],
            "version": "==0.3.9"
        },
        "coverage": {
            "hashes": [
                "sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba",
                "sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed",
                "sha256:104ab3934abaf5be871a583541e8829d6c19ce7bde2923b2751e0d3ca44db60a",
                "sha256:15b111b6a0f46ee1a485414a52a7ad1d703bdf984e9ed3c288a4414d3871dcbd",
                "sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640",
                "sha256:1c383d2ef13ade2acc636556fd544dba6e14fa30755f26812f54300e401f98f2",
                "sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162",
                "sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508",
                "sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249",
                "sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694",
                "sha256:3c79a6f7b95751cdebcd9037e4d06f8d5a9b60e4ed0cd231342aa8ad7124882a",
                "sha256:3d72c20bd105022d29b14a7d628462ebdc61de2f303322c0212a054352f3b287",
                "sha256:3eb42bf89a6be7deb64116dd1cc4b08171734d721e7a7e57ad64cc4ef29ed2f1",
                "sha256:4635a184d0bbe537aa185a34193898eee409332a8ccb27eea36f262566585000",
                "sha256:56e448f051a201c5ebbaa86a5efd0ca90d327204d8b059ab25ad0f35fbfd79f1",
                "sha256:5a13ea7911ff5e1796b6d5e4fbbf6952381a611209b736d48e675c2756f3f74e",
                "sha256:69bf008a06b76619d3c3f3b1983f5145c75a305a0fea513aca094cae5c40a8f5",
                "sha256:6bc583dc18d5979dc0f6cec26a8603129de0304d5ae1f17e57a12834e7235062",
                "sha256:701cd6093d63e6b8ad7009d8a92425428bc4d6e7ab8d75efbb665c806c1d79ba",
                "sha256:7608a3dd5d73cb06c531b8925e0ef8d3de31fed2544a7de6c63960a1e73ea4bc",
                "sha256:76ecd006d1d8f739430ec50cc872889af1f9c1b6b8f48e29941814b09b0fd3cc",
                "sha256:7aa36d2b844a3e4a4b356708d79fd2c260281a7390d678a10b91ca595ddc9e99",
                "sha256:7d3f553904b0c5c016d1dad058a7554c7ac4c91a789fca496e7d8347ad040653",
                "sha256:7e1fe19bd6dce69d9fd159d8e4a80a8f52101380d5d3a4d374b6d3eae0e5de9c",
                "sha256:8c3cb8c35ec4d9506979b4cf90ee9918bc2e49f84189d9bf5c36c0c1119c6558",
                "sha256:9d6dd10d49e01571bf6e147d3b505141ffc093a06756c60b053a859cb2128b1f",
                "sha256:9e112fcbe0148a6fa4f0a02e8d58e94470fc6cb82a5481618fea901699bf34c4",
                "sha256:ac4fef68da01116a5c117eba4dd46f2e06847a497de5ed1d64bb99a5fda1ef91",
                "sha256:b8815995e050764c8610dbc82641807d196927c3dbed207f0a079833ffcf588d",
                "sha256:be6cfcd8053d13f5f5eeb284aa8a814220c3da1b0078fa859011c7fffd86dab9",
                "sha256:c1bb572fab8208c400adaf06a8133ac0712179a334c09224fb11393e920abcdd",
                "sha256:de4418dadaa1c01d497e539210cb6baa015965526ff5afc078c57ca69160108d",
                "sha256:e05cb4d9aad6233d67e0541caa7e511fa4047ed7750ec2510d466e806e0255d6",
                "sha256:e4d96c07229f58cb686120f168276e434660e4358cc9cf3b0464210b04913e77",
                "sha256:f3f501f345f24383c0000395b26b726e46758b71393267aeae0bd36f8b3ade80",
                "sha256:f8a923a85cb099422ad5a2e345fe877bbc89a8a8b23235824a93488150e45f6e"
            ],
            "index": "pypi",
            "version": "==4.5.1"
        },
        "flake8": {
            "hashes": [
                "sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0",
                "sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37"
            ],
            "index": "pypi",
            "version": "==3.5.0"
        },
        "flake8-bugbear": {
            "hashes": [
                "sha256:541746f0f3b2f1a8d7278e1d2d218df298996b60b02677708560db7c7e620e3b",
                "sha256:5f14a99d458e29cb92be9079c970030e0dd398b2decb179d76d39a5266ea1578"
            ],
            "index": "pypi",
            "version": "==18.2.0"
        },
        "honcho": {
            "hashes": [
                "sha256:af5806bf13e3b20acdcb9ff8c0beb91eee6fe07393c3448dfad89667e6ac7576",
                "sha256:c189402ad2e337777283c6a12d0f4f61dc6dd20c254c9a3a4af5087fc66cea6e"
            ],
            "index": "pypi",
            "version": "==1.0.1"
        },
        "identify": {
            "hashes": [
                "sha256:067c206bb7a6926d30de0e77d6297729a176c0aa8b2d810a5be809cb46b045b2",
                "sha256:5eae91e34881bed02ea4f8c3886df8bd1232536d6f0dbf0405ff734268b7f425"
            ],
            "version": "==1.0.18"
        },
        "mccabe": {
            "hashes": [
                "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
                "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
            ],
            "version": "==0.6.1"
        },
        "nodeenv": {
            "hashes": [
                "sha256:dd0a34001090ff042cfdb4b0c8d6a6f7ec9baa49733f00b695bb8a8b4700ba6c"
            ],
            "version": "==1.3.0"
        },
        "nose": {
            "hashes": [
                "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac",
                "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a",
                "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"
            ],
            "index": "pypi",
            "version": "==1.3.7"
        },
        "pre-commit": {
            "hashes": [
                "sha256:4b86fd3e4cb602f26c277aec05c68f7f65956ed7de63e42787711a81f5b7b80b",
                "sha256:e3b3548c307b9efd69b2a908f894defcae099c113908553a868db49757d053eb"
            ],
            "index": "pypi",
            "version": "==1.9.0"
        },
        "pycodestyle": {
            "hashes": [
                "sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766",
                "sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9"
            ],
            "version": "==2.3.1"
        },
        "pyflakes": {
            "hashes": [
                "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f",
                "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805"
            ],
            "version": "==1.6.0"
        },
        "pyyaml": {
            "hashes": [
                "sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
                "sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
                "sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
                "sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
                "sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
                "sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
                "sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7",
                "sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
                "sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
                "sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
                "sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
                "sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
                "sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
                "sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269"
            ],
            "version": "==3.12"
        },
        "rednose": {
            "hashes": [
                "sha256:6da77917788be277b70259edc0bb92fc6f28fe268b765b4ea88206cc3543a3e1"
            ],
            "index": "pypi",
            "version": "==1.3.0"
        },
        "six": {
            "hashes": [
                "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
                "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
            ],
            "version": "==1.11.0"
        },
        "termstyle": {
            "hashes": [
                "sha256:ef74b83698ea014112040cf32b1a093c1ab3d91c4dd18ecc03ec178fd99c9f9f"
            ],
            "version": "==0.1.11"
        },
        "virtualenv": {
            "hashes": [
                "sha256:2ce32cd126117ce2c539f0134eb89de91a8413a29baac49cbab3eb50e2026669",
                "sha256:ca07b4c0b54e14a91af9f34d0919790b016923d157afda5efdde55c96718f752"
            ],
            "version": "==16.0.0"
        }
    }
}

A Procfile.dev => Procfile.dev +3 -0
@@ 0,0 1,3 @@
web: pipenv run fbctl serve --reload
worker: pipenv run fbcelery worker
assets: cd assets && yarn run gulp watch
\ No newline at end of file

M README.rst => README.rst +43 -26
@@ 17,56 17,73 @@ Board engine behind `Fanboi Channel`_ written in Python.
Installation
------------

Fanboi2 requires that the following softwares are installed.
Fanboi2 requires the following packages to be installed.

- `Python 3.6 <https://www.python.org/downloads/>`_
- `PostgreSQL 9.6 <http://www.postgresql.org/>`_
- `PostgreSQL 10 <http://www.postgresql.org/>`_
- `Redis 4.0 <http://redis.io/>`_
- `NodeJS 9.10 <http://nodejs.org/>`_ with `Yarn 1.5 <https://yarnpkg.com/>`_

After package mentioned are installed or started, you may now clone the application::
After all packages are installed and started, you may now clone the application::

  $ git clone https://github.com/forloopend/fanboi2.git fanboi2

Then setup the application::

  $ cd fanboi2/
  $ pip3 install -e .
  $ alembic upgrade head
  $ make prod

  $ cd assets/
  $ yarn
  $ yarn run typings install
  $ yarn run gulp
Configure ``.env`` (see the configuring section) and run::

  $ fbctl serve
  $ make migrate
  $ pipenv run fbctl serve

And you're done! Please visit `http://localhost:6543/admin/ <http://localhost:6543/admin/>`_ to perform initial configuration.

Development
Configuring
-----------

To develop Fanboi2, it is highly recommended to use `Vagrant`_ as it is currently replicating the production environment of `Fanboi Channel`_. You can follow these steps to get the app running:
Fanboi2 uses environment variable to configure the application. In case `Pipenv <https://docs.pipenv.org/>`_ is used, you can create a file name ``.env`` in the root directory of the project and Pipenv will happily read the file on ``pipenv run``. Otherwise you may want to use something like `Direnv <https://github.com/direnv/direnv>`_.

========================= =========================================================================
Key                       Description
========================= =========================================================================
``AUTH_SECRET``           **Required**. Secret for authentication/authorization cookie.
``CELERY_BROKER_URL``     **Required**. Redis URL for Celery broker, e.g. `redis://127.0.0.1/1`
``DATABASE_URL``          **Required**. Database URL, e.g. `postgres://127.0.0.1/fanboi2`
``MEMCACHED_URL``         **Required**. Memcached URL, e.g. `127.0.0.1:11211`
``REDIS_URL``             **Required**. Redis URL, e.g. `redis://127.0.0.1/0`
``SESSION_SECRET``        **Required**. Secret for session cookie. Must not reuse ``AUTH_SECRET``.
``GEOIP_PATH``            Path to GeoIP database, e.g. `/usr/share/geoip/GeoLite2-Country.mmdb`
``SERVER_DEV``            Boolean flag whether to enable dev console, default `False`
``SERVER_SECURE``         Boolean flag whether to only authenticate via HTTPS, default `False`.
========================= =========================================================================

- Install `Vagrant`_ of your preferred platform.
- Install `VirtualBox`_ or other providers supported by Vagrant.
- Run `vagrant up` and read Getting Started while waiting.
- Run `vagrant ssh` to SSH into the development machine.
Contributing
------------

Once the development box is up and running, you can now run the server (inside the development machine)::
Fanboi2 is open to any contributors, whether you are learning Python or an expert. To contribute to Fanboi2, it is highly recommended to use `Vagrant`_ as it is currently replicating the production environment of `Fanboi Channel`_ and perform all the necessary setup steps for you. You can follow these steps to get the app running:

    $ cd /vagrant
    $ fbctl serve
1. Install `Vagrant`_ of your preferred platform.
2. Install `VirtualBox`_ or other providers supported by Vagrant.
3. Run `vagrant up` and read Getting Started while waiting.
4. Run `vagrant ssh` to SSH into the development machine (remember to ``cd /vagrant``).

Contributing
------------
In case you do not want to use Vagrant, you can install the dependencies from the installation section and run::

  $ make develop
  $ make devhook

You can then configure the application (see configuration section) and run the server::

  $ make migrate
  $ pipenv run honcho start -f Procfile.dev

Fanboi2 is open to any contributors, whether you're learning Python or an expert. Simply open a `pull request <https://github.com/forloopend/fanboi2/pulls>`_ against the **master** branch. Our reviewer will review and merge the pull request as soon as possible. It would be much appreciated if you could follow the following guidelines:
Once you've made your changes, simply open a `pull request <https://github.com/forloopend/fanboi2/pulls>`_ against the **master** branch. Our reviewer will review and merge the pull request as soon as possible. It would be much appreciated if you could follow the following guidelines:

- It's always a good idea to open `an issue <https://github.com/forloopend/fanboi2/issues>`_ prior to starting.
- No need for 100% coverage but please make sure new features has bare minimum tests.
- Remember to run `flake8 <https://pypi.python.org/pypi/flake8>`_ and fix any styling issues.
- After done, simply open a `pull request <https://github.com/forloopend/fanboi2/pulls>`_ against **master** branch.
- When making a non-trivial changes, please create `an issue <https://github.com/forloopend/fanboi2/issues>`_ prior to starting.
- Make sure new features has enough tests and no regressions.
- Fix any offenses as reported by pre-commit hooks.

License
-------

M Vagrantfile => Vagrantfile +43 -44
@@ 2,76 2,75 @@
# vi: set ft=ruby :

Vagrant.configure("2") do |config|
  config.vm.box = "pxfs/freebsd-10.3"
  config.vm.synced_folder ".", "/vagrant", nfs: true, mount_options: ['actimeo=2']
  config.vm.network :forwarded_port, guest: 6543, host: 6543
  config.vm.network :forwarded_port, guest: 9000, host: 9000
  config.vm.box = "pxfs/freebsd-11.1"

  config.vm.network "private_network", ip: "10.200.80.100"
  config.vm.network "forwarded_port", guest: 6543, host: 6543
  config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ["actimeo=2"]
  config.ssh.shell = "sh"

  config.vm.provision :shell, privileged: true, inline: <<-EOF
    pkg install -y ca_root_nss
    pkg install -y git-lite
    pkg install -y postgresql95-server
    pkg install -y node
    pkg install -y redis
    pkg install -y memcached
    sysrc hostname=vagrant

    pkg update
    pkg install -y ca_root_nss git-lite curl ntp bash
    pkg install -y postgresql10-server node redis memcached yarn
    pkg install -y bzip2 sqlite3 gmake
    pkg install -y python27 python36

    ntpd -qg

    sysrc ntpd_enable=YES
    sysrc postgresql_enable=YES
    sysrc redis_enable=YES
    sysrc memcached_enable=YES

    service ntpd start
    service postgresql initdb
    service postgresql start
    service redis start
    service memcached start

    sudo -u pgsql createuser -ds vagrant || true
    sudo -u pgsql createuser -ds fanboi2 || true
    sh -c 'echo "local all all trust" > /usr/local/pgsql/data/pg_hba.conf'
    sh -c 'echo "host all all 127.0.0.1/32 trust" >> /usr/local/pgsql/data/pg_hba.conf'
    sh -c 'echo "host all all ::1/128 trust" >> /usr/local/pgsql/data/pg_hba.conf'
    sudo -u postgres createuser -ds vagrant || true
    sudo -u postgres createuser -ds fanboi2 || true
    sh -c 'echo "local all all trust" > /var/db/postgres/data10/pg_hba.conf'
    sh -c 'echo "host all all 127.0.0.1/32 trust" >> /var/db/postgres/data10/pg_hba.conf'
    sh -c 'echo "host all all ::1/128 trust" >> /var/db/postgres/data10/pg_hba.conf'
    service postgresql restart

    fetch -o - https://bootstrap.pypa.io/get-pip.py | /usr/local/bin/python3.6 -
    /usr/local/bin/pip3.6 install virtualenv
  EOF

  config.vm.provision :shell, privileged: false, inline: <<-EOF
    virtualenv -p python3.6 $HOME/python3.6

    mkdir $HOME/yarn
    curl -sL https://yarnpkg.com/latest.tar.gz | tar -xvzf - -C $HOME/yarn --strip 1

    echo 'EDITOR=vi; export EDITOR' > $HOME/.profile
    echo 'PAGER=more; export PAGER' >> $HOME/.profile
    echo 'ENV=$HOME/.shrc; export ENV' >> $HOME/.profile
    echo 'PATH="$HOME/yarn/bin:$PATH"' >> $HOME/.profile
    echo 'PATH="$HOME/python3.6/bin:$PATH"' >> $HOME/.profile
    echo 'PATH="$HOME/bin:$PATH"' >> $HOME/.profile
    echo 'export PATH' >> $HOME/.profile
    echo 'LANG=en_US.UTF-8; export LANG' >> $HOME/.profile
    echo 'PYENV_ROOT="$HOME/.pyenv"; export PYENV_ROOT' >> $HOME/.profile
    echo 'PATH="$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH"; export PATH' >> $HOME/.profile

    psql template1 -c "CREATE DATABASE fanboi2_dev;"
    psql template1 -c "CREATE DATABASE fanboi2_test;"

    echo 'CELERY_BROKER_URL="redis://127.0.0.1:6379/1"; export CELERY_BROKER_URL' >> $HOME/.profile
    echo 'DATABASE_URL="postgresql://vagrant:@127.0.0.1:5432/fanboi2_dev"; export DATABASE_URL' >> $HOME/.profile
    echo 'MEMCACHED_URL="127.0.0.1:11211"; export MEMCACHED_URL' >> $HOME/.profile
    echo 'REDIS_URL="redis://127.0.0.1:6379/0"; export REDIS_URL' >> $HOME/.profile
    echo 'SERVER_DEV=true; export SERVER_DEV' >> $HOME/.profile
    echo 'SERVER_HOST="0.0.0.0"; export SERVER_HOST' >> $HOME/.profile
    echo 'SERVER_PORT=6543; export SERVER_PORT' >> $HOME/.profile
    echo "SESSION_SECRET=$(openssl rand -hex 32); export SESSION_SECRET" >> $HOME/.profile
    echo "AUTH_SECRET=$(openssl rand -hex 32); export AUTH_SECRET" >> $HOME/.profile
    git clone https://github.com/pyenv/pyenv.git $HOME/.pyenv

    cd /vagrant
    $HOME/python3.6/bin/pip3 install -e .
    $HOME/python3.6/bin/alembic upgrade head
    $HOME/.pyenv/bin/pyenv install 3.6.4
    $HOME/.pyenv/bin/pyenv global 3.6.4
    $HOME/.pyenv/versions/3.6.4/bin/pip3.6 install pip --upgrade
    $HOME/.pyenv/versions/3.6.4/bin/pip3.6 install pipenv
    $HOME/.pyenv/bin/pyenv rehash

    . $HOME/.profile

    cd /vagrant/assets
    env PYTHON=/usr/local/bin/python2.7 $HOME/yarn/bin/yarn
    $HOME/yarn/bin/yarn run typings install
    $HOME/yarn/bin/yarn run gulp
    echo 'CELERY_BROKER_URL="redis://127.0.0.1:6379/1"' > /vagrant/.env
    echo 'DATABASE_URL="postgresql://vagrant:@127.0.0.1:5432/fanboi2_dev"' >> /vagrant/.env
    echo 'MEMCACHED_URL="127.0.0.1:11211"' >> /vagrant/.env
    echo 'REDIS_URL="redis://127.0.0.1:6379/0"' >> /vagrant/.env
    echo 'SERVER_DEV=true' >> /vagrant/.env
    echo 'SERVER_HOST="0.0.0.0"' >> /vagrant/.env
    echo 'SERVER_PORT=6543' >> /vagrant/.env
    echo "SESSION_SECRET=$(openssl rand -hex 32)" >> /vagrant/.env
    echo "AUTH_SECRET=$(openssl rand -hex 32)" >> /vagrant/.env

    cd /vagrant
    make develop
    make migrate
  EOF
end

A assets/Makefile => assets/Makefile +9 -0
@@ 0,0 1,9 @@
.PHONY: init watch

init:
	yarn
	yarn run typings install
	yarn run gulp

watch:
	yarn run gulp watch

M assets/app/javascripts/app.ts => assets/app/javascripts/app.ts +9 -10
@@ 1,13 1,12 @@
import domready = require('domready');
import {BoardSelector} from './components/board_selector';
import {ThemeSelector} from './components/theme_selector';
import {AnchorPopover} from './components/anchor_popover';
import {TopicManager} from './components/topic_manager';
import {TopicReloader} from './components/topic_reloader';
import {TopicStateTracker} from './components/topic_state_tracker';
import {TopicInlineReply} from './components/topic_inline_reply';
import {TopicQuickReply} from './components/topic_quick_reply';

import domready = require("domready");
import { BoardSelector } from "./components/board_selector";
import { ThemeSelector } from "./components/theme_selector";
import { AnchorPopover } from "./components/anchor_popover";
import { TopicManager } from "./components/topic_manager";
import { TopicReloader } from "./components/topic_reloader";
import { TopicStateTracker } from "./components/topic_state_tracker";
import { TopicInlineReply } from "./components/topic_inline_reply";
import { TopicQuickReply } from "./components/topic_quick_reply";

domready(function(): void {
    new BoardSelector();

M assets/app/javascripts/components/anchor_popover.ts => assets/app/javascripts/components/anchor_popover.ts +41 -45
@@ 1,22 1,21 @@
import {VNode, create, diff, patch, h} from 'virtual-dom';

import {DelegationComponent} from './base';
import {TopicManager} from './topic_manager';
import {Board} from '../models/board';
import {Post} from '../models/post';
import {Topic} from '../models/topic';
import {BoardView} from '../views/board_view';
import {PopoverView} from '../views/popover_view';
import {PostCollectionView} from '../views/post_collection_view';
import {TopicView} from '../views/topic_view';
import {CancellableToken, CancelToken} from '../utils/cancellable';

import { VNode, create, diff, patch, h } from "virtual-dom";

import { DelegationComponent } from "./base";
import { TopicManager } from "./topic_manager";
import { Board } from "../models/board";
import { Post } from "../models/post";
import { Topic } from "../models/topic";
import { BoardView } from "../views/board_view";
import { PopoverView } from "../views/popover_view";
import { PostCollectionView } from "../views/post_collection_view";
import { TopicView } from "../views/topic_view";
import { CancellableToken, CancelToken } from "../utils/cancellable";

export class AnchorPopover extends DelegationComponent {
    public targetSelector = '[data-anchor]';
    public targetSelector = "[data-anchor]";

    protected bindGlobal(): void {
        document.body.addEventListener('mouseover', (e: Event): void => {
        document.body.addEventListener("mouseover", (e: Event): void => {
            if (e.target instanceof Element) {
                if (e.target.matches(this.targetSelector)) {
                    e.preventDefault();


@@ 27,17 26,17 @@ export class AnchorPopover extends DelegationComponent {
    }

    private static attach($target: Element): void {
        let $parent = $target.closest('.js-popover');
        let $parent = $target.closest(".js-popover");

        if (!$parent) {
            $parent = $target.closest('[data-topic]');
            $parent = $target.closest("[data-topic]");
        }

        if ($parent) {
            let cancelToken = new CancelToken();
            let boardSlug = $target.getAttribute('data-anchor-board') || '';
            let postNumber = $target.getAttribute('data-anchor') || '';
            let anchorAttr = $target.getAttribute('data-anchor-topic');
            let boardSlug = $target.getAttribute("data-anchor-board") || "";
            let postNumber = $target.getAttribute("data-anchor") || "";
            let anchorAttr = $target.getAttribute("data-anchor-topic");
            let topicId: number = 0;

            if (anchorAttr) {


@@ 49,31 48,31 @@ export class AnchorPopover extends DelegationComponent {
                    return Post.queryAll(topicId, postNumber, cancelToken).then(
                        (posts: Post[]): VNode => {
                            return new PostCollectionView(posts).render();
                        }
                        },
                    );
                } else if (topicId && !postNumber) {
                    return Topic.queryId(topicId, cancelToken).then(
                        (topic: Topic): VNode => {
                            return new TopicView(topic).render();
                        }
                        },
                    );
                } else {
                    return Board.querySlug(boardSlug, cancelToken).then(
                        (board: Board): VNode => {
                            return new BoardView(board).render();
                        }
                        },
                    );
                }
            }
            };

            let _finalizeRequest = () => {
                cancelToken.cancel();
                $target.removeEventListener('mouseout', _finalizeRequest);
            }
                $target.removeEventListener("mouseout", _finalizeRequest);
            };

            $target.addEventListener('mouseout', _finalizeRequest);
            $target.addEventListener("mouseout", _finalizeRequest);

            _render().then((node) => {
            _render().then(node => {
                let $popover: Element;
                let popoverView = new PopoverView(node);
                let popoverNode = popoverView.render($target);


@@ 83,24 82,21 @@ export class AnchorPopover extends DelegationComponent {
                let _detach = (): void => {
                    if ($parent && $popover && $popover.parentNode) {
                        $parent.removeChild($popover);
                        $target.removeEventListener('mouseout', _detachOnTarget);
                        $target.removeEventListener("mouseout", _detachOnTarget);
                        document.body.removeEventListener(
                            'mouseover',
                             _detachOnPopover
                            "mouseover",
                            _detachOnPopover,
                        );
                    }
                }
                };

                let _switchDetachMode = (e: Event): void => {
                    e.preventDefault();
                    clearTimeout(dismissTimer);

                    $popover.removeEventListener('mouseover', _switchDetachMode);
                    document.body.addEventListener(
                        'mouseover',
                        _detachOnPopover
                    );
                }
                    $popover.removeEventListener("mouseover", _switchDetachMode);
                    document.body.addEventListener("mouseover", _detachOnPopover);
                };

                let _detachOnPopover = (e: Event): void => {
                    e.preventDefault();


@@ 113,7 109,7 @@ export class AnchorPopover extends DelegationComponent {
                    if (_n != $popover) {
                        _detach();
                    }
                }
                };

                let _detachOnTarget = (e: Event): void => {
                    e.preventDefault();


@@ 128,7 124,7 @@ export class AnchorPopover extends DelegationComponent {
                    } else {
                        _detach();
                    }
                }
                };

                _finalizeRequest();



@@ 136,11 132,11 @@ export class AnchorPopover extends DelegationComponent {
                // another topic so that quick reply and etc. binds to the
                // correct context.
                if (topicId && postNumber) {
                    let $localTopic = $target.closest('[data-topic]');
                    let $localTopic = $target.closest("[data-topic]");
                    let localTopicId: number = 0;

                    if ($localTopic) {
                        let topicAttr = $localTopic.getAttribute('data-topic');
                        let topicAttr = $localTopic.getAttribute("data-topic");
                        if (topicAttr) {
                            localTopicId = parseInt(topicAttr, 10);
                        }


@@ 151,7 147,7 @@ export class AnchorPopover extends DelegationComponent {
                        popoverNode = popoverView.render($target, {
                            dataset: {
                                topic: topicId,
                            }
                            },
                        });
                    }
                }


@@ 164,8 160,8 @@ export class AnchorPopover extends DelegationComponent {
                        new TopicManager($parent);
                    }

                    $target.addEventListener('mouseout', _detachOnTarget);
                    $popover.addEventListener('mouseover', _switchDetachMode);
                    $target.addEventListener("mouseout", _detachOnTarget);
                    $popover.addEventListener("mouseover", _switchDetachMode);
                }
            });
        }

M assets/app/javascripts/components/base.ts => assets/app/javascripts/components/base.ts +4 -8
@@ 1,11 1,9 @@
import {NotImplementedError} from '../utils/errors';

import { NotImplementedError } from "../utils/errors";

export interface IComponent {
    targetSelector: string;
}


export class DelegationComponent implements IComponent {
    public targetSelector: string;



@@ 16,11 14,10 @@ export class DelegationComponent implements IComponent {
    }

    protected bindGlobal(): void {
        throw new NotImplementedError;
        throw new NotImplementedError();
    }
}


export class SingletonComponent implements IComponent {
    public targetSelector: string;



@@ 40,11 37,10 @@ export class SingletonComponent implements IComponent {
    }

    protected bindOne(element: Element): void {
        throw new NotImplementedError;
        throw new NotImplementedError();
    }
}


export class CollectionComponent implements IComponent {
    public targetSelector: string;



@@ 68,6 64,6 @@ export class CollectionComponent implements IComponent {
    }

    protected bindOne($target: Element): void {
        throw new NotImplementedError;
        throw new NotImplementedError();
    }
}

M assets/app/javascripts/components/board_selector.ts => assets/app/javascripts/components/board_selector.ts +25 -28
@@ 1,16 1,14 @@
import {VNode, create, diff, h, patch} from 'virtual-dom';

import {SingletonComponent} from './base';
import {Board} from '../models/board';
import {BoardSelectorView} from '../views/board_selector_view';
import {addClass} from '../utils/elements';
import { VNode, create, diff, h, patch } from "virtual-dom";

import { SingletonComponent } from "./base";
import { Board } from "../models/board";
import { BoardSelectorView } from "../views/board_selector_view";
import { addClass } from "../utils/elements";

const animationDuration = 200;


export class BoardSelector extends SingletonComponent {
    public targetSelector = '[data-board-selector]';
    public targetSelector = "[data-board-selector]";

    protected bindOne($element: Element): void {
        let $button: Element;


@@ 29,25 27,25 @@ export class BoardSelector extends SingletonComponent {
                    document.body.insertBefore($selector, $element.nextSibling);
                });
            } else {
                return new Promise((resolve) => {
                return new Promise(resolve => {
                    resolve();
                });
            }
        }
        };

        let _update = (height: number): void => {
            if ($selector && selectorView && selectorNode) {
                let newSelectorNode = selectorView.render({
                    style: {
                        height: `${height}px`,
                    }
                    },
                });

                let patches = diff(selectorNode, newSelectorNode);
                $selector = patch($selector, patches);
                selectorNode = newSelectorNode;
            }
        }
        };

        let _animate = (updateFn: ((elapsedPercent: number) => void)) => {
            let startTime: number = 0;


@@ 57,30 55,29 @@ export class BoardSelector extends SingletonComponent {
                }

                let elapsed = Math.min(time - startTime, animationDuration);
                let elapsedPercent = elapsed/animationDuration;
                let elapsedPercent = elapsed / animationDuration;

                updateFn(elapsedPercent);

                if (elapsed < animationDuration) {
                    requestAnimationFrame(_animateStep);
                }
            }
            };

            requestAnimationFrame(_animateStep);
        }
        };

        $button = create(h('div',
            {className: 'js-board-selector-button'},
            [h('a', {'href': '#'}, ['Boards'])]
        ));
        $button = create(
            h("div", { className: "js-board-selector-button" }, [
                h("a", { href: "#" }, ["Boards"]),
            ]),
        );

        $button.addEventListener('click', (e) => {
        $button.addEventListener("click", e => {
            e.preventDefault();
            _render().then(() => {
                if ($selector) {
                    let selectorHeight = BoardSelector.getSelectorHeight(
                        $selector
                    );
                    let selectorHeight = BoardSelector.getSelectorHeight($selector);

                    if (selectorState) {
                        selectorState = false;


@@ 97,8 94,8 @@ export class BoardSelector extends SingletonComponent {
            });
        });

        $element.querySelector('.container').appendChild($button);
        addClass($element, ['js-board-selector-wrapper']);
        $element.querySelector(".container").appendChild($button);
        addClass($element, ["js-board-selector-wrapper"]);

        // Attempt to restore height on resize. Since the resize may cause
        // clientHeight to change (and will cause the board selector to be


@@ 106,7 103,7 @@ export class BoardSelector extends SingletonComponent {
        //
        // Do nothing if resize was called before board selector was attached
        // or if selector was attached but not displayed.
        window.addEventListener('resize', (e: Event) => {
        window.addEventListener("resize", (e: Event) => {
            clearTimeout(throttleTimer);
            throttleTimer = setTimeout(() => {
                if ($selector && selectorState) {


@@ 118,12 115,12 @@ export class BoardSelector extends SingletonComponent {
    }

    private static getSelectorHeight($selector: Element): number {
        let $el = $selector.querySelector('.js-board-selector-inner');
        let $el = $selector.querySelector(".js-board-selector-inner");

        if ($el) {
            return $el.clientHeight;
        }

        throw new Error('Could not retrieve board selector height.');
        throw new Error("Could not retrieve board selector height.");
    }
}

M assets/app/javascripts/components/theme_selector.ts => assets/app/javascripts/components/theme_selector.ts +21 -24
@@ 1,57 1,54 @@
import Cookies = require('js-cookie');
import {VNode, create, diff, patch, h} from 'virtual-dom';
import {SingletonComponent} from './base';
import {ThemeSelectorView, ITheme} from '../views/theme_selector_view';

import Cookies = require("js-cookie");
import { VNode, create, diff, patch, h } from "virtual-dom";
import { SingletonComponent } from "./base";
import { ThemeSelectorView, ITheme } from "../views/theme_selector_view";

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


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


export class ThemeSelector extends SingletonComponent {
    public targetSelector = '[data-theme-selector]';
    public targetSelector = "[data-theme-selector]";

    protected bindOne($target: Element) {
        let selectorView = new ThemeSelectorView(themes);
        let selectorNode = selectorView.render(
            document.body.className.replace(/theme-/, '')
            document.body.className.replace(/theme-/, ""),
        );

        let $selector = create(selectorNode);

        $target.appendChild($selector);
        $target.addEventListener('click', (e: Event) => {
        $target.addEventListener("click", (e: Event) => {
            let $click = e.target;

            if (
                $click instanceof Element &&
                $click.matches('[data-theme-selector-item]')
                $click.matches("[data-theme-selector-item]")
            ) {
                let themeId = $click.getAttribute('data-theme-selector-item');
                let themeId = $click.getAttribute("data-theme-selector-item");

                e.preventDefault();

                if (themeId) {
                  let viewNode = selectorView.render(themeId);
                  let patches = diff(selectorNode, viewNode);
                    let viewNode = selectorView.render(themeId);
                    let patches = diff(selectorNode, viewNode);

                  $selector = patch($selector, patches);
                  selectorNode = viewNode;
                    $selector = patch($selector, patches);
                    selectorNode = viewNode;

                  document.body.className = `theme-${themeId}`;
                  Cookies.set('_theme', themeId, {
                      expires: 365,
                      path: '/'
                  });
                    document.body.className = `theme-${themeId}`;
                    Cookies.set("_theme", themeId, {
                        expires: 365,
                        path: "/",
                    });
                }
            }
        });

M assets/app/javascripts/components/topic/base.ts => assets/app/javascripts/components/topic/base.ts +1 -2
@@ 2,9 2,8 @@ export interface ITopicEventConstructor {
    new (topicId: number, element: Element): ITopicEventHandler;
}


export interface ITopicEventHandler {
    topicId: number;
    element: Element;
    bind (event: CustomEvent): void;
    bind(event: CustomEvent): void;
}

M assets/app/javascripts/components/topic/topic_load_posts.ts => assets/app/javascripts/components/topic/topic_load_posts.ts +32 -42
@@ 1,11 1,10 @@
import {VNode, create, diff, patch} from 'virtual-dom';

import {ITopicEventHandler} from './base';
import {Post} from '../../models/post';
import {PostCollectionView} from '../../views/post_collection_view';
import {dispatchCustomEvent} from '../../utils/elements';
import {LoadingState} from '../../utils/loading';
import { VNode, create, diff, patch } from "virtual-dom";

import { ITopicEventHandler } from "./base";
import { Post } from "../../models/post";
import { PostCollectionView } from "../../views/post_collection_view";
import { dispatchCustomEvent } from "../../utils/elements";
import { LoadingState } from "../../utils/loading";

export class TopicLoadPosts implements ITopicEventHandler {
    lastPostNumber: number;


@@ 16,7 15,7 @@ export class TopicLoadPosts implements ITopicEventHandler {
    loadingState: LoadingState;

    constructor(public topicId: number, public element: Element) {
        let postNumbers = element.querySelectorAll('.post-header-item.number');
        let postNumbers = element.querySelectorAll(".post-header-item.number");
        let lastPostNumber = postNumbers[postNumbers.length - 1];

        this.loadedPosts = [];


@@ 28,40 27,37 @@ export class TopicLoadPosts implements ITopicEventHandler {
        let callback: ((lastPostNumber: number) => void) = e.detail.callback;

        this.loadingState.bind(() => {
            return Post.queryAll(
                this.topicId,
                `${this.lastPostNumber + 1}-`
            ).then((posts: Post[]) => {
                if (posts && posts.length) {
                    this.lastPostNumber = posts[posts.length - 1].number;
                    this.loadedPosts = this.loadedPosts.concat(posts);
                    this.appendPosts();
                    this.updateHistoryState();
                }

                if (callback) {
                    callback(this.lastPostNumber);
                }

                dispatchCustomEvent(this.element, 'postsLoaded', {
                    lastPostNumber: this.lastPostNumber
                });
            });
            return Post.queryAll(this.topicId, `${this.lastPostNumber + 1}-`).then(
                (posts: Post[]) => {
                    if (posts && posts.length) {
                        this.lastPostNumber = posts[posts.length - 1].number;
                        this.loadedPosts = this.loadedPosts.concat(posts);
                        this.appendPosts();
                        this.updateHistoryState();
                    }

                    if (callback) {
                        callback(this.lastPostNumber);
                    }

                    dispatchCustomEvent(this.element, "postsLoaded", {
                        lastPostNumber: this.lastPostNumber,
                    });
                },
            );
        });
    }

    private appendPosts(): void {
        let newCollectionNode = new PostCollectionView(
            this.loadedPosts
        ).render();
        let newCollectionNode = new PostCollectionView(this.loadedPosts).render();

        if (!this.collectionElement) {
            this.collectionElement = create(newCollectionNode);
            let postElements = this.element.querySelectorAll('.post');
            let postElements = this.element.querySelectorAll(".post");
            let lastElement = postElements[postElements.length - 1];
            lastElement.parentElement.insertBefore(
                this.collectionElement,
                lastElement.nextSibling
                lastElement.nextSibling,
            );
        } else {
            let patches = diff(this.collectionNode, newCollectionNode);


@@ 74,25 70,19 @@ export class TopicLoadPosts implements ITopicEventHandler {
    private updateHistoryState(): void {
        if (window.history.replaceState) {
            let path = window.location.pathname;
            let newPath: string = '';
            let newPath: string = "";

            if (path.match(/\/\d+\/\d+\/?$/)) {
                newPath = path.replace(
                    /\/(\d+)\/?$/,
                    `/$1-${this.lastPostNumber}/`
                );
                newPath = path.replace(/\/(\d+)\/?$/, `/$1-${this.lastPostNumber}/`);
            } else if (path.match(/\-\d+\/?$/)) {
                newPath = path.replace(
                    /(\d+)\/?$/,
                    `${this.lastPostNumber}/`
                );
                newPath = path.replace(/(\d+)\/?$/, `${this.lastPostNumber}/`);
            }

            if (newPath) {
                window.history.replaceState(
                    undefined,
                    `Posts up to ${this.lastPostNumber}`,
                    newPath
                    newPath,
                );
            }
        }

M assets/app/javascripts/components/topic/topic_new_post.ts => assets/app/javascripts/components/topic/topic_new_post.ts +25 -22
@@ 1,9 1,8 @@
import {ITopicEventHandler} from './base';
import {Post} from '../../models/post';
import {dispatchCustomEvent} from '../../utils/elements';
import {ResourceError} from '../../utils/errors';
import {LoadingState} from '../../utils/loading';

import { ITopicEventHandler } from "./base";
import { Post } from "../../models/post";
import { dispatchCustomEvent } from "../../utils/elements";
import { ResourceError } from "../../utils/errors";
import { LoadingState } from "../../utils/loading";

export class TopicNewPost implements ITopicEventHandler {
    loadingState: LoadingState;


@@ 17,26 16,30 @@ export class TopicNewPost implements ITopicEventHandler {
        let callback: ((lastPostNumber: number) => void) = e.detail.callback;
        let errCb: ((error: ResourceError) => void) = e.detail.errorCallback;

        if (!params) { throw new Error('newPost require a params'); }
        if (!params) {
            throw new Error("newPost require a params");
        }

        this.loadingState.bind(() => {
            return Post.createOne(this.topicId, params).then(() => {
                dispatchCustomEvent(this.element, 'postCreated');
                dispatchCustomEvent(this.element, 'loadPosts', {
                    callback: (lastPostNumber: number) => {
                        if (callback) {
                            callback(lastPostNumber);
                        }
            return Post.createOne(this.topicId, params)
                .then(() => {
                    dispatchCustomEvent(this.element, "postCreated");
                    dispatchCustomEvent(this.element, "loadPosts", {
                        callback: (lastPostNumber: number) => {
                            if (callback) {
                                callback(lastPostNumber);
                            }
                        },
                    });
                })
                .catch((error: ResourceError) => {
                    if (errCb) {
                        errCb(error);
                        dispatchCustomEvent(this.element, "postCreateError", {
                            error: error,
                        });
                    }
                });
            }).catch((error: ResourceError) => {
                if (errCb) {
                    errCb(error);
                    dispatchCustomEvent(this.element, 'postCreateError', {
                        error: error
                    });
                }
            });
        });
    }
}

M assets/app/javascripts/components/topic/topic_state.ts => assets/app/javascripts/components/topic/topic_state.ts +16 -11
@@ 1,5 1,4 @@
import {ITopicEventHandler} from './base';

import { ITopicEventHandler } from "./base";

export class TopicState implements ITopicEventHandler {
    stateName: string;


@@ 10,12 9,12 @@ export class TopicState implements ITopicEventHandler {

    bind(event: CustomEvent): void {
        switch (event.type) {
        case 'readState':
            this.bindReadState(event);
            break;
        case 'updateState':
            this.bindUpdateState(event);
            break;
            case "readState":
                this.bindReadState(event);
                break;
            case "updateState":
                this.bindUpdateState(event);
                break;
        }
    }



@@ 23,8 22,12 @@ export class TopicState implements ITopicEventHandler {
        let name: string = e.detail.name;
        let callback: ((name: string, value: any) => void) = e.detail.callback;

        if (!name) { throw new Error('readState require a name'); }
        if (!callback) { throw new Error('readState require a callback'); }
        if (!name) {
            throw new Error("readState require a name");
        }
        if (!callback) {
            throw new Error("readState require a callback");
        }

        let state = this.readState();
        callback(name, state[name]);


@@ 35,7 38,9 @@ export class TopicState implements ITopicEventHandler {
        let value: any = e.detail.value;
        let callback: ((name: string, value: any) => void) = e.detail.callback;

        if (!name) { throw new Error('updateState require a name'); }
        if (!name) {
            throw new Error("updateState require a name");
        }

        let state = this.readState();
        state[name] = value;

M assets/app/javascripts/components/topic_inline_reply.ts => assets/app/javascripts/components/topic_inline_reply.ts +12 -13
@@ 1,28 1,27 @@
import {create, h} from 'virtual-dom';

import {SingletonComponent} from './base';
import {ResourceError} from '../utils/errors';
import {dispatchCustomEvent} from '../utils/elements';
import {attachErrors, detachErrors, serializeForm} from '../utils/forms';
import {LoadingState} from '../utils/loading';
import { create, h } from "virtual-dom";

import { SingletonComponent } from "./base";
import { ResourceError } from "../utils/errors";
import { dispatchCustomEvent } from "../utils/elements";
import { attachErrors, detachErrors, serializeForm } from "../utils/forms";
import { LoadingState } from "../utils/loading";

export class TopicInlineReply extends SingletonComponent {
    public targetSelector = '[data-topic-inline-reply]';
    public targetSelector = "[data-topic-inline-reply]";

    protected bindOne($target: Element) {
        let $form = $target;

        if ($form instanceof HTMLFormElement) {
            let $button = $target.querySelector('button');
            let $button = $target.querySelector("button");
            let loadingState = new LoadingState();

            $form.addEventListener('submit', (e: Event): void => {
            $form.addEventListener("submit", (e: Event): void => {
                e.preventDefault();
                loadingState.bind(() => {
                    return new Promise((resolve) => {
                    return new Promise(resolve => {
                        if ($form instanceof HTMLFormElement) {
                            dispatchCustomEvent($form, 'newPost', {
                            dispatchCustomEvent($form, "newPost", {
                                params: serializeForm($form),
                                callback: () => {
                                    if ($form instanceof HTMLFormElement) {


@@ 37,7 36,7 @@ export class TopicInlineReply extends SingletonComponent {
                                        attachErrors($form, error);
                                        resolve();
                                    }
                                }
                                },
                            });
                        }
                    });

M assets/app/javascripts/components/topic_manager.ts => assets/app/javascripts/components/topic_manager.ts +14 -15
@@ 1,24 1,23 @@
import {VNode, create, diff, patch} from 'virtual-dom';
import {CollectionComponent} from './base';
import {ITopicEventConstructor, ITopicEventHandler} from './topic/base';
import {TopicState} from './topic/topic_state';
import {TopicLoadPosts} from './topic/topic_load_posts';
import {TopicNewPost} from './topic/topic_new_post';

import { VNode, create, diff, patch } from "virtual-dom";
import { CollectionComponent } from "./base";
import { ITopicEventConstructor, ITopicEventHandler } from "./topic/base";
import { TopicState } from "./topic/topic_state";
import { TopicLoadPosts } from "./topic/topic_load_posts";
import { TopicNewPost } from "./topic/topic_new_post";

export class TopicManager extends CollectionComponent {
    public targetSelector = '[data-topic]';
    public targetSelector = "[data-topic]";

    protected bindOne($target: Element): void {
        let topicIdAttr = $target.getAttribute('data-topic');
        let topicIdAttr = $target.getAttribute("data-topic");

        if (topicIdAttr) {
          let topicId = parseInt(topicIdAttr, 10);
            let topicId = parseInt(topicIdAttr, 10);

          this.bindEvent($target, topicId, 'updateState', TopicState);
          this.bindEvent($target, topicId, 'readState',   TopicState);
          this.bindEvent($target, topicId, 'loadPosts',   TopicLoadPosts);
          this.bindEvent($target, topicId, 'newPost',     TopicNewPost);
            this.bindEvent($target, topicId, "updateState", TopicState);
            this.bindEvent($target, topicId, "readState", TopicState);
            this.bindEvent($target, topicId, "loadPosts", TopicLoadPosts);
            this.bindEvent($target, topicId, "newPost", TopicNewPost);
        }
    }



@@ 26,7 25,7 @@ export class TopicManager extends CollectionComponent {
        $target: Element,
        topicId: number,
        eventName: string,
        eventHandler: ITopicEventConstructor
        eventHandler: ITopicEventConstructor,
    ): void {
        let handler = new eventHandler(topicId, $target);


M assets/app/javascripts/components/topic_quick_reply.ts => assets/app/javascripts/components/topic_quick_reply.ts +30 -43
@@ 1,39 1,33 @@
import {VNode, create, diff, patch} from 'virtual-dom';
import { VNode, create, diff, patch } from "virtual-dom";

import {DelegationComponent} from './base';
import {TopicInlineReply} from './topic_inline_reply';
import {TopicStateTracker} from './topic_state_tracker';

import {PopoverView} from '../views/popover_view';
import {PostForm} from '../views/post_form';
import {dispatchCustomEvent} from '../utils/elements';
import { DelegationComponent } from "./base";
import { TopicInlineReply } from "./topic_inline_reply";
import { TopicStateTracker } from "./topic_state_tracker";

import { PopoverView } from "../views/popover_view";
import { PostForm } from "../views/post_form";
import { dispatchCustomEvent } from "../utils/elements";

export class TopicQuickReply extends DelegationComponent {
    public targetSelector = '[data-topic-quick-reply]';
    public targetSelector = "[data-topic-quick-reply]";

    protected bindGlobal(): void {
        document.body.addEventListener('click', (e: Event): void => {
        document.body.addEventListener("click", (e: Event): void => {
            let $target = e.target;

            if (
                $target instanceof Element &&
                $target.matches(this.targetSelector)
            ) {
                let anchor = $target.getAttribute('data-topic-quick-reply');
                let $topic = $target.closest('[data-topic]');
            if ($target instanceof Element && $target.matches(this.targetSelector)) {
                let anchor = $target.getAttribute("data-topic-quick-reply");
                let $topic = $target.closest("[data-topic]");
                let $input: Element;

                e.preventDefault();

                if (!anchor) {
                    throw new Error('Reply anchor is empty when it should not.');
                    throw new Error("Reply anchor is empty when it should not.");
                }

                if ($topic) {
                    $input = $topic.querySelector(
                        '[data-topic-quick-reply-input]'
                    );
                    $input = $topic.querySelector("[data-topic-quick-reply-input]");

                    if ($input instanceof HTMLTextAreaElement) {
                        this.insertTextAtCursor($input, anchor);


@@ 45,10 39,7 @@ export class TopicQuickReply extends DelegationComponent {
        });
    }

    private insertTextAtCursor(
        $input: HTMLTextAreaElement,
        anchor: string
    ): void {
    private insertTextAtCursor($input: HTMLTextAreaElement, anchor: string): void {
        let anchorText = `>>${anchor} `;
        let startPos = $input.selectionStart;
        let endPos = $input.selectionEnd;


@@ 60,16 51,12 @@ export class TopicQuickReply extends DelegationComponent {
            currentValue.substring(endPos, currentValue.length);

        $input.focus();
        $input.dispatchEvent(new Event('change'));
        $input.dispatchEvent(new Event("change"));
        $input.selectionStart = startPos + anchorText.length;
    }

    private attachForm(
        $target: Element,
        $topic: Element,
        anchor: string
    ): void {
        let $parent = $target.closest('.js-popover');
    private attachForm($target: Element, $topic: Element, anchor: string): void {
        let $parent = $target.closest(".js-popover");
        let $popover: Element;
        let $textarea: Element;
        let popoverView: PopoverView;


@@ 81,19 68,19 @@ export class TopicQuickReply extends DelegationComponent {
        }

        let _removePopover = () => {
            $topic.removeEventListener('postCreated', _removePopover);
            document.body.removeEventListener('click', _clickRemovePopover);
            window.removeEventListener('resize', _repositionPopover);
            $topic.removeEventListener("postCreated", _removePopover);
            document.body.removeEventListener("click", _clickRemovePopover);
            window.removeEventListener("resize", _repositionPopover);

            if ($parent) {
              $parent.removeChild($popover);
                $parent.removeChild($popover);
            }
        }
        };

        popoverView = new PopoverView(
            new PostForm().render(),
            "Quick Reply",
            _removePopover
            _removePopover,
        );

        let _repositionPopover = () => {


@@ 107,7 94,7 @@ export class TopicQuickReply extends DelegationComponent {
                    popoverNode = newPopoverNode;
                }
            }, 100);
        }
        };

        let _clickRemovePopover = (e: Event) => {
            let _n = e.target;


@@ 119,7 106,7 @@ export class TopicQuickReply extends DelegationComponent {
            if (_n != $popover) {
                _removePopover();
            }
        }
        };

        popoverNode = popoverView.render($target);
        $popover = create(popoverNode);


@@ 128,13 115,13 @@ export class TopicQuickReply extends DelegationComponent {
        new TopicInlineReply($popover);
        new TopicStateTracker($popover);

        $textarea = $popover.querySelector('textarea');
        $textarea = $popover.querySelector("textarea");
        if ($textarea instanceof HTMLTextAreaElement) {
            this.insertTextAtCursor($textarea, anchor);
        }

        document.body.addEventListener('click', _clickRemovePopover);
        $topic.addEventListener('postCreated', _removePopover);
        window.addEventListener('resize', _repositionPopover);
        document.body.addEventListener("click", _clickRemovePopover);
        $topic.addEventListener("postCreated", _removePopover);
        window.addEventListener("resize", _repositionPopover);
    }
}

M assets/app/javascripts/components/topic_reloader.ts => assets/app/javascripts/components/topic_reloader.ts +24 -25
@@ 1,40 1,39 @@
import {SingletonComponent} from './base';
import {dispatchCustomEvent} from '../utils/elements';
import {LoadingState} from '../utils/loading';

import { SingletonComponent } from "./base";
import { dispatchCustomEvent } from "../utils/elements";
import { LoadingState } from "../utils/loading";

export class TopicReloader extends SingletonComponent {
    public targetSelector = '[data-topic-reloader]';
    public targetSelector = "[data-topic-reloader]";

    protected bindOne($target: Element) {
        let $topic = $target.closest('[data-topic]');
        let $topic = $target.closest("[data-topic]");
        let loadingState = new LoadingState();

        $target.addEventListener('click', (e: Event) => {
        $target.addEventListener("click", (e: Event) => {
            e.preventDefault();
            loadingState.bind(() => {
                return new Promise((resolve) => {
                    dispatchCustomEvent($target, 'loadPosts', {
                return new Promise(resolve => {
                    dispatchCustomEvent($target, "loadPosts", {
                        callback: () => {
                            resolve();
                        }
                        },
                    });
                });
            }, $target);
        });

        if ($topic) {
          $topic.addEventListener('postsLoaded', (e: CustomEvent) => {
              this.updateButtonAlt($target);
              this.refreshButtonState($target, e.detail.lastPostNumber);
              this.updateButtonAlt($target);
          });
            $topic.addEventListener("postsLoaded", (e: CustomEvent) => {
                this.updateButtonAlt($target);
                this.refreshButtonState($target, e.detail.lastPostNumber);
                this.updateButtonAlt($target);
            });
        }
    }

    private updateButtonAlt($target: Element): void {
        let altLabel = $target.getAttribute('data-topic-reloader-label');
        let altClass = $target.getAttribute('data-topic-reloader-class');
        let altLabel = $target.getAttribute("data-topic-reloader-label");
        let altClass = $target.getAttribute("data-topic-reloader-class");

        if (altLabel) {
            $target.innerHTML = altLabel;


@@ 46,16 45,16 @@ export class TopicReloader extends SingletonComponent {
    }

    private refreshButtonState($target: Element, lastPostNumber: number): void {
        let originalHref = $target.getAttribute('href');
        let originalHref = $target.getAttribute("href");

        if (originalHref) {
          $target.setAttribute(
              'href',
              originalHref.replace(
                  /^(\/\w+\/\d+)\/\d+\-\/$/,
                  `$1/${lastPostNumber}-/`
              )
          );
            $target.setAttribute(
                "href",
                originalHref.replace(
                    /^(\/\w+\/\d+)\/\d+\-\/$/,
                    `$1/${lastPostNumber}-/`,
                ),
            );
        }
    }
}

M assets/app/javascripts/components/topic_state_tracker.ts => assets/app/javascripts/components/topic_state_tracker.ts +20 -27
@@ 1,72 1,65 @@
import {CollectionComponent} from './base';
import {dispatchCustomEvent} from '../utils/elements';

import { CollectionComponent } from "./base";
import { dispatchCustomEvent } from "../utils/elements";

export class TopicStateTracker extends CollectionComponent {
    public targetSelector = '[data-topic-state-tracker]';
    public targetSelector = "[data-topic-state-tracker]";

    protected bindOne($target: Element): void {
        let trackerName = $target.getAttribute('data-topic-state-tracker');
        let trackerName = $target.getAttribute("data-topic-state-tracker");

        if (!trackerName) {
            throw new Error('State tracker name is empty when it should not.');
            throw new Error("State tracker name is empty when it should not.");
        }

        if ($target instanceof HTMLInputElement) {
            switch ($target.type) {
            case 'checkbox':
                this.bindCheckbox(trackerName, $target);
                break;
                case "checkbox":
                    this.bindCheckbox(trackerName, $target);
                    break;
            }
        } else if ($target instanceof HTMLTextAreaElement) {
            this.bindTextarea(trackerName, $target);
        }
    }

    private bindCheckbox(
        trackerName: string,
        $target: HTMLInputElement
    ): void {
        dispatchCustomEvent($target, 'readState', {
    private bindCheckbox(trackerName: string, $target: HTMLInputElement): void {
        dispatchCustomEvent($target, "readState", {
            name: trackerName,
            callback: (name: string, value?: boolean) => {
                if (value != undefined) {
                    $target.checked = value;
                    $target.defaultChecked = $target.checked;
                }
            }
            },
        });

        $target.addEventListener('change', (e: Event): void => {
        $target.addEventListener("change", (e: Event): void => {
            $target.defaultChecked = $target.checked;
            dispatchCustomEvent($target, 'updateState', {
            dispatchCustomEvent($target, "updateState", {
                name: trackerName,
                value: $target.checked
                value: $target.checked,
            });
        });
    }

    private bindTextarea(
        trackerName: string,
        $target: HTMLTextAreaElement
    ): void {
    private bindTextarea(trackerName: string, $target: HTMLTextAreaElement): void {
        let throttleTimer: number;

        dispatchCustomEvent($target, 'readState', {
        dispatchCustomEvent($target, "readState", {
            name: trackerName,
            callback: (name: string, value?: string) => {
                if (value != undefined) {
                    $target.value = value;
                }
            }
            },
        });

        $target.addEventListener('change', (e: Event): void => {
        $target.addEventListener("change", (e: Event): void => {
            clearTimeout(throttleTimer);
            throttleTimer = setTimeout(() => {
                dispatchCustomEvent($target, 'updateState', {
                dispatchCustomEvent($target, "updateState", {
                    name: trackerName,
                    value: $target.value
                    value: $target.value,
                });
            }, 500);
        });

M assets/app/javascripts/models/base.ts => assets/app/javascripts/models/base.ts +7 -12
@@ 1,11 1,9 @@
import {NotImplementedError, ResourceError} from '../utils/errors';

import { NotImplementedError, ResourceError } from "../utils/errors";

export interface IModelData {
    [key: string]: any;
}


export class Model {
    constructor(data: IModelData) {
        this.throwError(data);


@@ 13,23 11,20 @@ export class Model {
    }

    protected serialize(data: IModelData) {
        throw new NotImplementedError;
        throw new NotImplementedError();
    }

    private throwError(data: IModelData) {
        if (data['type'] == 'error') {
            throw new ResourceError(
                'An error object was returned from the API.',
                data
            );
        if (data["type"] == "error") {
            throw new ResourceError("An error object was returned from the API.", data);
        }
    }

    static assertType(data: IModelData, type: string) {
        if (data['type'] != type) {
        if (data["type"] != type) {
            throw new ResourceError(
                `Expected ${type} type but got ${data['type']}.`,
                data
                `Expected ${type} type but got ${data["type"]}.`,
                data,
            );
        }
    }

M assets/app/javascripts/models/board.ts => assets/app/javascripts/models/board.ts +27 -33
@@ 1,8 1,7 @@
import {Model, IModelData} from './base';
import {Topic} from './topic';
import {CancellableToken} from '../utils/cancellable';
import {request} from '../utils/request';

import { Model, IModelData } from "./base";
import { Topic } from "./topic";
import { CancellableToken } from "../utils/cancellable";
import { request } from "../utils/request";

export class Board extends Model {
    type: string;


@@ 20,48 19,43 @@ export class Board extends Model {
    path: string;

    protected serialize(data: IModelData) {
        Model.assertType(data, 'board');
        Model.assertType(data, "board");

        this.type = data['type'];
        this.id = data['id'];
        this.agreements = data['agreements'];
        this.description = data['description'];
        this.slug = data['slug'];
        this.title = data['title'];
        this.path = data['path'];
        this.type = data["type"];
        this.id = data["id"];
        this.agreements = data["agreements"];
        this.description = data["description"];
        this.slug = data["slug"];
        this.title = data["title"];
        this.path = data["path"];

        this.settings = {
            postDelay: data['settings']['post_delay'],
            useIdent: data['settings']['use_ident'],
            name: data['settings']['name'],
            maxPosts: data['settings']['max_posts'],
            postDelay: data["settings"]["post_delay"],
            useIdent: data["settings"]["use_ident"],
            name: data["settings"]["name"],
            maxPosts: data["settings"]["max_posts"],
        };
    }

    static queryAll(
        token?: CancellableToken
    ): Promise<Board[]> {
        return request('GET', '/api/1.0/boards/', {}, token).
            then((resp: string): Board[] => {
    static queryAll(token?: CancellableToken): Promise<Board[]> {
        return request("GET", "/api/1.0/boards/", {}, token).then(
            (resp: string): Board[] => {
                return JSON.parse(resp).map((data: Object) => {
                    return new Board(data);
                });
            });
            },
        );
    }

    static querySlug(
        slug: string,
        token?: CancellableToken
    ): Promise<Board> {
        return request('GET', `/api/1.0/boards/${slug}/`, {}, token).
            then((resp: string): Board => {
    static querySlug(slug: string, token?: CancellableToken): Promise<Board> {
        return request("GET", `/api/1.0/boards/${slug}/`, {}, token).then(
            (resp: string): Board => {
                return new Board(JSON.parse(resp));
            });
            },
        );
    }

    getTopics(
        token?: CancellableToken
    ): Promise<Topic[]> {
    getTopics(token?: CancellableToken): Promise<Topic[]> {
        return Topic.queryAll(this.slug, token);
    }
}

M assets/app/javascripts/models/post.ts => assets/app/javascripts/models/post.ts +23 -29
@@ 1,8 1,7 @@
import {Model, IModelData} from './base';
import {Task} from './task';
import {CancellableToken} from '../utils/cancellable';
import {request, IRequestBody} from '../utils/request';

import { Model, IModelData } from "./base";
import { Task } from "./task";
import { CancellableToken } from "../utils/cancellable";
import { request, IRequestBody } from "../utils/request";

export class Post extends Model {
    type: string;


@@ 18,25 17,25 @@ export class Post extends Model {
    path: string;

    serialize(data: IModelData) {
        Model.assertType(data, 'post');
        Model.assertType(data, "post");

        this.type = data['type'];
        this.id = data['id'];
        this.body = data['body'];
        this.bodyFormatted = data['body_formatted'];
        this.bumped = data['bumped'];
        this.createdAt = data['created_at'];
        this.ident = data['ident'];
        this.name = data['name'];
        this.number = data['number'];
        this.topicId = data['topic_id'];
        this.path = data['path'];
        this.type = data["type"];
        this.id = data["id"];
        this.body = data["body"];
        this.bodyFormatted = data["body_formatted"];
        this.bumped = data["bumped"];
        this.createdAt = data["created_at"];
        this.ident = data["ident"];
        this.name = data["name"];
        this.number = data["number"];
        this.topicId = data["topic_id"];
        this.path = data["path"];
    }

    static queryAll(
        topicId: number,
        query?: string,
        token?: CancellableToken
        token?: CancellableToken,
    ): Promise<Post[]> {
        let entryPoint = `/api/1.0/topics/${topicId}/posts/`;



@@ 44,7 43,7 @@ export class Post extends Model {
            entryPoint = `${entryPoint}${query}/`;
        }

        return request('GET', entryPoint, {}, token).then((resp) => {
        return request("GET", entryPoint, {}, token).then(resp => {
            return JSON.parse(resp).map((data: Object): Post => {
                return new Post(data);
            });


@@ 54,25 53,20 @@ export class Post extends Model {
    static createOne(
        topicId: number,
        params: IRequestBody,
        token?: CancellableToken
        token?: CancellableToken,
    ): Promise<Post> {
        return request(
            'POST',
            `/api/1.0/topics/${topicId}/posts/`,
            params,
            token
        ).then(
        return request("POST", `/api/1.0/topics/${topicId}/posts/`, params, token).then(
            (resp: string) => {
                let data: IModelData = JSON.parse(resp);

                if (data['type'] == 'task') {
                    return Task.waitFor(data['id'], token).then((task: Task) => {
                if (data["type"] == "task") {
                    return Task.waitFor(data["id"], token).then((task: Task) => {
                        return new Post(task.data);
                    });
                } else {
                    return new Post(data);
                }
            }
            },
        );
    }
}

M assets/app/javascripts/models/task.ts => assets/app/javascripts/models/task.ts +34 -30
@@ 1,8 1,7 @@
import {Model, IModelData} from './base';
import {CancellableToken} from '../utils/cancellable';
import {ResourceError} from '../utils/errors';
import {request} from '../utils/request';

import { Model, IModelData } from "./base";
import { CancellableToken } from "../utils/cancellable";
import { ResourceError } from "../utils/errors";
import { request } from "../utils/request";

enum Statuses {
    Queued,


@@ 13,52 12,57 @@ enum Statuses {
    Success,
}


export class Task extends Model {
    type: string;
    id: string;
    path: string;
    status: Statuses;
    data: {[key: string]: any};
    data: { [key: string]: any };

    serialize(data: IModelData) {
        Model.assertType(data, 'task');
        Model.assertType(data, "task");

        this.type = data['type'];
        this.id = data['id'];
        this.path = data['path'];
        this.data = data['data'];
        this.type = data["type"];
        this.id = data["id"];
        this.path = data["path"];
        this.data = data["data"];

        switch (data['status']) {
            case 'queued': this.status = Statuses.Queued; break;
            case 'pending': this.status = Statuses.Pending; break;
            case 'started': this.status = Statuses.Started; break;
            case 'retry': this.status = Statuses.Retry; break;
            case 'failure': this.status = Statuses.Failure; break;
            case 'success': this.status = Statuses.Success; break;
        switch (data["status"]) {
            case "queued":
                this.status = Statuses.Queued;
                break;
            case "pending":
                this.status = Statuses.Pending;
                break;
            case "started":
                this.status = Statuses.Started;
                break;
            case "retry":
                this.status = Statuses.Retry;
                break;
            case "failure":
                this.status = Statuses.Failure;
                break;
            case "success":
                this.status = Statuses.Success;
                break;
        }
    }

    static queryId(
        id: string,
        token?: CancellableToken
    ): Promise<Task> {
        return request('GET', `/api/1.0/tasks/${id}/`, {}, token).then(
    static queryId(id: string, token?: CancellableToken): Promise<Task> {
        return request("GET", `/api/1.0/tasks/${id}/`, {}, token).then(
            (resp: string) => {
                return new Task(JSON.parse(resp));
            }
            },
        );
    }

    static waitFor(
        id: string,
        token?: CancellableToken
    ): Promise<Task> {
    static waitFor(id: string, token?: CancellableToken): Promise<Task> {
        return Task.queryId(id, token).then((task: Task) => {
            if (task.status == Statuses.Success) {
                return task;
            } else if (task.status == Statuses.Failure) {
                throw new ResourceError('Task could not be completed.');
                throw new ResourceError("Task could not be completed.");
            } else {
                return Task.waitFor(id, token);
            }

M assets/app/javascripts/models/topic.ts => assets/app/javascripts/models/topic.ts +35 -38
@@ 1,8 1,7 @@
import {Model, IModelData} from './base';
import {Post} from './post';
import {CancellableToken} from '../utils/cancellable';
import {request} from '../utils/request';

import { Model, IModelData } from "./base";
import { Post } from "./post";
import { CancellableToken } from "../utils/cancellable";
import { request } from "../utils/request";

enum Statuses {
    Open,


@@ 10,7 9,6 @@ enum Statuses {
    Archived,
}


export class Topic extends Model {
    type: string;
    id: number;


@@ 24,51 22,50 @@ export class Topic extends Model {
    path: string;

    serialize(data: IModelData) {
        Model.assertType(data, 'topic');
        Model.assertType(data, "topic");

        this.type = data['type'];
        this.id = data['id'];
        this.boardId = data['board_id'];
        this.bumpedAt = data['bumped_at'];
        this.createdAt = data['created_at'];
        this.postCount = data['post_count'];
        this.postedAt = data['posted_at'];
        this.title = data['title'];
        this.path = data['path'];
        this.type = data["type"];
        this.id = data["id"];
        this.boardId = data["board_id"];
        this.bumpedAt = data["bumped_at"];
        this.createdAt = data["created_at"];
        this.postCount = data["post_count"];
        this.postedAt = data["posted_at"];
        this.title = data["title"];
        this.path = data["path"];

        switch (data['status']) {
            case "open" : this.status = Statuses.Open; break;
            case "locked" : this.status = Statuses.Locked; break;
            case "archived" : this.status = Statuses.Archived; break;
        switch (data["status"]) {
            case "open":
                this.status = Statuses.Open;
                break;
            case "locked":
                this.status = Statuses.Locked;
                break;
            case "archived":
                this.status = Statuses.Archived;
                break;
        }
    }

    static queryAll(
        slug: string,
        token?: CancellableToken
    ): Promise<Topic[]> {
        return request('GET', `/api/1.0/boards/${slug}/topics/`, {}, token).
            then((resp: string): Topic[] => {
    static queryAll(slug: string, token?: CancellableToken): Promise<Topic[]> {
        return request("GET", `/api/1.0/boards/${slug}/topics/`, {}, token).then(
            (resp: string): Topic[] => {
                return JSON.parse(resp).map((data: Object) => {
                    return new Topic(data);
                });
            });
            },
        );
    }

    static queryId(
        id: number,
        token?: CancellableToken
    ): Promise<Topic> {
        return request('GET', `/api/1.0/topics/${id}/`, {}, token).
            then((resp: string) => {
    static queryId(id: number, token?: CancellableToken): Promise<Topic> {
        return request("GET", `/api/1.0/topics/${id}/`, {}, token).then(
            (resp: string) => {
                return new Topic(JSON.parse(resp));
            });
            },
        );
    }

    getPosts(
        query?: string,
        token?: CancellableToken
    ): Promise<Post[]> {
    getPosts(query?: string, token?: CancellableToken): Promise<Post[]> {
        return Post.queryAll(this.id, query, token);
    }
}

M assets/app/javascripts/utils/cancellable.ts => assets/app/javascripts/utils/cancellable.ts +5 -9
@@ 1,21 1,17 @@
import {Error} from './errors';

import { Error } from "./errors";

export interface CancellableToken {
    cancel: (() => void);
}


export class Cancelled implements Error {
    public name = 'Cancelled';
    public name = "Cancelled";

    constructor(
        public message: string = 'Promise was explicitly aborted by the user.'
    ) {
    }
        public message: string = "Promise was explicitly aborted by the user.",
    ) {}
}


export class CancelToken implements CancellableToken {
    cancel(): void {}
};
}

M assets/app/javascripts/utils/elements.ts => assets/app/javascripts/utils/elements.ts +15 -21
@@ 1,8 1,5 @@
export const addClass = (
    element: Element,
    newClasses: string[]
): void => {
    let classNames = element.className.split(' ');
export const addClass = (element: Element, newClasses: string[]): void => {
    let classNames = element.className.split(" ");

    for (let i = 0, len = newClasses.length; i < len; i++) {
        let newClass = newClasses[i];


@@ 12,15 9,11 @@ export const addClass = (
        }
    }

    element.className = classNames.join(' ');
}

    element.className = classNames.join(" ");
};

export const removeClass = (
    element: Element,
    removeClasses: string[]
): void => {
    let classNames = element.className.split(' ');
export const removeClass = (element: Element, removeClasses: string[]): void => {
    let classNames = element.className.split(" ");

    for (let i = 0, len = removeClasses.length; i < len; i++) {
        let removeClass = removeClasses[i];


@@ 31,17 24,18 @@ export const removeClass = (
        }
    }

    element.className = classNames.join(' ');
}

    element.className = classNames.join(" ");
};

export const dispatchCustomEvent = (
    element: Element,
    eventName: string,
    opts: any = {},
): void => {
    element.dispatchEvent(new CustomEvent(eventName, {
        bubbles: true,
        detail: opts
    }));
}
    element.dispatchEvent(
        new CustomEvent(eventName, {
            bubbles: true,
            detail: opts,
        }),
    );
};

M assets/app/javascripts/utils/errors.ts => assets/app/javascripts/utils/errors.ts +6 -10
@@ 4,24 4,20 @@ export interface Error {
    object?: any;
}


export class NotImplementedError implements Error {
    public name = 'NotImplementedError';
    public name = "NotImplementedError";

    constructor(
        public message: string = 'The method was called but not implemented.',
        public message: string = "The method was called but not implemented.",
        public object?: any,
    ) {
    }
    ) {}
}


export class ResourceError implements Error {
    public name = 'ResourceError';
    public name = "ResourceError";

    constructor(
        public message: string = 'The resource could not be retrieved.',
        public message: string = "The resource could not be retrieved.",
        public object?: any,
    ) {
    }
    ) {}
}

M assets/app/javascripts/utils/formatters.ts => assets/app/javascripts/utils/formatters.ts +13 -14
@@ 1,19 1,18 @@
const monthNames = [
    'Jan',
    'Feb',
    'Mar',
    'Apr',
    'May',
    'Jun',
    'Jul',
    'Aug',
    'Sep',
    'Oct',
    'Nov',
    'Dec',
    "Jan",
    "Feb",
    "Mar",
    "Apr",
    "May",
    "Jun",
    "Jul",
    "Aug",
    "Sep",
    "Oct",
    "Nov",
    "Dec",
];


export let formatDate = (date: Date): string => {
    let yyyy = date.getFullYear();
    let mmm = monthNames[date.getMonth()];


@@ 25,4 24,4 @@ export let formatDate = (date: Date): string => {
    let timeFormatted = `${hh}:${nn}:${ss}`;

    return `${dateFormatted} at ${timeFormatted}`;
}
};

M assets/app/javascripts/utils/forms.ts => assets/app/javascripts/utils/forms.ts +45 -57
@@ 1,49 1,48 @@
import {create, h} from 'virtual-dom';
import {ResourceError} from './errors';
import {addClass, removeClass} from './elements';

import { create, h } from "virtual-dom";
import { ResourceError } from "./errors";
import { addClass, removeClass } from "./elements";

export let serializeForm = (form: HTMLFormElement): any => {
    let formData: {[key: string]: any} = {};
    let formData: { [key: string]: any } = {};

    let _insertField = (name: string, value: any) => {
        if (name) {
            formData[name] = value;
        }
    }
    };

    for (let i = 0, len = form.elements.length; i < len; i++) {
        let field = form.elements[i];

        if (field instanceof HTMLInputElement) {
            switch (field.type) {
            case 'file':
            case 'button':
                break;
            case 'checkbox':
            case 'radio':
                if (field.checked) {
                case "file":
                case "button":
                    break;
                case "checkbox":
                case "radio":
                    if (field.checked) {
                        _insertField(field.name, field.value);
                    }
                    break;
                default:
                    _insertField(field.name, field.value);
                }
                break;
            default:
                _insertField(field.name, field.value);
                break;
                    break;
            }
        } else if (field instanceof HTMLSelectElement) {
            switch (field.type) {
            case 'select-multiple':
                let fieldData: string[] = [];
                for (let n = 0, l = field.options.length; n < l; n++) {
                    if (field.options[n].selected) {
                        fieldData.push(field.options[0].value);
                case "select-multiple":
                    let fieldData: string[] = [];
                    for (let n = 0, l = field.options.length; n < l; n++) {
                        if (field.options[n].selected) {
                            fieldData.push(field.options[0].value);
                        }
                    }
                }
                _insertField(field.name, fieldData);
                break;
            default:
                _insertField(field.name, field.value);
                break;
                    _insertField(field.name, fieldData);
                    break;
                default:
                    _insertField(field.name, field.value);
                    break;
            }
        } else if (field instanceof HTMLTextAreaElement) {
            _insertField(field.name, field.value);


@@ 51,56 50,45 @@ export let serializeForm = (form: HTMLFormElement): any => {
    }

    return formData;
}

};

export let attachErrors = (form: HTMLFormElement, error: ResourceError) => {
    let data = error.object;

    let _attachError = (fieldElement: Element, message: string): void => {
        let formItemElement = fieldElement.closest('.form-item');
        let formItemElement = fieldElement.closest(".form-item");

        if (!!formItemElement) {
          let err = h('span', {className: 'form-item-error'}, [message]);
            let err = h("span", { className: "form-item-error" }, [message]);

          addClass(formItemElement, ['error']);
          fieldElement.parentElement.insertBefore(
              create(err),
              fieldElement.nextSibling
          );
            addClass(formItemElement, ["error"]);
            fieldElement.parentElement.insertBefore(
                create(err),
                fieldElement.nextSibling,
            );
        }
    }
    };

    if (data.status == 'params_invalid') {
        for (let field in <{[key: string]: string[]}>data.message) {
    if (data.status == "params_invalid") {
        for (let field in <{ [key: string]: string[] }>data.message) {
            if (data.message.hasOwnProperty(field)) {
                _attachError(
                    form[field],
                    data.message[field]
                );
                _attachError(form[field], data.message[field]);
            }
        }
    } else {
        _attachError(
            form.querySelector('[data-form-anchor]'),
            data.message
        );
        _attachError(form.querySelector("[data-form-anchor]"), data.message);
    }
}

};

export let detachErrors = (form: HTMLFormElement) => {
    let errorElements = form.querySelectorAll('.error');
    let msgElements = form.querySelectorAll('.form-item-error');
    let errorElements = form.querySelectorAll(".error");
    let msgElements = form.querySelectorAll(".form-item-error");

    for (let i = 0, len = errorElements.length; i < len; i++) {
        removeClass(
            errorElements[0],
            ['error']
        );
        removeClass(errorElements[0], ["error"]);
    }

    for (let i = 0, len = msgElements.length; i < len; i++) {
        msgElements[0].parentElement.removeChild(msgElements[0]);
    }
}
};

M assets/app/javascripts/utils/loading.ts => assets/app/javascripts/utils/loading.ts +10 -7
@@ 1,5 1,4 @@
import {addClass, removeClass} from './elements';

import { addClass, removeClass } from "./elements";

export class LoadingState {
    isLoading: boolean = false;


@@ 9,12 8,16 @@ export class LoadingState {
            this.isLoading = true;

            if (buttonElement) {
                addClass(buttonElement, ['js-button-loading']);
                addClass(buttonElement, ["js-button-loading"]);
            }

            fn().
                then(() => { this.unbind(buttonElement); }).
                catch(() => { this.unbind(buttonElement); });
            fn()
                .then(() => {
                    this.unbind(buttonElement);
                })
                .catch(() => {
                    this.unbind(buttonElement);
                });
        }
    }



@@ 22,7 25,7 @@ export class LoadingState {
        this.isLoading = false;

        if (buttonElement) {
            removeClass(buttonElement, ['js-button-loading']);
            removeClass(buttonElement, ["js-button-loading"]);
        }
    }
}

M assets/app/javascripts/utils/request.ts => assets/app/javascripts/utils/request.ts +9 -9
@@ 1,34 1,34 @@
import {CancellableToken, Cancelled} from './cancellable';

import { CancellableToken, Cancelled } from "./cancellable";

export interface IRequestBody {
    [key: string]: any;
}


export const request = (
    method: string,
    url: string,
    params: IRequestBody = {},
    token?: CancellableToken
    token?: CancellableToken,
): Promise<string> => {
    let xhr = new XMLHttpRequest();
    let body = JSON.stringify(params);

    xhr.open(method, url);
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.setRequestHeader("Content-Type", "application/json");

    return new Promise((resolve, reject) => {
        xhr.onload = () => { resolve(xhr.responseText); }
        xhr.onload = () => {
            resolve(xhr.responseText);
        };
        xhr.onerror = reject;

        if (token) {
            token.cancel = (): void => {
                xhr.abort();
                reject(new Cancelled);
            }
                reject(new Cancelled());
            };
        }

        xhr.send(body);
    });
}
};

M assets/app/javascripts/views/board_selector_view.ts => assets/app/javascripts/views/board_selector_view.ts +9 -12
@@ 1,7 1,6 @@
import {VNode, h} from 'virtual-dom';
import {Board} from '../models/board';
import {BoardView} from './board_view';

import { VNode, h } from "virtual-dom";
import { Board } from "../models/board";
import { BoardView } from "./board_view";

export class BoardSelectorView {
    boardListNode: VNode[];


@@ 11,26 10,24 @@ export class BoardSelectorView {
    }

    render(args: any = {}): VNode {
        return h('div', BoardSelectorView.getViewClassName(args), [
            h('div', {className: 'js-board-selector-inner'},
                this.boardListNode
            )
        return h("div", BoardSelectorView.getViewClassName(args), [
            h("div", { className: "js-board-selector-inner" }, this.boardListNode),
        ]);
    }

    private static renderBoards(boards: Board[]): VNode[] {
        return boards.map((board: Board): VNode => {
            return new BoardView(board).render();
        })
        });
    }

    private static getViewClassName(args: any): any {
        const className = 'js-board-selector';
        const className = "js-board-selector";

        if (args.className) {
            let classNames = args.className.split(' ');
            let classNames = args.className.split(" ");
            classNames.push(className);
            args.className = classNames.join(' ');
            args.className = classNames.join(" ");
        } else {
            args.className = className;
        }

M assets/app/javascripts/views/board_view.ts => assets/app/javascripts/views/board_view.ts +17 -14
@@ 1,6 1,5 @@
import {VNode, h} from 'virtual-dom';
import {Board} from '../models/board';

import { VNode, h } from "virtual-dom";
import { Board } from "../models/board";

export class BoardView {
    boardNode: VNode;


@@ 14,26 13,30 @@ export class BoardView {
    }

    private static renderBoard(board: Board): VNode {
        return h('div', {className: 'js-board'}, [
            h('div', {className: 'cascade'}, [
                h('div', {className: 'container'}, [
        return h("div", { className: "js-board" }, [
            h("div", { className: "cascade" }, [
                h("div", { className: "container" }, [
                    BoardView.renderTitle(board),
                    BoardView.renderDescription(board),
                ])
            ])
                ]),
            ]),
        ]);
    }

    private static renderTitle(board: Board): VNode {
        return h('div', {className: 'cascade-header'}, [
            h('a', {
                className: 'cascade-header-link',
                href: `/${board.slug}/`
            }, [board.title])
        return h("div", { className: "cascade-header" }, [
            h(
                "a",
                {
                    className: "cascade-header-link",
                    href: `/${board.slug}/`,
                },
                [board.title],
            ),
        ]);
    }

    private static renderDescription(board: Board): VNode {
        return h('div', {className: 'cascade-body'}, [board.description]);
        return h("div", { className: "cascade-body" }, [board.description]);
    }
}

M assets/app/javascripts/views/popover_view.ts => assets/app/javascripts/views/popover_view.ts +59 -42
@@ 1,71 1,88 @@
import {VNode, h} from 'virtual-dom';

import { VNode, h } from "virtual-dom";

export class PopoverView {
    popoverChildNodes: VNode[];

    constructor(childNode: VNode, title?: string, dismissFn?: (() => void)) {
        this.popoverChildNodes = PopoverView.renderChild(
            childNode,
            title,
            dismissFn,
        );
        this.popoverChildNodes = PopoverView.renderChild(childNode, title, dismissFn);
    }

    render(targetElement: Element, args: any = {}): VNode {
        let pos = PopoverView.computePosition(targetElement);

        return h('div', PopoverView.getViewClassName(args), [
            h('div', {
                className: 'js-popover-inner',
                style: {
                    position: 'absolute',
                    top: `${pos.posX}px`,
                    left: `${pos.posY}px`,
                }
            }, this.popoverChildNodes)
        return h("div", PopoverView.getViewClassName(args), [
            h(
                "div",
                {
                    className: "js-popover-inner",
                    style: {
                        position: "absolute",
                        top: `${pos.posX}px`,
                        left: `${pos.posY}px`,
                    },
                },
                this.popoverChildNodes,
            ),
        ]);
    }

    private static renderChild(
        childNode: VNode,
        title?: string,
        dismissFn?: (() => void)
        dismissFn?: (() => void),
    ): VNode[] {
        let popoverChildNodes: VNode[] = [];

        if (title) {
            let titleChildNodes = [
                h('span', {
                    className: 'js-popover-inner-title-label'
                }, [title])
                h(
                    "span",
                    {
                        className: "js-popover-inner-title-label",
                    },
                    [title],
                ),
            ];

            if (dismissFn) {
                titleChildNodes.push(h('a', {
                    className: 'js-popover-inner-title-dismiss',
                    href: '#',
                    onclick: (e: Event): void => {
                        e.preventDefault();
                        if (dismissFn) {
                            dismissFn();
                        }
                    },
                }, ['Close']));
                titleChildNodes.push(
                    h(
                        "a",
                        {
                            className: "js-popover-inner-title-dismiss",
                            href: "#",
                            onclick: (e: Event): void => {
                                e.preventDefault();
                                if (dismissFn) {
                                    dismissFn();
                                }
                            },
                        },
                        ["Close"],
                    ),
                );
            }

            popoverChildNodes.push(h('div', {
                className: 'js-popover-inner-title',
            }, titleChildNodes));
            popoverChildNodes.push(
                h(
                    "div",
                    {
                        className: "js-popover-inner-title",
                    },
                    titleChildNodes,
                ),
            );
        }

        popoverChildNodes.push(childNode);
        return popoverChildNodes;
    }

    private static computePosition(targetElement: Element): {
        posX: number,
        posY: number
    private static computePosition(
        targetElement: Element,
    ): {
        posX: number;
        posY: number;
    } {
        let bodyRect = document.body.getBoundingClientRect();
        let elemRect = targetElement.getBoundingClientRect();


@@ 73,24 90,24 @@ export class PopoverView {

        // Indent relative to container rather than element if there is
        // container in element ancestor.
        let containerElement = targetElement.closest('.container');
        let containerElement = targetElement.closest(".container");
        if (containerElement) {
            yRefRect = containerElement.getBoundingClientRect();
        }

        return {
            posX: (elemRect.bottom + 5) - bodyRect.top,
            posX: elemRect.bottom + 5 - bodyRect.top,
            posY: yRefRect.left - bodyRect.left,
        }
        };
    }

    private static getViewClassName(args: any): any {
        const className = 'js-popover';
        const className = "js-popover";

        if (args.className) {
            let classNames = args.className.split(' ');
            let classNames = args.className.split(" ");
            classNames.push(className);
            args.className = classNames.join(' ');
            args.className = classNames.join(" ");
        } else {
            args.className = className;
        }

M assets/app/javascripts/views/post_collection_view.ts => assets/app/javascripts/views/post_collection_view.ts +54 -34
@@ 1,7 1,6 @@
import {VNode, h} from 'virtual-dom';
import {formatDate} from '../utils/formatters';
import {Post} from '../models/post';

import { VNode, h } from "virtual-dom";
import { formatDate } from "../utils/formatters";
import { Post } from "../models/post";

export class PostCollectionView {
    postsNode: VNode;


@@ 15,21 14,22 @@ export class PostCollectionView {
    }

    private static renderPosts(posts: Post[]): VNode {
        return h('div',
            {className: 'js-post-collection'},
        return h(
            "div",
            { className: "js-post-collection" },
            posts.map((post: Post): VNode => {
                return h('div', {className: 'post'}, [
                    h('div', {className: 'container'}, [
                return h("div", { className: "post" }, [
                    h("div", { className: "container" }, [
                        PostCollectionView.renderHeader(post),
                        PostCollectionView.renderBody(post),
                    ])
                ])
            })
                    ]),
                ]);
            }),
        );
    }

    private static renderHeader(post: Post): VNode {
        return h('div', {className: 'post-header'}, [
        return h("div", { className: "post-header" }, [
            PostCollectionView.renderHeaderNumber(post),
            PostCollectionView.renderHeaderName(post),
            PostCollectionView.renderHeaderDate(post),


@@ 38,49 38,69 @@ export class PostCollectionView {
    }

    private static renderHeaderNumber(post: Post): VNode {
        let classList = ['post-header-item', 'number'];
        let classList = ["post-header-item", "number"];

        if (post.bumped) {
            classList.push('bumped');
            classList.push("bumped");
        }

        return h('span', {
            className: classList.join(' '),
            dataset: {
                topicQuickReply: post.number
            }
        }, [post.number.toString()]);
        return h(
            "span",
            {
                className: classList.join(" "),
                dataset: {
                    topicQuickReply: post.number,
                },
            },
            [post.number.toString()],
        );
    }

    private static renderHeaderName(post: Post): VNode {
        return h('span', {
            className: 'post-header-item name'
        }, [post.name]);
        return h(
            "span",
            {
                className: "post-header-item name",
            },
            [post.name],
        );
    }

    private static renderHeaderDate(post: Post): VNode {
        let createdAt = new Date(post.createdAt);
        let formatter = formatDate(createdAt);

        return h('span', {
            className: 'post-header-item date'
        }, [`Posted ${formatter}`]);
        return h(
            "span",
            {
                className: "post-header-item date",
            },
            [`Posted ${formatter}`],
        );
    }

    private static renderHeaderIdent(post: Post): VNode | string {
        if (post.ident) {
            return h('span', {
                className: 'post-header-item ident'
            }, [`ID:${post.ident}`]);
            return h(
                "span",
                {
                    className: "post-header-item ident",
                },
                [`ID:${post.ident}`],
            );
        } else {
            return '';
            return "";
        }
    }

    private static renderBody(post: Post): VNode {
        return h('div', {
            className: 'post-body',
            innerHTML: post.bodyFormatted,
        }, []);
        return h(
            "div",
            {
                className: "post-body",
                innerHTML: post.bodyFormatted,
            },
            [],
        );
    }
}

M assets/app/javascripts/views/post_form.ts => assets/app/javascripts/views/post_form.ts +69 -53
@@ 1,10 1,9 @@
import {VNode, h} from 'virtual-dom';

import { VNode, h } from "virtual-dom";

export class PostForm {
    postFormNode: VNode;

    constructor(defaultText: string = '') {
    constructor(defaultText: string = "") {
        this.postFormNode = PostForm.renderForm(defaultText);
    }



@@ 13,79 12,96 @@ export class PostForm {
    }

    private static renderForm(defaultText: string): VNode {
        return h('div', {className: 'js-post-form'}, [
            h('form', {
                className: 'form',
                dataset: {
                    topicInlineReply: true
                }
            }, [
                h('div', {className: 'container'}, [
                    h('div', {className: 'form-item'}, [
                        PostForm.renderBodyFormItemLabel(),
                        PostForm.renderBodyFormItemInput(defaultText),
        return h("div", { className: "js-post-form" }, [
            h(
                "form",
                {
                    className: "form",
                    dataset: {
                        topicInlineReply: true,
                    },
                },
                [
                    h("div", { className: "container" }, [
                        h("div", { className: "form-item" }, [
                            PostForm.renderBodyFormItemLabel(),
                            PostForm.renderBodyFormItemInput(defaultText),
                        ]),
                        h("div", { className: "form-item" }, [
                            PostForm.renderPostFormItemButton(),
                            " ",
                            PostForm.renderPostFormItemBump(),
                        ]),
                    ]),
                    h('div', {className: 'form-item'}, [
                        PostForm.renderPostFormItemButton(),
                        ' ',
                        PostForm.renderPostFormItemBump(),
                    ])
                ])
            ])
                ],
            ),
        ]);
    }

    private static renderBodyFormItemLabel(): VNode {
        return h('label', {
            className: 'form-item-label',
            htmlFor: 'js-body',
        }, ['Reply']);
        return h(
            "label",
            {
                className: "form-item-label",
                htmlFor: "js-body",
            },
            ["Reply"],
        );
    }

    private static renderBodyFormItemInput(defaultText: string): VNode {
        return h('textarea', {
            id: 'js-body',
            className: 'input block content',
            name: 'body',
            rows: 4,
            dataset: {
                formAnchor: true,
        return h(
            "textarea",
            {
                id: "js-body",
                className: "input block content",
                name: "body",
                rows: 4,
                dataset: {
                    formAnchor: true,
                },
            },
        }, [defaultText]);
            [defaultText],
        );
    }

    private static renderPostFormItemButton(): VNode {
        return h('button', {
            className: 'button green',
            type: 'submit'
        }, ['Post Reply']);
        return h(
            "button",
            {
                className: "button green",
                type: "submit",
            },
            ["Post Reply"],
        );
    }

    private static renderPostFormItemBump(): VNode {
        return h('span', {className: 'form-item-inline'}, [
        return h("span", { className: "form-item-inline" }, [
            PostForm.renderPostFormItemBumpInput(),
            ' ',
            " ",
            PostForm.renderPostFormItemBumpLabel(),
        ]);
    }

    private static renderPostFormItemBumpInput(): VNode {
        return h('input', {
            id: 'js-bumped',
            type: 'checkbox',
            name: 'bumped',
            value: 'y',
            checked: true,
            dataset: {
                topicStateTracker: 'bump',
            }
        }, []);
        return h(
            "input",
            {
                id: "js-bumped",
                type: "checkbox",
                name: "bumped",
                value: "y",
                checked: true,
                dataset: {
                    topicStateTracker: "bump",
                },
            },
            [],
        );
    }

    private static renderPostFormItemBumpLabel(): VNode {
        return h('label',
            {htmlFor: 'js-bumped'},
            ['Bump this topic']
        );
        return h("label", { htmlFor: "js-bumped" }, ["Bump this topic"]);
    }
}

M assets/app/javascripts/views/theme_selector_view.ts => assets/app/javascripts/views/theme_selector_view.ts +22 -22
@@ 1,5 1,4 @@
import {VNode, create, diff, patch, h} from 'virtual-dom';

import { VNode, create, diff, patch, h } from "virtual-dom";

export interface ITheme {
    className: string;


@@ 7,39 6,40 @@ export interface ITheme {
    name: string;
}


export class ThemeSelectorView {
    constructor(public themes: ITheme[]) {}

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

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

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

M assets/app/javascripts/views/topic_view.ts => assets/app/javascripts/views/topic_view.ts +19 -23
@@ 1,7 1,6 @@
import {VNode, h} from 'virtual-dom';
import {formatDate} from '../utils/formatters';
import {Topic} from '../models/topic';

import { VNode, h } from "virtual-dom";
import { formatDate } from "../utils/formatters";
import { Topic } from "../models/topic";

export class TopicView {
    topicNode: VNode;


@@ 15,37 14,34 @@ export class TopicView {
    }

    private static renderTopic(topic: Topic): VNode {
        return h('div',
            {className: 'js-topic'},
            [
                h('div', {className: 'topic-header'}, [
                    h('div', {className: 'container'}, [
                        TopicView.renderTitle(topic),
                        TopicView.renderDate(topic),
                        TopicView.renderCount(topic),
                    ])
                ])
            ]
        );
        return h("div", { className: "js-topic" }, [
            h("div", { className: "topic-header" }, [
                h("div", { className: "container" }, [
                    TopicView.renderTitle(topic),
                    TopicView.renderDate(topic),
                    TopicView.renderCount(topic),
                ]),
            ]),
        ]);
    }

    private static renderTitle(topic: Topic): VNode {
        return h('h3', {className: 'topic-header-title'}, [topic.title]);
        return h("h3", { className: "topic-header-title" }, [topic.title]);
    }

    private static renderDate(topic: Topic): VNode {
        let postedAt = new Date(topic.postedAt);
        let formatter = formatDate(postedAt);
        return h('p', {className: 'topic-header-item'}, [
            'Last posted ',
            h('strong', {}, [formatter]),
        return h("p", { className: "topic-header-item" }, [
            "Last posted ",
            h("strong", {}, [formatter]),
        ]);
    }

    private static renderCount(topic: Topic): VNode {
        return h('p', {className: 'topic-header-item'}, [
            'Total of ',
            h('strong', {}, [`${topic.postCount} posts`]),
        return h("p", { className: "topic-header-item" }, [
            "Total of ",
            h("strong", {}, [`${topic.postCount} posts`]),
        ]);
    }
}

M assets/app/stylesheets/_mixins.scss => assets/app/stylesheets/_mixins.scss +3 -3
@@ 2,17 2,17 @@
 * ------------------------------------------------------------------------ */

@mixin retina-background($image, $extension, $width, $height) {
    background-image: url($image + "." + $extension);
    background-image: url($image+"."+$extension);
    background-position: 0 50%;
    background-repeat: no-repeat;
    background-size: $width $height;

    @media (min-device-pixel-ratio: 1.3), (min-resolution: 1.3dppx) {
        background-image: url($image + "@2x." + $extension);
        background-image: url($image+"@2x."+$extension);
    }

    @media (min-device-pixel-ratio: 2.3), (min-resolution: 2.3dppx) {
        background-image: url($image + "@3x." + $extension);
        background-image: url($image+"@3x."+$extension);
    }
}


M assets/app/stylesheets/_variables.scss => assets/app/stylesheets/_variables.scss +32 -32
@@ 1,55 1,55 @@
/* Colors
 * ------------------------------------------------------------------------ */

$color-brand:                #fc2e05;
$color-brand-dark:           #1c2123;
$color-gray-base:            #5e6b71;
$color-brand: #fc2e05;
$color-brand-dark: #1c2123;
$color-gray-base: #5e6b71;

$color-green:                #1fba41;
$color-red:                  #e01a1a;
$color-green: #1fba41;
$color-red: #e01a1a;

/* Responsive width boundaries
 * ------------------------------------------------------------------------ */

$bound-mobile:               320px;
$bound-tablet:               640px;
$bound-desktop:              980px;
$bound-mobile: 320px;
$bound-tablet: 640px;
$bound-desktop: 980px;

/* Typography
 * ------------------------------------------------------------------------ */

$font-size-base:             16px;
$font-size-smaller:          $font-size-base * 0.7;
$font-size-small:            $font-size-base * 0.8;
$font-size:                  $font-size-base;
$font-size-large:            $font-size-base * 1.15;
$font-size-larger:           $font-size-base * 1.3;
$font-size-base: 16px;
$font-size-smaller: $font-size-base * 0.7;
$font-size-small: $font-size-base * 0.8;
$font-size: $font-size-base;
$font-size-large: $font-size-base * 1.15;
$font-size-larger: $font-size-base * 1.3;

$font-size-content:          $font-size;
$line-height-content:        1.5;
$font-size-content: $font-size;
$line-height-content: 1.5;

$font-size-input:            14px;
$font-size-input: 14px;

/* Animations
 * ------------------------------------------------------------------------ */

$animation-timing:           0.08s;
$animation-function:         linear;
$animation-timing: 0.08s;
$animation-function: linear;

/* Metrics
 * ------------------------------------------------------------------------ */

$spacing-horizontal-smaller: 4px;
$spacing-horizontal-small:   6px;
$spacing-horizontal:         12px;
$spacing-horizontal-large:   18px;
$spacing-horizontal-larger:  24px;

$spacing-vertical-smaller:   4px;
$spacing-vertical-small:     6px;
$spacing-vertical:           12px;
$spacing-vertical-large:     18px;
$spacing-vertical-larger:    24px;

$spacing-vertical-input:     8px;
$spacing-horizontal-input:   8px;
$spacing-horizontal-small: 6px;
$spacing-horizontal: 12px;
$spacing-horizontal-large: 18px;
$spacing-horizontal-larger: 24px;

$spacing-vertical-smaller: 4px;
$spacing-vertical-small: 6px;
$spacing-vertical: 12px;
$spacing-vertical-large: 18px;
$spacing-vertical-larger: 24px;

$spacing-vertical-input: 8px;
$spacing-horizontal-input: 8px;

M assets/app/stylesheets/api.scss => assets/app/stylesheets/api.scss +6 -2
@@ 93,8 93,12 @@
    @media (min-width: $bound-tablet) {
        display: table-row-group;

        .lead { width: 120px; }
        .sublead { width: 80px; }
        .lead {
            width: 120px;
        }
        .sublead {
            width: 80px;
        }
    }
}


M assets/app/stylesheets/app.scss => assets/app/stylesheets/app.scss +54 -28
@@ 2,15 2,8 @@
@import "mixins";

body {
    font-family: -apple-system,
        BlinkMacSystemFont,
        "Segoe UI",
        "Helvetica Neue",
        Arial,
        sans-serif,
        "Apple Color Emoji",
        "Segoe UI Emoji",
        "Segoe UI Symbol";
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial,
        sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
    font-size: $font-size;
    line-height: $font-size;
    min-width: $bound-mobile;


@@ 412,18 405,30 @@ body {
    font-size: $font-size-input;

    /* Default styling reset. */
    appearance: none;       /* WebKit on mobile */
    appearance: none; /* WebKit on mobile */
    background-color: #fff; /* Firefox on desktop */
    background-image: none; /* Firefox on mobile */
    border-radius: 0;       /* WebKit on mobile */
    outline: none;          /* WebKit on desktop */
    border-radius: 0; /* WebKit on mobile */
    outline: none; /* WebKit on desktop */

    &.font-monospaced { font-family: monospace; }
    &.font-smaller    { font-size: $font-size-smaller; }
    &.font-small      { font-size: $font-size-small; }
    &.font-content    { font-size: $font-size-content; }
    &.font-large      { font-size: $font-size-large; }
    &.font-larger     { font-size: $font-size-larger; }
    &.font-monospaced {
        font-family: monospace;
    }
    &.font-smaller {
        font-size: $font-size-smaller;
    }
    &.font-small {
        font-size: $font-size-small;
    }
    &.font-content {
        font-size: $font-size-content;
    }
    &.font-large {
        font-size: $font-size-large;
    }
    &.font-larger {
        font-size: $font-size-larger;
    }

    &.block {
        display: block;


@@ 455,10 460,14 @@ textarea.input {
    padding: $spacing-vertical-input $spacing-horizontal-input;
    font-size: $font-size-input;

    &.default {}
    &.muted   {}
    &.brand   {}
    &.green   {}
    &.default {
    }
    &.muted {
    }
    &.brand {
    }
    &.green {
    }

    &.block {
        display: block;


@@ 466,7 475,8 @@ textarea.input {
        width: 100%;
    }

    &.static {}
    &.static {
    }
}

/* Code block


@@ 490,16 500,32 @@ textarea.input {
    font-size: $font-size;
    line-height: $line-height-content;

    h1 { font-size: $font-size-larger; }
    h2 { font-size: $font-size-large; }
    h3, h4, h5 { font-size: $font-size; }
    h1 {
        font-size: $font-size-larger;
    }
    h2 {
        font-size: $font-size-large;
    }
    h3,
    h4,
    h5 {
        font-size: $font-size;
    }

    h1, h2, h3, h4, h5, p, ul, ol {
    h1,
    h2,
    h3,
    h4,
    h5,
    p,
    ul,
    ol {
        margin: 0 0 $spacing-vertical;
        padding: 0;
    }

    ul, ol {
    ul,
    ol {
        padding: 0 $spacing-horizontal-larger 0;
    }
}

M assets/app/stylesheets/themes/_variables_obsidian.scss => assets/app/stylesheets/themes/_variables_obsidian.scss +14 -14
@@ 1,17 1,17 @@
$color-gray-base:        #161b1e;
$color-gray-darker:      lighten($color-gray-base, 3.0%);
$color-gray-dark:        lighten($color-gray-base, 5.0%);
$color-gray:             lighten($color-gray-base, 8.0%);
$color-gray-light:       lighten($color-gray-base, 44.0%);
$color-gray-lighter:     lighten($color-gray-base, 80.0%);
$color-gray-base: #161b1e;
$color-gray-darker: lighten($color-gray-base, 3%);
$color-gray-dark: lighten($color-gray-base, 5%);
$color-gray: lighten($color-gray-base, 8%);
$color-gray-light: lighten($color-gray-base, 44%);
$color-gray-lighter: lighten($color-gray-base, 80%);

$color-muted-base:       #293033;
$color-muted-darker:     lighten($color-muted-base, 2.0%);
$color-muted-dark:       lighten($color-muted-base, 10.0%);
$color-muted:            lighten($color-muted-base, 20.0%);
$color-muted-light:      lighten($color-muted-base, 30.0%);
$color-muted-lighter:    lighten($color-muted-base, 50.0%);
$color-muted-base: #293033;
$color-muted-darker: lighten($color-muted-base, 2%);
$color-muted-dark: lighten($color-muted-base, 10%);
$color-muted: lighten($color-muted-base, 20%);
$color-muted-light: lighten($color-muted-base, 30%);
$color-muted-lighter: lighten($color-muted-base, 50%);

$color-background:       $color-gray-base;
$color-background: $color-gray-base;
$color-background-light: lighten($color-gray-base, 1.5%);
$color-text:             #cfcfcf;
$color-text: #cfcfcf;

M assets/app/stylesheets/themes/_variables_topaz.scss => assets/app/stylesheets/themes/_variables_topaz.scss +15 -15
@@ 1,18 1,18 @@
$color-gray-base:        #1b2326;
$color-gray-darker:      lighten($color-gray-base, 22.0%);
$color-gray-dark:        lighten($color-gray-base, 33.0%);
$color-gray:             lighten($color-gray-base, 44.0%);
$color-gray-light:       lighten($color-gray-base, 70.0%);
$color-gray-lighter:     lighten($color-gray-base, 80.0%);
$color-gray-base: #1b2326;
$color-gray-darker: lighten($color-gray-base, 22%);
$color-gray-dark: lighten($color-gray-base, 33%);
$color-gray: lighten($color-gray-base, 44%);
$color-gray-light: lighten($color-gray-base, 70%);
$color-gray-lighter: lighten($color-gray-base, 80%);

$color-tint-base:        #f2f5f7;
$color-tint-darker:      darken($color-tint-base, 28%);
$color-tint-dark:        darken($color-tint-base, 10%);
$color-tint:             darken($color-tint-base, 6%);
$color-tint-light:       darken($color-tint-base, 4%);
$color-tint-lighter:     $color-tint-base;
$color-tint-base: #f2f5f7;
$color-tint-darker: darken($color-tint-base, 28%);
$color-tint-dark: darken($color-tint-base, 10%);
$color-tint: darken($color-tint-base, 6%);
$color-tint-light: darken($color-tint-base, 4%);
$color-tint-lighter: $color-tint-base;

$color-background:       #f0f0f0;
$color-background-dark:  darken($color-background, 3%);
$color-background: #f0f0f0;
$color-background-dark: darken($color-background, 3%);

$color-text:             #333;
$color-text: #333;

M assets/app/stylesheets/themes/debug.scss => assets/app/stylesheets/themes/debug.scss +0 -1
@@ 69,7 69,6 @@
        background-color: #f9c8aa;
    }


    /* api.scss
     * -------------------------------------------------------------------- */


M assets/app/stylesheets/themes/obsidian.scss => assets/app/stylesheets/themes/obsidian.scss +8 -4
@@ 207,7 207,13 @@
    }

    .content {
        h1, h2, h3, h4, h5 { color: $color-brand; }
        h1,
        h2,
        h3,
        h4,
        h5 {
            color: $color-brand;
        }
    }

    .menu {


@@ 333,7 339,6 @@
        }
    }


    .topic-footer {
        background-color: $color-background-light;
        border-color: $color-gray-dark;


@@ 402,7 407,6 @@
            background-color: $color-gray-dark;
            color: $color-brand;
        }

    }

    .post-body {


@@ 480,7 484,7 @@
    }

    .js-popover-inner {
        box-shadow: 0 0 5px 0 rgba(0, 0, 0, .6);
        box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.6);
        border-color: $color-gray;
    }


M assets/app/stylesheets/themes/topaz.scss => assets/app/stylesheets/themes/topaz.scss +8 -2
@@ 223,7 223,13 @@
    }

    .content {
        h1, h2, h3, h4, h5 { color: $color-brand; }
        h1,
        h2,
        h3,
        h4,
        h5 {
            color: $color-brand;
        }
    }

    .menu {


@@ 516,7 522,7 @@
    }

    .js-popover-inner {
        box-shadow: 0 1px 2px 0 rgba(0, 0, 0, .3);
        box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
        border-color: #fff;
    }


M fanboi2/__init__.py => fanboi2/__init__.py +68 -71
@@ 18,26 18,26 @@ class NoValue(object):  # pragma: no cover
    """

    def __repr__(self):
        return 'NO_VALUE'
        return "NO_VALUE"


NO_VALUE = NoValue()


ENV_SETTINGS_MAP = (
    ('AUTH_SECRET', 'auth.secret', NO_VALUE, None),
    ('CELERY_BROKER_URL', 'celery.broker', NO_VALUE, None),
    ('DATABASE_URL', 'sqlalchemy.url', NO_VALUE, None),
    ('GEOIP_PATH', 'geoip.path', None, None),
    ('MEMCACHED_URL', 'dogpile.arguments.url', NO_VALUE, None),
    ('REDIS_URL', 'redis.url', NO_VALUE, None),
    ('SERVER_DEV', 'server.development', False, asbool),
    ('SERVER_SECURE', 'server.secure', False, asbool),
    ('SESSION_SECRET', 'session.secret', NO_VALUE, None),
    ("AUTH_SECRET", "auth.secret", NO_VALUE, None),
    ("CELERY_BROKER_URL", "celery.broker", NO_VALUE, None),
    ("DATABASE_URL", "sqlalchemy.url", NO_VALUE, None),
    ("GEOIP_PATH", "geoip.path", None, None),
    ("MEMCACHED_URL", "dogpile.arguments.url", NO_VALUE, None),
    ("REDIS_URL", "redis.url", NO_VALUE, None),
    ("SERVER_DEV", "server.development", False, asbool),
    ("SERVER_SECURE", "server.secure", False, asbool),
    ("SESSION_SECRET", "session.secret", NO_VALUE, None),
)

LOGGING_FMT = '%(asctime)s %(levelname)6s %(name)s[%(process)d] %(message)s'
LOGGING_DATEFMT = '%H:%M:%S'
LOGGING_FMT = "%(asctime)s %(levelname)s %(name)s[%(process)d] %(message)s"
LOGGING_DATEFMT = "%H:%M:%S"


def route_name(request):


@@ 55,15 55,15 @@ def _get_asset_hash(path):

    :param path: An asset specification to the asset file.
    """
    if ':' in path:
        package, path = path.split(':')
    if ":" in path:
        package, path = path.split(":")
        resolver = AssetResolver(package)
    else:
        resolver = AssetResolver()
    fullpath = resolver.resolve(path).abspath()
    md5 = hashlib.md5()
    with open(fullpath, 'rb') as f:
        for chunk in iter(lambda: f.read(128 * md5.block_size), b''):
    with open(fullpath, "rb") as f:
        for chunk in iter(lambda: f.read(128 * md5.block_size), b""):
            md5.update(chunk)
    return md5.hexdigest()



@@ 77,7 77,7 @@ def tagged_static_path(request, path, **kwargs):
    :param path: An asset specification to the asset file.
    :param kwargs: Arguments to pass to :meth:`request.static_path`.
    """
    kwargs['_query'] = {'h': _get_asset_hash(path)[:8]}
    kwargs["_query"] = {"h": _get_asset_hash(path)[:8]}
    return request.static_path(path, **kwargs)




@@ 87,7 87,7 @@ def settings_from_env(settings_map=ENV_SETTINGS_MAP, environ=os.environ):
    for env, rkey, default, fn in settings_map:
        value = environ.get(env, default)
        if value is NO_VALUE:
            raise RuntimeError('{} is not set'.format(env))
            raise RuntimeError("{} is not set".format(env))
        if fn is not None:
            value = fn(value)
        settings[rkey] = value


@@ 96,73 96,70 @@ def settings_from_env(settings_map=ENV_SETTINGS_MAP, environ=os.environ):

def tm_maybe_activate(request):
    """Returns whether should the transaction manager be activated."""
    return not request.path_info.startswith('/static/')
    return not request.path_info.startswith("/static/")


def setup_logger(settings):  # pragma: no cover
    """Setup logger per configured in settings."""
    if settings['server.development']:
        import coloredlogs
        coloredlogs.install(
            level=logging.DEBUG,
            fmt=LOGGING_FMT,
            datefmt=LOGGING_DATEFMT)
    else:
        logging.basicConfig(
            level=logging.WARN,
            format=LOGGING_FMT,
            datefmt=LOGGING_DATEFMT)
    log_level = logging.WARN
    if settings["server.development"]:
        log_level = logging.DEBUG
    logging.basicConfig(level=log_level, format=LOGGING_FMT, datefmt=LOGGING_DATEFMT)


def make_config(settings):  # pragma: no cover
    """Returns a Pyramid configurator."""
    config = Configurator(settings=settings)
    config.add_settings({
        'mako.directories': 'fanboi2:templates',
        'dogpile.backend': 'dogpile.cache.memcached',
        'dogpile.arguments.distributed_lock': True,
        'tm.activate_hook': tm_maybe_activate})

    if config.registry.settings['server.development']:
        config.add_settings({
            'pyramid.reload_templates': True,
            'pyramid.debug_authorization': True,
            'pyramid.debug_notfound': True,
            'pyramid.default_locale_name': 'en',
            'debugtoolbar.hosts': '0.0.0.0/0'})
        config.include('pyramid_debugtoolbar')

    config.include('pyramid_mako')
    config.include('pyramid_services')

    session_secret_hex = config.registry.settings['session.secret'].strip()
    config.add_settings(
        {
            "mako.directories": "fanboi2:templates",
            "dogpile.backend": "dogpile.cache.memcached",
            "dogpile.arguments.distributed_lock": True,
            "tm.activate_hook": tm_maybe_activate,
        }
    )

    if config.registry.settings["server.development"]:
        config.add_settings(
            {
                "pyramid.reload_templates": True,
                "pyramid.debug_authorization": True,
                "pyramid.debug_notfound": True,
                "pyramid.default_locale_name": "en",
                "debugtoolbar.hosts": "0.0.0.0/0",
            }
        )
        config.include("pyramid_debugtoolbar")

    config.include("pyramid_mako")
    config.include("pyramid_services")

    session_secret_hex = config.registry.settings["session.secret"].strip()
    session_secret = binascii.unhexlify(session_secret_hex)
    session_factory = EncryptedCookieSessionFactory(
        session_secret,
        cookie_name='_session',
        timeout=3600,
        httponly=True)
        session_secret, cookie_name="_session", timeout=3600, httponly=True
    )

    config.set_session_factory(session_factory)
    config.set_csrf_storage_policy(SessionCSRFStoragePolicy(key='_csrf'))
    config.set_csrf_storage_policy(SessionCSRFStoragePolicy(key="_csrf"))
    config.set_request_property(route_name)
    config.add_request_method(tagged_static_path)
    config.add_route('robots', '/robots.txt')

    config.include('fanboi2.auth')
    config.include('fanboi2.cache')
    config.include('fanboi2.filters')
    config.include('fanboi2.geoip')
    config.include('fanboi2.models')
    config.include('fanboi2.redis')
    config.include('fanboi2.serializers')
    config.include('fanboi2.services')
    config.include('fanboi2.tasks')

    config.include('fanboi2.views.admin', route_prefix='/admin')
    config.include('fanboi2.views.api', route_prefix='/api')
    config.include('fanboi2.views.pages', route_prefix='/pages')
    config.include('fanboi2.views.boards', route_prefix='/')
    config.add_static_view('static', 'static', cache_max_age=3600)
    config.add_route("robots", "/robots.txt")

    config.include("fanboi2.auth")
    config.include("fanboi2.cache")
    config.include("fanboi2.filters")
    config.include("fanboi2.geoip")
    config.include("fanboi2.models")
    config.include("fanboi2.redis")
    config.include("fanboi2.serializers")
    config.include("fanboi2.services")
    config.include("fanboi2.tasks")

    config.include("fanboi2.views.admin", route_prefix="/admin")
    config.include("fanboi2.views.api", route_prefix="/api")
    config.include("fanboi2.views.pages", route_prefix="/pages")
    config.include("fanboi2.views.boards", route_prefix="/")
    config.add_static_view("static", "static", cache_max_age=3600)

    return config

M fanboi2/auth.py => fanboi2/auth.py +7 -9
@@ 1,7 1,6 @@
from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.security import ALL_PERMISSIONS
from pyramid.security import Allow
from pyramid.security import ALL_PERMISSIONS, Allow

from .interfaces import IUserLoginService



@@ 11,9 10,7 @@ SESSION_TOKEN_REISSUE = 300


class Root(object):  # pragma: no cover
    __acl__ = [
        (Allow, 'g:admin', ALL_PERMISSIONS),
    ]
    __acl__ = [(Allow, "g:admin", ALL_PERMISSIONS)]

    def __init__(self, request):
        self.request = request


@@