diff --git a/.env.dev b/.env.dev
index bc2d667b1d4bbb61b6e81583c981debb229d3204..d42cdad02b312920fe355ab34dfcddd228c34fd3 100644
--- a/.env.dev
+++ b/.env.dev
@@ -1,3 +1,4 @@
-BACKEND_URL=http://localhost:6001
 API_AUTHENTICATION_REQUIRED=True
 CACHALOT_ENABLED=False
+RAVEN_ENABLED=false
+RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 91b11e8bd174a1262d0cc70fa6dc4da077152d6e..0fa450c46c763c54153855a1733a1e92690933b3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -2,7 +2,7 @@ variables:
   IMAGE_NAME: funkwhale/funkwhale
   IMAGE: $IMAGE_NAME:$CI_COMMIT_REF_NAME
   IMAGE_LATEST: $IMAGE_NAME:latest
-
+  PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
 
 
 stages:
@@ -14,40 +14,62 @@ test_api:
   services:
     - postgres:9.4
   stage: test
-  image: funkwhale/funkwhale:base
+  image: funkwhale/funkwhale:latest
+  cache:
+    key: "$CI_PROJECT_ID/pip_cache"
+    paths:
+      - "$PIP_CACHE_DIR"
   variables:
-    PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
+    DJANGO_ALLOWED_HOSTS: "localhost"
     DATABASE_URL: "postgresql://postgres@postgres/postgres"
 
   before_script:
-    - python3 -m venv --copies virtualenv
-    - source virtualenv/bin/activate
     - cd api
     - pip install -r requirements/base.txt
     - pip install -r requirements/local.txt
     - pip install -r requirements/test.txt
   script:
     - pytest
+  tags:
+    - docker
+
+
+test_front:
+  stage: test
+  image: node:9
+  before_script:
+    - cd front
+
+  script:
+    - yarn install
+    - yarn run unit
   cache:
-    key: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
+    key: "$CI_PROJECT_ID/front_dependencies"
     paths:
-      - "$CI_PROJECT_DIR/pip-cache"
+      - front/node_modules
+      - front/yarn.lock
+  artifacts:
+    name: "front_${CI_COMMIT_REF_NAME}"
+    paths:
+      - front/dist/
   tags:
     - docker
 
+
 build_front:
   stage: build
-  image: node:6-alpine
+  image: node:9
   before_script:
     - cd front
 
   script:
-    - npm install
-    - npm run build
+    - yarn install
+    - yarn run build
   cache:
-    key: "$CI_COMMIT_REF_NAME"
+    key: "$CI_PROJECT_ID/front_dependencies"
     paths:
       - front/node_modules
+      - front/yarn.lock
   artifacts:
     name: "front_${CI_COMMIT_REF_NAME}"
     paths:
diff --git a/CHANGELOG b/CHANGELOG
index 6909d7d788ffda722ba1a54c88fe57b1dd41e46e..2d005e1a36b0cbca335ee63d9d4ccb70d2381d6e 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -2,8 +2,23 @@ Changelog
 =========
 
 
-0.3.5 (Unreleased)
-------------------
+0.5 (Unreleased)
+----------------
+
+
+0.4 (2018-02-18)
+----------------
+
+- Front: ambiant colors in player based on current track cover (#59)
+- Front: simplified front dev setup thanks to webpack proxy (#59)
+- Front: added some unittests for the store (#55)
+- Front: fixed broken login redirection when 401
+- Front: Removed autoplay on page reload
+- API: Added a /instance/settings endpoint
+- Front: load /instance/settings on page load
+- Added settings to report JS and Python error to a Sentry instance
+  This is disabled by default, but feel free to enable it if you want
+  to help us by sending your error reports :) (#8)
 
 
 0.3.5 (2018-01-07)
diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index d64eeb5fdbb62b5cdfaceaef8740a82cc9d9c2ef..c7ebc4ed3668196ccd11d6844ba14d704e3ba43e 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -1,5 +1,6 @@
 from rest_framework import routers
 from django.conf.urls import include, url
+from funkwhale_api.instance import views as instance_views
 from funkwhale_api.music import views
 from funkwhale_api.playlists import views as playlists_views
 from rest_framework_jwt import views as jwt_views
@@ -25,6 +26,10 @@ router.register(
 v1_patterns = router.urls
 
 v1_patterns += [
+    url(r'^instance/',
+        include(
+            ('funkwhale_api.instance.urls', 'instance'),
+            namespace='instance')),
     url(r'^providers/',
         include(
             ('funkwhale_api.providers.urls', 'providers'),
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 6f821dfba4de6a3b8af13dd7273c7a3e213cbf2c..6d02cbbc1038999cc7a3de46448369aa00e030bf 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -12,6 +12,7 @@ from __future__ import absolute_import, unicode_literals
 
 import os
 import environ
+from funkwhale_api import __version__
 
 ROOT_DIR = environ.Path(__file__) - 3  # (/a/b/myfile.py - 3 = /)
 APPS_DIR = ROOT_DIR.path('funkwhale_api')
@@ -22,6 +23,10 @@ try:
     env.read_env(ROOT_DIR.file('.env'))
 except FileNotFoundError:
     pass
+
+ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
+
+
 # APP CONFIGURATION
 # ------------------------------------------------------------------------------
 DJANGO_APPS = (
@@ -56,10 +61,28 @@ THIRD_PARTY_APPS = (
     'django_filters',
 )
 
+
+# Sentry
+RAVEN_ENABLED = env.bool("RAVEN_ENABLED", default=False)
+RAVEN_DSN = env("RAVEN_DSN", default='')
+
+if RAVEN_ENABLED:
+    RAVEN_CONFIG = {
+        'dsn': RAVEN_DSN,
+        # If you are using git, you can also automatically configure the
+        # release based on the git info.
+        'release': __version__,
+    }
+    THIRD_PARTY_APPS += (
+        'raven.contrib.django.raven_compat',
+    )
+
+
 # Apps specific for this project go here.
 LOCAL_APPS = (
     'funkwhale_api.users',  # custom users app
     # Your stuff: custom apps go here
+    'funkwhale_api.instance',
     'funkwhale_api.music',
     'funkwhale_api.favorites',
     'funkwhale_api.radios',
@@ -71,6 +94,7 @@ LOCAL_APPS = (
 )
 
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
+
 INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
 
 # MIDDLEWARE CONFIGURATION
diff --git a/api/config/settings/production.py b/api/config/settings/production.py
index e009833050ead06ea7d62089f4c8182b3bbd172c..df15d325f22d8d78616c937a3142b4a11b34ded8 100644
--- a/api/config/settings/production.py
+++ b/api/config/settings/production.py
@@ -54,7 +54,6 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
 # ------------------------------------------------------------------------------
 # Hosts/domain names that are valid for this site
 # See https://docs.djangoproject.com/en/1.6/ref/settings/#allowed-hosts
-ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
 CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
 
 # END SITE CONFIGURATION
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index 11607c4e16cab93a808b195b9eb99c7c25de01ed..d1c7fcdf447cc845b96ee86c5de04dbb97805eb6 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8 -*-
-__version__ = '0.3.5'
+__version__ = '0.4'
 __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py
index 98c0cfc08de60fc21f2ffc8d66918b638bc28c6c..08ae00b684c30bcf5df91d5438dae1c03c790259 100644
--- a/api/funkwhale_api/favorites/views.py
+++ b/api/funkwhale_api/favorites/views.py
@@ -43,7 +43,7 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin,
         favorite = models.TrackFavorite.add(track=track, user=self.request.user)
         return favorite
 
-    @list_route(methods=['delete'])
+    @list_route(methods=['delete', 'post'])
     def remove(self, request, *args, **kwargs):
         try:
             pk = int(request.data['track'])
diff --git a/api/funkwhale_api/instance/__init__.py b/api/funkwhale_api/instance/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d93c383eb80372c507862a3b4f2e3450d792fca
--- /dev/null
+++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py
@@ -0,0 +1,37 @@
+from dynamic_preferences import types
+from dynamic_preferences.registries import global_preferences_registry
+
+raven = types.Section('raven')
+
+
+@global_preferences_registry.register
+class RavenDSN(types.StringPreference):
+    show_in_api = True
+    section = raven
+    name = 'front_dsn'
+    default = 'https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4'
+    verbose_name = (
+        'A raven DSN key used to report front-ent errors to '
+        'a sentry instance'
+    )
+    help_text = (
+        'Keeping the default one will report errors to funkwhale developers'
+    )
+
+
+SENTRY_HELP_TEXT = (
+    'Error reporting is disabled by default but you can enable it if'
+    ' you want to help us improve funkwhale'
+)
+
+
+@global_preferences_registry.register
+class RavenEnabled(types.BooleanPreference):
+    show_in_api = True
+    section = raven
+    name = 'front_enabled'
+    default = False
+    verbose_name = (
+        'Wether error reporting to a Sentry instance using raven is enabled'
+        ' for front-end errors'
+    )
diff --git a/api/funkwhale_api/instance/urls.py b/api/funkwhale_api/instance/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f2b46b87a4fe301387c5e134cc7a6fcdf6291b2
--- /dev/null
+++ b/api/funkwhale_api/instance/urls.py
@@ -0,0 +1,7 @@
+from django.conf.urls import url
+from . import views
+
+
+urlpatterns = [
+    url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'),
+]
diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..44ee228735d0ad7297dcf31d513ecc087d8c9d59
--- /dev/null
+++ b/api/funkwhale_api/instance/views.py
@@ -0,0 +1,25 @@
+from rest_framework import views
+from rest_framework.response import Response
+
+from dynamic_preferences.api import serializers
+from dynamic_preferences.registries import global_preferences_registry
+
+
+class InstanceSettings(views.APIView):
+    permission_classes = []
+    authentication_classes = []
+
+    def get(self, request, *args, **kwargs):
+        manager = global_preferences_registry.manager()
+        manager.all()
+        all_preferences = manager.model.objects.all().order_by(
+            'section', 'name'
+        )
+        api_preferences = [
+            p
+            for p in all_preferences
+            if getattr(p.preference, 'show_in_api', False)
+        ]
+        data = serializers.GlobalPreferenceSerializer(
+            api_preferences, many=True).data
+        return Response(data, status=200)
diff --git a/api/funkwhale_api/music/fake_data.py b/api/funkwhale_api/music/fake_data.py
index ef5eaf4be811f580d82378c17ab838694df52ea7..892b784caec0c7bcdd3db7927852bd83b5ad9b2b 100644
--- a/api/funkwhale_api/music/fake_data.py
+++ b/api/funkwhale_api/music/fake_data.py
@@ -4,7 +4,7 @@ Populates the database with fake data
 import random
 
 from funkwhale_api.music import models
-from funkwhale_api.music.tests import factories
+from funkwhale_api.music import factories
 
 
 def create_data(count=25):
@@ -19,4 +19,4 @@ def create_data(count=25):
 
 
 if __name__ == '__main__':
-    main()
+    create_data()
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 9f96dad0e300a64ae0d03eaf36cca177f8f89589..506893a4d23ae1026f5f26a159eeb841443dde96 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -31,10 +31,7 @@ class TrackFileSerializer(serializers.ModelSerializer):
         fields = ('id', 'path', 'duration', 'source', 'filename', 'track')
 
     def get_path(self, o):
-        request = self.context.get('request')
         url = o.path
-        if request:
-            url = request.build_absolute_uri(url)
         return url
 
 
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index cff16d3f1de30ff37810d2c934923bb595d6abdf..f38da9629041fcd8f9f1a0620d00ec17c0823f32 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -47,8 +47,7 @@ mutagen>=1.39,<1.40
 
 
 # Until this is merged
-#django-taggit>=0.22,<0.23
-git+https://github.com/alex/django-taggit.git@95776ac66948ed7ba7c12e35c1170551e3be66a5
+django-taggit>=0.22,<0.23
 # Until this is merged
 git+https://github.com/EliotBerriot/PyMemoize.git@django
 # Until this is merged
@@ -57,3 +56,4 @@ git+https://github.com/EliotBerriot/django-cachalot.git@django-2
 
 django-dynamic-preferences>=1.5,<1.6
 pyacoustid>=1.1.5,<1.2
+raven>=6.5,<7
diff --git a/api/test.yml b/api/test.yml
index c59ce45bbbbaf2357a2b50782bc004f51b584cce..e892dfb178221ccd5ba8ed83558f3a6436b20dad 100644
--- a/api/test.yml
+++ b/api/test.yml
@@ -10,6 +10,7 @@ services:
     volumes:
       - .:/app
     environment:
+      - "DJANGO_ALLOWED_HOSTS=localhost"
       - "DATABASE_URL=postgresql://postgres@postgres/postgres"
   postgres:
     image: postgres
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 6c0cffa4e0da24ed87501a692464598563de2480..4d7a6fa981d1431cd01a97d4578d2a992b4cb749 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -3,6 +3,7 @@ import shutil
 import pytest
 from django.core.cache import cache as django_cache
 from dynamic_preferences.registries import global_preferences_registry
+from rest_framework.test import APIClient
 
 from funkwhale_api.taskapp import celery
 
@@ -29,7 +30,9 @@ def factories(db):
 
 @pytest.fixture
 def preferences(db):
-    yield global_preferences_registry.manager()
+    manager = global_preferences_registry.manager()
+    manager.all()
+    yield manager
 
 
 @pytest.fixture
@@ -48,6 +51,11 @@ def logged_in_client(db, factories, client):
     delattr(client, 'user')
 
 
+@pytest.fixture
+def api_client(client):
+    return APIClient()
+
+
 @pytest.fixture
 def superuser_client(db, factories, client):
     user = factories['users.SuperUser']()
diff --git a/api/tests/instance/test_preferences.py b/api/tests/instance/test_preferences.py
new file mode 100644
index 0000000000000000000000000000000000000000..c89bfa3492091a93d0000f661bd83f2fc8ebedc0
--- /dev/null
+++ b/api/tests/instance/test_preferences.py
@@ -0,0 +1,22 @@
+from django.urls import reverse
+
+from dynamic_preferences.api import serializers
+
+
+def test_can_list_settings_via_api(preferences, api_client):
+    url = reverse('api:v1:instance:settings')
+    all_preferences = preferences.model.objects.all()
+    expected_preferences = {
+        p.preference.identifier(): p
+        for p in all_preferences
+        if getattr(p.preference, 'show_in_api', False)}
+
+    assert len(expected_preferences) > 0
+
+    response = api_client.get(url)
+    assert response.status_code == 200
+    assert len(response.data) == len(expected_preferences)
+
+    for p in response.data:
+        i = '__'.join([p['section'], p['name']])
+        assert i in expected_preferences
diff --git a/api/tests/test_favorites.py b/api/tests/test_favorites.py
index 418166d8e0c11aa133e87a44a55f7b1713a3ad2f..8165722eacc969a9ea651dd70815d3fa1d9c75ea 100644
--- a/api/tests/test_favorites.py
+++ b/api/tests/test_favorites.py
@@ -58,11 +58,14 @@ def test_user_can_remove_favorite_via_api(logged_in_client, factories, client):
     assert response.status_code == 204
     assert TrackFavorite.objects.count() == 0
 
-def test_user_can_remove_favorite_via_api_using_track_id(factories, logged_in_client):
+
+@pytest.mark.parametrize('method', ['delete', 'post'])
+def test_user_can_remove_favorite_via_api_using_track_id(
+        method, factories, logged_in_client):
     favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
 
     url = reverse('api:v1:favorites:tracks-remove')
-    response = logged_in_client.delete(
+    response = getattr(logged_in_client, method)(
         url, json.dumps({'track': favorite.track.pk}),
         content_type='application/json'
     )
diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample
index 9cbe278e827e96b015945b7a3beac1c6d1606d63..5bdfeb9c626fa074eaedb5c7f499871946d3dea9 100644
--- a/deploy/env.prod.sample
+++ b/deploy/env.prod.sample
@@ -78,3 +78,10 @@ API_AUTHENTICATION_REQUIRED=True
 # public: anybody can register an account
 # disabled: nobody can register an account
 REGISTRATION_MODE=disabled
+
+# Sentry/Raven error reporting (server side)
+# Enable Raven if you want to help improve funkwhale by
+# automatically sending error reports our Sentry instance.
+# This will help us detect and correct bugs
+RAVEN_ENABLED=false
+RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
diff --git a/dev.yml b/dev.yml
index befc4b2434848aca07b1c594f221889a3b227d3d..e3cd50da7ab29b8de22783ed686313dc79dac6cd 100644
--- a/dev.yml
+++ b/dev.yml
@@ -3,9 +3,7 @@ version: '2'
 services:
 
   front:
-    build:
-      dockerfile: docker/Dockerfile.dev
-      context: ./front
+    build: front
     env_file: .env.dev
     environment:
       - "HOST=0.0.0.0"
@@ -51,13 +49,11 @@ services:
       - ./api:/app
       - ./data/music:/music
     environment:
-      - "DJANGO_ALLOWED_HOSTS=localhost"
+      - "DJANGO_ALLOWED_HOSTS=localhost,nginx"
       - "DJANGO_SETTINGS_MODULE=config.settings.local"
       - "DJANGO_SECRET_KEY=dev"
       - "DATABASE_URL=postgresql://postgres@postgres/postgres"
       - "CACHE_URL=redis://redis:6379/0"
-    ports:
-      - "12081:12081"
     links:
       - postgres
       - redis
diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev
index 48436173bcb5ce236bc6a5cce4455467e3794039..1b749c30a24006e2e954044bc50ec03a94d1adb6 100644
--- a/docker/nginx/conf.dev
+++ b/docker/nginx/conf.dev
@@ -40,8 +40,8 @@ http {
             proxy_set_header X-Real-IP $remote_addr;
             proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
             proxy_set_header X-Forwarded-Proto $scheme;
-            proxy_set_header X-Forwarded-Host   $host:$server_port;
-            proxy_set_header X-Forwarded-Port   $server_port;
+            proxy_set_header X-Forwarded-Host   localhost:8080;
+            proxy_set_header X-Forwarded-Port   8080;
             proxy_redirect off;
             proxy_pass   http://api:12081/;
         }
diff --git a/front/Dockerfile b/front/Dockerfile
index ad05f72eb8ab839f04b841c20663db00426caeb3..cdf92446b1c5fde91d3e8511ba5dec9e1743527c 100644
--- a/front/Dockerfile
+++ b/front/Dockerfile
@@ -1,13 +1,11 @@
-FROM node:6-alpine
+FROM node:9
 
 EXPOSE 8080
-
-RUN mkdir /app
-WORKDIR /app
+WORKDIR /app/
 ADD package.json .
+RUN yarn install --only=production
+RUN yarn install --only=dev
+VOLUME ["/app/node_modules"]
+COPY . .
 
-RUN npm install
-
-ADD . .
-
-RUN npm run build
+CMD ["npm", "run", "dev"]
diff --git a/front/config/index.js b/front/config/index.js
index a312c7b26c9f137ee8b57c89cc3f969da4a07ab4..7ce6e26e1c63ad12fc681a5317158856d30a99c8 100644
--- a/front/config/index.js
+++ b/front/config/index.js
@@ -28,7 +28,20 @@ module.exports = {
     autoOpenBrowser: true,
     assetsSubDirectory: 'static',
     assetsPublicPath: '/',
-    proxyTable: {},
+    proxyTable: {
+      '/api': {
+        target: 'http://nginx:6001',
+        changeOrigin: true,
+      },
+      '/media': {
+        target: 'http://nginx:6001',
+        changeOrigin: true,
+      },
+      '/staticfiles': {
+        target: 'http://nginx:6001',
+        changeOrigin: true,
+      }
+    },
     // CSS Sourcemaps off by default because relative paths are "buggy"
     // with this option, according to the CSS-Loader README
     // (https://github.com/webpack/css-loader#sourcemaps)
diff --git a/front/config/prod.env.js b/front/config/prod.env.js
index fe0e80b8f7c6db0a9b7bae510a4129820bb4a62f..decfe36154adc59fbf4a432cecac77119bbcdbf7 100644
--- a/front/config/prod.env.js
+++ b/front/config/prod.env.js
@@ -1,4 +1,4 @@
 module.exports = {
   NODE_ENV: '"production"',
-  BACKEND_URL: '"' + (process.env.BACKEND_URL  || '/') + '"'
+  BACKEND_URL: '"/"'
 }
diff --git a/front/docker/Dockerfile.dev b/front/docker/Dockerfile.dev
deleted file mode 100644
index 1a0c90c9e0fd8bc8a578a25e797fc04a3e30f6ea..0000000000000000000000000000000000000000
--- a/front/docker/Dockerfile.dev
+++ /dev/null
@@ -1,13 +0,0 @@
-FROM node:6-alpine
-
-EXPOSE 8080
-
-RUN mkdir /app
-WORKDIR /app
-ADD package.json .
-
-RUN npm install
-
-VOLUME ["/app/node_modules"]
-
-CMD ["npm", "run", "dev"]
diff --git a/front/package.json b/front/package.json
index 58c22a4082c4d37762c32a3e23e7edf453a7fbca..ac3895f6d65655fd198699c92cd55300df301b1e 100644
--- a/front/package.json
+++ b/front/package.json
@@ -9,24 +9,28 @@
     "start": "node build/dev-server.js",
     "build": "node build/build.js",
     "unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
+    "unit-watch": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js",
     "e2e": "node test/e2e/runner.js",
     "test": "npm run unit && npm run e2e",
     "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs"
   },
   "dependencies": {
+    "axios": "^0.17.1",
     "dateformat": "^2.0.0",
     "js-logger": "^1.3.0",
     "jwt-decode": "^2.2.0",
     "lodash": "^4.17.4",
+    "moxios": "^0.4.0",
+    "raven-js": "^3.22.3",
     "semantic-ui-css": "^2.2.10",
     "vue": "^2.3.3",
     "vue-lazyload": "^1.1.4",
-    "vue-resource": "^1.3.4",
     "vue-router": "^2.3.1",
     "vue-upload-component": "^2.7.4",
     "vuedraggable": "^2.14.1",
     "vuex": "^3.0.1",
-    "vuex-persistedstate": "^2.4.2"
+    "vuex-persistedstate": "^2.4.2",
+    "vuex-router-sync": "^5.0.0"
   },
   "devDependencies": {
     "autoprefixer": "^6.7.2",
@@ -46,6 +50,7 @@
     "cross-env": "^4.0.0",
     "cross-spawn": "^5.0.1",
     "css-loader": "^0.28.0",
+    "es6-promise": "^4.2.2",
     "eslint": "^3.19.0",
     "eslint-config-standard": "^6.2.1",
     "eslint-friendly-formatter": "^2.0.7",
@@ -67,6 +72,7 @@
     "karma-phantomjs-launcher": "^1.0.2",
     "karma-phantomjs-shim": "^1.4.0",
     "karma-sinon-chai": "^1.3.1",
+    "karma-sinon-stub-promise": "^1.0.0",
     "karma-sourcemap-loader": "^0.3.7",
     "karma-spec-reporter": "0.0.30",
     "karma-webpack": "^2.0.2",
@@ -85,6 +91,7 @@
     "shelljs": "^0.7.6",
     "sinon": "^2.1.0",
     "sinon-chai": "^2.8.0",
+    "sinon-stub-promise": "^4.0.0",
     "url-loader": "^0.5.8",
     "vue-loader": "^12.1.0",
     "vue-style-loader": "^3.0.1",
diff --git a/front/src/App.vue b/front/src/App.vue
index d1d63e65143df782703d035a14c8ca0f21da78e1..98ad48d3ff41cd37cfa4df496c51c24f8dbd0544 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -22,15 +22,26 @@
         </div>
       </div>
     </div>
+    <raven
+      v-if="$store.state.instance.settings.raven.front_enabled.value"
+      :dsn="$store.state.instance.settings.raven.front_dsn.value">
+    </raven>
   </div>
 </template>
 
 <script>
 import Sidebar from '@/components/Sidebar'
+import Raven from '@/components/Raven'
 
 export default {
   name: 'app',
-  components: { Sidebar }
+  components: {
+    Sidebar,
+    Raven
+  },
+  created () {
+    this.$store.dispatch('instance/fetchSettings')
+  }
 }
 </script>
 
@@ -40,25 +51,33 @@ export default {
 // and we end up with CSS rules not applied,
 // see https://github.com/webpack/webpack/issues/215
 @import 'semantic/semantic.css';
+@import 'style/vendor/media';
 
 
+html, body {
+  @include media("<desktop") {
+    font-size: 200%;
+  }
+}
 #app {
   font-family: 'Avenir', Helvetica, Arial, sans-serif;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
 }
 .main.pusher, .footer {
-  margin-left: 350px !important;
+  @include media(">desktop") {
+    margin-left: 350px !important;
+  }
   transform: none !important;
 }
 .main-pusher {
   padding: 1.5rem 0;
 }
-#footer {
-  padding: 4em;
-}
-.ui.stripe.segment {
-  padding: 4em;
+.ui.stripe.segment, #footer {
+  padding: 2em;
+  @include media(">tablet") {
+    padding: 4em;
+  }
 }
 
 .ui.small.text.container {
diff --git a/front/src/assets/logo/favicon.ico b/front/src/assets/logo/favicon.png
similarity index 100%
rename from front/src/assets/logo/favicon.ico
rename to front/src/assets/logo/favicon.png
diff --git a/front/src/components/Raven.vue b/front/src/components/Raven.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e5e125b810ec0cb53e4080d64f3b0c9133305307
--- /dev/null
+++ b/front/src/components/Raven.vue
@@ -0,0 +1,41 @@
+<template>
+  <div class="raven"></div>
+</template>
+
+<script>
+import Raven from 'raven-js'
+import RavenVue from 'raven-js/plugins/vue'
+import Vue from 'vue'
+import logger from '@/logging'
+
+export default {
+  props: ['dsn'],
+  created () {
+    Raven.uninstall()
+    this.setUp()
+  },
+  destroyed () {
+    Raven.uninstall()
+  },
+  methods: {
+    setUp () {
+      Raven.uninstall()
+      logger.default.info('Installing raven...')
+      Raven.config(this.dsn).addPlugin(RavenVue, Vue).install()
+      console.log({}.test.test)
+    }
+  },
+  watch: {
+    dsn: function () {
+      this.setUp()
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped >
+.raven {
+  display: none;
+}
+</style>
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index a315aab199c341a24db3baed09cdad32a813975b..86ec578194df2d3b5d3dddc28fd4dd085884e5d1 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -1,13 +1,16 @@
 <template>
-<div class="ui vertical left visible wide sidebar">
+<div :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar',]">
   <div class="ui inverted segment header-wrapper">
-    <search-bar>
+    <search-bar @search="isCollapsed = false">
       <router-link :title="'Funkwhale'" :to="{name: 'index'}">
         <i class="logo bordered inverted orange big icon">
           <logo class="logo"></logo>
         </i>
-      </router-link>
-
+      </router-link><span
+        slot="after"
+        @click="isCollapsed = !isCollapsed"
+        :class="['ui', 'basic', 'big', {'inverted': isCollapsed}, 'orange', 'icon', 'collapse', 'button']">
+          <i class="sidebar icon"></i></span>
     </search-bar>
   </div>
 
@@ -49,7 +52,7 @@
       </div>
     </div>
     <div class="ui bottom attached tab" data-tab="queue">
-      <table class="ui compact inverted very basic fixed single line table">
+      <table class="ui compact inverted very basic fixed single line unstackable table">
         <draggable v-model="queue.tracks" element="tbody" @update="reorder">
           <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
               <td class="right aligned">{{ index + 1}}</td>
@@ -84,9 +87,7 @@
       </div>
     </div>
   </div>
-  <div class="ui inverted segment player-wrapper">
-    <player></player>
-  </div>
+  <player></player>
 </div>
 </template>
 
@@ -111,7 +112,8 @@ export default {
   },
   data () {
     return {
-      backend: backend
+      backend: backend,
+      isCollapsed: true
     }
   },
   mounted () {
@@ -119,7 +121,8 @@ export default {
   },
   computed: {
     ...mapState({
-      queue: state => state.queue
+      queue: state => state.queue,
+      url: state => state.route.path
     })
   },
   methods: {
@@ -129,19 +132,42 @@ export default {
     reorder: function (oldValue, newValue) {
       this.$store.commit('queue/reorder', {oldValue, newValue})
     }
+  },
+  watch: {
+    url: function () {
+      this.isCollapsed = true
+    }
   }
 }
 </script>
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
 <style scoped lang="scss">
+@import '../style/vendor/media';
 
 $sidebar-color: #1B1C1D;
 
 .sidebar {
-  display:flex;
-  flex-direction:column;
-  justify-content: space-between;
+	background: $sidebar-color;
+  @include media(">tablet") {
+    display:flex;
+    flex-direction:column;
+    justify-content: space-between;
+  }
+  @include media(">desktop") {
+    .collapse.button {
+      display: none;
+    }
+  }
+  @include media("<desktop") {
+    position: static !important;
+    width: 100% !important;
+    &.collapsed {
+      .menu-area, .player-wrapper, .tabs {
+        display: none;
+      }
+    }
+  }
 
   > div {
     margin: 0;
@@ -160,7 +186,12 @@ $sidebar-color: #1B1C1D;
 }
 .tabs {
   overflow-y: auto;
-  height: 0px;
+  @include media(">tablet") {
+    height: 0px;
+  }
+  @include media("<desktop") {
+    max-height: 400px;
+  }
 }
 .tab[data-tab="queue"] {
   tr {
@@ -174,26 +205,22 @@ $sidebar-color: #1B1C1D;
 
 .ui.inverted.segment.header-wrapper {
   padding: 0;
-  padding-bottom: 1rem;
 }
 .tabs {
   flex: 1;
 }
 
-.player-wrapper {
-  border-top: 1px solid rgba(255, 255, 255, 0.1) !important;
-  background-color: rgb(46, 46, 46) !important;
-}
-
 .logo {
   cursor: pointer;
   display: inline-block;
 }
 
 .ui.search {
-  display: inline-block;
-  > a {
-    margin-right: 1.5rem;
+  display: block;
+  .collapse.button {
+    margin-right: 0.5rem;
+    margin-top: 0.5rem;
+    float: right;
   }
 }
 </style>
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index 500f4dc1d2b09e26cf036b871f842ca608b81d3b..e44a92d4fe09c054ae2ffae43c780371769f270c 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -1,145 +1,147 @@
 <template>
-  <div class="player">
-    <audio-track
-      ref="currentAudio"
-      v-if="currentTrack"
-      :key="(currentIndex, currentTrack.id)"
-      :is-current="true"
-      :start-time="$store.state.player.currentTime"
-      :autoplay="$store.state.player.playing"
-      :track="currentTrack">
-    </audio-track>
+  <div class="ui inverted segment player-wrapper" :style="style">
+    <div class="player">
+      <audio-track
+        ref="currentAudio"
+        v-if="currentTrack"
+        :key="(currentIndex, currentTrack.id)"
+        :is-current="true"
+        :start-time="$store.state.player.currentTime"
+        :autoplay="$store.state.player.playing"
+        :track="currentTrack">
+      </audio-track>
 
-    <div v-if="currentTrack" class="track-area ui items">
-      <div class="ui inverted item">
-        <div class="ui tiny image">
-          <img v-if="currentTrack.album.cover" :src="Track.getCover(currentTrack)">
-          <img v-else src="../../assets/audio/default-cover.png">
-        </div>
-        <div class="middle aligned content">
-          <router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}">
-            {{ currentTrack.title }}
-          </router-link>
-          <div class="meta">
-            <router-link class="artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
-              {{ currentTrack.artist.name }}
-            </router-link> /
-            <router-link class="album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
-              {{ currentTrack.album.title }}
-            </router-link>
+      <div v-if="currentTrack" class="track-area ui unstackable items">
+        <div class="ui inverted item">
+          <div class="ui tiny image">
+            <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="Track.getCover(currentTrack)">
+            <img v-else src="../../assets/audio/default-cover.png">
           </div>
-          <div class="description">
-            <track-favorite-icon :track="currentTrack"></track-favorite-icon>
+          <div class="middle aligned content">
+            <router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}">
+              {{ currentTrack.title }}
+            </router-link>
+            <div class="meta">
+              <router-link class="artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
+                {{ currentTrack.artist.name }}
+              </router-link> /
+              <router-link class="album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
+                {{ currentTrack.album.title }}
+              </router-link>
+            </div>
+            <div class="description">
+              <track-favorite-icon :track="currentTrack"></track-favorite-icon>
+            </div>
           </div>
         </div>
       </div>
-    </div>
-    <div class="progress-area" v-if="currentTrack">
-      <div class="ui grid">
-        <div class="left floated four wide column">
-          <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p>
-        </div>
+      <div class="progress-area" v-if="currentTrack">
+        <div class="ui grid">
+          <div class="left floated four wide column">
+            <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p>
+          </div>
 
-        <div class="right floated four wide column">
-          <p class="timer total">{{durationFormatted}}</p>
+          <div class="right floated four wide column">
+            <p class="timer total">{{durationFormatted}}</p>
+          </div>
+        </div>
+        <div ref="progress" class="ui small orange inverted progress" @click="touchProgress">
+          <div class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div>
         </div>
       </div>
-      <div ref="progress" class="ui small orange inverted progress" @click="touchProgress">
-        <div class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div>
-      </div>
-    </div>
 
-    <div class="two wide column controls ui grid">
-      <div
-        @click="previous"
-        title="Previous track"
-        class="two wide column control"
-        :disabled="!hasPrevious">
-          <i :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i>
-      </div>
-      <div
-        v-if="!playing"
-        @click="togglePlay"
-        title="Play track"
-        class="two wide column control">
-          <i :class="['ui', 'play', {'disabled': !currentTrack}, 'big', 'icon']"></i>
-      </div>
-      <div
-        v-else
-        @click="togglePlay"
-        title="Pause track"
-        class="two wide column control">
-          <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'big', 'icon']"></i>
-      </div>
-      <div
-        @click="next"
-        title="Next track"
-        class="two wide column control"
-        :disabled="!hasNext">
-          <i :class="['ui', {'disabled': !hasNext}, 'step', 'forward', 'big', 'icon']" ></i>
-      </div>
-      <div class="two wide column control volume-control">
-        <i title="Unmute" @click="$store.commit('player/volume', 1)" v-if="volume === 0" class="volume off secondary icon"></i>
-        <i title="Mute" @click="$store.commit('player/volume', 0)" v-else-if="volume < 0.5" class="volume down secondary icon"></i>
-        <i title="Mute" @click="$store.commit('player/volume', 0)" v-else class="volume up secondary icon"></i>
-        <input type="range" step="0.05" min="0" max="1" v-model="sliderVolume" />
-      </div>
-      <div class="two wide column control looping">
-        <i
-          title="Looping disabled. Click to switch to single-track looping."
-          v-if="looping === 0"
-          @click="$store.commit('player/looping', 1)"
-          :disabled="!currentTrack"
-          :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'secondary', 'icon']"></i>
-        <i
-          title="Looping on a single track. Click to switch to whole queue looping."
-          v-if="looping === 1"
-          @click="$store.commit('player/looping', 2)"
-          :disabled="!currentTrack"
-          class="repeat secondary icon">
-          <span class="ui circular tiny orange label">1</span>
-        </i>
-        <i
-          title="Looping on whole queue. Click to disable looping."
-          v-if="looping === 2"
-          @click="$store.commit('player/looping', 0)"
-          :disabled="!currentTrack"
-          class="repeat orange secondary icon">
-        </i>
-      </div>
-      <div
-        @click="shuffle()"
-        :disabled="queue.tracks.length === 0"
-        title="Shuffle your queue"
-        class="two wide column control">
-        <i :class="['ui', 'random', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
-      </div>
-      <div class="one wide column"></div>
-      <div
-        @click="clean()"
-        :disabled="queue.tracks.length === 0"
-        title="Clear your queue"
-        class="two wide column control">
-        <i :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
+      <div class="two wide column controls ui grid">
+        <div
+          @click="previous"
+          title="Previous track"
+          class="two wide column control"
+          :disabled="!hasPrevious">
+            <i :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i>
+        </div>
+        <div
+          v-if="!playing"
+          @click="togglePlay"
+          title="Play track"
+          class="two wide column control">
+            <i :class="['ui', 'play', {'disabled': !currentTrack}, 'big', 'icon']"></i>
+        </div>
+        <div
+          v-else
+          @click="togglePlay"
+          title="Pause track"
+          class="two wide column control">
+            <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'big', 'icon']"></i>
+        </div>
+        <div
+          @click="next"
+          title="Next track"
+          class="two wide column control"
+          :disabled="!hasNext">
+            <i :class="['ui', {'disabled': !hasNext}, 'step', 'forward', 'big', 'icon']" ></i>
+        </div>
+        <div class="two wide column control volume-control">
+          <i title="Unmute" @click="$store.commit('player/volume', 1)" v-if="volume === 0" class="volume off secondary icon"></i>
+          <i title="Mute" @click="$store.commit('player/volume', 0)" v-else-if="volume < 0.5" class="volume down secondary icon"></i>
+          <i title="Mute" @click="$store.commit('player/volume', 0)" v-else class="volume up secondary icon"></i>
+          <input type="range" step="0.05" min="0" max="1" v-model="sliderVolume" />
+        </div>
+        <div class="two wide column control looping">
+          <i
+            title="Looping disabled. Click to switch to single-track looping."
+            v-if="looping === 0"
+            @click="$store.commit('player/looping', 1)"
+            :disabled="!currentTrack"
+            :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'secondary', 'icon']"></i>
+          <i
+            title="Looping on a single track. Click to switch to whole queue looping."
+            v-if="looping === 1"
+            @click="$store.commit('player/looping', 2)"
+            :disabled="!currentTrack"
+            class="repeat secondary icon">
+            <span class="ui circular tiny orange label">1</span>
+          </i>
+          <i
+            title="Looping on whole queue. Click to disable looping."
+            v-if="looping === 2"
+            @click="$store.commit('player/looping', 0)"
+            :disabled="!currentTrack"
+            class="repeat orange secondary icon">
+          </i>
+        </div>
+        <div
+          @click="shuffle()"
+          :disabled="queue.tracks.length === 0"
+          title="Shuffle your queue"
+          class="two wide column control">
+          <i :class="['ui', 'random', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
+        </div>
+        <div class="one wide column"></div>
+        <div
+          @click="clean()"
+          :disabled="queue.tracks.length === 0"
+          title="Clear your queue"
+          class="two wide column control">
+          <i :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
+        </div>
       </div>
+      <GlobalEvents
+        @keydown.space.prevent.exact="togglePlay"
+        @keydown.ctrl.left.prevent.exact="previous"
+        @keydown.ctrl.right.prevent.exact="next"
+        @keydown.ctrl.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)"
+        @keydown.ctrl.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)"
+        @keydown.f.prevent.exact="$store.dispatch('favorites/toggle', currentTrack.id)"
+        @keydown.l.prevent.exact="$store.commit('player/toggleLooping')"
+        @keydown.s.prevent.exact="shuffle"
+        />
     </div>
-    <GlobalEvents
-      @keydown.space.prevent.exact="togglePlay"
-      @keydown.ctrl.left.prevent.exact="previous"
-      @keydown.ctrl.right.prevent.exact="next"
-      @keydown.ctrl.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)"
-      @keydown.ctrl.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)"
-      @keydown.f.prevent.exact="$store.dispatch('favorites/toggle', currentTrack.id)"
-      @keydown.l.prevent.exact="$store.commit('player/toggleLooping')"
-      @keydown.s.prevent.exact="shuffle"
-      />
-
   </div>
 </template>
 
 <script>
 import {mapState, mapGetters, mapActions} from 'vuex'
 import GlobalEvents from '@/components/utils/global-events'
+import ColorThief from '@/vendor/color-thief'
 
 import Track from '@/audio/track'
 import AudioTrack from '@/components/audio/Track'
@@ -153,9 +155,12 @@ export default {
     AudioTrack
   },
   data () {
+    let defaultAmbiantColors = [[46, 46, 46], [46, 46, 46], [46, 46, 46], [46, 46, 46]]
     return {
       sliderVolume: this.volume,
-      Track: Track
+      Track: Track,
+      defaultAmbiantColors: defaultAmbiantColors,
+      ambiantColors: defaultAmbiantColors
     }
   },
   mounted () {
@@ -177,6 +182,14 @@ export default {
       let target = this.$refs.progress
       time = e.layerX / target.offsetWidth * this.duration
       this.$refs.currentAudio.setCurrentTime(time)
+    },
+    updateBackground () {
+      if (!this.currentTrack.album.cover) {
+        this.ambiantColors = this.defaultAmbiantColors
+        return
+      }
+      let image = this.$refs.cover
+      this.ambiantColors = ColorThief.prototype.getPalette(image, 4).slice(0, 4)
     }
   },
   computed: {
@@ -195,9 +208,34 @@ export default {
       durationFormatted: 'player/durationFormatted',
       currentTimeFormatted: 'player/currentTimeFormatted',
       progress: 'player/progress'
-    })
+    }),
+    style: function () {
+      let style = {
+        'background': this.ambiantGradiant
+      }
+      return style
+    },
+    ambiantGradiant: function () {
+      let indexConf = [
+        {orientation: 330, percent: 100, opacity: 0.7},
+        {orientation: 240, percent: 90, opacity: 0.7},
+        {orientation: 150, percent: 80, opacity: 0.7},
+        {orientation: 60, percent: 70, opacity: 0.7}
+      ]
+      let gradients = this.ambiantColors.map((e, i) => {
+        let [r, g, b] = e
+        let conf = indexConf[i]
+        return `linear-gradient(${conf.orientation}deg, rgba(${r}, ${g}, ${b}, ${conf.opacity}) 10%, rgba(255, 255, 255, 0) ${conf.percent}%)`
+      }).join(', ')
+      return gradients
+    }
   },
   watch: {
+    currentTrack (newValue) {
+      if (!newValue) {
+        this.ambiantColors = this.defaultAmbiantColors
+      }
+    },
     volume (newValue) {
       this.sliderVolume = newValue
     },
diff --git a/front/src/components/audio/Search.vue b/front/src/components/audio/Search.vue
index 2811c2b5c4f58ca71f8c5c55c239ffcf41c0e5d6..bb0881862397324f679296ae5cf6dfd93869cea6 100644
--- a/front/src/components/audio/Search.vue
+++ b/front/src/components/audio/Search.vue
@@ -29,13 +29,11 @@
 </template>
 
 <script>
+import axios from 'axios'
 import logger from '@/logging'
 import backend from '@/audio/backend'
 import AlbumCard from '@/components/audio/album/Card'
 import ArtistCard from '@/components/audio/artist/Card'
-import config from '@/config'
-
-const SEARCH_URL = config.API_URL + 'search'
 
 export default {
   components: {
@@ -73,17 +71,8 @@ export default {
       let params = {
         query: this.query
       }
-      this.$http.get(SEARCH_URL, {
-        params: params,
-        before (request) {
-          // abort previous request, if exists
-          if (this.previousRequest) {
-            this.previousRequest.abort()
-          }
-
-          // set previous request on Vue instance
-          this.previousRequest = request
-        }
+      axios.get('search', {
+        params: params
       }).then((response) => {
         self.results = self.castResults(response.data)
         self.isLoading = false
diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue
index 9d8b39f870cac19021fc56d9df3368d8f05f6ddf..988ff0a7d7ccb78707e3b66c3474e380e6838e72 100644
--- a/front/src/components/audio/SearchBar.vue
+++ b/front/src/components/audio/SearchBar.vue
@@ -1,11 +1,11 @@
 <template>
   <div class="ui fluid category search">
-    <slot></slot>
-    <div class="ui icon input">
+    <slot></slot><div class="ui icon input">
       <input class="prompt" placeholder="Search for artists, albums, tracks..." type="text">
       <i class="search icon"></i>
     </div>
     <div class="results"></div>
+    <slot name="after"></slot>
   </div>
 </template>
 
@@ -25,6 +25,9 @@ export default {
       onSelect (result, response) {
         router.push(result.routerUrl)
       },
+      onSearchQuery (query) {
+        self.$emit('search')
+      },
       apiSettings: {
         beforeXHR: function (xhrObject) {
           xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue
index c8627925ebdd404e4d8dc9887d8a0307c22705fd..a513c468f6792d2013bb25b6b4946a863be667a7 100644
--- a/front/src/components/audio/Track.vue
+++ b/front/src/components/audio/Track.vue
@@ -59,13 +59,15 @@ export default {
 
     },
     loaded: function () {
-      if (this.isCurrent && this.autoplay) {
+      if (this.isCurrent) {
         this.$store.commit('player/duration', this.$refs.audio.duration)
         if (this.startTime) {
           this.setCurrentTime(this.startTime)
         }
-        this.$store.commit('player/playing', true)
-        this.$refs.audio.play()
+        if (this.autoplay) {
+          this.$store.commit('player/playing', true)
+          this.$refs.audio.play()
+        }
       }
     },
     updateProgress: function () {
diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue
index 4c803b29cc704fbc7e05258bfe595ee9b6908d99..968b828a49df356baf3ab104edc6e7fa5c811bc5 100644
--- a/front/src/components/audio/album/Card.vue
+++ b/front/src/components/audio/album/Card.vue
@@ -14,7 +14,7 @@
           </router-link>
         </div>
         <div class="description" v-if="mode === 'rich'">
-          <table class="ui very basic fixed single line compact table">
+          <table class="ui very basic fixed single line compact unstackable table">
             <tbody>
               <tr v-for="track in tracks">
                 <td>
diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue
index 8a02163fbb17258badb78fe6f4d133e5096f6749..9a82d6c8f315e09ab6d6ce2fb08c491ec31183e9 100644
--- a/front/src/components/audio/artist/Card.vue
+++ b/front/src/components/audio/artist/Card.vue
@@ -7,7 +7,7 @@
           </router-link>
         </div>
         <div class="description">
-          <table class="ui compact very basic fixed single line table">
+          <table class="ui compact very basic fixed single line unstackable table">
             <tbody>
               <tr v-for="album in albums">
                 <td>
diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue
index 8a591d3bd05e9542484a2b8bf8ef9b931363a837..00bcf9f7de239ab6f54f1925d7550760aba95c23 100644
--- a/front/src/components/audio/track/Table.vue
+++ b/front/src/components/audio/track/Table.vue
@@ -1,5 +1,5 @@
 <template>
-  <table class="ui compact very basic fixed single line table">
+  <table class="ui compact very basic fixed single line unstackable table">
     <thead>
       <tr>
         <th></th>
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index d93373a1496937380eab3531855696289617996a..f090581ef7305927e293e3f6ffce3e6033131004 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -36,7 +36,7 @@
 </template>
 
 <script>
-import Vue from 'vue'
+import axios from 'axios'
 import config from '@/config'
 import logger from '@/logging'
 
@@ -61,8 +61,8 @@ export default {
         new_password1: this.new_password,
         new_password2: this.new_password
       }
-      let resource = Vue.resource(config.BACKEND_URL + 'api/auth/registration/change-password/')
-      return resource.save({}, credentials).then(response => {
+      let url = config.BACKEND_URL + 'api/auth/registration/change-password/'
+      return axios.post(url, credentials).then(response => {
         logger.default.info('Password successfully changed')
         self.$router.push('/profile/me')
       }, response => {
diff --git a/front/src/components/favorites/List.vue b/front/src/components/favorites/List.vue
index 8577e84ca5d339e7ed3ad0b10a4e99ef69411c83..c65144a93bf9c6ae1c43f0148f2df75ee4be9105 100644
--- a/front/src/components/favorites/List.vue
+++ b/front/src/components/favorites/List.vue
@@ -54,15 +54,15 @@
 </template>
 
 <script>
+import axios from 'axios'
 import $ from 'jquery'
 import logger from '@/logging'
-import config from '@/config'
 import TrackTable from '@/components/audio/track/Table'
 import RadioButton from '@/components/radios/Button'
 import Pagination from '@/components/Pagination'
 import OrderingMixin from '@/components/mixins/Ordering'
 import PaginationMixin from '@/components/mixins/Pagination'
-const FAVORITES_URL = config.API_URL + 'tracks/'
+const FAVORITES_URL = 'tracks/'
 
 export default {
   mixins: [OrderingMixin, PaginationMixin],
@@ -115,7 +115,7 @@ export default {
         ordering: this.getOrderingAsString()
       }
       logger.default.time('Loading user favorites')
-      this.$http.get(url, {params: params}).then((response) => {
+      axios.get(url, {params: params}).then((response) => {
         self.results = response.data
         self.nextLink = response.data.next
         self.previousLink = response.data.previous
diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue
index dcfea560095064e20384dd1e77dc37707c7f42e3..65768aafe7851868b35030e2b6273dfbcfefc3b7 100644
--- a/front/src/components/library/Album.vue
+++ b/front/src/components/library/Album.vue
@@ -41,14 +41,13 @@
 </template>
 
 <script>
-
+import axios from 'axios'
 import logger from '@/logging'
 import backend from '@/audio/backend'
 import PlayButton from '@/components/audio/PlayButton'
 import TrackTable from '@/components/audio/track/Table'
-import config from '@/config'
 
-const FETCH_URL = config.API_URL + 'albums/'
+const FETCH_URL = 'albums/'
 
 export default {
   props: ['id'],
@@ -71,7 +70,7 @@ export default {
       this.isLoading = true
       let url = FETCH_URL + this.id + '/'
       logger.default.debug('Fetching album "' + this.id + '"')
-      this.$http.get(url).then((response) => {
+      axios.get(url).then((response) => {
         self.album = backend.Album.clean(response.data)
         self.isLoading = false
       })
diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue
index 5eab08ddf0fd9c9f7f3d82b0572f85cba4e1dc52..c2834e1de87b11fd3fdd2f9e4e8a65c5c1045e75 100644
--- a/front/src/components/library/Artist.vue
+++ b/front/src/components/library/Artist.vue
@@ -41,15 +41,14 @@
 </template>
 
 <script>
-
+import axios from 'axios'
 import logger from '@/logging'
 import backend from '@/audio/backend'
 import AlbumCard from '@/components/audio/album/Card'
 import RadioButton from '@/components/radios/Button'
 import PlayButton from '@/components/audio/PlayButton'
-import config from '@/config'
 
-const FETCH_URL = config.API_URL + 'artists/'
+const FETCH_URL = 'artists/'
 
 export default {
   props: ['id'],
@@ -74,7 +73,7 @@ export default {
       this.isLoading = true
       let url = FETCH_URL + this.id + '/'
       logger.default.debug('Fetching artist "' + this.id + '"')
-      this.$http.get(url).then((response) => {
+      axios.get(url).then((response) => {
         self.artist = response.data
         self.albums = JSON.parse(JSON.stringify(self.artist.albums)).map((album) => {
           return backend.Album.clean(album)
diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue
index 23a30e7711fda31fcbb61f7860fa70b93807c7c6..c9bea5efc0c4667f9c625a62bf12f9b820c1dbf5 100644
--- a/front/src/components/library/Artists.vue
+++ b/front/src/components/library/Artists.vue
@@ -57,10 +57,10 @@
 </template>
 
 <script>
+import axios from 'axios'
 import _ from 'lodash'
 import $ from 'jquery'
 
-import config from '@/config'
 import backend from '@/audio/backend'
 import logger from '@/logging'
 
@@ -69,7 +69,7 @@ import PaginationMixin from '@/components/mixins/Pagination'
 import ArtistCard from '@/components/audio/artist/Card'
 import Pagination from '@/components/Pagination'
 
-const FETCH_URL = config.API_URL + 'artists/'
+const FETCH_URL = 'artists/'
 
 export default {
   mixins: [OrderingMixin, PaginationMixin],
@@ -124,7 +124,7 @@ export default {
         ordering: this.getOrderingAsString()
       }
       logger.default.debug('Fetching artists')
-      this.$http.get(url, {params: params}).then((response) => {
+      axios.get(url, {params: params}).then((response) => {
         self.result = response.data
         self.result.results.map((artist) => {
           var albums = JSON.parse(JSON.stringify(artist.albums)).map((album) => {
diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue
index 624da62c567e75fee65577726b5aaa0d5b2f32bd..cdcbe4b72ee7153d757da33eddb393d1d67a568e 100644
--- a/front/src/components/library/Home.vue
+++ b/front/src/components/library/Home.vue
@@ -24,14 +24,14 @@
 </template>
 
 <script>
+import axios from 'axios'
 import Search from '@/components/audio/Search'
 import backend from '@/audio/backend'
 import logger from '@/logging'
 import ArtistCard from '@/components/audio/artist/Card'
-import config from '@/config'
 import RadioCard from '@/components/radios/Card'
 
-const ARTISTS_URL = config.API_URL + 'artists/'
+const ARTISTS_URL = 'artists/'
 
 export default {
   name: 'library',
@@ -58,7 +58,7 @@ export default {
       }
       let url = ARTISTS_URL
       logger.default.time('Loading latest artists')
-      this.$http.get(url, {params: params}).then((response) => {
+      axios.get(url, {params: params}).then((response) => {
         self.artists = response.data.results
         self.artists.map((artist) => {
           var albums = JSON.parse(JSON.stringify(artist.albums)).map((album) => {
diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue
index c27313dc36d2a9d08c0cbba2c0e704c7527c5063..5fe192022c8e34416259118f262fb87076e98458 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -22,8 +22,12 @@ export default {
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
 <style lang="scss">
+@import '../../style/vendor/media';
+
 .library.pusher > .ui.secondary.menu {
-  margin: 0 2.5rem;
+  @include media(">tablet") {
+    margin: 0 2.5rem;
+  }
   .item {
     padding-top: 1.5em;
     padding-bottom: 1.5em;
@@ -37,7 +41,10 @@ export default {
     padding: 0;
     .segment-content {
       margin: 0 auto;
-      padding: 4em;
+      padding: 2em;
+      @include media(">tablet") {
+        padding: 4em;
+      }
     }
     &.with-background {
       .header {
diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue
index 409b6b6741137f6fef494cd42a11b33648498658..1952908ff8a5a56a6731c4ca3b6c74803beca8d5 100644
--- a/front/src/components/library/Radios.vue
+++ b/front/src/components/library/Radios.vue
@@ -59,10 +59,10 @@
 </template>
 
 <script>
+import axios from 'axios'
 import _ from 'lodash'
 import $ from 'jquery'
 
-import config from '@/config'
 import logger from '@/logging'
 
 import OrderingMixin from '@/components/mixins/Ordering'
@@ -70,7 +70,7 @@ import PaginationMixin from '@/components/mixins/Pagination'
 import RadioCard from '@/components/radios/Card'
 import Pagination from '@/components/Pagination'
 
-const FETCH_URL = config.API_URL + 'radios/radios/'
+const FETCH_URL = 'radios/radios/'
 
 export default {
   mixins: [OrderingMixin, PaginationMixin],
@@ -125,7 +125,7 @@ export default {
         ordering: this.getOrderingAsString()
       }
       logger.default.debug('Fetching radios')
-      this.$http.get(url, {params: params}).then((response) => {
+      axios.get(url, {params: params}).then((response) => {
         self.result = response.data
         self.isLoading = false
       })
diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue
index 48cd801c3d8a96fc34b4904febbacb0d6243d66f..a40409615dc75bb0e29ebe230f626a1757b7a9f4 100644
--- a/front/src/components/library/Track.vue
+++ b/front/src/components/library/Track.vue
@@ -60,15 +60,14 @@
 </template>
 
 <script>
-
+import axios from 'axios'
 import url from '@/utils/url'
 import logger from '@/logging'
 import backend from '@/audio/backend'
 import PlayButton from '@/components/audio/PlayButton'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
-import config from '@/config'
 
-const FETCH_URL = config.API_URL + 'tracks/'
+const FETCH_URL = 'tracks/'
 
 export default {
   props: ['id'],
@@ -94,7 +93,7 @@ export default {
       this.isLoadingTrack = true
       let url = FETCH_URL + this.id + '/'
       logger.default.debug('Fetching track "' + this.id + '"')
-      this.$http.get(url).then((response) => {
+      axios.get(url).then((response) => {
         self.track = response.data
         self.isLoadingTrack = false
       })
@@ -104,7 +103,7 @@ export default {
       this.isLoadingLyrics = true
       let url = FETCH_URL + this.id + '/lyrics/'
       logger.default.debug('Fetching lyrics for track "' + this.id + '"')
-      this.$http.get(url).then((response) => {
+      axios.get(url).then((response) => {
         self.lyrics = response.data
         self.isLoadingLyrics = false
       }, (response) => {
diff --git a/front/src/components/library/import/ArtistImport.vue b/front/src/components/library/import/ArtistImport.vue
index 870a886e1835c0d811890c9fa4e911b4f74a57a4..fb531439b4eb06c1afb1aab9be605ec5bc6219d6 100644
--- a/front/src/components/library/import/ArtistImport.vue
+++ b/front/src/components/library/import/ArtistImport.vue
@@ -37,8 +37,8 @@
 
 <script>
 import Vue from 'vue'
+import axios from 'axios'
 import logger from '@/logging'
-import config from '@/config'
 
 import ImportMixin from './ImportMixin'
 import ReleaseImport from './ReleaseImport'
@@ -92,9 +92,8 @@ export default Vue.extend({
     fetchReleaseGroupsData () {
       let self = this
       this.releaseGroups.forEach(group => {
-        let url = config.API_URL + 'providers/musicbrainz/releases/browse/' + group.id + '/'
-        let resource = Vue.resource(url)
-        resource.get({}).then((response) => {
+        let url = 'providers/musicbrainz/releases/browse/' + group.id + '/'
+        return axios.get(url).then((response) => {
           logger.default.info('successfully fetched release group', group.id)
           let release = response.data['release-list'].filter(r => {
             return r.status === 'Official'
diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue
index 762074c107ff9a2f61fbccb92611041f5d9ecc6f..417fd55c2bc6f208208bfb16963d6b1d4d6f5d44 100644
--- a/front/src/components/library/import/BatchDetail.vue
+++ b/front/src/components/library/import/BatchDetail.vue
@@ -17,7 +17,7 @@
         <div v-if="batch.status === 'pending'" class="label">Importing {{ batch.jobs.length }} tracks...</div>
         <div v-if="batch.status === 'finished'" class="label">Imported {{ batch.jobs.length }} tracks!</div>
       </div>
-      <table class="ui table">
+      <table class="ui unstackable table">
         <thead>
           <tr>
             <th>Job ID</th>
@@ -52,11 +52,10 @@
 </template>
 
 <script>
-
+import axios from 'axios'
 import logger from '@/logging'
-import config from '@/config'
 
-const FETCH_URL = config.API_URL + 'import-batches/'
+const FETCH_URL = 'import-batches/'
 
 export default {
   props: ['id'],
@@ -75,7 +74,7 @@ export default {
       this.isLoading = true
       let url = FETCH_URL + this.id + '/'
       logger.default.debug('Fetching batch "' + this.id + '"')
-      this.$http.get(url).then((response) => {
+      axios.get(url).then((response) => {
         self.batch = response.data
         self.isLoading = false
         if (self.batch.status === 'pending') {
diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue
index f6e7b03e5c06323d5033a1278626efa262d8b123..de4fef554c5c4699ec1d501dbcbc471dc28d2379 100644
--- a/front/src/components/library/import/BatchList.vue
+++ b/front/src/components/library/import/BatchList.vue
@@ -12,7 +12,7 @@
         :disabled="!nextLink">Next <i class="right arrow icon"></i></button>
       <div class="ui hidden clearing divider"></div>
       <div class="ui hidden clearing divider"></div>
-      <table v-if="results.length > 0" class="ui table">
+      <table v-if="results.length > 0" class="ui unstackable table">
         <thead>
           <tr>
             <th>ID</th>
@@ -42,10 +42,10 @@
 </template>
 
 <script>
+import axios from 'axios'
 import logger from '@/logging'
-import config from '@/config'
 
-const BATCHES_URL = config.API_URL + 'import-batches/'
+const BATCHES_URL = 'import-batches/'
 
 export default {
   components: {},
@@ -65,7 +65,7 @@ export default {
       var self = this
       this.isLoading = true
       logger.default.time('Loading import batches')
-      this.$http.get(url, {}).then((response) => {
+      axios.get(url, {}).then((response) => {
         self.results = response.data.results
         self.nextLink = response.data.next
         self.previousLink = response.data.previous
diff --git a/front/src/components/library/import/FileUpload.vue b/front/src/components/library/import/FileUpload.vue
index 93ca75c3961d445f924bf920df7ef5e1da330ab3..35b7b636ad79200d36e68c9b53a0fff8918edd3d 100644
--- a/front/src/components/library/import/FileUpload.vue
+++ b/front/src/components/library/import/FileUpload.vue
@@ -62,10 +62,9 @@
 </template>
 
 <script>
-import Vue from 'vue'
+import axios from 'axios'
 import logger from '@/logging'
 import FileUploadWidget from './FileUploadWidget'
-import config from '@/config'
 
 export default {
   components: {
@@ -74,7 +73,7 @@ export default {
   data () {
     return {
       files: [],
-      uploadUrl: config.API_URL + 'import-jobs/',
+      uploadUrl: 'import-jobs/',
       batch: null
     }
   },
@@ -106,9 +105,7 @@ export default {
     },
     createBatch () {
       let self = this
-      let url = config.API_URL + 'import-batches/'
-      let resource = Vue.resource(url)
-      resource.save({}, {}).then((response) => {
+      return axios.post('import-batches/', {}).then((response) => {
         self.batch = response.data
       }, (response) => {
         logger.default.error('error while launching creating batch')
diff --git a/front/src/components/library/import/ImportMixin.vue b/front/src/components/library/import/ImportMixin.vue
index 475241f3d5b24415a6c5cecf11c59f597142d1b4..33c6193bd7c0a95afa6e59847dc6056a0ca38145 100644
--- a/front/src/components/library/import/ImportMixin.vue
+++ b/front/src/components/library/import/ImportMixin.vue
@@ -3,9 +3,8 @@
 </template>
 
 <script>
+import axios from 'axios'
 import logger from '@/logging'
-import config from '@/config'
-import Vue from 'vue'
 import router from '@/router'
 
 export default {
@@ -31,10 +30,9 @@ export default {
     launchImport () {
       let self = this
       this.isImporting = true
-      let url = config.API_URL + 'submit/' + self.importType + '/'
+      let url = 'submit/' + self.importType + '/'
       let payload = self.importData
-      let resource = Vue.resource(url)
-      resource.save({}, payload).then((response) => {
+      axios.post(url, payload).then((response) => {
         logger.default.info('launched import for', self.type, self.metadata.id)
         self.isImporting = false
         router.push({
diff --git a/front/src/components/library/import/TrackImport.vue b/front/src/components/library/import/TrackImport.vue
index 2275bcf34e6b9f3cf80e22a478d72f79a3e14dae..edd444d92ee071ebbb0946c16badecf21a378346 100644
--- a/front/src/components/library/import/TrackImport.vue
+++ b/front/src/components/library/import/TrackImport.vue
@@ -70,9 +70,9 @@
 </template>
 
 <script>
+import axios from 'axios'
 import Vue from 'vue'
 import time from '@/utils/time'
-import config from '@/config'
 import logger from '@/logging'
 import ImportMixin from './ImportMixin'
 
@@ -117,10 +117,8 @@ export default Vue.extend({
     search () {
       let self = this
       this.isLoading = true
-      let url = config.API_URL + 'providers/' + this.currentBackendId + '/search/'
-      let resource = Vue.resource(url)
-
-      resource.get({query: this.query}).then((response) => {
+      let url = 'providers/' + this.currentBackendId + '/search/'
+      axios.get(url, {params: {query: this.query}}).then((response) => {
         logger.default.debug('searching', self.query, 'on', self.currentBackendId)
         self.results = response.data
         self.isLoading = false
diff --git a/front/src/components/library/radios/Builder.vue b/front/src/components/library/radios/Builder.vue
index f58d5003fd5b7ac21adac9b77142fa96206e0284..8d67b61e18b5f21d67e360c3c4fe05def2e52fcc 100644
--- a/front/src/components/library/radios/Builder.vue
+++ b/front/src/components/library/radios/Builder.vue
@@ -67,7 +67,7 @@
   </div>
 </template>
 <script>
-import config from '@/config'
+import axios from 'axios'
 import $ from 'jquery'
 import _ from 'lodash'
 import BuilderFilter from './Filter'
@@ -107,8 +107,8 @@ export default {
   methods: {
     fetchFilters: function () {
       let self = this
-      let url = config.API_URL + 'radios/radios/filters/'
-      return this.$http.get(url).then((response) => {
+      let url = 'radios/radios/filters/'
+      return axios.get(url).then((response) => {
         self.availableFilters = response.data
       })
     },
@@ -130,8 +130,8 @@ export default {
     },
     fetch: function () {
       let self = this
-      let url = config.API_URL + 'radios/radios/' + this.id + '/'
-      this.$http.get(url).then((response) => {
+      let url = 'radios/radios/' + this.id + '/'
+      axios.get(url).then((response) => {
         self.filters = response.data.config.map(f => {
           return {
             config: f,
@@ -145,7 +145,7 @@ export default {
     },
     fetchCandidates: function () {
       let self = this
-      let url = config.API_URL + 'radios/radios/validate/'
+      let url = 'radios/radios/validate/'
       let final = this.filters.map(f => {
         let c = _.clone(f.config)
         c.type = f.filter.type
@@ -156,7 +156,7 @@ export default {
           {'type': 'group', filters: final}
         ]
       }
-      this.$http.post(url, final).then((response) => {
+      axios.post(url, final).then((response) => {
         self.checkResult = response.data.filters[0]
       })
     },
@@ -173,12 +173,12 @@ export default {
         'config': final
       }
       if (this.id) {
-        let url = config.API_URL + 'radios/radios/' + this.id + '/'
-        this.$http.put(url, final).then((response) => {
+        let url = 'radios/radios/' + this.id + '/'
+        axios.put(url, final).then((response) => {
         })
       } else {
-        let url = config.API_URL + 'radios/radios/'
-        this.$http.post(url, final).then((response) => {
+        let url = 'radios/radios/'
+        axios.post(url, final).then((response) => {
           self.$router.push({
             name: 'library.radios.edit',
             params: {
diff --git a/front/src/components/library/radios/Filter.vue b/front/src/components/library/radios/Filter.vue
index dd170d8b3104da08e4fbd3b93091182fe4a3a2a4..722ecbfbe93ce5afee0227ac476d034e21547705 100644
--- a/front/src/components/library/radios/Filter.vue
+++ b/front/src/components/library/radios/Filter.vue
@@ -62,6 +62,7 @@
   </tr>
 </template>
 <script>
+import axios from 'axios'
 import config from '@/config'
 import $ from 'jquery'
 import _ from 'lodash'
@@ -132,11 +133,11 @@ export default {
   methods: {
     fetchCandidates: function () {
       let self = this
-      let url = config.API_URL + 'radios/radios/validate/'
+      let url = 'radios/radios/validate/'
       let final = _.clone(this.config)
       final.type = this.filter.type
       final = {'filters': [final]}
-      this.$http.post(url, final).then((response) => {
+      axios.post(url, final).then((response) => {
         self.checkResult = response.data.filters[0]
       })
     }
diff --git a/front/src/components/metadata/CardMixin.vue b/front/src/components/metadata/CardMixin.vue
index 78aae5e7e06da465de2037f1e427e646eee88317..a7cd476f6c2d534eb04c6bf2f1038711a56dc897 100644
--- a/front/src/components/metadata/CardMixin.vue
+++ b/front/src/components/metadata/CardMixin.vue
@@ -3,11 +3,9 @@
 </template>
 
 <script>
+import axios from 'axios'
 import logger from '@/logging'
 
-import config from '@/config'
-import Vue from 'vue'
-
 export default {
   props: {
     mbId: {type: String, required: true}
@@ -25,9 +23,8 @@ export default {
     fetchData () {
       let self = this
       this.isLoading = true
-      let url = config.API_URL + 'providers/musicbrainz/' + this.type + 's/' + this.mbId + '/'
-      let resource = Vue.resource(url)
-      resource.get({}).then((response) => {
+      let url = 'providers/musicbrainz/' + this.type + 's/' + this.mbId + '/'
+      axios.get(url).then((response) => {
         logger.default.info('successfully fetched', self.type, self.mbId)
         self.data = response.data[self.type]
         this.$emit('metadata-changed', self.data)
diff --git a/front/src/config.js b/front/src/config.js
index 3ba1247acf21ec5564cff0b9975030c84bcb4908..47d9d7b8b2cdc83ded7330e2400c48d4b72b8e2e 100644
--- a/front/src/config.js
+++ b/front/src/config.js
@@ -1,12 +1,6 @@
 class Config {
   constructor () {
     this.BACKEND_URL = process.env.BACKEND_URL
-    if (this.BACKEND_URL === '/') {
-      this.BACKEND_URL = window.location.protocol + '//' + window.location.hostname + ':' + window.location.port
-    }
-    if (!this.BACKEND_URL.endsWith('/')) {
-      this.BACKEND_URL += '/'
-    }
     this.API_URL = this.BACKEND_URL + 'api/v1/'
   }
 }
diff --git a/front/src/main.js b/front/src/main.js
index f7a6b65f4df7cdd2ddfc4b37914999b12b3e9186..d1ff90c3256846b65848021e1abf0f0b3fb19bc7 100644
--- a/front/src/main.js
+++ b/front/src/main.js
@@ -8,9 +8,13 @@ logger.default.debug('Environment variables:', process.env)
 import Vue from 'vue'
 import App from './App'
 import router from './router'
-import VueResource from 'vue-resource'
+import axios from 'axios'
 import VueLazyload from 'vue-lazyload'
 import store from './store'
+import config from './config'
+import { sync } from 'vuex-router-sync'
+
+sync(store, router)
 
 window.$ = window.jQuery = require('jquery')
 
@@ -19,25 +23,33 @@ window.$ = window.jQuery = require('jquery')
 // require('./semantic/semantic.css')
 require('semantic-ui-css/semantic.js')
 
-Vue.use(VueResource)
 Vue.use(VueLazyload)
 Vue.config.productionTip = false
 
-Vue.http.interceptors.push(function (request, next) {
-  // modify headers
+axios.defaults.baseURL = config.API_URL
+axios.interceptors.request.use(function (config) {
+  // Do something before request is sent
   if (store.state.auth.authenticated) {
-    request.headers.set('Authorization', store.getters['auth/header'])
+    config.headers['Authorization'] = store.getters['auth/header']
   }
-  next(function (response) {
-    // redirect to login form when we get unauthorized response from server
-    if (response.status === 401) {
-      store.commit('auth/authenticated', false)
-      logger.default.warn('Received 401 response from API, redirecting to login form')
-      router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
-    }
-  })
+  return config
+}, function (error) {
+  // Do something with request error
+  return Promise.reject(error)
 })
 
+// Add a response interceptor
+axios.interceptors.response.use(function (response) {
+  return response
+}, function (error) {
+  if (error.response.status === 401) {
+    store.commit('auth/authenticated', false)
+    logger.default.warn('Received 401 response from API, redirecting to login form')
+    router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
+  }
+  // Do something with response error
+  return Promise.reject(error)
+})
 store.dispatch('auth/check')
 /* eslint-disable no-new */
 new Vue({
diff --git a/front/src/semantic/semantic.css b/front/src/semantic/semantic.css
index 99c5ddaa52f2a7f9e4456421afac0d1697a15510..f12877b8573788c43dcd4435cf3080b55f250368 100755
--- a/front/src/semantic/semantic.css
+++ b/front/src/semantic/semantic.css
@@ -515,7 +515,7 @@ body {
 }
 
 html {
-  font-size: 14px;
+  font-size: 100%;
 }
 
 body {
@@ -525,7 +525,7 @@ body {
   min-width: 320px;
   background: #FFFFFF;
   font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif;
-  font-size: 14px;
+  font-size: 100%;
   line-height: 1.4285em;
   color: rgba(0, 0, 0, 0.87);
   font-smoothing: antialiased;
diff --git a/front/src/store/auth.js b/front/src/store/auth.js
index d8bd197f33fabd1938fe65a5c71ca950314b663f..24dafcd7266d7fe23f2e0c60807ea29fb440db98 100644
--- a/front/src/store/auth.js
+++ b/front/src/store/auth.js
@@ -1,13 +1,8 @@
-import Vue from 'vue'
+import axios from 'axios'
 import jwtDecode from 'jwt-decode'
-import config from '@/config'
 import logger from '@/logging'
 import router from '@/router'
 
-const LOGIN_URL = config.API_URL + 'token/'
-const REFRESH_TOKEN_URL = config.API_URL + 'token/refresh/'
-const USER_PROFILE_URL = config.API_URL + 'users/users/me/'
-
 export default {
   namespaced: true,
   state: {
@@ -54,9 +49,8 @@ export default {
   },
   actions: {
     // Send a request to the login URL and save the returned JWT
-    login ({commit, dispatch, state}, {next, credentials, onError}) {
-      let resource = Vue.resource(LOGIN_URL)
-      return resource.save({}, credentials).then(response => {
+    login ({commit, dispatch}, {next, credentials, onError}) {
+      return axios.post('token/', credentials).then(response => {
         logger.default.info('Successfully logged in as', credentials.username)
         commit('token', response.data.token)
         commit('username', credentials.username)
@@ -91,8 +85,7 @@ export default {
       }
     },
     fetchProfile ({commit, dispatch, state}) {
-      let resource = Vue.resource(USER_PROFILE_URL)
-      return resource.get({}).then((response) => {
+      return axios.get('users/users/me/').then((response) => {
         logger.default.info('Successfully fetched user profile')
         let data = response.data
         commit('profile', data)
@@ -107,8 +100,7 @@ export default {
       })
     },
     refreshToken ({commit, dispatch, state}) {
-      let resource = Vue.resource(REFRESH_TOKEN_URL)
-      return resource.save({}, {token: state.token}).then(response => {
+      return axios.post('token/refresh/', {token: state.token}).then(response => {
         logger.default.info('Refreshed auth token')
         commit('token', response.data.token)
       }, response => {
diff --git a/front/src/store/favorites.js b/front/src/store/favorites.js
index 9337966fdf68bc84202a8d2fe3e7add193d1fa20..a4f85b235daa36567f16528d8396d259e63b1db0 100644
--- a/front/src/store/favorites.js
+++ b/front/src/store/favorites.js
@@ -1,10 +1,6 @@
-import Vue from 'vue'
-import config from '@/config'
+import axios from 'axios'
 import logger from '@/logging'
 
-const REMOVE_URL = config.API_URL + 'favorites/tracks/remove/'
-const FAVORITES_URL = config.API_URL + 'favorites/tracks/'
-
 export default {
   namespaced: true,
   state: {
@@ -35,16 +31,14 @@ export default {
     set ({commit, state}, {id, value}) {
       commit('track', {id, value})
       if (value) {
-        let resource = Vue.resource(FAVORITES_URL)
-        resource.save({}, {'track': id}).then((response) => {
+        return axios.post('favorites/tracks/', {'track': id}).then((response) => {
           logger.default.info('Successfully added track to favorites')
         }, (response) => {
           logger.default.info('Error while adding track to favorites')
           commit('track', {id, value: !value})
         })
       } else {
-        let resource = Vue.resource(REMOVE_URL)
-        resource.delete({}, {'track': id}).then((response) => {
+        return axios.post('favorites/tracks/remove/', {'track': id}).then((response) => {
           logger.default.info('Successfully removed track from favorites')
         }, (response) => {
           logger.default.info('Error while removing track from favorites')
@@ -57,9 +51,8 @@ export default {
     },
     fetch ({dispatch, state, commit}, url) {
       // will fetch favorites by batches from API to have them locally
-      url = url || FAVORITES_URL
-      let resource = Vue.resource(url)
-      resource.get().then((response) => {
+      url = url || 'favorites/tracks/'
+      return axios.get(url).then((response) => {
         logger.default.info('Fetched a batch of ' + response.data.results.length + ' favorites')
         response.data.results.forEach(result => {
           commit('track', {id: result.track, value: true})
diff --git a/front/src/store/index.js b/front/src/store/index.js
index 507f0b5876772364185fb6654e0b0a25fe95c913..74f9d42b195be8d38aaa8f6a5745c895f029a346 100644
--- a/front/src/store/index.js
+++ b/front/src/store/index.js
@@ -4,6 +4,7 @@ import createPersistedState from 'vuex-persistedstate'
 
 import favorites from './favorites'
 import auth from './auth'
+import instance from './instance'
 import queue from './queue'
 import radios from './radios'
 import player from './player'
@@ -14,6 +15,7 @@ export default new Vuex.Store({
   modules: {
     auth,
     favorites,
+    instance,
     queue,
     radios,
     player
@@ -37,7 +39,6 @@ export default new Vuex.Store({
       key: 'player',
       paths: [
         'player.looping',
-        'player.playing',
         'player.volume',
         'player.duration',
         'player.errored'],
@@ -45,21 +46,6 @@ export default new Vuex.Store({
         return mutation.type.startsWith('player/') && mutation.type !== 'player/currentTime'
       }
     }),
-    createPersistedState({
-      key: 'progress',
-      paths: ['player.currentTime'],
-      filter: (mutation) => {
-        let delay = 10
-        return mutation.type === 'player/currentTime' && parseInt(mutation.payload) % delay === 0
-      },
-      reducer: (state) => {
-        return {
-          player: {
-            currentTime: state.player.currentTime
-          }
-        }
-      }
-    }),
     createPersistedState({
       key: 'queue',
       filter: (mutation) => {
diff --git a/front/src/store/instance.js b/front/src/store/instance.js
new file mode 100644
index 0000000000000000000000000000000000000000..a0071f0961d6536f2133331787d90245a6dc1df4
--- /dev/null
+++ b/front/src/store/instance.js
@@ -0,0 +1,42 @@
+import axios from 'axios'
+import logger from '@/logging'
+import _ from 'lodash'
+
+export default {
+  namespaced: true,
+  state: {
+    settings: {
+      raven: {
+        front_enabled: {
+          value: false
+        },
+        front_dsn: {
+          value: null
+        }
+      }
+    }
+  },
+  mutations: {
+    settings: (state, value) => {
+      _.merge(state.settings, value)
+    }
+  },
+  actions: {
+    // Send a request to the login URL and save the returned JWT
+    fetchSettings ({commit}) {
+      return axios.get('instance/settings/').then(response => {
+        logger.default.info('Successfully fetched instance settings')
+        let sections = {}
+        response.data.forEach(e => {
+          sections[e.section] = {}
+        })
+        response.data.forEach(e => {
+          sections[e.section][e.name] = e
+        })
+        commit('settings', sections)
+      }, response => {
+        logger.default.error('Error while fetching settings', response.data)
+      })
+    }
+  }
+}
diff --git a/front/src/store/player.js b/front/src/store/player.js
index 74b0b9f9ea72dcbc38d0c3cefc6fa90d1647fc49..fb348042fa107bdef406406bf67aac596da3d4b9 100644
--- a/front/src/store/player.js
+++ b/front/src/store/player.js
@@ -1,5 +1,4 @@
-import Vue from 'vue'
-import config from '@/config'
+import axios from 'axios'
 import logger from '@/logging'
 import time from '@/utils/time'
 
@@ -61,8 +60,8 @@ export default {
     }
   },
   actions: {
-    incrementVolume (context, value) {
-      context.commit('volume', context.state.volume + value)
+    incrementVolume ({commit, state}, value) {
+      commit('volume', state.volume + value)
     },
     stop (context) {
     },
@@ -70,9 +69,7 @@ export default {
       commit('playing', !state.playing)
     },
     trackListened ({commit}, track) {
-      let url = config.API_URL + 'history/listenings/'
-      let resource = Vue.resource(url)
-      resource.save({}, {'track': track.id}).then((response) => {}, (response) => {
+      return axios.post('history/listenings/', {'track': track.id}).then((response) => {}, (response) => {
         logger.default.error('Could not record track in history')
       })
     },
diff --git a/front/src/store/queue.js b/front/src/store/queue.js
index 5dde19bd8e6f1665a826b522820e37ae3c14efaf..ac28a1e0e9cbef5b384f08517e537f56032b0c4b 100644
--- a/front/src/store/queue.js
+++ b/front/src/store/queue.js
@@ -55,33 +55,32 @@ export default {
     }
   },
   actions: {
-    append (context, {track, index, skipPlay}) {
-      index = index || context.state.tracks.length
-      if (index > context.state.tracks.length - 1) {
+    append ({commit, state, dispatch}, {track, index, skipPlay}) {
+      index = index || state.tracks.length
+      if (index > state.tracks.length - 1) {
         // we simply push to the end
-        context.commit('insert', {track, index: context.state.tracks.length})
+        commit('insert', {track, index: state.tracks.length})
       } else {
         // we insert the track at given position
-        context.commit('insert', {track, index})
+        commit('insert', {track, index})
       }
       if (!skipPlay) {
-        context.dispatch('resume')
+        dispatch('resume')
       }
-      // this.cache()
     },
 
-    appendMany (context, {tracks, index}) {
+    appendMany ({state, dispatch}, {tracks, index}) {
       logger.default.info('Appending many tracks to the queue', tracks.map(e => { return e.title }))
-      if (context.state.tracks.length === 0) {
+      if (state.tracks.length === 0) {
         index = 0
       } else {
-        index = index || context.state.tracks.length
+        index = index || state.tracks.length
       }
       tracks.forEach((t) => {
-        context.dispatch('append', {track: t, index: index, skipPlay: true})
+        dispatch('append', {track: t, index: index, skipPlay: true})
         index += 1
       })
-      context.dispatch('resume')
+      dispatch('resume')
     },
 
     cleanTrack ({state, dispatch, commit}, index) {
@@ -100,14 +99,14 @@ export default {
       }
     },
 
-    resume (context) {
-      if (context.state.ended | context.rootState.player.errored) {
-        context.dispatch('next')
+    resume ({state, dispatch, rootState}) {
+      if (state.ended | rootState.player.errored) {
+        dispatch('next')
       }
     },
-    previous (context) {
-      if (context.state.currentIndex > 0) {
-        context.dispatch('currentIndex', context.state.currentIndex - 1)
+    previous ({state, dispatch}) {
+      if (state.currentIndex > 0) {
+        dispatch('currentIndex', state.currentIndex - 1)
       }
     },
     next ({state, dispatch, commit, rootState}) {
diff --git a/front/src/store/radios.js b/front/src/store/radios.js
index 600b24b31e7fb77eafdda8a0992bdf58879f3a54..922083d8841083afbd2ddb29848c62a0e240d6f7 100644
--- a/front/src/store/radios.js
+++ b/front/src/store/radios.js
@@ -1,10 +1,6 @@
-import Vue from 'vue'
-import config from '@/config'
+import axios from 'axios'
 import logger from '@/logging'
 
-const CREATE_RADIO_URL = config.API_URL + 'radios/sessions/'
-const GET_TRACK_URL = config.API_URL + 'radios/tracks/'
-
 export default {
   namespaced: true,
   state: {
@@ -39,13 +35,12 @@ export default {
   },
   actions: {
     start ({commit, dispatch}, {type, objectId, customRadioId}) {
-      let resource = Vue.resource(CREATE_RADIO_URL)
       var params = {
         radio_type: type,
         related_object_id: objectId,
         custom_radio: customRadioId
       }
-      resource.save({}, params).then((response) => {
+      return axios.post('radios/sessions/', params).then((response) => {
         logger.default.info('Successfully started radio ', type)
         commit('current', {type, objectId, session: response.data.id, customRadioId})
         commit('running', true)
@@ -62,12 +57,10 @@ export default {
       if (!state.running) {
         return
       }
-      let resource = Vue.resource(GET_TRACK_URL)
       var params = {
         session: state.current.session
       }
-      let promise = resource.save({}, params)
-      promise.then((response) => {
+      return axios.post('radios/tracks/', params).then((response) => {
         logger.default.info('Adding track to queue from radio')
         dispatch('queue/append', {track: response.data.track}, {root: true})
       }, (response) => {
diff --git a/front/src/style/vendor/_media.scss b/front/src/style/vendor/_media.scss
new file mode 100644
index 0000000000000000000000000000000000000000..2328eff8cf577a82454de1d2fcb175bf60a1404d
--- /dev/null
+++ b/front/src/style/vendor/_media.scss
@@ -0,0 +1,567 @@
+@charset "UTF-8";
+
+//     _            _           _                           _ _
+//    (_)          | |         | |                         | (_)
+//     _ _ __   ___| |_   _  __| | ___   _ __ ___   ___  __| |_  __ _
+//    | | '_ \ / __| | | | |/ _` |/ _ \ | '_ ` _ \ / _ \/ _` | |/ _` |
+//    | | | | | (__| | |_| | (_| |  __/ | | | | | |  __/ (_| | | (_| |
+//    |_|_| |_|\___|_|\__,_|\__,_|\___| |_| |_| |_|\___|\__,_|_|\__,_|
+//
+//      Simple, elegant and maintainable media queries in Sass
+//                        v1.4.9
+//
+//                http://include-media.com
+//
+//         Authors: Eduardo Boucas (@eduardoboucas)
+//                  Hugo Giraudel (@hugogiraudel)
+//
+//      This project is licensed under the terms of the MIT license
+
+
+////
+/// include-media library public configuration
+/// @author Eduardo Boucas
+/// @access public
+////
+
+
+///
+/// Creates a list of global breakpoints
+///
+/// @example scss - Creates a single breakpoint with the label `phone`
+///  $breakpoints: ('phone': 320px);
+///
+$breakpoints: (
+  'phone': 320px,
+  'tablet': 768px,
+  'desktop': 1024px
+) !default;
+
+
+///
+/// Creates a list of static expressions or media types
+///
+/// @example scss - Creates a single media type (screen)
+///  $media-expressions: ('screen': 'screen');
+///
+/// @example scss - Creates a static expression with logical disjunction (OR operator)
+///  $media-expressions: (
+///    'retina2x': '(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)'
+///  );
+///
+$media-expressions: (
+  'screen': 'screen',
+  'print': 'print',
+  'handheld': 'handheld',
+  'landscape': '(orientation: landscape)',
+  'portrait': '(orientation: portrait)',
+  'retina2x': '(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi), (min-resolution: 2dppx)',
+  'retina3x': '(-webkit-min-device-pixel-ratio: 3), (min-resolution: 350dpi), (min-resolution: 3dppx)'
+) !default;
+
+
+///
+/// Defines a number to be added or subtracted from each unit when declaring breakpoints with exclusive intervals
+///
+/// @example scss - Interval for pixels is defined as `1` by default
+///  @include media('>128px') {}
+///
+///  /* Generates: */
+///  @media (min-width: 129px) {}
+///
+/// @example scss - Interval for ems is defined as `0.01` by default
+///  @include media('>20em') {}
+///
+///  /* Generates: */
+///  @media (min-width: 20.01em) {}
+///
+/// @example scss - Interval for rems is defined as `0.1` by default, to be used with `font-size: 62.5%;`
+///  @include media('>2.0rem') {}
+///
+///  /* Generates: */
+///  @media (min-width: 2.1rem) {}
+///
+$unit-intervals: (
+  'px': 1,
+  'em': 0.01,
+  'rem': 0.1,
+  '': 0
+) !default;
+
+///
+/// Defines whether support for media queries is available, useful for creating separate stylesheets
+/// for browsers that don't support media queries.
+///
+/// @example scss - Disables support for media queries
+///  $im-media-support: false;
+///  @include media('>=tablet') {
+///    .foo {
+///      color: tomato;
+///    }
+///  }
+///
+///  /* Generates: */
+///  .foo {
+///    color: tomato;
+///  }
+///
+$im-media-support: true !default;
+
+///
+/// Selects which breakpoint to emulate when support for media queries is disabled. Media queries that start at or
+/// intercept the breakpoint will be displayed, any others will be ignored.
+///
+/// @example scss - This media query will show because it intercepts the static breakpoint
+///  $im-media-support: false;
+///  $im-no-media-breakpoint: 'desktop';
+///  @include media('>=tablet') {
+///    .foo {
+///      color: tomato;
+///    }
+///  }
+///
+///  /* Generates: */
+///  .foo {
+///    color: tomato;
+///  }
+///
+/// @example scss - This media query will NOT show because it does not intercept the desktop breakpoint
+///  $im-media-support: false;
+///  $im-no-media-breakpoint: 'tablet';
+///  @include media('>=desktop') {
+///    .foo {
+///      color: tomato;
+///    }
+///  }
+///
+///  /* No output */
+///
+$im-no-media-breakpoint: 'desktop' !default;
+
+///
+/// Selects which media expressions are allowed in an expression for it to be used when media queries
+/// are not supported.
+///
+/// @example scss - This media query will show because it intercepts the static breakpoint and contains only accepted media expressions
+///  $im-media-support: false;
+///  $im-no-media-breakpoint: 'desktop';
+///  $im-no-media-expressions: ('screen');
+///  @include media('>=tablet', 'screen') {
+///    .foo {
+///      color: tomato;
+///    }
+///  }
+///
+///   /* Generates: */
+///   .foo {
+///     color: tomato;
+///   }
+///
+/// @example scss - This media query will NOT show because it intercepts the static breakpoint but contains a media expression that is not accepted
+///  $im-media-support: false;
+///  $im-no-media-breakpoint: 'desktop';
+///  $im-no-media-expressions: ('screen');
+///  @include media('>=tablet', 'retina2x') {
+///    .foo {
+///      color: tomato;
+///    }
+///  }
+///
+///  /* No output */
+///
+$im-no-media-expressions: ('screen', 'portrait', 'landscape') !default;
+
+////
+/// Cross-engine logging engine
+/// @author Hugo Giraudel
+/// @access private
+////
+
+
+///
+/// Log a message either with `@error` if supported
+/// else with `@warn`, using `feature-exists('at-error')`
+/// to detect support.
+///
+/// @param {String} $message - Message to log
+///
+@function im-log($message) {
+  @if feature-exists('at-error') {
+    @error $message;
+  } @else {
+    @warn $message;
+    $_: noop();
+  }
+
+  @return $message;
+}
+
+
+///
+/// Wrapper mixin for the log function so it can be used with a more friendly
+/// API than `@if im-log('..') {}` or `$_: im-log('..')`. Basically, use the function
+/// within functions because it is not possible to include a mixin in a function
+/// and use the mixin everywhere else because it's much more elegant.
+///
+/// @param {String} $message - Message to log
+///
+@mixin log($message) {
+  @if im-log($message) {}
+}
+
+
+///
+/// Function with no `@return` called next to `@warn` in Sass 3.3
+/// to trigger a compiling error and stop the process.
+///
+@function noop() {}
+
+///
+/// Determines whether a list of conditions is intercepted by the static breakpoint.
+///
+/// @param {Arglist}   $conditions  - Media query conditions
+///
+/// @return {Boolean} - Returns true if the conditions are intercepted by the static breakpoint
+///
+@function im-intercepts-static-breakpoint($conditions...) {
+  $no-media-breakpoint-value: map-get($breakpoints, $im-no-media-breakpoint);
+
+  @if not $no-media-breakpoint-value {
+    @if im-log('`#{$im-no-media-breakpoint}` is not a valid breakpoint.') {}
+  }
+
+  @each $condition in $conditions {
+    @if not map-has-key($media-expressions, $condition) {
+      $operator: get-expression-operator($condition);
+      $prefix: get-expression-prefix($operator);
+      $value: get-expression-value($condition, $operator);
+
+      @if ($prefix == 'max' and $value <= $no-media-breakpoint-value) or
+          ($prefix == 'min' and $value > $no-media-breakpoint-value) {
+        @return false;
+      }
+    } @else if not index($im-no-media-expressions, $condition) {
+      @return false;
+    }
+  }
+
+  @return true;
+}
+
+////
+/// Parsing engine
+/// @author Hugo Giraudel
+/// @access private
+////
+
+
+///
+/// Get operator of an expression
+///
+/// @param {String} $expression - Expression to extract operator from
+///
+/// @return {String} - Any of `>=`, `>`, `<=`, `<`, `≥`, `≤`
+///
+@function get-expression-operator($expression) {
+  @each $operator in ('>=', '>', '<=', '<', '≥', '≤') {
+    @if str-index($expression, $operator) {
+      @return $operator;
+    }
+  }
+
+  // It is not possible to include a mixin inside a function, so we have to
+  // rely on the `im-log(..)` function rather than the `log(..)` mixin. Because
+  // functions cannot be called anywhere in Sass, we need to hack the call in
+  // a dummy variable, such as `$_`. If anybody ever raise a scoping issue with
+  // Sass 3.3, change this line in `@if im-log(..) {}` instead.
+  $_: im-log('No operator found in `#{$expression}`.');
+}
+
+
+///
+/// Get dimension of an expression, based on a found operator
+///
+/// @param {String} $expression - Expression to extract dimension from
+/// @param {String} $operator - Operator from `$expression`
+///
+/// @return {String} - `width` or `height` (or potentially anything else)
+///
+@function get-expression-dimension($expression, $operator) {
+  $operator-index: str-index($expression, $operator);
+  $parsed-dimension: str-slice($expression, 0, $operator-index - 1);
+  $dimension: 'width';
+
+  @if str-length($parsed-dimension) > 0 {
+    $dimension: $parsed-dimension;
+  }
+
+  @return $dimension;
+}
+
+
+///
+/// Get dimension prefix based on an operator
+///
+/// @param {String} $operator - Operator
+///
+/// @return {String} - `min` or `max`
+///
+@function get-expression-prefix($operator) {
+  @return if(index(('<', '<=', '≤'), $operator), 'max', 'min');
+}
+
+
+///
+/// Get value of an expression, based on a found operator
+///
+/// @param {String} $expression - Expression to extract value from
+/// @param {String} $operator - Operator from `$expression`
+///
+/// @return {Number} - A numeric value
+///
+@function get-expression-value($expression, $operator) {
+  $operator-index: str-index($expression, $operator);
+  $value: str-slice($expression, $operator-index + str-length($operator));
+
+  @if map-has-key($breakpoints, $value) {
+    $value: map-get($breakpoints, $value);
+  } @else {
+    $value: to-number($value);
+  }
+
+  $interval: map-get($unit-intervals, unit($value));
+
+  @if not $interval {
+    // It is not possible to include a mixin inside a function, so we have to
+    // rely on the `im-log(..)` function rather than the `log(..)` mixin. Because
+    // functions cannot be called anywhere in Sass, we need to hack the call in
+    // a dummy variable, such as `$_`. If anybody ever raise a scoping issue with
+    // Sass 3.3, change this line in `@if im-log(..) {}` instead.
+    $_: im-log('Unknown unit `#{unit($value)}`.');
+  }
+
+  @if $operator == '>' {
+    $value: $value + $interval;
+  } @else if $operator == '<' {
+    $value: $value - $interval;
+  }
+
+  @return $value;
+}
+
+
+///
+/// Parse an expression to return a valid media-query expression
+///
+/// @param {String} $expression - Expression to parse
+///
+/// @return {String} - Valid media query
+///
+@function parse-expression($expression) {
+  // If it is part of $media-expressions, it has no operator
+  // then there is no need to go any further, just return the value
+  @if map-has-key($media-expressions, $expression) {
+    @return map-get($media-expressions, $expression);
+  }
+
+  $operator: get-expression-operator($expression);
+  $dimension: get-expression-dimension($expression, $operator);
+  $prefix: get-expression-prefix($operator);
+  $value: get-expression-value($expression, $operator);
+
+  @return '(#{$prefix}-#{$dimension}: #{$value})';
+}
+
+///
+/// Slice `$list` between `$start` and `$end` indexes
+///
+/// @access private
+///
+/// @param {List} $list - List to slice
+/// @param {Number} $start [1] - Start index
+/// @param {Number} $end [length($list)] - End index
+///
+/// @return {List} Sliced list
+///
+@function slice($list, $start: 1, $end: length($list)) {
+  @if length($list) < 1 or $start > $end {
+    @return ();
+  }
+
+  $result: ();
+
+  @for $i from $start through $end {
+    $result: append($result, nth($list, $i));
+  }
+
+  @return $result;
+}
+
+////
+/// String to number converter
+/// @author Hugo Giraudel
+/// @access private
+////
+
+
+///
+/// Casts a string into a number
+///
+/// @param {String | Number} $value - Value to be parsed
+///
+/// @return {Number}
+///
+@function to-number($value) {
+  @if type-of($value) == 'number' {
+    @return $value;
+  } @else if type-of($value) != 'string' {
+    $_: im-log('Value for `to-number` should be a number or a string.');
+  }
+
+  $first-character: str-slice($value, 1, 1);
+  $result: 0;
+  $digits: 0;
+  $minus: ($first-character == '-');
+  $numbers: ('0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9);
+
+  // Remove +/- sign if present at first character
+  @if ($first-character == '+' or $first-character == '-') {
+    $value: str-slice($value, 2);
+  }
+
+  @for $i from 1 through str-length($value) {
+    $character: str-slice($value, $i, $i);
+
+    @if not (index(map-keys($numbers), $character) or $character == '.') {
+      @return to-length(if($minus, -$result, $result), str-slice($value, $i))
+    }
+
+    @if $character == '.' {
+      $digits: 1;
+    } @else if $digits == 0 {
+      $result: $result * 10 + map-get($numbers, $character);
+    } @else {
+      $digits: $digits * 10;
+      $result: $result + map-get($numbers, $character) / $digits;
+    }
+  }
+
+  @return if($minus, -$result, $result);
+}
+
+
+///
+/// Add `$unit` to `$value`
+///
+/// @param {Number} $value - Value to add unit to
+/// @param {String} $unit - String representation of the unit
+///
+/// @return {Number} - `$value` expressed in `$unit`
+///
+@function to-length($value, $unit) {
+  $units: ('px': 1px, 'cm': 1cm, 'mm': 1mm, '%': 1%, 'ch': 1ch, 'pc': 1pc, 'in': 1in, 'em': 1em, 'rem': 1rem, 'pt': 1pt, 'ex': 1ex, 'vw': 1vw, 'vh': 1vh, 'vmin': 1vmin, 'vmax': 1vmax);
+
+  @if not index(map-keys($units), $unit) {
+    $_: im-log('Invalid unit `#{$unit}`.');
+  }
+
+  @return $value * map-get($units, $unit);
+}
+
+///
+/// This mixin aims at redefining the configuration just for the scope of
+/// the call. It is helpful when having a component needing an extended
+/// configuration such as custom breakpoints (referred to as tweakpoints)
+/// for instance.
+///
+/// @author Hugo Giraudel
+///
+/// @param {Map} $tweakpoints [()] - Map of tweakpoints to be merged with `$breakpoints`
+/// @param {Map} $tweak-media-expressions [()] - Map of tweaked media expressions to be merged with `$media-expression`
+///
+/// @example scss - Extend the global breakpoints with a tweakpoint
+///  @include media-context(('custom': 678px)) {
+///    .foo {
+///      @include media('>phone', '<=custom') {
+///       // ...
+///      }
+///    }
+///  }
+///
+/// @example scss - Extend the global media expressions with a custom one
+///  @include media-context($tweak-media-expressions: ('all': 'all')) {
+///    .foo {
+///      @include media('all', '>phone') {
+///       // ...
+///      }
+///    }
+///  }
+///
+/// @example scss - Extend both configuration maps
+///  @include media-context(('custom': 678px), ('all': 'all')) {
+///    .foo {
+///      @include media('all', '>phone', '<=custom') {
+///       // ...
+///      }
+///    }
+///  }
+///
+@mixin media-context($tweakpoints: (), $tweak-media-expressions: ()) {
+  // Save global configuration
+  $global-breakpoints: $breakpoints;
+  $global-media-expressions: $media-expressions;
+
+  // Update global configuration
+  $breakpoints: map-merge($breakpoints, $tweakpoints) !global;
+  $media-expressions: map-merge($media-expressions, $tweak-media-expressions) !global;
+
+  @content;
+
+  // Restore global configuration
+  $breakpoints: $global-breakpoints !global;
+  $media-expressions: $global-media-expressions !global;
+}
+
+////
+/// include-media public exposed API
+/// @author Eduardo Boucas
+/// @access public
+////
+
+
+///
+/// Generates a media query based on a list of conditions
+///
+/// @param {Arglist}   $conditions  - Media query conditions
+///
+/// @example scss - With a single set breakpoint
+///  @include media('>phone') { }
+///
+/// @example scss - With two set breakpoints
+///  @include media('>phone', '<=tablet') { }
+///
+/// @example scss - With custom values
+///  @include media('>=358px', '<850px') { }
+///
+/// @example scss - With set breakpoints with custom values
+///  @include media('>desktop', '<=1350px') { }
+///
+/// @example scss - With a static expression
+///  @include media('retina2x') { }
+///
+/// @example scss - Mixing everything
+///  @include media('>=350px', '<tablet', 'retina3x') { }
+///
+@mixin media($conditions...) {
+  @if ($im-media-support and length($conditions) == 0) or
+      (not $im-media-support and im-intercepts-static-breakpoint($conditions...)) {
+    @content;
+  } @else if ($im-media-support and length($conditions) > 0) {
+    @media #{unquote(parse-expression(nth($conditions, 1)))} {
+      // Recursive call
+      @include media(slice($conditions, 2)...) {
+        @content;
+      }
+    }
+  }
+}
diff --git a/front/src/vendor/color-thief.js b/front/src/vendor/color-thief.js
new file mode 100644
index 0000000000000000000000000000000000000000..0acb7c13ae9e396fca00c5b9cc8040267e156c16
--- /dev/null
+++ b/front/src/vendor/color-thief.js
@@ -0,0 +1,660 @@
+/* eslint-disable */
+/*
+ * Color Thief v2.0
+ * by Lokesh Dhakar - http://www.lokeshdhakar.com
+ *
+ * Thanks
+ * ------
+ * Nick Rabinowitz - For creating quantize.js.
+ * John Schulz - For clean up and optimization. @JFSIII
+ * Nathan Spady - For adding drag and drop support to the demo page.
+ *
+ * License
+ * -------
+ * Copyright 2011, 2015 Lokesh Dhakar
+ * Released under the MIT license
+ * https://raw.githubusercontent.com/lokesh/color-thief/master/LICENSE
+ *
+ * @license
+ */
+
+
+/*
+  CanvasImage Class
+  Class that wraps the html image element and canvas.
+  It also simplifies some of the canvas context manipulation
+  with a set of helper functions.
+*/
+var CanvasImage = function (image) {
+    this.canvas  = document.createElement('canvas');
+    this.context = this.canvas.getContext('2d');
+
+    document.body.appendChild(this.canvas);
+
+    this.width  = this.canvas.width  = image.width;
+    this.height = this.canvas.height = image.height;
+
+    this.context.drawImage(image, 0, 0, this.width, this.height);
+};
+
+CanvasImage.prototype.clear = function () {
+    this.context.clearRect(0, 0, this.width, this.height);
+};
+
+CanvasImage.prototype.update = function (imageData) {
+    this.context.putImageData(imageData, 0, 0);
+};
+
+CanvasImage.prototype.getPixelCount = function () {
+    return this.width * this.height;
+};
+
+CanvasImage.prototype.getImageData = function () {
+    return this.context.getImageData(0, 0, this.width, this.height);
+};
+
+CanvasImage.prototype.removeCanvas = function () {
+    this.canvas.parentNode.removeChild(this.canvas);
+};
+
+
+var ColorThief = function () {};
+
+/*
+ * getColor(sourceImage[, quality])
+ * returns {r: num, g: num, b: num}
+ *
+ * Use the median cut algorithm provided by quantize.js to cluster similar
+ * colors and return the base color from the largest cluster.
+ *
+ * Quality is an optional argument. It needs to be an integer. 1 is the highest quality settings.
+ * 10 is the default. There is a trade-off between quality and speed. The bigger the number, the
+ * faster a color will be returned but the greater the likelihood that it will not be the visually
+ * most dominant color.
+ *
+ * */
+ColorThief.prototype.getColor = function(sourceImage, quality) {
+    var palette       = this.getPalette(sourceImage, 5, quality);
+    var dominantColor = palette[0];
+    return dominantColor;
+};
+
+
+/*
+ * getPalette(sourceImage[, colorCount, quality])
+ * returns array[ {r: num, g: num, b: num}, {r: num, g: num, b: num}, ...]
+ *
+ * Use the median cut algorithm provided by quantize.js to cluster similar colors.
+ *
+ * colorCount determines the size of the palette; the number of colors returned. If not set, it
+ * defaults to 10.
+ *
+ * BUGGY: Function does not always return the requested amount of colors. It can be +/- 2.
+ *
+ * quality is an optional argument. It needs to be an integer. 1 is the highest quality settings.
+ * 10 is the default. There is a trade-off between quality and speed. The bigger the number, the
+ * faster the palette generation but the greater the likelihood that colors will be missed.
+ *
+ *
+ */
+ColorThief.prototype.getPalette = function(sourceImage, colorCount, quality) {
+
+    if (typeof colorCount === 'undefined' || colorCount < 2 || colorCount > 256) {
+        colorCount = 10;
+    }
+    if (typeof quality === 'undefined' || quality < 1) {
+        quality = 10;
+    }
+
+    // Create custom CanvasImage object
+    var image      = new CanvasImage(sourceImage);
+    var imageData  = image.getImageData();
+    var pixels     = imageData.data;
+    var pixelCount = image.getPixelCount();
+
+    // Store the RGB values in an array format suitable for quantize function
+    var pixelArray = [];
+    for (var i = 0, offset, r, g, b, a; i < pixelCount; i = i + quality) {
+        offset = i * 4;
+        r = pixels[offset + 0];
+        g = pixels[offset + 1];
+        b = pixels[offset + 2];
+        a = pixels[offset + 3];
+        // If pixel is mostly opaque and not white
+        if (a >= 125) {
+            if (!(r > 250 && g > 250 && b > 250)) {
+                pixelArray.push([r, g, b]);
+            }
+        }
+    }
+
+    // Send array to quantize function which clusters values
+    // using median cut algorithm
+    var cmap    = MMCQ.quantize(pixelArray, colorCount);
+    var palette = cmap? cmap.palette() : null;
+
+    // Clean up
+    image.removeCanvas();
+
+    return palette;
+};
+
+ColorThief.prototype.getColorFromUrl = function(imageUrl, callback, quality) {
+    sourceImage = document.createElement("img");
+    var thief = this;
+    sourceImage.addEventListener('load' , function(){
+        var palette = thief.getPalette(sourceImage, 5, quality);
+        var dominantColor = palette[0];
+        callback(dominantColor, imageUrl);
+    });
+    sourceImage.src = imageUrl
+};
+
+
+ColorThief.prototype.getImageData = function(imageUrl, callback) {
+    xhr = new XMLHttpRequest();
+    xhr.open('GET', imageUrl, true);
+    xhr.responseType = 'arraybuffer'
+    xhr.onload = function(e) {
+        if (this.status == 200) {
+            uInt8Array = new Uint8Array(this.response)
+            i = uInt8Array.length
+            binaryString = new Array(i);
+            for (var i = 0; i < uInt8Array.length; i++){
+                binaryString[i] = String.fromCharCode(uInt8Array[i])
+            }
+            data = binaryString.join('')
+            base64 = window.btoa(data)
+            callback ("data:image/png;base64,"+base64)
+        }
+    }
+    xhr.send();
+};
+
+ColorThief.prototype.getColorAsync = function(imageUrl, callback, quality) {
+    var thief = this;
+    this.getImageData(imageUrl, function(imageData){
+        sourceImage = document.createElement("img");
+        sourceImage.addEventListener('load' , function(){
+            var palette = thief.getPalette(sourceImage, 5, quality);
+            var dominantColor = palette[0];
+            callback(dominantColor, this);
+        });
+        sourceImage.src = imageData;
+    });
+};
+
+
+
+/*!
+ * quantize.js Copyright 2008 Nick Rabinowitz.
+ * Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
+ * @license
+ */
+
+// fill out a couple protovis dependencies
+/*!
+ * Block below copied from Protovis: http://mbostock.github.com/protovis/
+ * Copyright 2010 Stanford Visualization Group
+ * Licensed under the BSD License: http://www.opensource.org/licenses/bsd-license.php
+ * @license
+ */
+if (!pv) {
+    var pv = {
+        map: function(array, f) {
+          var o = {};
+          return f ? array.map(function(d, i) { o.index = i; return f.call(o, d); }) : array.slice();
+        },
+        naturalOrder: function(a, b) {
+            return (a < b) ? -1 : ((a > b) ? 1 : 0);
+        },
+        sum: function(array, f) {
+          var o = {};
+          return array.reduce(f ? function(p, d, i) { o.index = i; return p + f.call(o, d); } : function(p, d) { return p + d; }, 0);
+        },
+        max: function(array, f) {
+          return Math.max.apply(null, f ? pv.map(array, f) : array);
+        }
+    };
+}
+
+
+
+/**
+ * Basic Javascript port of the MMCQ (modified median cut quantization)
+ * algorithm from the Leptonica library (http://www.leptonica.com/).
+ * Returns a color map you can use to map original pixels to the reduced
+ * palette. Still a work in progress.
+ *
+ * @author Nick Rabinowitz
+ * @example
+
+// array of pixels as [R,G,B] arrays
+var myPixels = [[190,197,190], [202,204,200], [207,214,210], [211,214,211], [205,207,207]
+                // etc
+                ];
+var maxColors = 4;
+
+var cmap = MMCQ.quantize(myPixels, maxColors);
+var newPalette = cmap.palette();
+var newPixels = myPixels.map(function(p) {
+    return cmap.map(p);
+});
+
+ */
+var MMCQ = (function() {
+    // private constants
+    var sigbits = 5,
+        rshift = 8 - sigbits,
+        maxIterations = 1000,
+        fractByPopulations = 0.75;
+
+    // get reduced-space color index for a pixel
+    function getColorIndex(r, g, b) {
+        return (r << (2 * sigbits)) + (g << sigbits) + b;
+    }
+
+    // Simple priority queue
+    function PQueue(comparator) {
+        var contents = [],
+            sorted = false;
+
+        function sort() {
+            contents.sort(comparator);
+            sorted = true;
+        }
+
+        return {
+            push: function(o) {
+                contents.push(o);
+                sorted = false;
+            },
+            peek: function(index) {
+                if (!sorted) sort();
+                if (index===undefined) index = contents.length - 1;
+                return contents[index];
+            },
+            pop: function() {
+                if (!sorted) sort();
+                return contents.pop();
+            },
+            size: function() {
+                return contents.length;
+            },
+            map: function(f) {
+                return contents.map(f);
+            },
+            debug: function() {
+                if (!sorted) sort();
+                return contents;
+            }
+        };
+    }
+
+    // 3d color space box
+    function VBox(r1, r2, g1, g2, b1, b2, histo) {
+        var vbox = this;
+        vbox.r1 = r1;
+        vbox.r2 = r2;
+        vbox.g1 = g1;
+        vbox.g2 = g2;
+        vbox.b1 = b1;
+        vbox.b2 = b2;
+        vbox.histo = histo;
+    }
+    VBox.prototype = {
+        volume: function(force) {
+            var vbox = this;
+            if (!vbox._volume || force) {
+                vbox._volume = ((vbox.r2 - vbox.r1 + 1) * (vbox.g2 - vbox.g1 + 1) * (vbox.b2 - vbox.b1 + 1));
+            }
+            return vbox._volume;
+        },
+        count: function(force) {
+            var vbox = this,
+                histo = vbox.histo;
+            if (!vbox._count_set || force) {
+                var npix = 0,
+                    index, i, j, k;
+                for (i = vbox.r1; i <= vbox.r2; i++) {
+                    for (j = vbox.g1; j <= vbox.g2; j++) {
+                        for (k = vbox.b1; k <= vbox.b2; k++) {
+                             index = getColorIndex(i,j,k);
+                             npix += (histo[index] || 0);
+                        }
+                    }
+                }
+                vbox._count = npix;
+                vbox._count_set = true;
+            }
+            return vbox._count;
+        },
+        copy: function() {
+            var vbox = this;
+            return new VBox(vbox.r1, vbox.r2, vbox.g1, vbox.g2, vbox.b1, vbox.b2, vbox.histo);
+        },
+        avg: function(force) {
+            var vbox = this,
+                histo = vbox.histo;
+            if (!vbox._avg || force) {
+                var ntot = 0,
+                    mult = 1 << (8 - sigbits),
+                    rsum = 0,
+                    gsum = 0,
+                    bsum = 0,
+                    hval,
+                    i, j, k, histoindex;
+                for (i = vbox.r1; i <= vbox.r2; i++) {
+                    for (j = vbox.g1; j <= vbox.g2; j++) {
+                        for (k = vbox.b1; k <= vbox.b2; k++) {
+                             histoindex = getColorIndex(i,j,k);
+                             hval = histo[histoindex] || 0;
+                             ntot += hval;
+                             rsum += (hval * (i + 0.5) * mult);
+                             gsum += (hval * (j + 0.5) * mult);
+                             bsum += (hval * (k + 0.5) * mult);
+                        }
+                    }
+                }
+                if (ntot) {
+                    vbox._avg = [~~(rsum/ntot), ~~(gsum/ntot), ~~(bsum/ntot)];
+                } else {
+//                    console.log('empty box');
+                    vbox._avg = [
+                        ~~(mult * (vbox.r1 + vbox.r2 + 1) / 2),
+                        ~~(mult * (vbox.g1 + vbox.g2 + 1) / 2),
+                        ~~(mult * (vbox.b1 + vbox.b2 + 1) / 2)
+                    ];
+                }
+            }
+            return vbox._avg;
+        },
+        contains: function(pixel) {
+            var vbox = this,
+                rval = pixel[0] >> rshift;
+                gval = pixel[1] >> rshift;
+                bval = pixel[2] >> rshift;
+            return (rval >= vbox.r1 && rval <= vbox.r2 &&
+                    gval >= vbox.g1 && gval <= vbox.g2 &&
+                    bval >= vbox.b1 && bval <= vbox.b2);
+        }
+    };
+
+    // Color map
+    function CMap() {
+        this.vboxes = new PQueue(function(a,b) {
+            return pv.naturalOrder(
+                a.vbox.count()*a.vbox.volume(),
+                b.vbox.count()*b.vbox.volume()
+            );
+        });
+    }
+    CMap.prototype = {
+        push: function(vbox) {
+            this.vboxes.push({
+                vbox: vbox,
+                color: vbox.avg()
+            });
+        },
+        palette: function() {
+            return this.vboxes.map(function(vb) { return vb.color; });
+        },
+        size: function() {
+            return this.vboxes.size();
+        },
+        map: function(color) {
+            var vboxes = this.vboxes;
+            for (var i=0; i<vboxes.size(); i++) {
+                if (vboxes.peek(i).vbox.contains(color)) {
+                    return vboxes.peek(i).color;
+                }
+            }
+            return this.nearest(color);
+        },
+        nearest: function(color) {
+            var vboxes = this.vboxes,
+                d1, d2, pColor;
+            for (var i=0; i<vboxes.size(); i++) {
+                d2 = Math.sqrt(
+                    Math.pow(color[0] - vboxes.peek(i).color[0], 2) +
+                    Math.pow(color[1] - vboxes.peek(i).color[1], 2) +
+                    Math.pow(color[2] - vboxes.peek(i).color[2], 2)
+                );
+                if (d2 < d1 || d1 === undefined) {
+                    d1 = d2;
+                    pColor = vboxes.peek(i).color;
+                }
+            }
+            return pColor;
+        },
+        forcebw: function() {
+            // XXX: won't  work yet
+            var vboxes = this.vboxes;
+            vboxes.sort(function(a,b) { return pv.naturalOrder(pv.sum(a.color), pv.sum(b.color));});
+
+            // force darkest color to black if everything < 5
+            var lowest = vboxes[0].color;
+            if (lowest[0] < 5 && lowest[1] < 5 && lowest[2] < 5)
+                vboxes[0].color = [0,0,0];
+
+            // force lightest color to white if everything > 251
+            var idx = vboxes.length-1,
+                highest = vboxes[idx].color;
+            if (highest[0] > 251 && highest[1] > 251 && highest[2] > 251)
+                vboxes[idx].color = [255,255,255];
+        }
+    };
+
+    // histo (1-d array, giving the number of pixels in
+    // each quantized region of color space), or null on error
+    function getHisto(pixels) {
+        var histosize = 1 << (3 * sigbits),
+            histo = new Array(histosize),
+            index, rval, gval, bval;
+        pixels.forEach(function(pixel) {
+            rval = pixel[0] >> rshift;
+            gval = pixel[1] >> rshift;
+            bval = pixel[2] >> rshift;
+            index = getColorIndex(rval, gval, bval);
+            histo[index] = (histo[index] || 0) + 1;
+        });
+        return histo;
+    }
+
+    function vboxFromPixels(pixels, histo) {
+        var rmin=1000000, rmax=0,
+            gmin=1000000, gmax=0,
+            bmin=1000000, bmax=0,
+            rval, gval, bval;
+        // find min/max
+        pixels.forEach(function(pixel) {
+            rval = pixel[0] >> rshift;
+            gval = pixel[1] >> rshift;
+            bval = pixel[2] >> rshift;
+            if (rval < rmin) rmin = rval;
+            else if (rval > rmax) rmax = rval;
+            if (gval < gmin) gmin = gval;
+            else if (gval > gmax) gmax = gval;
+            if (bval < bmin) bmin = bval;
+            else if (bval > bmax)  bmax = bval;
+        });
+        return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo);
+    }
+
+    function medianCutApply(histo, vbox) {
+        if (!vbox.count()) return;
+
+        var rw = vbox.r2 - vbox.r1 + 1,
+            gw = vbox.g2 - vbox.g1 + 1,
+            bw = vbox.b2 - vbox.b1 + 1,
+            maxw = pv.max([rw, gw, bw]);
+        // only one pixel, no split
+        if (vbox.count() == 1) {
+            return [vbox.copy()];
+        }
+        /* Find the partial sum arrays along the selected axis. */
+        var total = 0,
+            partialsum = [],
+            lookaheadsum = [],
+            i, j, k, sum, index;
+        if (maxw == rw) {
+            for (i = vbox.r1; i <= vbox.r2; i++) {
+                sum = 0;
+                for (j = vbox.g1; j <= vbox.g2; j++) {
+                    for (k = vbox.b1; k <= vbox.b2; k++) {
+                        index = getColorIndex(i,j,k);
+                        sum += (histo[index] || 0);
+                    }
+                }
+                total += sum;
+                partialsum[i] = total;
+            }
+        }
+        else if (maxw == gw) {
+            for (i = vbox.g1; i <= vbox.g2; i++) {
+                sum = 0;
+                for (j = vbox.r1; j <= vbox.r2; j++) {
+                    for (k = vbox.b1; k <= vbox.b2; k++) {
+                        index = getColorIndex(j,i,k);
+                        sum += (histo[index] || 0);
+                    }
+                }
+                total += sum;
+                partialsum[i] = total;
+            }
+        }
+        else {  /* maxw == bw */
+            for (i = vbox.b1; i <= vbox.b2; i++) {
+                sum = 0;
+                for (j = vbox.r1; j <= vbox.r2; j++) {
+                    for (k = vbox.g1; k <= vbox.g2; k++) {
+                        index = getColorIndex(j,k,i);
+                        sum += (histo[index] || 0);
+                    }
+                }
+                total += sum;
+                partialsum[i] = total;
+            }
+        }
+        partialsum.forEach(function(d,i) {
+            lookaheadsum[i] = total-d;
+        });
+        function doCut(color) {
+            var dim1 = color + '1',
+                dim2 = color + '2',
+                left, right, vbox1, vbox2, d2, count2=0;
+            for (i = vbox[dim1]; i <= vbox[dim2]; i++) {
+                if (partialsum[i] > total / 2) {
+                    vbox1 = vbox.copy();
+                    vbox2 = vbox.copy();
+                    left = i - vbox[dim1];
+                    right = vbox[dim2] - i;
+                    if (left <= right)
+                        d2 = Math.min(vbox[dim2] - 1, ~~(i + right / 2));
+                    else d2 = Math.max(vbox[dim1], ~~(i - 1 - left / 2));
+                    // avoid 0-count boxes
+                    while (!partialsum[d2]) d2++;
+                    count2 = lookaheadsum[d2];
+                    while (!count2 && partialsum[d2-1]) count2 = lookaheadsum[--d2];
+                    // set dimensions
+                    vbox1[dim2] = d2;
+                    vbox2[dim1] = vbox1[dim2] + 1;
+//                    console.log('vbox counts:', vbox.count(), vbox1.count(), vbox2.count());
+                    return [vbox1, vbox2];
+                }
+            }
+
+        }
+        // determine the cut planes
+        return maxw == rw ? doCut('r') :
+            maxw == gw ? doCut('g') :
+            doCut('b');
+    }
+
+    function quantize(pixels, maxcolors) {
+        // short-circuit
+        if (!pixels.length || maxcolors < 2 || maxcolors > 256) {
+//            console.log('wrong number of maxcolors');
+            return false;
+        }
+
+        // XXX: check color content and convert to grayscale if insufficient
+
+        var histo = getHisto(pixels),
+            histosize = 1 << (3 * sigbits);
+
+        // check that we aren't below maxcolors already
+        var nColors = 0;
+        histo.forEach(function() { nColors++; });
+        if (nColors <= maxcolors) {
+            // XXX: generate the new colors from the histo and return
+        }
+
+        // get the beginning vbox from the colors
+        var vbox = vboxFromPixels(pixels, histo),
+            pq = new PQueue(function(a,b) { return pv.naturalOrder(a.count(), b.count()); });
+        pq.push(vbox);
+
+        // inner function to do the iteration
+        function iter(lh, target) {
+            var ncolors = 1,
+                niters = 0,
+                vbox;
+            while (niters < maxIterations) {
+                vbox = lh.pop();
+                if (!vbox.count())  { /* just put it back */
+                    lh.push(vbox);
+                    niters++;
+                    continue;
+                }
+                // do the cut
+                var vboxes = medianCutApply(histo, vbox),
+                    vbox1 = vboxes[0],
+                    vbox2 = vboxes[1];
+
+                if (!vbox1) {
+//                    console.log("vbox1 not defined; shouldn't happen!");
+                    return;
+                }
+                lh.push(vbox1);
+                if (vbox2) {  /* vbox2 can be null */
+                    lh.push(vbox2);
+                    ncolors++;
+                }
+                if (ncolors >= target) return;
+                if (niters++ > maxIterations) {
+//                    console.log("infinite loop; perhaps too few pixels!");
+                    return;
+                }
+            }
+        }
+
+        // first set of colors, sorted by population
+        iter(pq, fractByPopulations * maxcolors);
+
+        // Re-sort by the product of pixel occupancy times the size in color space.
+        var pq2 = new PQueue(function(a,b) {
+            return pv.naturalOrder(a.count()*a.volume(), b.count()*b.volume());
+        });
+        while (pq.size()) {
+            pq2.push(pq.pop());
+        }
+
+        // next set - generate the median cuts using the (npix * vol) sorting.
+        iter(pq2, maxcolors - pq2.size());
+
+        // calculate the actual colors
+        var cmap = new CMap();
+        while (pq2.size()) {
+            cmap.push(pq2.pop());
+        }
+
+        return cmap;
+    }
+
+    return {
+        quantize: quantize
+    };
+})();
+
+export default ColorThief
diff --git a/front/test/unit/karma.conf.js b/front/test/unit/karma.conf.js
index 8e4951c9e4ecc597be347be1fd8e163cdbab13e2..193aaff7662983b2b428af919c5b18b6e9179303 100644
--- a/front/test/unit/karma.conf.js
+++ b/front/test/unit/karma.conf.js
@@ -12,12 +12,17 @@ module.exports = function (config) {
     //    http://karma-runner.github.io/0.13/config/browsers.html
     // 2. add it to the `browsers` array below.
     browsers: ['PhantomJS'],
-    frameworks: ['mocha', 'sinon-chai', 'phantomjs-shim'],
+    frameworks: ['mocha', 'sinon-stub-promise', 'sinon-chai', 'phantomjs-shim'],
     reporters: ['spec', 'coverage'],
-    files: ['./index.js'],
+    files: [
+      '../../node_modules/es6-promise/dist/es6-promise.auto.js',
+      './index.js'
+    ],
     preprocessors: {
       './index.js': ['webpack', 'sourcemap']
     },
+    captureTimeout: 15000,
+    retryLimit: 1,
     webpack: webpackConfig,
     webpackMiddleware: {
       noInfo: true
diff --git a/front/test/unit/specs/Hello.spec.js b/front/test/unit/specs/Hello.spec.js
deleted file mode 100644
index 80140baa9664664d35751ce9c16e0d9ccbe75427..0000000000000000000000000000000000000000
--- a/front/test/unit/specs/Hello.spec.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Vue from 'vue'
-import Hello from '@/components/Hello'
-
-describe('Hello.vue', () => {
-  it('should render correct contents', () => {
-    const Constructor = Vue.extend(Hello)
-    const vm = new Constructor().$mount()
-    expect(vm.$el.querySelector('.hello h1').textContent)
-      .to.equal('Welcome to Your Vue.js App')
-  })
-})
diff --git a/front/test/unit/specs/store/auth.spec.js b/front/test/unit/specs/store/auth.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..aa07f9f8bfc03c5f2c766f4a333ff233058ace39
--- /dev/null
+++ b/front/test/unit/specs/store/auth.spec.js
@@ -0,0 +1,200 @@
+var sinon = require('sinon')
+import moxios from 'moxios'
+import store from '@/store/auth'
+
+import { testAction } from '../../utils'
+
+describe('store/auth', () => {
+  var sandbox
+
+  beforeEach(function () {
+    sandbox = sinon.sandbox.create()
+    moxios.install()
+  })
+  afterEach(function () {
+    sandbox.restore()
+    moxios.uninstall()
+  })
+
+  describe('mutations', () => {
+    it('profile', () => {
+      const state = {}
+      store.mutations.profile(state, {})
+      expect(state.profile).to.deep.equal({})
+    })
+    it('username', () => {
+      const state = {}
+      store.mutations.username(state, 'world')
+      expect(state.username).to.equal('world')
+    })
+    it('authenticated true', () => {
+      const state = {}
+      store.mutations.authenticated(state, true)
+      expect(state.authenticated).to.equal(true)
+    })
+    it('authenticated false', () => {
+      const state = {
+        username: 'dummy',
+        token: 'dummy',
+        tokenData: 'dummy',
+        profile: 'dummy',
+        availablePermissions: 'dummy'
+      }
+      store.mutations.authenticated(state, false)
+      expect(state.authenticated).to.equal(false)
+      expect(state.username).to.equal(null)
+      expect(state.token).to.equal(null)
+      expect(state.tokenData).to.equal(null)
+      expect(state.profile).to.equal(null)
+      expect(state.availablePermissions).to.deep.equal({})
+    })
+    it('token null', () => {
+      const state = {}
+      store.mutations.token(state, null)
+      expect(state.token).to.equal(null)
+      expect(state.tokenData).to.deep.equal({})
+    })
+    it('token real', () => {
+      // generated on http://kjur.github.io/jsjws/tool_jwt.html
+      const state = {}
+      let token = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2p3dC1pZHAuZXhhbXBsZS5jb20iLCJzdWIiOiJtYWlsdG86bWlrZUBleGFtcGxlLmNvbSIsIm5iZiI6MTUxNTUzMzQyOSwiZXhwIjoxNTE1NTM3MDI5LCJpYXQiOjE1MTU1MzM0MjksImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9.'
+      let tokenData = {
+        iss: 'https://jwt-idp.example.com',
+        sub: 'mailto:mike@example.com',
+        nbf: 1515533429,
+        exp: 1515537029,
+        iat: 1515533429,
+        jti: 'id123456',
+        typ: 'https://example.com/register'
+      }
+      store.mutations.token(state, token)
+      expect(state.token).to.equal(token)
+      expect(state.tokenData).to.deep.equal(tokenData)
+    })
+    it('permissions', () => {
+      const state = { availablePermissions: {} }
+      store.mutations.permission(state, {key: 'admin', status: true})
+      expect(state.availablePermissions).to.deep.equal({admin: true})
+    })
+  })
+  describe('getters', () => {
+    it('header', () => {
+      const state = { token: 'helloworld' }
+      expect(store.getters['header'](state)).to.equal('JWT helloworld')
+    })
+  })
+  describe('actions', () => {
+    it('logout', (done) => {
+      testAction({
+        action: store.actions.logout,
+        params: {state: {}},
+        expectedMutations: [
+          { type: 'authenticated', payload: false }
+        ]
+      }, done)
+    })
+    it('check jwt null', (done) => {
+      testAction({
+        action: store.actions.check,
+        params: {state: {}},
+        expectedMutations: [
+          { type: 'authenticated', payload: false }
+        ]
+      }, done)
+    })
+    it('check jwt set', (done) => {
+      testAction({
+        action: store.actions.check,
+        params: {state: {token: 'test', username: 'user'}},
+        expectedMutations: [
+          { type: 'authenticated', payload: true },
+          { type: 'username', payload: 'user' },
+          { type: 'token', payload: 'test' }
+        ],
+        expectedActions: [
+          { type: 'fetchProfile' },
+          { type: 'refreshToken' }
+        ]
+      }, done)
+    })
+    it('login success', (done) => {
+      moxios.stubRequest('token/', {
+        status: 200,
+        response: {
+          token: 'test'
+        }
+      })
+      const credentials = {
+        username: 'bob'
+      }
+      testAction({
+        action: store.actions.login,
+        payload: {credentials: credentials},
+        expectedMutations: [
+          { type: 'token', payload: 'test' },
+          { type: 'username', payload: 'bob' },
+          { type: 'authenticated', payload: true }
+        ],
+        expectedActions: [
+          { type: 'fetchProfile' }
+        ]
+      }, done)
+    })
+    it('login error', (done) => {
+      moxios.stubRequest('token/', {
+        status: 500,
+        response: {
+          token: 'test'
+        }
+      })
+      const credentials = {
+        username: 'bob'
+      }
+      let spy = sandbox.spy()
+      testAction({
+        action: store.actions.login,
+        payload: {credentials: credentials, onError: spy}
+      }, () => {
+        expect(spy.calledOnce).to.equal(true)
+        done()
+      })
+    })
+    it('fetchProfile', (done) => {
+      const profile = {
+        username: 'bob',
+        permissions: {
+          admin: {
+            status: true
+          }
+        }
+      }
+      moxios.stubRequest('users/users/me/', {
+        status: 200,
+        response: profile
+      })
+      testAction({
+        action: store.actions.fetchProfile,
+        expectedMutations: [
+          { type: 'profile', payload: profile },
+          { type: 'permission', payload: {key: 'admin', status: true} }
+        ],
+        expectedActions: [
+          { type: 'favorites/fetch', payload: null, options: {root: true} }
+        ]
+      }, done)
+    })
+    it('refreshToken', (done) => {
+      moxios.stubRequest('token/refresh/', {
+        status: 200,
+        response: {token: 'newtoken'}
+      })
+      testAction({
+        action: store.actions.refreshToken,
+        params: {state: {token: 'oldtoken'}},
+        expectedMutations: [
+          { type: 'token', payload: 'newtoken' }
+        ]
+      }, done)
+    })
+  })
+})
diff --git a/front/test/unit/specs/store/favorites.spec.js b/front/test/unit/specs/store/favorites.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..6d4314ca661a2ffbb3865d3a4edc53a2b40b1cc8
--- /dev/null
+++ b/front/test/unit/specs/store/favorites.spec.js
@@ -0,0 +1,52 @@
+import store from '@/store/favorites'
+
+import { testAction } from '../../utils'
+
+describe('store/favorites', () => {
+  describe('mutations', () => {
+    it('track true', () => {
+      const state = { tracks: [] }
+      store.mutations.track(state, {id: 1, value: true})
+      expect(state.tracks).to.deep.equal([1])
+      expect(state.count).to.deep.equal(1)
+    })
+    it('track false', () => {
+      const state = { tracks: [1] }
+      store.mutations.track(state, {id: 1, value: false})
+      expect(state.tracks).to.deep.equal([])
+      expect(state.count).to.deep.equal(0)
+    })
+  })
+  describe('getters', () => {
+    it('isFavorite true', () => {
+      const state = { tracks: [1] }
+      expect(store.getters['isFavorite'](state)(1)).to.equal(true)
+    })
+    it('isFavorite false', () => {
+      const state = { tracks: [] }
+      expect(store.getters['isFavorite'](state)(1)).to.equal(false)
+    })
+  })
+  describe('actions', () => {
+    it('toggle true', (done) => {
+      testAction({
+        action: store.actions.toggle,
+        payload: 1,
+        params: {getters: {isFavorite: () => false}},
+        expectedActions: [
+          { type: 'set', payload: {id: 1, value: true} }
+        ]
+      }, done)
+    })
+    it('toggle true', (done) => {
+      testAction({
+        action: store.actions.toggle,
+        payload: 1,
+        params: {getters: {isFavorite: () => true}},
+        expectedActions: [
+          { type: 'set', payload: {id: 1, value: false} }
+        ]
+      }, done)
+    })
+  })
+})
diff --git a/front/test/unit/specs/store/instance.spec.js b/front/test/unit/specs/store/instance.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4b06cb5f029bc0b2180cb4e3aa90854eda4b73b2
--- /dev/null
+++ b/front/test/unit/specs/store/instance.spec.js
@@ -0,0 +1,70 @@
+var sinon = require('sinon')
+import moxios from 'moxios'
+import store from '@/store/instance'
+import { testAction } from '../../utils'
+
+describe('store/instance', () => {
+  var sandbox
+
+  beforeEach(function () {
+    sandbox = sinon.sandbox.create()
+    moxios.install()
+  })
+  afterEach(function () {
+    sandbox.restore()
+    moxios.uninstall()
+  })
+
+  describe('mutations', () => {
+    it('settings', () => {
+      const state = {settings: {raven: {front_dsn: {value: 'test'}}}}
+      let settings = {raven: {front_enabled: {value: true}}}
+      store.mutations.settings(state, settings)
+      expect(state.settings).to.deep.equal({
+        raven: {front_dsn: {value: 'test'}, front_enabled: {value: true}}
+      })
+    })
+  })
+  describe('actions', () => {
+    it('fetchSettings', (done) => {
+      moxios.stubRequest('instance/settings/', {
+        status: 200,
+        response: [
+          {
+            section: 'raven',
+            name: 'front_dsn',
+            value: 'test'
+          },
+          {
+            section: 'raven',
+            name: 'front_enabled',
+            value: false
+          }
+        ]
+      })
+      testAction({
+        action: store.actions.fetchSettings,
+        payload: null,
+        expectedMutations: [
+          {
+            type: 'settings',
+            payload: {
+              raven: {
+                front_dsn: {
+                  section: 'raven',
+                  name: 'front_dsn',
+                  value: 'test'
+                },
+                front_enabled: {
+                  section: 'raven',
+                  name: 'front_enabled',
+                  value: false
+                }
+              }
+            }
+          }
+        ]
+      }, done)
+    })
+  })
+})
diff --git a/front/test/unit/specs/store/player.spec.js b/front/test/unit/specs/store/player.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..af0b6b4354394dbc9c1c62186796e62653ca52fb
--- /dev/null
+++ b/front/test/unit/specs/store/player.spec.js
@@ -0,0 +1,153 @@
+import store from '@/store/player'
+
+import { testAction } from '../../utils'
+
+describe('store/player', () => {
+  describe('mutations', () => {
+    it('set volume', () => {
+      const state = { volume: 0 }
+      store.mutations.volume(state, 0.9)
+      expect(state.volume).to.equal(0.9)
+    })
+    it('set volume max 1', () => {
+      const state = { volume: 0 }
+      store.mutations.volume(state, 2)
+      expect(state.volume).to.equal(1)
+    })
+    it('set volume min to 0', () => {
+      const state = { volume: 0.5 }
+      store.mutations.volume(state, -2)
+      expect(state.volume).to.equal(0)
+    })
+    it('increment volume', () => {
+      const state = { volume: 0 }
+      store.mutations.incrementVolume(state, 0.1)
+      expect(state.volume).to.equal(0.1)
+    })
+    it('increment volume max 1', () => {
+      const state = { volume: 0 }
+      store.mutations.incrementVolume(state, 2)
+      expect(state.volume).to.equal(1)
+    })
+    it('increment volume min to 0', () => {
+      const state = { volume: 0.5 }
+      store.mutations.incrementVolume(state, -2)
+      expect(state.volume).to.equal(0)
+    })
+    it('set duration', () => {
+      const state = { duration: 42 }
+      store.mutations.duration(state, 14)
+      expect(state.duration).to.equal(14)
+    })
+    it('set errored', () => {
+      const state = { errored: false }
+      store.mutations.errored(state, true)
+      expect(state.errored).to.equal(true)
+    })
+    it('set looping', () => {
+      const state = { looping: 1 }
+      store.mutations.looping(state, 2)
+      expect(state.looping).to.equal(2)
+    })
+    it('set playing', () => {
+      const state = { playing: false }
+      store.mutations.playing(state, true)
+      expect(state.playing).to.equal(true)
+    })
+    it('set current time', () => {
+      const state = { currentTime: 1 }
+      store.mutations.currentTime(state, 2)
+      expect(state.currentTime).to.equal(2)
+    })
+    it('toggle looping from 0', () => {
+      const state = { looping: 0 }
+      store.mutations.toggleLooping(state)
+      expect(state.looping).to.equal(1)
+    })
+    it('toggle looping from 1', () => {
+      const state = { looping: 1 }
+      store.mutations.toggleLooping(state)
+      expect(state.looping).to.equal(2)
+    })
+    it('toggle looping from 2', () => {
+      const state = { looping: 2 }
+      store.mutations.toggleLooping(state)
+      expect(state.looping).to.equal(0)
+    })
+  })
+  describe('getters', () => {
+    it('durationFormatted', () => {
+      const state = { duration: 12.51 }
+      expect(store.getters['durationFormatted'](state)).to.equal('00:13')
+    })
+    it('currentTimeFormatted', () => {
+      const state = { currentTime: 12.51 }
+      expect(store.getters['currentTimeFormatted'](state)).to.equal('00:13')
+    })
+    it('progress', () => {
+      const state = { currentTime: 4, duration: 10 }
+      expect(store.getters['progress'](state)).to.equal(40)
+    })
+  })
+  describe('actions', () => {
+    it('incrementVolume', (done) => {
+      testAction({
+        action: store.actions.incrementVolume,
+        payload: 0.2,
+        params: {state: {volume: 0.7}},
+        expectedMutations: [
+          { type: 'volume', payload: 0.7 + 0.2 }
+        ]
+      }, done)
+    })
+    it('toggle play false', (done) => {
+      testAction({
+        action: store.actions.togglePlay,
+        params: {state: {playing: false}},
+        expectedMutations: [
+          { type: 'playing', payload: true }
+        ]
+      }, done)
+    })
+    it('toggle play true', (done) => {
+      testAction({
+        action: store.actions.togglePlay,
+        params: {state: {playing: true}},
+        expectedMutations: [
+          { type: 'playing', payload: false }
+        ]
+      }, done)
+    })
+    it('trackEnded', (done) => {
+      testAction({
+        action: store.actions.trackEnded,
+        payload: {test: 'track'},
+        expectedActions: [
+          { type: 'trackListened', payload: {test: 'track'} },
+          { type: 'queue/next', payload: null, options: {root: true} }
+        ]
+      }, done)
+    })
+    it('trackErrored', (done) => {
+      testAction({
+        action: store.actions.trackErrored,
+        payload: {test: 'track'},
+        expectedMutations: [
+          { type: 'errored', payload: true }
+        ],
+        expectedActions: [
+          { type: 'queue/next', payload: null, options: {root: true} }
+        ]
+      }, done)
+    })
+    it('updateProgress', (done) => {
+      testAction({
+        action: store.actions.updateProgress,
+        payload: 1,
+        expectedMutations: [
+          { type: 'currentTime', payload: 1 }
+        ]
+      }, done)
+    })
+  })
+})
diff --git a/front/test/unit/specs/store/queue.spec.js b/front/test/unit/specs/store/queue.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..8a79c07bd982cd8df7b58231a184898b1cea3aca
--- /dev/null
+++ b/front/test/unit/specs/store/queue.spec.js
@@ -0,0 +1,333 @@
+var sinon = require('sinon')
+import _ from 'lodash'
+
+import store from '@/store/queue'
+import { testAction } from '../../utils'
+
+describe('store/queue', () => {
+  var sandbox
+
+  beforeEach(function () {
+    // Create a sandbox for the test
+    sandbox = sinon.sandbox.create()
+  })
+
+  afterEach(function () {
+    // Restore all the things made through the sandbox
+    sandbox.restore()
+  })
+  describe('mutations', () => {
+    it('currentIndex', () => {
+      const state = {}
+      store.mutations.currentIndex(state, 2)
+      expect(state.currentIndex).to.equal(2)
+    })
+    it('ended', () => {
+      const state = {}
+      store.mutations.ended(state, false)
+      expect(state.ended).to.equal(false)
+    })
+    it('tracks', () => {
+      const state = {}
+      store.mutations.tracks(state, [1, 2])
+      expect(state.tracks).to.deep.equal([1, 2])
+    })
+    it('splice', () => {
+      const state = {tracks: [1, 2, 3]}
+      store.mutations.splice(state, {start: 1, size: 2})
+      expect(state.tracks).to.deep.equal([1])
+    })
+    it('insert', () => {
+      const state = {tracks: [1, 3]}
+      store.mutations.insert(state, {track: 2, index: 1})
+      expect(state.tracks).to.deep.equal([1, 2, 3])
+    })
+    it('reorder before', () => {
+      const state = {currentIndex: 3}
+      store.mutations.reorder(state, {oldIndex: 2, newIndex: 1})
+      expect(state.currentIndex).to.equal(3)
+    })
+    it('reorder from after to before', () => {
+      const state = {currentIndex: 3}
+      store.mutations.reorder(state, {oldIndex: 4, newIndex: 1})
+      expect(state.currentIndex).to.equal(4)
+    })
+    it('reorder after', () => {
+      const state = {currentIndex: 3}
+      store.mutations.reorder(state, {oldIndex: 4, newIndex: 5})
+      expect(state.currentIndex).to.equal(3)
+    })
+    it('reorder before to after', () => {
+      const state = {currentIndex: 3}
+      store.mutations.reorder(state, {oldIndex: 1, newIndex: 5})
+      expect(state.currentIndex).to.equal(2)
+    })
+    it('reorder current', () => {
+      const state = {currentIndex: 3}
+      store.mutations.reorder(state, {oldIndex: 3, newIndex: 1})
+      expect(state.currentIndex).to.equal(1)
+    })
+  })
+  describe('getters', () => {
+    it('currentTrack', () => {
+      const state = { tracks: [1, 2, 3], currentIndex: 2 }
+      expect(store.getters['currentTrack'](state)).to.equal(3)
+    })
+    it('hasNext true', () => {
+      const state = { tracks: [1, 2, 3], currentIndex: 1 }
+      expect(store.getters['hasNext'](state)).to.equal(true)
+    })
+    it('hasNext false', () => {
+      const state = { tracks: [1, 2, 3], currentIndex: 2 }
+      expect(store.getters['hasNext'](state)).to.equal(false)
+    })
+    it('hasPrevious true', () => {
+      const state = { currentIndex: 1 }
+      expect(store.getters['hasPrevious'](state)).to.equal(true)
+    })
+    it('hasPrevious false', () => {
+      const state = { currentIndex: 0 }
+      expect(store.getters['hasPrevious'](state)).to.equal(false)
+    })
+  })
+  describe('actions', () => {
+    it('append at end', (done) => {
+      testAction({
+        action: store.actions.append,
+        payload: {track: 4, skipPlay: true},
+        params: {state: {tracks: [1, 2, 3]}},
+        expectedMutations: [
+          { type: 'insert', payload: {track: 4, index: 3} }
+        ]
+      }, done)
+    })
+    it('append at index', (done) => {
+      testAction({
+        action: store.actions.append,
+        payload: {track: 2, index: 1, skipPlay: true},
+        params: {state: {tracks: [1, 3]}},
+        expectedMutations: [
+          { type: 'insert', payload: {track: 2, index: 1} }
+        ]
+      }, done)
+    })
+    it('append and play', (done) => {
+      testAction({
+        action: store.actions.append,
+        payload: {track: 3},
+        params: {state: {tracks: [1, 2]}},
+        expectedMutations: [
+          { type: 'insert', payload: {track: 3, index: 2} }
+        ],
+        expectedActions: [
+          { type: 'resume' }
+        ]
+      }, done)
+    })
+    it('appendMany', (done) => {
+      const tracks = [{title: 1}, {title: 2}]
+      testAction({
+        action: store.actions.appendMany,
+        payload: {tracks: tracks},
+        params: {state: {tracks: []}},
+        expectedActions: [
+          { type: 'append', payload: {track: tracks[0], index: 0, skipPlay: true} },
+          { type: 'append', payload: {track: tracks[1], index: 1, skipPlay: true} },
+          { type: 'resume' }
+        ]
+      }, done)
+    })
+    it('appendMany at index', (done) => {
+      const tracks = [{title: 1}, {title: 2}]
+      testAction({
+        action: store.actions.appendMany,
+        payload: {tracks: tracks, index: 1},
+        params: {state: {tracks: [1, 2]}},
+        expectedActions: [
+          { type: 'append', payload: {track: tracks[0], index: 1, skipPlay: true} },
+          { type: 'append', payload: {track: tracks[1], index: 2, skipPlay: true} },
+          { type: 'resume' }
+        ]
+      }, done)
+    })
+    it('cleanTrack after current', (done) => {
+      testAction({
+        action: store.actions.cleanTrack,
+        payload: 3,
+        params: {state: {currentIndex: 2}},
+        expectedMutations: [
+          { type: 'splice', payload: {start: 3, size: 1} }
+        ]
+      }, done)
+    })
+    it('cleanTrack before current', (done) => {
+      testAction({
+        action: store.actions.cleanTrack,
+        payload: 1,
+        params: {state: {currentIndex: 2}},
+        expectedMutations: [
+          { type: 'splice', payload: {start: 1, size: 1} }
+        ],
+        expectedActions: [
+          { type: 'currentIndex', payload: 1 }
+        ]
+      }, done)
+    })
+    it('cleanTrack current', (done) => {
+      testAction({
+        action: store.actions.cleanTrack,
+        payload: 2,
+        params: {state: {currentIndex: 2}},
+        expectedMutations: [
+          { type: 'splice', payload: {start: 2, size: 1} }
+        ],
+        expectedActions: [
+          { type: 'player/stop', payload: null, options: {root: true} },
+          { type: 'currentIndex', payload: 2 }
+        ]
+      }, done)
+    })
+    it('resume when ended', (done) => {
+      testAction({
+        action: store.actions.resume,
+        params: {state: {ended: true}, rootState: {player: {errored: false}}},
+        expectedActions: [
+          { type: 'next' }
+        ]
+      }, done)
+    })
+    it('resume when errored', (done) => {
+      testAction({
+        action: store.actions.resume,
+        params: {state: {ended: false}, rootState: {player: {errored: true}}},
+        expectedActions: [
+          { type: 'next' }
+        ]
+      }, done)
+    })
+    it('skip resume when not ended or not error', (done) => {
+      testAction({
+        action: store.actions.resume,
+        params: {state: {ended: false}, rootState: {player: {errored: false}}},
+        expectedActions: []
+      }, done)
+    })
+    it('previous when at beginning does nothing', (done) => {
+      testAction({
+        action: store.actions.previous,
+        params: {state: {currentIndex: 0}},
+        expectedActions: []
+      }, done)
+    })
+    it('previous', (done) => {
+      testAction({
+        action: store.actions.previous,
+        params: {state: {currentIndex: 1}},
+        expectedActions: [
+          { type: 'currentIndex', payload: 0 }
+        ]
+      }, done)
+    })
+    it('next on last track when looping on queue', (done) => {
+      testAction({
+        action: store.actions.next,
+        params: {state: {tracks: [1, 2], currentIndex: 1}, rootState: {player: {looping: 2}}},
+        expectedActions: [
+          { type: 'currentIndex', payload: 0 }
+        ]
+      }, done)
+    })
+    it('next track when last track', (done) => {
+      testAction({
+        action: store.actions.next,
+        params: {state: {tracks: [1, 2], currentIndex: 1}, rootState: {player: {looping: 0}}},
+        expectedMutations: [
+          { type: 'ended', payload: true }
+        ]
+      }, done)
+    })
+    it('next track when not last track', (done) => {
+      testAction({
+        action: store.actions.next,
+        params: {state: {tracks: [1, 2], currentIndex: 0}, rootState: {player: {looping: 0}}},
+        expectedActions: [
+          { type: 'currentIndex', payload: 1 }
+        ]
+      }, done)
+    })
+    it('currentIndex', (done) => {
+      testAction({
+        action: store.actions.currentIndex,
+        payload: 1,
+        params: {state: {tracks: [1, 2], currentIndex: 0}, rootState: {radios: {running: false}}},
+        expectedMutations: [
+          { type: 'ended', payload: false },
+          { type: 'player/currentTime', payload: 0, options: {root: true} },
+          { type: 'player/playing', payload: true, options: {root: true} },
+          { type: 'player/errored', payload: false, options: {root: true} },
+          { type: 'currentIndex', payload: 1 }
+        ]
+      }, done)
+    })
+    it('currentIndex with radio and many tracks remaining', (done) => {
+      testAction({
+        action: store.actions.currentIndex,
+        payload: 1,
+        params: {state: {tracks: [1, 2, 3, 4], currentIndex: 0}, rootState: {radios: {running: true}}},
+        expectedMutations: [
+          { type: 'ended', payload: false },
+          { type: 'player/currentTime', payload: 0, options: {root: true} },
+          { type: 'player/playing', payload: true, options: {root: true} },
+          { type: 'player/errored', payload: false, options: {root: true} },
+          { type: 'currentIndex', payload: 1 }
+        ]
+      }, done)
+    })
+    it('currentIndex with radio and less than two tracks remaining', (done) => {
+      testAction({
+        action: store.actions.currentIndex,
+        payload: 1,
+        params: {state: {tracks: [1, 2, 3], currentIndex: 0}, rootState: {radios: {running: true}}},
+        expectedMutations: [
+          { type: 'ended', payload: false },
+          { type: 'player/currentTime', payload: 0, options: {root: true} },
+          { type: 'player/playing', payload: true, options: {root: true} },
+          { type: 'player/errored', payload: false, options: {root: true} },
+          { type: 'currentIndex', payload: 1 }
+        ],
+        expectedActions: [
+          { type: 'radios/populateQueue', payload: null, options: {root: true} }
+        ]
+      }, done)
+    })
+    it('clean', (done) => {
+      testAction({
+        action: store.actions.clean,
+        expectedMutations: [
+          { type: 'tracks', payload: [] },
+          { type: 'ended', payload: true }
+        ],
+        expectedActions: [
+          { type: 'player/stop', payload: null, options: {root: true} },
+          { type: 'currentIndex', payload: -1 }
+        ]
+      }, done)
+    })
+    it('shuffle', (done) => {
+      let _shuffle = sandbox.stub(_, 'shuffle')
+      let tracks = [1, 2, 3]
+      let shuffledTracks = [2, 3, 1]
+      _shuffle.returns(shuffledTracks)
+      testAction({
+        action: store.actions.shuffle,
+        params: {state: {tracks: tracks}},
+        expectedMutations: [
+          { type: 'tracks', payload: [] }
+        ],
+        expectedActions: [
+          { type: 'appendMany', payload: {tracks: shuffledTracks} }
+        ]
+      }, done)
+    })
+  })
+})
diff --git a/front/test/unit/specs/store/radios.spec.js b/front/test/unit/specs/store/radios.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..3ff8a05ed49df400cf71c588732794c1f907b144
--- /dev/null
+++ b/front/test/unit/specs/store/radios.spec.js
@@ -0,0 +1,86 @@
+var sinon = require('sinon')
+import moxios from 'moxios'
+import store from '@/store/radios'
+import { testAction } from '../../utils'
+
+describe('store/radios', () => {
+  var sandbox
+
+  beforeEach(function () {
+    sandbox = sinon.sandbox.create()
+    moxios.install()
+  })
+  afterEach(function () {
+    sandbox.restore()
+    moxios.uninstall()
+  })
+
+  describe('mutations', () => {
+    it('current', () => {
+      const state = {}
+      store.mutations.current(state, 1)
+      expect(state.current).to.equal(1)
+    })
+    it('running', () => {
+      const state = {}
+      store.mutations.running(state, false)
+      expect(state.running).to.equal(false)
+    })
+  })
+  describe('actions', () => {
+    it('start', (done) => {
+      moxios.stubRequest('radios/sessions/', {
+        status: 200,
+        response: {id: 2}
+      })
+      testAction({
+        action: store.actions.start,
+        payload: {type: 'favorites', objectId: 0, customRadioId: null},
+        expectedMutations: [
+          {
+            type: 'current',
+            payload: {
+              type: 'favorites',
+              objectId: 0,
+              customRadioId: null,
+              session: 2
+            }
+          },
+          { type: 'running', payload: true }
+        ],
+        expectedActions: [
+          { type: 'populateQueue' }
+        ]
+      }, done)
+    })
+    it('stop', (done) => {
+      testAction({
+        action: store.actions.stop,
+        expectedMutations: [
+          { type: 'current', payload: null },
+          { type: 'running', payload: false }
+        ]
+      }, done)
+    })
+    it('populateQueue', (done) => {
+      moxios.stubRequest('radios/tracks/', {
+        status: 201,
+        response: {track: {id: 1}}
+      })
+      testAction({
+        action: store.actions.populateQueue,
+        params: {state: {running: true, current: {session: 1}}},
+        expectedActions: [
+          { type: 'queue/append', payload: {track: {id: 1}}, options: {root: true} }
+        ]
+      }, done)
+    })
+    it('populateQueue does nothing when not running', (done) => {
+      testAction({
+        action: store.actions.populateQueue,
+        params: {state: {running: false}},
+        expectedActions: []
+      }, done)
+    })
+  })
+})
diff --git a/front/test/unit/utils.js b/front/test/unit/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..233ee982e5221e0997da8bd29d33cfac17a113e3
--- /dev/null
+++ b/front/test/unit/utils.js
@@ -0,0 +1,73 @@
+// helper for testing action with expected mutations
+export const testAction = ({action, payload, params, expectedMutations, expectedActions}, done) => {
+  let mutationsCount = 0
+  let actionsCount = 0
+
+  if (!expectedMutations) {
+    expectedMutations = []
+  }
+  if (!expectedActions) {
+    expectedActions = []
+  }
+  const isOver = () => {
+    return mutationsCount >= expectedMutations.length && actionsCount >= expectedActions.length
+  }
+  // mock commit
+  const commit = (type, payload) => {
+    const mutation = expectedMutations[mutationsCount]
+
+    try {
+      expect(mutation.type).to.equal(type)
+      if (payload) {
+        expect(mutation.payload).to.deep.equal(payload)
+      }
+    } catch (error) {
+      done(error)
+    }
+
+    mutationsCount++
+    if (isOver()) {
+      done()
+    }
+  }
+  // mock dispatch
+  const dispatch = (type, payload, options) => {
+    const a = expectedActions[actionsCount]
+    try {
+      expect(a.type).to.equal(type)
+      if (payload) {
+        expect(a.payload).to.deep.equal(payload)
+      }
+      if (a.options) {
+        expect(options).to.deep.equal(a.options)
+      }
+    } catch (error) {
+      done(error)
+    }
+
+    actionsCount++
+    if (isOver()) {
+      done()
+    }
+  }
+
+  let end = function () {
+    // check if no mutations should have been dispatched
+    if (expectedMutations.length === 0) {
+      expect(mutationsCount).to.equal(0)
+    }
+    if (expectedActions.length === 0) {
+      expect(actionsCount).to.equal(0)
+    }
+    if (isOver()) {
+      done()
+    }
+  }
+  // call the action with mocked store and arguments
+  let promise = action({ commit, dispatch, ...params }, payload)
+  if (promise) {
+    return promise.then(end)
+  } else {
+    return end()
+  }
+}