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() + } +}