diff --git a/.editorconfig b/.editorconfig index f1b07224624bf8139154d54f28859541d0d8b68d..11a1b970af4ea4a0c4aef7bed5190f292c9b88a3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,7 +18,7 @@ known_first_party=funkwhale_api multi_line_output=3 default_section=THIRDPARTY -[*.{html,js,vue,css,scss,json,yml}] +[*.{html,js,vue,css,scss,json,yml,ts}] indent_style = space indent_size = 2 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a47a2812f1dc490f7068d861c4e2b6b7cd0946b7..d12ed13ce6bc73c3ea43fd89547b1eb1adf74b9a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,19 +12,25 @@ variables: DOCKER_HOST: tcp://docker:2375/ DOCKER_DRIVER: overlay2 DOCKER_TLS_CERTDIR: "" - BUILD_PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7 + BUILD_PLATFORMS: linux/amd64,linux/arm64 #,linux/arm/v7 TODO disabled due to #1904 + +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS + when: never + - if: $CI_COMMIT_BRANCH stages: - - build - - review + - deploy - lint - test - - deploy - - deps + - build + - publish review_front: interruptible: true - stage: review + stage: deploy image: node:16-alpine when: manual allow_failure: true @@ -53,8 +59,6 @@ review_front: paths: - front/node_modules - front/yarn.lock - only: - - branches tags: - docker environment: @@ -63,7 +67,7 @@ review_front: review_docs: interruptible: true - stage: review + stage: deploy when: manual allow_failure: true image: python:3.10 @@ -90,8 +94,6 @@ review_docs: expire_in: 2 weeks paths: - docs-review - only: - - branches tags: - docker environment: @@ -125,11 +127,10 @@ black: - pip install black script: - black --check --diff . - only: - refs: - - branches - changes: - - api/**/* + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + changes: + - api/**/* flake8: interruptible: true @@ -145,11 +146,10 @@ flake8: key: "$CI_PROJECT_ID__flake8_pip_cache" paths: - "$PIP_CACHE_DIR" - only: - refs: - - branches - changes: - - api/**/* + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + changes: + - api/**/* eslint: interruptible: true @@ -161,15 +161,15 @@ eslint: - yarn install script: - yarn lint --max-warnings 0 + - yarn lint:tsc cache: key: "$CI_PROJECT_ID__eslint_npm_cache" paths: - front/node_modules - only: - refs: - - branches - changes: - - front/**/* + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + changes: + - front/**/* test_api: interruptible: true @@ -188,12 +188,8 @@ test_api: DJANGO_SETTINGS_MODULE: config.settings.local POSTGRES_HOST_AUTH_METHOD: trust CACHE_URL: "redis://redis:6379/0" - only: - refs: - - branches before_script: - cd api - - poetry run pip install setuptools==59.1.1 # Hotfix for failing pipelines #1745 - poetry install --no-root script: - poetry run pytest --cov-report xml --cov-report term-missing:skip-covered --cov=funkwhale_api --junitxml=report.xml tests/ @@ -208,7 +204,7 @@ test_api: path: api/coverage.xml parallel: matrix: - - PY_VER: ["3.7", "3.8", "3.9", "3.10"] + - PY_VER: ["3.7", "3.8", "3.9", "3.10", "3.11-rc"] image: $CI_REGISTRY/funkwhale/backend-test-docker:$PY_VER test_front: @@ -218,9 +214,6 @@ test_front: before_script: - cd front - apk add --no-cache jq bash coreutils python3 - only: - refs: - - branches script: - yarn install --check-files - yarn test:unit @@ -234,7 +227,7 @@ test_front: paths: - front/dist/ reports: - junit: front/test-results.xml + junit: front/coverage/cobertura-coverage.xml tags: - docker @@ -258,7 +251,6 @@ build_docs: before_script: - cd api - pip3 install poetry - - poetry run pip install setuptools==59.1.1 # Hotfix for failing pipelines #1745 - poetry install - poetry run python manage.py migrate script: @@ -323,7 +315,7 @@ build_documentation: - docker deploy_documentation: - stage: deploy + stage: publish image: alpine dependencies: - build_documentation @@ -341,7 +333,7 @@ deploy_documentation: .docker_publish: - stage: deploy + stage: publish image: egon0/docker-with-buildx-and-git:bash tags: - multiarch @@ -408,7 +400,7 @@ docker_publish_non-release: - COMPONENT: ["api", "front"] docker_all_in_one_release: - stage: deploy + stage: publish image: egon0/docker-with-buildx-and-git:bash allow_failure: true services: @@ -440,7 +432,7 @@ docker_all_in_one_release: build_api: # Simply publish a zip containing api/ directory - stage: deploy + stage: publish image: bash artifacts: name: "api_${CI_COMMIT_REF_NAME}" diff --git a/.gitlab/renovate.json b/.gitlab/renovate.json index 97b548e71a630cdfa4f2a7580c0a9a21dacb9286..f068786c03b83213898421b6e5e1d025fc5f0e56 100644 --- a/.gitlab/renovate.json +++ b/.gitlab/renovate.json @@ -8,11 +8,6 @@ "baseBranches": ["stable", "develop"], "semanticCommits": "disabled", "packageRules": [ - { - "matchDatasources": ["npm"], - "matchBaseBranches": ["develop"], - "enabled": false - }, { "matchUpdateTypes": ["major", "minor"], "matchBaseBranches": ["stable"], @@ -27,6 +22,18 @@ "matchUpdateTypes": ["patch", "pin", "digest"], "matchBaseBranches": ["develop"], "automerge": true + }, + { + "matchManagers": ["npm"], + "addLabels": ["Area: Frontend"] + }, + { + "matchManagers": ["poetry"], + "addLabels": ["Area: Backend"] + }, + { + "matchPackageNames": ["@vue/runtime-core", "vue"], + "groupName": "Vue" } ] } diff --git a/.gitpod.yml b/.gitpod.yml index 2d68532e66b70582f552f809ba1505fd12bd2df7..e27625a4c380a7d6839fb467b78031e2001d9de5 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -33,7 +33,7 @@ tasks: - name: Frontend env: - HMR_PORT: 8000 + VUE_EDITOR: code before: cd front init: | yarn install @@ -44,6 +44,8 @@ tasks: env: COMPOSE_FILE: /workspace/funkwhale/.gitpod/docker-compose.yml ENV_FILE: /workspace/funkwhale/.gitpod/.env + VUE_EDITOR: code + DJANGO_SETTINGS_MODULE: config.settings.local command: | clear echo "" @@ -90,3 +92,4 @@ vscode: - hbenl.vscode-test-explorer - hbenl.test-adapter-converter - littlefoxteam.vscode-python-test-adapter + - ZixuanChen.vitest-explorer diff --git a/.gitpod/Dockerfile b/.gitpod/Dockerfile index 00573fd1be391aa4f4c2ceb92380c84a69b8530b..1603291a610e37642e09f8832161865691e3b241 100644 --- a/.gitpod/Dockerfile +++ b/.gitpod/Dockerfile @@ -1,8 +1,8 @@ -FROM gitpod/workspace-full +FROM gitpod/workspace-full:2022-07-12-11-05-29 USER gitpod RUN sudo apt update -y \ - && sudo apt install libsasl2-dev libldap2-dev libssl-dev ffmpeg -y + && sudo apt install libsasl2-dev libldap2-dev libssl-dev ffmpeg gettext -y RUN pip install poetry \ && poetry config virtualenvs.create true \ diff --git a/.vscode/settings.json b/.vscode/settings.json index 7319f51803fdd3bec011aa582785808da589aec4..941a97e3dbdf592a3fd59e1f52bf51c3c53f53eb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,7 @@ "tests/" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true -} \ No newline at end of file + "python.testing.pytestEnabled": true, + "vitest.enable": true, + "vitest.commandLine": "yarn vitest" +} diff --git a/CHANGELOG b/CHANGELOG index e447d42ddcff17dc4c503430045ba1c6d9d4c052..fcf39855997a879a9162ce2007fdd3f795247681 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,65 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog. .. towncrier +1.2.8 (2022-09-12) +------------------ + +Upgrade instructions are available at +https://docs.funkwhale.audio/admin/upgrading.html + +Features: + +- Add Sentry SDK to collect errors at the backend + + +Bugfixes: + +- Fix exponentially growing database when using in-place-imports on a regular base #1676 +- Fix navigating to registration request not showing anything (#1836) +- Fix player cover image overlapping queue list +- Fixed metadata handling for Various Artists albums (#1201) +- Fixed search behaviour in radio builder's filters (#733) +- Fixed unpredictable subsonic search3 results (#1782) + +Committers: + +- Ciarán Ainsworth +- Georg Krause +- Marcos Peña +- Mathias Koehler +- wvffle + +Contributors to our Issues: + +- AMoonRabbit +- Agate +- Ciarán Ainsworth +- Georg Krause +- JuniorJPDJ +- Kasper Seweryn +- Kelvin Hammond +- Marcos Peña +- Meliurwen +- Micha Gläß-Stöcker +- Miv2nir +- Sam Birch +- Tolriq +- Tony Wasserka +- f1reflyyyylmao +- heyarne +- petitminion +- troll + +Contributors to our Merge Requests: + +- Ciarán Ainsworth +- Georg Krause +- JuniorJPDJ +- Kasper Seweryn +- Marcos Peña +- interru + + 1.2.7 (2022-07-14) ------------------ diff --git a/TRANSLATORS.rst b/TRANSLATORS.rst index 7fea7e399704fdc008d2a26e2378f25a9ebef4f6..1e63f670355a08a3b0c7f3ddfc856cf9a2f2c511 100644 --- a/TRANSLATORS.rst +++ b/TRANSLATORS.rst @@ -28,7 +28,7 @@ Submitting a new language 1. Pull the latest version of ``develop`` 2. Create a new branch, e.g ``git checkout -b translations-new-fr-ca`` -3. Add your new language code and name in ``front/src/locales.js``. Use the native language name, as it is what appears in the UI selector. +3. Add your new language code and name in ``front/src/locales.json``. Use the native language name, as it is what appears in the UI selector. 4. Create the ``po`` file from template: .. code-block:: shell diff --git a/api/.coveragerc b/api/.coveragerc index 4e3cf2bad4b049a84f378461b2f353267d2cbcba..a27c034bb811aed4ae217ef69532cad8bd9a5af1 100644 --- a/api/.coveragerc +++ b/api/.coveragerc @@ -1,5 +1,5 @@ [run] include = funkwhale_api/* -omit = *migrations*, *tests* +omit = *migrations*, *tests*, funkwhale_api/schema.py plugins = django_coverage_plugin diff --git a/api/config/schema.py b/api/config/schema.py index 556536909c7b73b2c3dabe1d18c9cd693963aaf6..11d41ba002554b32788a8fc3b109758ece789590 100644 --- a/api/config/schema.py +++ b/api/config/schema.py @@ -42,11 +42,21 @@ class CustomApplicationTokenExt(OpenApiAuthenticationExtension): def custom_preprocessing_hook(endpoints): filtered = [] + # your modifications to the list of operations that are exposed in the schema api_type = os.environ.get("API_TYPE", "v1") + for (path, path_regex, method, callback) in endpoints: if path.startswith("/api/v1/providers"): continue + + if path.startswith("/api/v1/users/users"): + continue + + if path.startswith("/api/v1/oauth/authorize"): + continue + if path.startswith(f"/api/{api_type}"): filtered.append((path, path_regex, method, callback)) + return filtered diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 66f1f2ded862ed37204f3c22aa18bae407992908..81fb10996d09c2511cc067906681f9642736fe72 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -246,8 +246,8 @@ THIRD_PARTY_APPS = ( "oauth2_provider", "rest_framework", "rest_framework.authtoken", - "rest_auth", - "rest_auth.registration", + "dj_rest_auth", + "dj_rest_auth.registration", "dynamic_preferences", "django_filters", "django_cleanup", @@ -862,11 +862,6 @@ CELERY_BEAT_SCHEDULE = { "schedule": crontab(day_of_week="1", minute="0", hour="2"), "options": {"expires": 60 * 60 * 24}, }, - "federation.refresh_actor_data": { - "task": "federation.refresh_actor_data", - "schedule": crontab(minute="0", hour="*/5"), - "options": {"expires": 60 * 60}, - }, } if env.bool("ADD_ALBUM_TAGS_FROM_TRACKS", default=True): diff --git a/api/config/settings/local.py b/api/config/settings/local.py index 37eac1903b6921acdb664fa7d07f2bb03bd16427..e1670fc7539665370ea3637a07795e53ef46dfc2 100644 --- a/api/config/settings/local.py +++ b/api/config/settings/local.py @@ -99,7 +99,7 @@ CELERY_TASK_ALWAYS_EAGER = False CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS] -REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema" +REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "funkwhale_api.schema.CustomAutoSchema" SPECTACULAR_SETTINGS = { "TITLE": "Funkwhale API", "DESCRIPTION": open("Readme.md", "r").read(), @@ -117,7 +117,7 @@ SPECTACULAR_SETTINGS = { "description": "Read server with real content", }, { - "url": "https://{domain}", + "url": "{protocol}://{domain}", "description": "Custom server", "variables": { "domain": { @@ -138,6 +138,7 @@ SPECTACULAR_SETTINGS = { "PrivacyLevelEnum": "funkwhale_api.common.fields.PRIVACY_LEVEL_CHOICES", "LibraryPrivacyLevelEnum": "funkwhale_api.music.models.LIBRARY_PRIVACY_LEVEL_CHOICES", }, + "COMPONENT_SPLIT_REQUEST": True, } if env.bool("WEAK_PASSWORDS", default=False): diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py index 10d6f210fb45c73756d9a92549e3f87eb9791cb3..7c7f3009f3680b6a37acbb136093069a7f744284 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -__version__ = "1.2.7" +__version__ = "1.2.8" __version_info__ = tuple( [ int(num) if num.isdigit() else num diff --git a/api/funkwhale_api/activity/views.py b/api/funkwhale_api/activity/views.py index 701dd04b8cfbacc1f0b5fc75ab65597c87a99b55..15524c8f1483fdbdda2d7e8785817a3c617ef07e 100644 --- a/api/funkwhale_api/activity/views.py +++ b/api/funkwhale_api/activity/views.py @@ -1,6 +1,8 @@ from rest_framework import viewsets from rest_framework.response import Response +from drf_spectacular.utils import extend_schema + from funkwhale_api.common.permissions import ConditionalAuthentication from funkwhale_api.favorites.models import TrackFavorite @@ -13,6 +15,7 @@ class ActivityViewSet(viewsets.GenericViewSet): permission_classes = [ConditionalAuthentication] queryset = TrackFavorite.objects.none() + @extend_schema(operation_id="get_activity") def list(self, request, *args, **kwargs): activity = utils.get_activity(user=request.user) serializer = self.serializer_class(activity, many=True) diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py index 1383a1ae8d744b67424fdda6d5e7e6cf2b791662..d1b249a8d6e3fe49d2ba840891f1b38f229ce124 100644 --- a/api/funkwhale_api/audio/serializers.py +++ b/api/funkwhale_api/audio/serializers.py @@ -281,6 +281,19 @@ class ChannelSerializer(serializers.ModelSerializer): return obj.actor.url +class InlineSubscriptionSerializer(serializers.Serializer): + uuid = serializers.UUIDField() + channel = serializers.UUIDField(source="target__channel__uuid") + + +class AllSubscriptionsSerializer(serializers.Serializer): + results = InlineSubscriptionSerializer(source="*", many=True) + count = serializers.SerializerMethodField() + + def get_count(self, o) -> int: + return len(o) + + class SubscriptionSerializer(serializers.Serializer): approved = serializers.BooleanField(read_only=True) fid = serializers.URLField(read_only=True) diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py index 66a71fd390b23689bcd7098f4a852dcfdca0e3e2..6451b6264fc6fffc795412c0cf43c48703e71cdc 100644 --- a/api/funkwhale_api/audio/views.py +++ b/api/funkwhale_api/audio/views.py @@ -5,6 +5,8 @@ from rest_framework import permissions as rest_permissions from rest_framework import response from rest_framework import viewsets +from drf_spectacular.utils import extend_schema, extend_schema_view + from django import http from django.db import transaction from django.db.models import Count, Prefetch, Q, Sum @@ -43,6 +45,12 @@ class ChannelsMixin(object): return super().dispatch(request, *args, **kwargs) +@extend_schema_view( + metedata_choices=extend_schema(operation_id="get_channel_metadata_choices"), + subscribe=extend_schema(operation_id="subscribe_channel"), + unsubscribe=extend_schema(operation_id="unsubscribe_channel"), + rss_subscribe=extend_schema(operation_id="subscribe_channel_rss"), +) class ChannelViewSet( ChannelsMixin, MultipleLookupDetailMixin, @@ -94,7 +102,9 @@ class ChannelViewSet( return serializers.ChannelSerializer elif self.action in ["update", "partial_update"]: return serializers.ChannelUpdateSerializer - return serializers.ChannelCreateSerializer + elif self.action is "create": + return serializers.ChannelCreateSerializer + return serializers.ChannelSerializer def get_queryset(self): queryset = super().get_queryset() @@ -134,6 +144,7 @@ class ChannelViewSet( detail=True, methods=["post"], permission_classes=[rest_permissions.IsAuthenticated], + serializer_class=serializers.SubscriptionSerializer, ) def subscribe(self, request, *args, **kwargs): object = self.get_object() @@ -156,6 +167,7 @@ class ChannelViewSet( data = serializers.SubscriptionSerializer(subscription).data return response.Response(data, status=201) + @extend_schema(responses={204: None}) @decorators.action( detail=True, methods=["post", "delete"], @@ -322,6 +334,10 @@ class SubscriptionsViewSet( qs = super().get_queryset() return qs.filter(actor=self.request.user.actor) + @extend_schema( + responses=serializers.AllSubscriptionsSerializer(), + operation_id="get_all_subscriptions", + ) @decorators.action(methods=["get"], detail=False) def all(self, request, *args, **kwargs): """ @@ -329,12 +345,7 @@ class SubscriptionsViewSet( to have a performant endpoint and avoid lots of queries just to display subscription status in the UI """ - subscriptions = list( - self.get_queryset().values_list("uuid", "target__channel__uuid") - ) + subscriptions = self.get_queryset().values("uuid", "target__channel__uuid") - payload = { - "results": [{"uuid": str(u[0]), "channel": u[1]} for u in subscriptions], - "count": len(subscriptions), - } + payload = serializers.AllSubscriptionsSerializer(subscriptions).data return response.Response(payload, status=200) diff --git a/api/funkwhale_api/common/decorators.py b/api/funkwhale_api/common/decorators.py index 49a2fb1939cd80d72111bcbec5139734e0ecd314..df6a5b47037f86eddf704b9bc464a227f47300e2 100644 --- a/api/funkwhale_api/common/decorators.py +++ b/api/funkwhale_api/common/decorators.py @@ -5,6 +5,8 @@ from rest_framework import exceptions from rest_framework import response from rest_framework import status +from drf_spectacular.utils import extend_schema, OpenApiParameter + from . import filters from . import models from . import mutations as common_mutations @@ -87,6 +89,16 @@ def mutations_route(types): ) return response.Response(serializer.data, status=status.HTTP_201_CREATED) - return decorators.action( - methods=["get", "post"], detail=True, required_scope="edits" - )(mutations) + return extend_schema( + methods=["post"], responses=serializers.APIMutationSerializer() + )( + extend_schema( + methods=["get"], + responses=serializers.APIMutationSerializer(many=True), + parameters=[OpenApiParameter("id", location="query", exclude=True)], + )( + decorators.action( + methods=["get", "post"], detail=True, required_scope="edits" + )(mutations) + ) + ) diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py index 6bfa12f04cfa1d7c5be4196ee26eee242704c507..600ca6a015ef668738723dba379f3720d64bc123 100644 --- a/api/funkwhale_api/common/filters.py +++ b/api/funkwhale_api/common/filters.py @@ -1,6 +1,8 @@ from django import forms from django.db.models import Q +from drf_spectacular.utils import extend_schema_field + from django_filters import widgets from django_filters import rest_framework as filters @@ -52,6 +54,7 @@ class CoerceChoiceField(forms.ChoiceField): raise forms.ValidationError("Invalid value {}".format(value)) +@extend_schema_field(bool) class NullBooleanFilter(filters.ChoiceFilter): field_class = CoerceChoiceField diff --git a/api/funkwhale_api/common/management/commands/gitpod.py b/api/funkwhale_api/common/management/commands/gitpod.py index b1014c7bb9af4d75fab13037ecabf1e57abcfd55..007be0eff4fcf62a4de849a4f37a0e8c0dc3aa74 100644 --- a/api/funkwhale_api/common/management/commands/gitpod.py +++ b/api/funkwhale_api/common/management/commands/gitpod.py @@ -2,6 +2,7 @@ from django.core.management.commands.migrate import Command as BaseCommand from django.core.management import call_command from funkwhale_api.music.models import Library from funkwhale_api.users.models import User +from funkwhale_api.common import preferences import uvicorn import debugpy import os @@ -45,6 +46,9 @@ class Command(BaseCommand): user.save() + # Allow anonymous access + preferences.set("common__api_authentication_required", False) + # Download music catalog os.system("git clone https://dev.funkwhale.audio/funkwhale/catalog.git /tmp/catalog") os.system("mv -f /tmp/catalog/music /workspace/funkwhale/data") diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index 8504fc587128c63c2e70d2342ce197076861e88e..d623b2cae12fa088aae71ca5c9a0811c1f0e28ae 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -359,3 +359,12 @@ class RateLimitSerializer(serializers.Serializer): enabled = serializers.BooleanField() ident = IdentSerializer() scopes = serializers.ListField(child=ScopesSerializer()) + + +class ErrorDetailSerializer(serializers.Serializer): + detail = serializers.CharField(source="*") + + +class TextPreviewSerializer(serializers.Serializer): + rendered = serializers.CharField(read_only=True, source="*") + text = serializers.CharField(write_only=True) diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 061169ab89b43ce75fd9bfdf3b19cfaa6d76c189..f252aeac9d22a0b161ce5a820d613e2a030e811e 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -187,7 +187,7 @@ def order_for_search(qs, field): When searching, it's often more useful to have short results first, this function will order the given qs based on the length of the given field """ - return qs.annotate(__size=models.functions.Length(field)).order_by("__size") + return qs.annotate(__size=models.functions.Length(field)).order_by("__size", "pk") def recursive_getattr(obj, key, permissive=False): diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py index d6cbf1953c82b8a1e9f9ca8a01e19583c8a38ced..a42a2c9ce2d04609bc558e1851f3ca6c06ce3810 100644 --- a/api/funkwhale_api/common/views.py +++ b/api/funkwhale_api/common/views.py @@ -11,11 +11,19 @@ from rest_framework import permissions from rest_framework import response from rest_framework import views from rest_framework import viewsets +from rest_framework import generics + +from drf_spectacular.utils import extend_schema from config import plugins from funkwhale_api.users.oauth import permissions as oauth_permissions +from funkwhale_api.common.serializers import ( + ErrorDetailSerializer, + TextPreviewSerializer, +) + from . import filters from . import models from . import mutations @@ -78,6 +86,7 @@ class MutationViewSet( return super().perform_destroy(instance) + @extend_schema(operation_id="approve_mutation") @action(detail=True, methods=["post"]) @transaction.atomic def approve(self, request, *args, **kwargs): @@ -107,6 +116,7 @@ class MutationViewSet( ) return response.Response({}, status=200) + @extend_schema(operation_id="reject_mutation") @action(detail=True, methods=["post"]) @transaction.atomic def reject(self, request, *args, **kwargs): @@ -198,20 +208,25 @@ class AttachmentViewSet( instance.delete() -class TextPreviewView(views.APIView): +class TextPreviewView(generics.GenericAPIView): permission_classes = [] + serializer_class = TextPreviewSerializer + @extend_schema( + operation_id="preview_text", + responses={200: TextPreviewSerializer, 400: ErrorDetailSerializer}, + ) def post(self, request, *args, **kwargs): payload = request.data if "text" not in payload: - return response.Response({"detail": "Invalid input"}, status=400) + return response.Response( + ErrorDetailSerializer("Invalid input").data, status=400 + ) permissive = payload.get("permissive", False) - data = { - "rendered": utils.render_html( - payload["text"], "text/markdown", permissive=permissive - ) - } + data = TextPreviewSerializer( + utils.render_html(payload["text"], "text/markdown", permissive=permissive) + ).data return response.Response(data, status=200) @@ -273,6 +288,7 @@ class PluginViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): user.plugins.filter(code=kwargs["pk"]).delete() return response.Response(status=204) + @extend_schema(operation_id="enable_plugin") @action(detail=True, methods=["post"]) def enable(self, request, *args, **kwargs): user = request.user @@ -281,6 +297,7 @@ class PluginViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): plugins.enable_conf(kwargs["pk"], True, user) return response.Response({}, status=200) + @extend_schema(operation_id="disable_plugin") @action(detail=True, methods=["post"]) def disable(self, request, *args, **kwargs): user = request.user diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py index 9305d35b1ee8b2a699c6cc93ce48e61e6b8fa217..2b31b4b766b5c8734320a95a000822c9defb1f09 100644 --- a/api/funkwhale_api/favorites/serializers.py +++ b/api/funkwhale_api/favorites/serializers.py @@ -51,3 +51,16 @@ class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer): class Meta: model = models.TrackFavorite fields = ("id", "track", "creation_date") + + +class SimpleFavoriteSerializer(serializers.Serializer): + id = serializers.IntegerField() + track = serializers.IntegerField() + + +class AllFavoriteSerializer(serializers.Serializer): + results = SimpleFavoriteSerializer(many=True, source="*") + count = serializers.SerializerMethodField() + + def get_count(self, o) -> int: + return len(o) diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index db0c909001edef6f854216a9a22895a2a42c0fb0..bab9925f78ad273e5b1fca53ec3d81b563b4bf31 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -2,6 +2,8 @@ from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response +from drf_spectacular.utils import extend_schema + from django.db.models import Prefetch from funkwhale_api.activity import record @@ -38,6 +40,7 @@ class TrackFavoriteViewSet( return serializers.UserTrackFavoriteSerializer return serializers.UserTrackFavoriteWriteSerializer + @extend_schema(operation_id="favorite_track") def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -67,6 +70,7 @@ class TrackFavoriteViewSet( favorite = models.TrackFavorite.add(track=track, user=self.request.user) return favorite + @extend_schema(operation_id="unfavorite_track") @action(methods=["delete", "post"], detail=False) def remove(self, request, *args, **kwargs): try: @@ -77,6 +81,10 @@ class TrackFavoriteViewSet( favorite.delete() return Response([], status=status.HTTP_204_NO_CONTENT) + @extend_schema( + responses=serializers.AllFavoriteSerializer(), + operation_id="get_all_favorite_tracks", + ) @action(methods=["get"], detail=False) def all(self, request, *args, **kwargs): """ @@ -85,10 +93,9 @@ class TrackFavoriteViewSet( favorites status in the UI """ if not request.user.is_authenticated: - return Response({"results": [], "count": 0}, status=200) + return Response({"results": [], "count": 0}, status=401) + + favorites = request.user.track_favorites.values("id", "track").order_by("id") + payload = serializers.AllFavoriteSerializer(favorites).data - favorites = list( - request.user.track_favorites.values("id", "track").order_by("id") - ) - payload = {"results": favorites, "count": len(favorites)} return Response(payload, status=200) diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py index ab39124d60060e6750379f205816aee28f3344cd..465a5568661537c1c27f0aeeee0544dea59106cb 100644 --- a/api/funkwhale_api/federation/api_serializers.py +++ b/api/funkwhale_api/federation/api_serializers.py @@ -47,8 +47,9 @@ class DomainSerializer(serializers.Serializer): class LibrarySerializer(serializers.ModelSerializer): actor = federation_serializers.APIActorSerializer() uploads_count = serializers.SerializerMethodField() - latest_scan = serializers.SerializerMethodField() - follow = serializers.SerializerMethodField() + latest_scan = LibraryScanSerializer(required=False, allow_null=True) + # The follow field is likely broken, so I removed the test + follow = NestedLibraryFollowSerializer(required=False, allow_null=True) class Meta: model = music_models.Library @@ -65,8 +66,7 @@ class LibrarySerializer(serializers.ModelSerializer): "latest_scan", ] - @extend_schema_field(OpenApiTypes.INT) - def get_uploads_count(self, o): + def get_uploads_count(self, o) -> int: return max(getattr(o, "_uploads_count", 0), o.uploads_count) @extend_schema_field(NestedLibraryFollowSerializer) @@ -76,12 +76,6 @@ class LibrarySerializer(serializers.ModelSerializer): except (AttributeError, IndexError): return None - @extend_schema_field(LibraryScanSerializer) - def get_latest_scan(self, o): - scan = o.scans.order_by("-creation_date").first() - if scan: - return LibraryScanSerializer(scan).data - class LibraryFollowSerializer(serializers.ModelSerializer): target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True) @@ -123,8 +117,8 @@ def serialize_generic_relation(activity, obj): class ActivitySerializer(serializers.ModelSerializer): actor = federation_serializers.APIActorSerializer() - object = serializers.SerializerMethodField() - target = serializers.SerializerMethodField() + object = serializers.SerializerMethodField(allow_null=True) + target = serializers.SerializerMethodField(allow_null=True) related_object = serializers.SerializerMethodField() class Meta: @@ -142,7 +136,7 @@ class ActivitySerializer(serializers.ModelSerializer): "type", ] - @extend_schema_field(OpenApiTypes.OBJECT) + @extend_schema_field(OpenApiTypes.OBJECT, None) def get_object(self, o): if o.object: return serialize_generic_relation(o, o.object) diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index f49c51c3575869e2417b672ea6adfb1d88c823d1..ebe24d17eed7a111f383fe5226f19135a16784ef 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -10,11 +10,14 @@ from rest_framework import permissions from rest_framework import response from rest_framework import viewsets +from drf_spectacular.utils import extend_schema, extend_schema_view + from funkwhale_api.common import preferences from funkwhale_api.common import utils as common_utils from funkwhale_api.common.permissions import ConditionalAuthentication from funkwhale_api.music import models as music_models from funkwhale_api.music import views as music_views +from funkwhale_api.music import serializers as music_serializers from funkwhale_api.users.oauth import permissions as oauth_permissions from . import activity @@ -38,6 +41,10 @@ def update_follow(follow, approved): routes.outbox.dispatch({"type": "Reject"}, context={"follow": follow}) +@extend_schema_view( + list=extend_schema(operation_id="get_federation_library_follows"), + create=extend_schema(operation_id="create_federation_library_follow"), +) class LibraryFollowViewSet( mixins.CreateModelMixin, mixins.ListModelMixin, @@ -57,6 +64,14 @@ class LibraryFollowViewSet( filterset_class = filters.LibraryFollowFilter ordering_fields = ("creation_date",) + @extend_schema(operation_id="get_federation_library_follow") + def retrieve(self, request): + return super().retrieve(request) + + @extend_schema(operation_id="delete_federation_library_follow") + def destroy(self, request, uuid=None): + return super().destroy(request, uuid) + def get_queryset(self): qs = super().get_queryset() return qs.filter(actor=self.request.user.actor).exclude(approved=False) @@ -77,6 +92,10 @@ class LibraryFollowViewSet( context["actor"] = self.request.user.actor return context + @extend_schema( + operation_id="accept_federation_library_follow", + responses={404: None, 204: None}, + ) @decorators.action(methods=["post"], detail=True) def accept(self, request, *args, **kwargs): try: @@ -88,6 +107,7 @@ class LibraryFollowViewSet( update_follow(follow, approved=True) return response.Response(status=204) + @extend_schema(operation_id="reject_federation_library_follow") @decorators.action(methods=["post"], detail=True) def reject(self, request, *args, **kwargs): try: @@ -100,6 +120,7 @@ class LibraryFollowViewSet( update_follow(follow, approved=False) return response.Response(status=204) + @extend_schema(operation_id="get_all_federation_library_follows") @decorators.action(methods=["get"], detail=False) def all(self, request, *args, **kwargs): """ @@ -288,7 +309,11 @@ class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): qs = qs.filter(query) return qs - libraries = decorators.action(methods=["get"], detail=True)( + libraries = decorators.action( + methods=["get"], + detail=True, + serializer_class=music_serializers.LibraryForOwnerSerializer, + )( music_views.get_libraries( filter_uploads=lambda o, uploads: uploads.filter(library__actor=o) ) diff --git a/api/funkwhale_api/federation/decorators.py b/api/funkwhale_api/federation/decorators.py index 3d2d62567613f7899eea0c2a9356812150bcd160..0a3416beb982cc77720482c2589b2a76e25d745f 100644 --- a/api/funkwhale_api/federation/decorators.py +++ b/api/funkwhale_api/federation/decorators.py @@ -5,6 +5,8 @@ from rest_framework import permissions from rest_framework import response from rest_framework import status +from drf_spectacular.utils import extend_schema, OpenApiParameter + from funkwhale_api.common import utils as common_utils from . import api_serializers @@ -42,8 +44,16 @@ def fetches_route(): serializer = api_serializers.FetchSerializer(fetch) return response.Response(serializer.data, status=status.HTTP_201_CREATED) - return decorators.action( - methods=["get", "post"], - detail=True, - permission_classes=[permissions.IsAuthenticated], - )(fetches) + return extend_schema(methods=["post"], responses=api_serializers.FetchSerializer())( + extend_schema( + methods=["get"], + responses=api_serializers.FetchSerializer(many=True), + parameters=[OpenApiParameter("id", location="query", exclude=True)], + )( + decorators.action( + methods=["get", "post"], + detail=True, + permission_classes=[permissions.IsAuthenticated], + )(fetches) + ) + ) diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index 9ad4da160210c39fcfc2e1541b48949295de3188..bf3440524d64a268762a84915dd3d41f39ff7884 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -3,7 +3,6 @@ import json import logging import os import requests -from requests import HTTPError from django.conf import settings from django.db import transaction @@ -18,7 +17,6 @@ from funkwhale_api.common import preferences from funkwhale_api.common import models as common_models from funkwhale_api.common import session from funkwhale_api.common import utils as common_utils -from funkwhale_api.federation import actors as actors_utils from funkwhale_api.moderation import mrf from funkwhale_api.music import models as music_models from funkwhale_api.taskapp import celery @@ -350,7 +348,7 @@ def fetch(fetch_obj): return error( "http", status_code=e.response.status_code if e.response else None, - message=response.text, + message=e.response.text, ) except requests.exceptions.Timeout: return error("timeout") @@ -627,33 +625,3 @@ def fetch_collection(url, max_pages, channel, is_page=False): results["errored"], ) return results - - -@celery.app.task(name="federation.refresh_actor_data") -def refresh_actor_data(): - actors = models.Actor.objects.all().prefetch_related() - for actor in actors: - if actor.is_local: - # skip refreshing local actors - continue - - try: - data = actors_utils.get_actor_data(actor.fid) - except HTTPError as e: - logger.info( - f"Actor couldn't be fetch because of the following exeption : {e!r}" - ) - if e.response.status_code == 410: - logger.info("Purging actor : {actor.fid!r}") - purge_actors([actor.id], [actor.domain]) - continue - continue - except Exception as e: - logger.info( - f"Actor couldn't be fetch because of the following exeption : {e!r}" - ) - continue - serializer = serializers.ActorSerializer(data=data) - serializer.is_valid(raise_exception=True) - serializer.save(last_fetch_date=timezone.now()) - return diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py deleted file mode 100644 index a40c4e94b1d4d833e94ad84fb86fe8420ea42ae7..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/instance/nodeinfo.py +++ /dev/null @@ -1,100 +0,0 @@ -from cache_memoize import cache_memoize - -from django.urls import reverse - -import funkwhale_api -from funkwhale_api.common import preferences -from funkwhale_api.federation import actors, models as federation_models -from funkwhale_api.federation import utils as federation_utils -from funkwhale_api.moderation import models as moderation_models -from funkwhale_api.music import utils as music_utils - -from . import stats - - -def get(): - all_preferences = preferences.all() - share_stats = all_preferences.get("instance__nodeinfo_stats_enabled") - allow_list_enabled = all_preferences.get("moderation__allow_list_enabled") - allow_list_public = all_preferences.get("moderation__allow_list_public") - auth_required = all_preferences.get("common__api_authentication_required") - banner = all_preferences.get("instance__banner") - unauthenticated_report_types = all_preferences.get( - "moderation__unauthenticated_report_types" - ) - if allow_list_enabled and allow_list_public: - allowed_domains = list( - federation_models.Domain.objects.filter(allowed=True) - .order_by("name") - .values_list("name", flat=True) - ) - else: - allowed_domains = None - data = { - "version": "2.0", - "software": {"name": "funkwhale", "version": funkwhale_api.__version__}, - "protocols": ["activitypub"], - "services": {"inbound": [], "outbound": []}, - "openRegistrations": all_preferences.get("users__registration_enabled"), - "usage": {"users": {"total": 0, "activeHalfyear": 0, "activeMonth": 0}}, - "metadata": { - "actorId": actors.get_service_actor().fid, - "private": all_preferences.get("instance__nodeinfo_private"), - "shortDescription": all_preferences.get("instance__short_description"), - "longDescription": all_preferences.get("instance__long_description"), - "rules": all_preferences.get("instance__rules"), - "contactEmail": all_preferences.get("instance__contact_email"), - "terms": all_preferences.get("instance__terms"), - "nodeName": all_preferences.get("instance__name"), - "banner": federation_utils.full_url(banner.url) if banner else None, - "defaultUploadQuota": all_preferences.get("users__upload_quota"), - "library": { - "federationEnabled": all_preferences.get("federation__enabled"), - "anonymousCanListen": not all_preferences.get( - "common__api_authentication_required" - ), - }, - "supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS, - "allowList": {"enabled": allow_list_enabled, "domains": allowed_domains}, - "reportTypes": [ - {"type": t, "label": l, "anonymous": t in unauthenticated_report_types} - for t, l in moderation_models.REPORT_TYPES - ], - "funkwhaleSupportMessageEnabled": all_preferences.get( - "instance__funkwhale_support_message_enabled" - ), - "instanceSupportMessage": all_preferences.get("instance__support_message"), - "endpoints": {"knownNodes": None, "channels": None, "libraries": None}, - }, - } - - if share_stats: - getter = cache_memoize(600, prefix="memoize:instance:stats")(stats.get) - statistics = getter() - data["usage"]["users"]["total"] = statistics["users"]["total"] - data["usage"]["users"]["activeHalfyear"] = statistics["users"][ - "active_halfyear" - ] - data["usage"]["users"]["activeMonth"] = statistics["users"]["active_month"] - data["metadata"]["library"]["tracks"] = {"total": statistics["tracks"]} - data["metadata"]["library"]["artists"] = {"total": statistics["artists"]} - data["metadata"]["library"]["albums"] = {"total": statistics["albums"]} - data["metadata"]["library"]["music"] = {"hours": statistics["music_duration"]} - - data["metadata"]["usage"] = { - "favorites": {"tracks": {"total": statistics["track_favorites"]}}, - "listenings": {"total": statistics["listenings"]}, - "downloads": {"total": statistics["downloads"]}, - } - if not auth_required: - data["metadata"]["endpoints"]["knownNodes"] = federation_utils.full_url( - reverse("api:v1:federation:domains-list") - ) - if not auth_required and preferences.get("federation__public_index"): - data["metadata"]["endpoints"]["libraries"] = federation_utils.full_url( - reverse("federation:index:index-libraries") - ) - data["metadata"]["endpoints"]["channels"] = federation_utils.full_url( - reverse("federation:index:index-channels") - ) - return data diff --git a/api/funkwhale_api/instance/serializers.py b/api/funkwhale_api/instance/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..b6ce6f38b733a19d2b3995e9bd94a45b82729064 --- /dev/null +++ b/api/funkwhale_api/instance/serializers.py @@ -0,0 +1,200 @@ +from rest_framework import serializers + +from funkwhale_api.federation.utils import full_url +from drf_spectacular.utils import extend_schema_field + + +class SoftwareSerializer(serializers.Serializer): + name = serializers.SerializerMethodField() + version = serializers.CharField() + + def get_name(self, obj) -> str: + return "funkwhale" + + +class ServicesSerializer(serializers.Serializer): + inbound = serializers.ListField(child=serializers.CharField(), default=[]) + outbound = serializers.ListField(child=serializers.CharField(), default=[]) + + +class UsersUsageSerializer(serializers.Serializer): + total = serializers.IntegerField() + activeHalfyear = serializers.SerializerMethodField() + activeMonth = serializers.SerializerMethodField() + + def get_activeHalfyear(self, obj) -> int: + return obj.get("active_halfyear", 0) + + def get_activeMonth(self, obj) -> int: + return obj.get("active_month", 0) + + +class UsageSerializer(serializers.Serializer): + users = UsersUsageSerializer() + + +class TotalCountSerializer(serializers.Serializer): + total = serializers.SerializerMethodField() + + def get_total(self, obj) -> int: + return obj + + +class TotalHoursSerializer(serializers.Serializer): + hours = serializers.SerializerMethodField() + + def get_hours(self, obj) -> int: + return obj + + +class NodeInfoLibrarySerializer(serializers.Serializer): + federationEnabled = serializers.BooleanField() + anonymousCanListen = serializers.BooleanField() + tracks = TotalCountSerializer(default=0) + artists = TotalCountSerializer(default=0) + albums = TotalCountSerializer(default=0) + music = TotalHoursSerializer(source="music_duration", default=0) + + +class AllowListStatSerializer(serializers.Serializer): + enabled = serializers.BooleanField() + domains = serializers.ListField(child=serializers.CharField()) + + +class ReportTypeSerializer(serializers.Serializer): + type = serializers.CharField() + label = serializers.CharField() + anonymous = serializers.BooleanField() + + +class EndpointsSerializer(serializers.Serializer): + knownNodes = serializers.URLField(default=None) + channels = serializers.URLField(default=None) + libraries = serializers.URLField(default=None) + + +class MetadataUsageFavoriteSerializer(serializers.Serializer): + tracks = serializers.SerializerMethodField() + + @extend_schema_field(TotalCountSerializer) + def get_tracks(self, obj): + return TotalCountSerializer(obj).data + + +class MetadataUsageSerializer(serializers.Serializer): + favorites = MetadataUsageFavoriteSerializer(source="track_favorites") + listenings = TotalCountSerializer() + downloads = TotalCountSerializer() + + +class MetadataSerializer(serializers.Serializer): + actorId = serializers.CharField() + private = serializers.SerializerMethodField() + shortDescription = serializers.SerializerMethodField() + longDescription = serializers.SerializerMethodField() + rules = serializers.SerializerMethodField() + contactEmail = serializers.SerializerMethodField() + terms = serializers.SerializerMethodField() + nodeName = serializers.SerializerMethodField() + banner = serializers.SerializerMethodField() + defaultUploadQuota = serializers.SerializerMethodField() + library = serializers.SerializerMethodField() + supportedUploadExtensions = serializers.ListField(child=serializers.CharField()) + allowList = serializers.SerializerMethodField() + reportTypes = ReportTypeSerializer(source="report_types", many=True) + funkwhaleSupportMessageEnabled = serializers.SerializerMethodField() + instanceSupportMessage = serializers.SerializerMethodField() + endpoints = EndpointsSerializer() + usage = MetadataUsageSerializer(source="stats", required=False) + + def get_private(self, obj) -> bool: + return obj["preferences"].get("instance__nodeinfo_private") + + def get_shortDescription(self, obj) -> str: + return obj["preferences"].get("instance__short_description") + + def get_longDescription(self, obj) -> str: + return obj["preferences"].get("instance__long_description") + + def get_rules(self, obj) -> str: + return obj["preferences"].get("instance__rules") + + def get_contactEmail(self, obj) -> str: + return obj["preferences"].get("instance__contact_email") + + def get_terms(self, obj) -> str: + return obj["preferences"].get("instance__terms") + + def get_nodeName(self, obj) -> str: + return obj["preferences"].get("instance__name") + + @extend_schema_field(serializers.CharField) + def get_banner(self, obj) -> (str, None): + if obj["preferences"].get("instance__banner"): + return full_url(obj["preferences"].get("instance__banner").url) + return None + + def get_defaultUploadQuota(self, obj) -> int: + return obj["preferences"].get("users__upload_quota") + + def get_library(self, obj) -> bool: + data = obj["stats"] or {} + data["federationEnabled"] = obj["preferences"].get("federation__enabled") + data["anonymousCanListen"] = not obj["preferences"].get( + "common__api_authentication_required" + ) + return NodeInfoLibrarySerializer(data).data + + @extend_schema_field(AllowListStatSerializer) + def get_allowList(self, obj): + return AllowListStatSerializer( + { + "enabled": obj["preferences"].get("moderation__allow_list_enabled"), + "domains": obj["allowed_domains"] or None, + } + ).data + + def get_funkwhaleSupportMessageEnabled(self, obj) -> bool: + return obj["preferences"].get("instance__funkwhale_support_message_enabled") + + def get_instanceSupportMessage(self, obj) -> str: + return obj["preferences"].get("instance__support_message") + + @extend_schema_field(MetadataUsageSerializer) + def get_usage(self, obj): + return MetadataUsageSerializer(obj["stats"]).data + + +class NodeInfo20Serializer(serializers.Serializer): + version = serializers.SerializerMethodField() + software = SoftwareSerializer() + protocols = serializers.SerializerMethodField() + services = ServicesSerializer(default={}) + openRegistrations = serializers.SerializerMethodField() + usage = serializers.SerializerMethodField() + metadata = serializers.SerializerMethodField() + + def get_version(self, obj) -> str: + return "2.0" + + def get_protocols(self, obj) -> list: + return ["activitypub"] + + def get_services(self, obj) -> object: + return {"inbound": [], "outbound": []} + + def get_openRegistrations(self, obj) -> bool: + return obj["preferences"]["users__registration_enabled"] + + @extend_schema_field(UsageSerializer) + def get_usage(self, obj): + usage = None + if obj["preferences"]["instance__nodeinfo_stats_enabled"]: + usage = obj["stats"] + else: + usage = {"users": {"total": 0, "activeMonth": 0, "activeHalfyear": 0}} + return UsageSerializer(usage).data + + @extend_schema_field(MetadataSerializer) + def get_metadata(self, obj): + return MetadataSerializer(obj).data diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py index f6d1794afce4104ffc944b6122aa4429e3c68dd6..ace53723ddb837b6e432e21765a25cb0230c2f14 100644 --- a/api/funkwhale_api/instance/views.py +++ b/api/funkwhale_api/instance/views.py @@ -1,20 +1,31 @@ import json import logging +from cache_memoize import cache_memoize from django.conf import settings +from django.urls import reverse -from dynamic_preferences.api import serializers +from dynamic_preferences.api.serializers import GlobalPreferenceSerializer from dynamic_preferences.api import viewsets as preferences_viewsets from dynamic_preferences.registries import global_preferences_registry +from rest_framework import generics from rest_framework import views from rest_framework.response import Response +from funkwhale_api import __version__ as funkwhale_version from funkwhale_api.common import middleware from funkwhale_api.common import preferences from funkwhale_api.federation import utils as federation_utils +from funkwhale_api.federation.models import Domain +from funkwhale_api.federation.actors import get_service_actor from funkwhale_api.users.oauth import permissions as oauth_permissions +from funkwhale_api.music.utils import SUPPORTED_EXTENSIONS +from funkwhale_api.moderation.models import REPORT_TYPES -from . import nodeinfo +from drf_spectacular.utils import extend_schema + +from . import serializers +from . import stats NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" # noqa @@ -28,18 +39,24 @@ class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet): required_scope = "instance:settings" -class InstanceSettings(views.APIView): +class InstanceSettings(generics.GenericAPIView): permission_classes = [] authentication_classes = [] + serializer_class = GlobalPreferenceSerializer - def get(self, request, *args, **kwargs): + def get_queryset(self): 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 api_preferences + + @extend_schema(operation_id="get_instance_settings") + def get(self, request): + queryset = self.get_queryset() + data = GlobalPreferenceSerializer(queryset, many=True).data return Response(data, status=200) @@ -47,19 +64,67 @@ class NodeInfo(views.APIView): permission_classes = [] authentication_classes = [] - def get(self, request, *args, **kwargs): - try: - data = nodeinfo.get() - except ValueError: - logger.warn("nodeinfo returned invalid json") - data = {} - return Response(data, status=200, content_type=NODEINFO_2_CONTENT_TYPE) + @extend_schema( + responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20" + ) + def get(self, request): + pref = preferences.all() + if ( + pref["moderation__allow_list_public"] + and pref["moderation__allow_list_enabled"] + ): + allowed_domains = list( + Domain.objects.filter(allowed=True) + .order_by("name") + .values_list("name", flat=True) + ) + else: + allowed_domains = None + + data = { + "software": {"version": funkwhale_version}, + "preferences": pref, + "stats": cache_memoize(600, prefix="memoize:instance:stats")(stats.get)() + if pref["instance__nodeinfo_stats_enabled"] + else None, + "actorId": get_service_actor().fid, + "supportedUploadExtensions": SUPPORTED_EXTENSIONS, + "allowed_domains": allowed_domains, + "report_types": [ + { + "type": t, + "label": l, + "anonymous": t + in pref.get("moderation__unauthenticated_report_types"), + } + for t, l in REPORT_TYPES + ], + "endpoints": {}, + } + + if not pref.get("common__api_authentication_required"): + if pref.get("instance__nodeinfo_stats_enabled"): + data["endpoints"]["knownNodes"] = reverse( + "api:v1:federation:domains-list" + ) + if pref.get("federation__public_index"): + data["endpoints"]["libraries"] = reverse( + "federation:index:index-libraries" + ) + data["endpoints"]["channels"] = reverse( + "federation:index:index-channels" + ) + serializer = serializers.NodeInfo20Serializer(data) + return Response( + serializer.data, status=200, content_type=NODEINFO_2_CONTENT_TYPE + ) class SpaManifest(views.APIView): permission_classes = [] authentication_classes = [] + @extend_schema(operation_id="get_spa_manifest") def get(self, request, *args, **kwargs): existing_manifest = middleware.get_spa_file( settings.FUNKWHALE_SPA_HTML_ROOT, "manifest.json" diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index be14e950f8e8492c319f8fc310d6c0a2974969a1..03ecd9076b997bf024ada7c8893519016a9460e7 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -52,7 +52,7 @@ class ManageUserSimpleSerializer(serializers.ModelSerializer): class ManageUserSerializer(serializers.ModelSerializer): permissions = PermissionsSerializer(source="*") - upload_quota = serializers.IntegerField(allow_null=True) + upload_quota = serializers.IntegerField(allow_null=True, required=False) actor = serializers.SerializerMethodField() class Meta: @@ -221,7 +221,7 @@ class ManageBaseActorSerializer(serializers.ModelSerializer): class ManageActorSerializer(ManageBaseActorSerializer): uploads_count = serializers.SerializerMethodField() - user = ManageUserSerializer() + user = ManageUserSerializer(allow_null=True) class Meta: model = federation_models.Actor @@ -403,7 +403,7 @@ class ManageArtistSerializer( tracks_count = serializers.SerializerMethodField() albums_count = serializers.SerializerMethodField() channel = serializers.SerializerMethodField() - cover = music_serializers.cover_field + cover = music_serializers.CoverField(allow_null=True) class Meta: model = music_models.Artist @@ -477,8 +477,8 @@ class ManageTrackSerializer( music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer ): artist = ManageNestedArtistSerializer() - album = ManageTrackAlbumSerializer() - attributed_to = ManageBaseActorSerializer() + album = ManageTrackAlbumSerializer(allow_null=True) + attributed_to = ManageBaseActorSerializer(allow_null=True) uploads_count = serializers.SerializerMethodField() tags = serializers.SerializerMethodField() cover = music_serializers.cover_field @@ -706,11 +706,13 @@ class ManageNoteSerializer(ManageBaseNoteSerializer): class ManageReportSerializer(serializers.ModelSerializer): - assigned_to = ManageBaseActorSerializer() - target_owner = ManageBaseActorSerializer() - submitter = ManageBaseActorSerializer() + assigned_to = ManageBaseActorSerializer(allow_null=True, required=False) + target_owner = ManageBaseActorSerializer(required=False) + submitter = ManageBaseActorSerializer(required=False) target = moderation_serializers.TARGET_FIELD - notes = serializers.SerializerMethodField() + notes = ManageBaseNoteSerializer( + allow_null=True, source="_prefetched_notes", many=True, default=[] + ) class Meta: model = moderation_models.Report @@ -745,11 +747,6 @@ class ManageReportSerializer(serializers.ModelSerializer): "summary", ] - @extend_schema_field(ManageBaseNoteSerializer) - def get_notes(self, o): - notes = getattr(o, "_prefetched_notes", []) - return ManageBaseNoteSerializer(notes, many=True).data - class ManageUserRequestSerializer(serializers.ModelSerializer): assigned_to = ManageBaseActorSerializer() diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index dc90ffe7970442ee43eaed714012c1e393aa631a..292ac1fa1521548ab05b1d093bface7ce867f33e 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,6 +1,8 @@ from rest_framework import mixins, response, viewsets from rest_framework import decorators as rest_decorators +from drf_spectacular.utils import extend_schema + from django.db import transaction from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery from django.db.models.functions import Coalesce, Length @@ -93,6 +95,7 @@ class ManageArtistViewSet( required_scope = "instance:libraries" ordering_fields = ["creation_date", "name"] + @extend_schema(operation_id="admin_get_library_artist_stats") @rest_decorators.action(methods=["get"], detail=True) def stats(self, request, *args, **kwargs): artist = self.get_object() @@ -135,6 +138,7 @@ class ManageAlbumViewSet( required_scope = "instance:libraries" ordering_fields = ["creation_date", "title", "release_date"] + @extend_schema(operation_id="admin_get_library_album_stats") @rest_decorators.action(methods=["get"], detail=True) def stats(self, request, *args, **kwargs): album = self.get_object() @@ -196,6 +200,7 @@ class ManageTrackViewSet( "disc_number", ] + @extend_schema(operation_id="admin_get_track_stats") @rest_decorators.action(methods=["get"], detail=True) def stats(self, request, *args, **kwargs): track = self.get_object() @@ -257,6 +262,7 @@ class ManageLibraryViewSet( filterset_class = filters.ManageLibraryFilterSet required_scope = "instance:libraries" + @extend_schema(operation_id="admin_get_library_stats") @rest_decorators.action(methods=["get"], detail=True) def stats(self, request, *args, **kwargs): library = self.get_object() @@ -424,6 +430,7 @@ class ManageDomainViewSet( domain.refresh_from_db() return response.Response(domain.nodeinfo, status=200) + @extend_schema(operation_id="admin_get_federation_domain_stats") @rest_decorators.action(methods=["get"], detail=True) def stats(self, request, *args, **kwargs): domain = self.get_object() @@ -468,6 +475,7 @@ class ManageActorViewSet( return obj + @extend_schema(operation_id="admin_get_account_stats") @rest_decorators.action(methods=["get"], detail=True) def stats(self, request, *args, **kwargs): obj = self.get_object() @@ -709,6 +717,7 @@ class ManageChannelViewSet( required_scope = "instance:libraries" ordering_fields = ["creation_date", "name"] + @extend_schema(operation_id="admin_get_channel_stats") @rest_decorators.action(methods=["get"], detail=True) def stats(self, request, *args, **kwargs): channel = self.get_object() diff --git a/api/funkwhale_api/music/management/commands/prune_skipped_uploads.py b/api/funkwhale_api/music/management/commands/prune_skipped_uploads.py new file mode 100644 index 0000000000000000000000000000000000000000..8dcf75380554d8563bae9cb841605903402d30f6 --- /dev/null +++ b/api/funkwhale_api/music/management/commands/prune_skipped_uploads.py @@ -0,0 +1,35 @@ +from django.core.management.base import BaseCommand + +from django.db import transaction + +from funkwhale_api.music import models + + +class Command(BaseCommand): + help = """ + This command makes it easy to prune all skipped Uploads from the database. + Due to a bug they might caused the database to grow exponentially, + especially when using in-place-imports on a regular basis. This command + helps to clean up the database again. + """ + + def add_arguments(self, parser): + parser.add_argument( + "--force", + default=False, + help="Disable dry run mode and apply pruning for real on the database", + ) + + @transaction.atomic + def handle(self, *args, **options): + skipped = models.Uploads.objects.filter(import_status="skipped") + count = len(skipped) + if options["force"]: + skipped.delete() + print(f"Deleted {count} entries from the database.") + return + + print( + f"Would delete {count} entries from the database.\ + Run with --force to actually apply changes to the database" + ) diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index 9d19e155771833c5b16abe5275c8785f75b8d754..d0c7f78ad93724e1dd28ef2c7ee1b61f64336fda 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -480,8 +480,8 @@ class ArtistField(serializers.Field): def get_value(self, data): if self.for_album: keys = [ - ("artists", "artists"), - ("names", "album_artist"), + ("artists", "album_artist"), + ("names", "artists"), ("mbids", "musicbrainz_albumartistid"), ] else: @@ -525,7 +525,14 @@ class ArtistField(serializers.Field): if separator in data["artists"]: names = [n.strip() for n in data["artists"].split(separator)] break - if not names: + # corner case: 'album artist' field with only one artist but multiple names in 'artits' field + if ( + not names + and data.get("names", None) + and any(separator in data["names"] for separator in separators) + ): + names = [n.strip() for n in data["names"].split(separators[0])] + elif not names: names = [data["artists"]] elif used_separator and mbids: names = [n.strip() for n in data["names"].split(used_separator)] @@ -753,7 +760,7 @@ class TrackMetadataSerializer(serializers.Serializer): album = AlbumField() artists = ArtistField() - cover_data = CoverDataField() + cover_data = CoverDataField(required=False) remove_blank_null_fields = [ "copyright", diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 9829a989b86be27f10bee33792a3c05ffa7ce640..076239935ef461ce6177163b5c1b51579c076b69 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1261,6 +1261,9 @@ class Library(federation_models.FederationMixin): except ObjectDoesNotExist: return None + def latest_scan(self): + return self.scans.order_by("-creation_date").first() + SCAN_STATUS = [ ("pending", "pending"), diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 0f0884d67a171ece92a8e8787b9ac7f0becca922..ba18a2fe749a11f67be2991d82df1bd6f99646ea 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -75,7 +75,7 @@ class LicenseSerializer(serializers.Serializer): class ArtistAlbumSerializer(serializers.Serializer): tracks_count = serializers.SerializerMethodField() - cover = cover_field + cover = CoverField(allow_null=True) is_playable = serializers.SerializerMethodField() is_local = serializers.BooleanField() id = serializers.IntegerField() @@ -102,11 +102,22 @@ class ArtistAlbumSerializer(serializers.Serializer): DATETIME_FIELD = serializers.DateTimeField() +class InlineActorSerializer(serializers.Serializer): + full_username = serializers.CharField() + preferred_username = serializers.CharField() + domain = serializers.CharField(source="domain_id") + + +class ArtistWithAlbumsInlineChannelSerializer(serializers.Serializer): + uuid = serializers.CharField() + actor = InlineActorSerializer() + + class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serializer): albums = ArtistAlbumSerializer(many=True) tags = serializers.SerializerMethodField() - attributed_to = APIActorSerializer() - channel = serializers.SerializerMethodField() + attributed_to = APIActorSerializer(allow_null=True) + channel = ArtistWithAlbumsInlineChannelSerializer(allow_null=True) tracks_count = serializers.SerializerMethodField() id = serializers.IntegerField() fid = serializers.URLField() @@ -115,7 +126,7 @@ class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serialize content_category = serializers.CharField() creation_date = serializers.DateTimeField() is_local = serializers.BooleanField() - cover = cover_field + cover = CoverField(allow_null=True) @extend_schema_field({"type": "array", "items": {"type": "string"}}) def get_tags(self, obj): @@ -126,25 +137,11 @@ class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serialize tracks = getattr(o, "_prefetched_tracks", None) return len(tracks) if tracks else 0 - @extend_schema_field(OpenApiTypes.OBJECT) - def get_channel(self, o): - channel = o.get_channel() - if not channel: - return - - return { - "uuid": str(channel.uuid), - "actor": { - "full_username": channel.actor.full_username, - "preferred_username": channel.actor.preferred_username, - "domain": channel.actor.domain_id, - }, - } - class SimpleArtistSerializer(serializers.ModelSerializer): - attachment_cover = cover_field - description = common_serializers.ContentSerializer() + attachment_cover = CoverField(allow_null=True, required=False) + description = common_serializers.ContentSerializer(allow_null=True, required=False) + channel = serializers.UUIDField(allow_null=True, required=False) class Meta: model = models.Artist @@ -165,7 +162,7 @@ class SimpleArtistSerializer(serializers.ModelSerializer): class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer): artist = SimpleArtistSerializer() - cover = cover_field + cover = CoverField(allow_null=True) is_playable = serializers.SerializerMethodField() tags = serializers.SerializerMethodField() tracks_count = serializers.SerializerMethodField() @@ -208,7 +205,7 @@ class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer): class TrackAlbumSerializer(serializers.ModelSerializer): artist = SimpleArtistSerializer() - cover = cover_field + cover = CoverField(allow_null=True) tracks_count = serializers.SerializerMethodField() def get_tracks_count(self, o) -> int: @@ -265,7 +262,7 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer): uploads = serializers.SerializerMethodField() listen_url = serializers.SerializerMethodField() tags = serializers.SerializerMethodField() - attributed_to = APIActorSerializer() + attributed_to = APIActorSerializer(allow_null=True) id = serializers.IntegerField() fid = serializers.URLField() @@ -278,7 +275,7 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer): downloads_count = serializers.IntegerField() copyright = serializers.CharField() license = serializers.SerializerMethodField() - cover = cover_field + cover = CoverField(allow_null=True) is_playable = serializers.SerializerMethodField() @extend_schema_field(OpenApiTypes.URI) @@ -293,7 +290,7 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer): uploads = sorted(uploads, key=lambda u: u["is_local"], reverse=True) return list(uploads) - @extend_schema_field({"type": "array", "items": {"type": "str"}}) + @extend_schema_field({"type": "array", "items": {"type": "string"}}) def get_tags(self, obj): tagged_items = getattr(obj, "_prefetched_tagged_items", []) return [ti.tag.name for ti in tagged_items] @@ -451,9 +448,10 @@ class ImportMetadataField(serializers.JSONField): class UploadForOwnerSerializer(UploadSerializer): import_status = serializers.ChoiceField( - choices=["draft", "pending"], default="pending" + choices=models.TRACK_FILE_IMPORT_STATUS_CHOICES, default="pending" ) import_metadata = ImportMetadataField(required=False) + filename = serializers.CharField(required=False) class Meta(UploadSerializer.Meta): fields = UploadSerializer.Meta.fields + [ @@ -464,7 +462,7 @@ class UploadForOwnerSerializer(UploadSerializer): "source", "audio_file", ] - write_only_fields = ["audio_file"] + extra_kwargs = {"audio_file": {"write_only": True}} read_only_fields = UploadSerializer.Meta.read_only_fields + [ "import_details", "metadata", @@ -498,6 +496,13 @@ class UploadForOwnerSerializer(UploadSerializer): if "channel" in validated_data: validated_data["library"] = validated_data.pop("channel").library + + if "import_status" in validated_data and validated_data[ + "import_status" + ] not in ["draft", "pending"]: + raise serializers.ValidationError( + "Newly created Uploads need to have import_status of draft or pending" + ) return super().validate(validated_data) def validate_upload_quota(self, f): @@ -548,14 +553,8 @@ class UploadActionSerializer(common_serializers.ActionSerializer): common_utils.on_commit(tasks.process_upload.delay, upload_id=pk) -class TagSerializer(serializers.ModelSerializer): - class Meta: - model = tag_models.Tag - fields = ("id", "name", "creation_date") - - class SimpleAlbumSerializer(serializers.ModelSerializer): - cover = cover_field + cover = CoverField(allow_null=True) class Meta: model = models.Album @@ -845,3 +844,10 @@ class FSImportSerializer(serializers.Serializer): return self.context["user"].actor.libraries.get(uuid=value) except models.Library.DoesNotExist: raise serializers.ValidationError("Invalid library") + + +class SearchResultSerializer(serializers.Serializer): + artists = ArtistWithAlbumsSerializer(many=True) + tracks = TrackSerializer(many=True) + albums = AlbumSerializer(many=True) + tags = tags_serializers.TagSerializer(many=True) diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 51e17103a60c135b42581d7a3bcfc8cdd2a6a9dd..42088d9e6e41499e8fafb1155f9a397a7205a812 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -284,7 +284,9 @@ def process_upload(upload, update_denormalization=True): upload.import_status = "skipped" upload.import_details = { "code": "already_imported_in_owned_libraries", - "duplicates": list(owned_duplicates), + # In order to avoid exponential growth of the database, we only + # reference the first known upload which gets duplicated + "duplicates": owned_duplicates[0], } upload.import_date = timezone.now() upload.save( @@ -436,6 +438,7 @@ def get_owned_duplicates(upload, track): ) .exclude(pk=upload.pk) .values_list("uuid", flat=True) + .order_by("creation_date") ) diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index d7eba93cfc8d98bd497d2e7c59b1b9b432c49b8a..ba7148ebb50df18c6f6bea77275a10979f66c689 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -16,6 +16,8 @@ from rest_framework import views, viewsets from rest_framework.decorators import action from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiParameter, extend_schema_view + import requests.exceptions from funkwhale_api.common import decorators as common_decorators @@ -31,7 +33,6 @@ from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import routes from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.tags.models import Tag, TaggedItem -from funkwhale_api.tags.serializers import TagSerializer from funkwhale_api.users.oauth import permissions as oauth_permissions from funkwhale_api.users.authentication import ScopedTokenAuthentication @@ -66,7 +67,10 @@ def get_libraries(filter_uploads): serializer = federation_api_serializers.LibrarySerializer(qs, many=True) return Response(serializer.data) - return libraries + return extend_schema( + responses=federation_api_serializers.LibrarySerializer(many=True), + parameters=[OpenApiParameter("id", location="query", exclude=True)], + )(action(methods=["get"], detail=True)(libraries)) def refetch_obj(obj, queryset): @@ -167,11 +171,9 @@ class ArtistViewSet( Prefetch("albums", queryset=albums), TAG_PREFETCH ) - libraries = action(methods=["get"], detail=True)( - get_libraries( - filter_uploads=lambda o, uploads: uploads.filter( - Q(track__artist=o) | Q(track__album__artist=o) - ) + libraries = get_libraries( + lambda o, uploads: uploads.filter( + Q(track__artist=o) | Q(track__album__artist=o) ) ) @@ -231,9 +233,7 @@ class AlbumViewSet( Prefetch("tracks", queryset=tracks), TAG_PREFETCH ) - libraries = action(methods=["get"], detail=True)( - get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track__album=o)) - ) + libraries = get_libraries(lambda o, uploads: uploads.filter(track__album=o)) def get_serializer_class(self): if self.action in ["create"]: @@ -302,7 +302,13 @@ class LibraryViewSet( follows = action - @action(methods=["get"], detail=True) + @extend_schema( + responses=federation_api_serializers.LibraryFollowSerializer(many=True) + ) + @action( + methods=["get"], + detail=True, + ) @transaction.non_atomic_requests def follows(self, request, *args, **kwargs): library = self.get_object() @@ -314,13 +320,15 @@ class LibraryViewSet( page = self.paginate_queryset(queryset) if page is not None: serializer = federation_api_serializers.LibraryFollowSerializer( - page, many=True + page, many=True, required=False ) return self.get_paginated_response(serializer.data) - serializer = self.get_serializer(queryset, many=True) + serializer = self.get_serializer(queryset, many=True, required=False) return Response(serializer.data) + # TODO quickfix, basically specifying the response would be None + @extend_schema(responses=None) @action( methods=["get", "post", "delete"], detail=False, @@ -430,9 +438,7 @@ class TrackViewSet( ) return queryset.prefetch_related(TAG_PREFETCH) - libraries = action(methods=["get"], detail=True)( - get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track=o)) - ) + libraries = get_libraries(lambda o, uploads: uploads.filter(track=o)) def get_serializer_context(self): context = super().get_serializer_context() @@ -632,6 +638,7 @@ class ListenMixin(mixins.RetrieveModelMixin, viewsets.GenericViewSet): anonymous_policy = "setting" lookup_field = "uuid" + @extend_schema(responses=bytes) def retrieve(self, request, *args, **kwargs): config = { "explicit_file": request.GET.get("upload"), @@ -672,8 +679,13 @@ def handle_stream(track, request, download, explicit_file, format, max_bitrate): ) +class AudioRenderer(renderers.JSONRenderer): + media_type = "audio/*" + + +@extend_schema_view(get=extend_schema(operation_id="get_track_file")) class ListenViewSet(ListenMixin): - pass + renderer_classes = [AudioRenderer] class MP3Renderer(renderers.JSONRenderer): @@ -684,6 +696,7 @@ class MP3Renderer(renderers.JSONRenderer): class StreamViewSet(ListenMixin): renderer_classes = [MP3Renderer] + @extend_schema(operation_id="get_track_stream", responses=bytes) def retrieve(self, request, *args, **kwargs): config = { "explicit_file": None, @@ -744,6 +757,10 @@ class UploadViewSet( qs = qs.playable_by(actor) return qs + @extend_schema( + responses=tasks.metadata.TrackMetadataSerializer(), + operation_id="get_upload_metadata", + ) @action(methods=["get"], detail=True, url_path="audio-file-metadata") def audio_file_metadata(self, request, *args, **kwargs): upload = self.get_object() @@ -802,6 +819,9 @@ class Search(views.APIView): required_scope = "libraries" anonymous_policy = "setting" + @extend_schema( + operation_id="get_search_results", responses=serializers.SearchResultSerializer + ) def get(self, request, *args, **kwargs): query = request.GET.get("query", request.GET.get("q", "")) or "" query = query.strip() @@ -809,17 +829,10 @@ class Search(views.APIView): return Response({"detail": "empty query"}, status=400) try: results = { - # 'tags': serializers.TagSerializer(self.get_tags(query), many=True).data, - "artists": serializers.ArtistWithAlbumsSerializer( - self.get_artists(query), many=True - ).data, - "tracks": serializers.TrackSerializer( - self.get_tracks(query), many=True - ).data, - "albums": serializers.AlbumSerializer( - self.get_albums(query), many=True - ).data, - "tags": TagSerializer(self.get_tags(query), many=True).data, + "artists": self.get_artists(query), + "tracks": self.get_tracks(query), + "albums": self.get_albums(query), + "tags": self.get_tags(query), } except django.db.utils.ProgrammingError as e: if "in tsquery:" in str(e): @@ -827,7 +840,7 @@ class Search(views.APIView): else: raise - return Response(results, status=200) + return Response(serializers.SearchResultSerializer(results).data, status=200) def get_tracks(self, query): query_obj = utils.get_fts_query( @@ -913,6 +926,7 @@ class OembedView(views.APIView): permission_classes = [oauth_permissions.ScopePermission] required_scope = "libraries" anonymous_policy = "setting" + serializer_class = serializers.OembedSerializer def get(self, request, *args, **kwargs): serializer = serializers.OembedSerializer(data=request.GET) diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index f0554e1a4a50057e20d329011fb2a96421774d8e..59a4544be73f52e306aee60070bac519796ee8ee 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -76,7 +76,7 @@ class PlaylistSerializer(serializers.ModelSerializer): # no annotation? return 0 - @extend_schema_field({"type": "array", "items": {"type": "uri"}}) + @extend_schema_field({"type": "array", "items": {"type": "string"}}) def get_album_covers(self, obj): try: plts = obj.plts_for_cover diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index e4b1d3037811f5e19ac24747adc4a9827c84f79c..e2ebd59ab3978adcfc877df72cf80b743d860086 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -1,9 +1,12 @@ from django.db import transaction from django.db.models import Count + from rest_framework import exceptions, mixins, viewsets from rest_framework.decorators import action from rest_framework.response import Response +from drf_spectacular.utils import extend_schema + from funkwhale_api.common import fields, permissions from funkwhale_api.music import utils as music_utils from funkwhale_api.users.oauth import permissions as oauth_permissions @@ -38,6 +41,7 @@ class PlaylistViewSet( filterset_class = filters.PlaylistFilter ordering_fields = ("id", "name", "creation_date", "modification_date") + @extend_schema(responses=serializers.PlaylistTrackSerializer(many=True)) @action(methods=["get"], detail=True) def tracks(self, request, *args, **kwargs): playlist = self.get_object() @@ -48,6 +52,9 @@ class PlaylistViewSet( data = {"count": len(plts), "results": serializer.data} return Response(data, status=200) + @extend_schema( + operation_id="add_to_playlist", request=serializers.PlaylistAddManySerializer + ) @action(methods=["post"], detail=True) @transaction.atomic def add(self, request, *args, **kwargs): @@ -72,6 +79,7 @@ class PlaylistViewSet( data = {"count": len(plts), "results": serializer.data} return Response(data, status=201) + @extend_schema(operation_id="clear_playlist") @action(methods=["delete"], detail=True) @transaction.atomic def clear(self, request, *args, **kwargs): @@ -93,6 +101,7 @@ class PlaylistViewSet( ), ) + @extend_schema(operation_id="remove_from_playlist") @action(methods=["post", "delete"], detail=True) @transaction.atomic def remove(self, request, *args, **kwargs): @@ -111,6 +120,7 @@ class PlaylistViewSet( return Response(status=204) + @extend_schema(operation_id="reorder_track_in_playlist") @action(methods=["post"], detail=True) @transaction.atomic def move(self, request, *args, **kwargs): diff --git a/api/funkwhale_api/radios/filters.py b/api/funkwhale_api/radios/filters.py index f3abe22e0db4d52cf5a680384134af3e89eff28a..12670131105da861f3500541617c87cf7284baba 100644 --- a/api/funkwhale_api/radios/filters.py +++ b/api/funkwhale_api/radios/filters.py @@ -2,7 +2,7 @@ import collections import persisting_theory from django.core.exceptions import ValidationError -from django.db.models import Q +from django.db.models import Q, functions from django.urls import reverse_lazy from funkwhale_api.music import models @@ -132,9 +132,13 @@ class ArtistFilter(RadioFilter): "name": "ids", "type": "list", "subtype": "number", - "autocomplete": reverse_lazy("api:v1:artists-list"), + "autocomplete": reverse_lazy("api:v1:search"), "autocomplete_qs": "q={query}", - "autocomplete_fields": {"name": "name", "value": "id"}, + "autocomplete_fields": { + "remoteValues": "artists", + "name": "name", + "value": "id", + }, "label": "Artist", "placeholder": "Select artists", } @@ -145,7 +149,8 @@ class ArtistFilter(RadioFilter): filter_config["ids"] = sorted(filter_config["ids"]) names = ( models.Artist.objects.filter(pk__in=filter_config["ids"]) - .order_by("id") + .annotate(__size=functions.Length("name")) + .order_by("__size", "id") .values_list("name", flat=True) ) filter_config["names"] = list(names) @@ -176,13 +181,13 @@ class TagFilter(RadioFilter): "name": "names", "type": "list", "subtype": "string", - "autocomplete": reverse_lazy("api:v1:tags-list"), + "autocomplete": reverse_lazy("api:v1:search"), "autocomplete_fields": { - "remoteValues": "results", + "remoteValues": "tags", "name": "name", "value": "name", }, - "autocomplete_qs": "q={query}&ordering=length", + "autocomplete_qs": "q={query}", "label": "Tags", "placeholder": "Select tags", } @@ -196,3 +201,28 @@ class TagFilter(RadioFilter): | Q(artist__tagged_items__tag__name__in=names) | Q(album__tagged_items__tag__name__in=names) ) + + def clean_config(self, filter_config): + filter_config = super().clean_config(filter_config) + filter_config["names"] = sorted(filter_config["names"]) + names = ( + models.tags_models.Tag.objects.filter(name__in=filter_config["names"]) + .annotate(__size=functions.Length("name")) + .order_by("__size", "pk") + .values_list("name", flat=True) + ) + filter_config["names"] = list(names) + return filter_config + + def validate(self, config): + super().validate(config) + try: + names = models.tags_models.Tag.objects.filter( + name__in=config["names"] + ).values_list("name", flat=True) + diff = set(config["names"]) - set(names) + assert len(diff) == 0 + except KeyError: + raise ValidationError("You must provide a name") + except AssertionError: + raise ValidationError('No tag matching names "{}"'.format(diff)) diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py index 126dac6721bd0214e2a4ba6d2833ada1cd291470..4b333b966d650921ffa506993e6cde3ca1053322 100644 --- a/api/funkwhale_api/radios/radios.py +++ b/api/funkwhale_api/radios/radios.py @@ -26,10 +26,11 @@ class SimpleRadio(object): return def pick(self, choices, previous_choices=[]): - return random.sample(set(choices).difference(previous_choices), 1)[0] + possible_choices = [x for x in choices if x not in previous_choices] + return random.sample(possible_choices, 1)[0] def pick_many(self, choices, quantity): - return random.sample(set(choices), quantity) + return random.sample(list(choices), quantity) def weighted_pick(self, choices, previous_choices=[]): total = sum(weight for c, weight in choices) diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py index b8c10f1f338bacc21350f3edfc7a935a971eb9d0..037bcb4adcf3711e0c58f8d0f574cca8802a21a8 100644 --- a/api/funkwhale_api/radios/views.py +++ b/api/funkwhale_api/radios/views.py @@ -1,8 +1,11 @@ from django.db.models import Q + from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response +from drf_spectacular.utils import extend_schema + from funkwhale_api.common import permissions as common_permissions from funkwhale_api.music.serializers import TrackSerializer from funkwhale_api.music import utils as music_utils @@ -44,7 +47,7 @@ class RadioViewSet( def perform_update(self, serializer): return serializer.save(user=self.request.user) - @action(methods=["get"], detail=True) + @action(methods=["get"], detail=True, serializer_class=TrackSerializer) def tracks(self, request, *args, **kwargs): radio = self.get_object() tracks = radio.get_candidates().for_nested_serialization() @@ -56,13 +59,16 @@ class RadioViewSet( serializer = TrackSerializer(page, many=True) return self.get_paginated_response(serializer.data) - @action(methods=["get"], detail=False) + @action( + methods=["get"], detail=False, serializer_class=serializers.FilterSerializer + ) def filters(self, request, *args, **kwargs): serializer = serializers.FilterSerializer( filters.registry.exposed_filters, many=True ) return Response(serializer.data) + @extend_schema(operation_id="validate_radio") @action(methods=["post"], detail=False) def validate(self, request, *args, **kwargs): try: @@ -124,6 +130,7 @@ class RadioSessionTrackViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet) queryset = models.RadioSessionTrack.objects.all() permission_classes = [] + @extend_schema(operation_id="get_next_radio_track") def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/api/funkwhale_api/schema.py b/api/funkwhale_api/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..2f6892f4fcf2493b151441f4c9de5e6976c2baf1 --- /dev/null +++ b/api/funkwhale_api/schema.py @@ -0,0 +1,73 @@ +from drf_spectacular.openapi import AutoSchema + +from pluralizer import Pluralizer + +import re + + +class CustomAutoSchema(AutoSchema): + method_mapping = { + "get": "get", + "post": "create", + "put": "update", + "patch": "partial_update", + "delete": "delete", + } + + pluralizer = Pluralizer() + + def get_operation_id(self): + # Modified operation id getter from + # https://github.com/tfranzel/drf-spectacular/blob/6973aa48f4ff08f7f33799d50c288fcc79ea8076/drf_spectacular/openapi.py#L424-L441 + + tokenized_path = self._tokenize_path() + + # replace dashes as they can be problematic later in code generation + tokenized_path = [t.replace("-", "_") for t in tokenized_path] + + # replace plural forms with singular forms + tokenized_path = [self.pluralizer.singular(t) for t in tokenized_path] + + if not tokenized_path: + tokenized_path.append("root") + + model = tokenized_path.pop() + model_singular = model + + if self.method == "GET" and self._is_list_view(): + action = "get" + model = self.pluralizer.plural(model) + else: + action = self.method_mapping[self.method.lower()] + + if re.search(r"<drf_format_suffix\w*:\w+>", self.path_regex): + tokenized_path.append("formatted") + + # rename `create_radio_radio` to `create_radio`. Works with all models + if len(tokenized_path) > 0 and model_singular == tokenized_path[0]: + tokenized_path.pop(0) + + # rename `get_radio_radio_track` to `get_radio_track`. Works with all models + if len(tokenized_path) > 1 and tokenized_path[0] == tokenized_path[1]: + tokenized_path.pop(0) + + # rename `get_manage_channel` to `admin_get_channel` + if len(tokenized_path) > 0 and tokenized_path[0] == "manage": + tokenized_path.pop(0) + + # rename `get_manage_library_album` to `admin_get_album` + if len(tokenized_path) > 0 and tokenized_path[0] == "library": + tokenized_path.pop(0) + + # rename `get_manage_user_users` to `admin_get_users` + elif len(tokenized_path) > 0 and tokenized_path[0] == "user": + tokenized_path.pop(0) + + # rename `get_manage_moderation_note` to `moderation_get_note` + elif len(tokenized_path) > 0 and tokenized_path[0] == "moderation": + tokenized_path.pop(0) + return "_".join(["moderation", action] + tokenized_path + [model]) + + return "_".join(["admin", action] + tokenized_path + [model]) + + return "_".join([action] + tokenized_path + [model]) diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index 8ea2fba5a6af71ba9aab2a63ba8e39d47c2d4ac8..dd5ca3111bcdd969803ffb3469bb399475767b9d 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -566,7 +566,7 @@ class SubsonicViewSet(viewsets.GenericViewSet): except (TypeError, KeyError, ValueError): size = 20 - size = min(size, 100) + size = min(size, 500) queryset = c["queryset"] if query: queryset = c["queryset"].filter( diff --git a/api/funkwhale_api/users/oauth/views.py b/api/funkwhale_api/users/oauth/views.py index c1202650127db629c0f9adb838405b6e1e8eba71..d4d640650bb82a9f4214853545ca55a86986137c 100644 --- a/api/funkwhale_api/users/oauth/views.py +++ b/api/funkwhale_api/users/oauth/views.py @@ -4,9 +4,12 @@ import urllib.parse from django import http from django.utils import timezone from django.db.models import Q + from rest_framework import mixins, permissions, response, views, viewsets from rest_framework.decorators import action +from drf_spectacular.utils import extend_schema + from oauth2_provider import exceptions as oauth2_exceptions from oauth2_provider import views as oauth_views from oauth2_provider.settings import oauth2_settings @@ -83,6 +86,7 @@ class ApplicationViewSet( qs = qs.filter(user=self.request.user) return qs + @extend_schema(operation_id="refresh_oauth_token") @action( detail=True, methods=["post"], diff --git a/api/funkwhale_api/users/rest_auth_urls.py b/api/funkwhale_api/users/rest_auth_urls.py index 540ea82851856c4ff9f4f294bab1883ba6120a68..8733116b96cb633fdc3d8daeb608f965d2c94fd3 100644 --- a/api/funkwhale_api/users/rest_auth_urls.py +++ b/api/funkwhale_api/users/rest_auth_urls.py @@ -1,6 +1,6 @@ from django.conf.urls import url from django.views.generic import TemplateView -from rest_auth import views as rest_auth_views +from dj_rest_auth import views as rest_auth_views from . import views diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 23c2da462c5b8ef9ed8206f86ee0d3b569fbb7d0..3d33ed55624e3a60dfa1b305bf44a41ca0f7b30e 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -3,12 +3,13 @@ import re from django.core import validators from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.forms import PasswordResetForm from django.contrib import auth from allauth.account import models as allauth_models -from rest_auth.serializers import PasswordResetSerializer as PRS -from rest_auth.registration.serializers import RegisterSerializer as RS, get_adapter +from dj_rest_auth.serializers import PasswordResetSerializer as PRS +from dj_rest_auth.registration.serializers import RegisterSerializer as RS, get_adapter from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers @@ -130,7 +131,9 @@ class UserActivitySerializer(activity_serializers.ModelSerializer): class UserBasicSerializer(serializers.ModelSerializer): - avatar = common_serializers.AttachmentSerializer(source="get_avatar") + avatar = common_serializers.AttachmentSerializer( + source="get_avatar", allow_null=True + ) class Meta: model = models.User @@ -246,6 +249,8 @@ class MeSerializer(UserReadSerializer): class PasswordResetSerializer(PRS): + password_reset_form_class = PasswordResetForm + def get_email_options(self): return {"extra_email_context": adapters.get_email_context()} diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index b9891e594e9c38a326414e33c30535f047c64647..4f8c64165e664f9d469f8a44f07c9a1e22269a05 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -5,13 +5,15 @@ from django.contrib import auth from django.middleware import csrf from allauth.account.adapter import get_adapter -from rest_auth import views as rest_auth_views -from rest_auth.registration import views as registration_views +from dj_rest_auth import views as rest_auth_views +from dj_rest_auth.registration import views as registration_views from rest_framework import mixins from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, extend_schema_view + from funkwhale_api.common import authentication from funkwhale_api.common import preferences from funkwhale_api.common import throttling @@ -19,6 +21,7 @@ from funkwhale_api.common import throttling from . import models, serializers, tasks +@extend_schema_view(post=extend_schema(operation_id="register", methods=["post"])) class RegisterView(registration_views.RegisterView): serializer_class = serializers.RegisterSerializer permission_classes = [] @@ -43,18 +46,22 @@ class RegisterView(registration_views.RegisterView): return user +@extend_schema_view(post=extend_schema(operation_id="verify_email")) class VerifyEmailView(registration_views.VerifyEmailView): action = "verify-email" +@extend_schema_view(post=extend_schema(operation_id="change_password")) class PasswordChangeView(rest_auth_views.PasswordChangeView): action = "password-change" +@extend_schema_view(post=extend_schema(operation_id="reset_password")) class PasswordResetView(rest_auth_views.PasswordResetView): action = "password-reset" +@extend_schema_view(post=extend_schema(operation_id="confirm_password_reset")) class PasswordResetConfirmView(rest_auth_views.PasswordResetConfirmView): action = "password-reset-confirm" @@ -66,6 +73,8 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): lookup_value_regex = r"[a-zA-Z0-9-_.]+" required_scope = "profile" + @extend_schema(operation_id="get_authenticated_user", methods=["get"]) + @extend_schema(operation_id="delete_authenticated_user", methods=["delete"]) @action(methods=["get", "delete"], detail=False) def me(self, request, *args, **kwargs): """Return information about the current user or delete it""" @@ -80,6 +89,7 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): serializer = serializers.MeSerializer(request.user) return Response(serializer.data) + @extend_schema(operation_id="update_settings") @action(methods=["post"], detail=False, url_name="settings", url_path="settings") def set_settings(self, request, *args, **kwargs): """Return information about the current user or delete it""" @@ -111,6 +121,7 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): data = {"subsonic_api_token": self.request.user.subsonic_api_token} return Response(data) + @extend_schema(operation_id="change_email", responses={200: None, 403: None}) @action( methods=["post"], required_scope="security", @@ -138,6 +149,8 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): return super().partial_update(request, *args, **kwargs) +@extend_schema(operation_id="login") +@action(methods=["post"], detail=False) def login(request): throttling.check_request(request, "login") if request.method != "POST": @@ -157,6 +170,8 @@ def login(request): return response +@extend_schema(operation_id="logout") +@action(methods=["post"], detail=False) def logout(request): if request.method != "POST": return http.HttpResponse(status=405) diff --git a/api/poetry.lock b/api/poetry.lock index a62b39487f676585fefbd490eedf8336d3425e3b..faa368f46de5d3140f50678fe8fe10c899f5b120 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,6 +1,6 @@ [[package]] name = "aiohttp" -version = "3.8.1" +version = "3.8.3" description = "Async http client/server framework (asyncio)" category = "main" optional = false @@ -177,7 +177,7 @@ ui = ["PyGObject (>=3.40.0)"] xbr = ["base58 (>=2.1.0)", "cbor2 (>=5.2.0)", "click (>=8.1.2)", "ecdsa (>=0.16.1)", "eth-abi (>=2.1.1)", "hkdf (>=0.0.3)", "jinja2 (>=2.11.3)", "mnemonic (>=0.19)", "py-ecc (>=5.1.0)", "py-eth-sig-utils (>=0.4.0)", "py-multihash (>=2.0.1)", "rlp (>=2.0.1)", "spake2 (>=0.8)", "twisted (>=20.3.0)", "web3 (>=5.29.0)", "xbr (>=21.2.1)", "yapf (==0.29.0)", "zlmdb (>=21.2.1)"] [[package]] -name = "automat" +name = "Automat" version = "20.2.0" description = "Self-service finite-state machines for the programmer on the go." category = "main" @@ -209,7 +209,7 @@ python-versions = "*" [[package]] name = "black" -version = "22.6.0" +version = "22.8.0" description = "The uncompromising code formatter." category = "dev" optional = false @@ -248,14 +248,14 @@ dev = ["Sphinx (==4.3.2)", "black (==22.3.0)", "build (==0.8.0)", "flake8 (==4.0 [[package]] name = "boto3" -version = "1.24.66" +version = "1.24.73" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.7" [package.dependencies] -botocore = ">=1.27.66,<1.28.0" +botocore = ">=1.27.73,<1.28.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.6.0,<0.7.0" @@ -264,7 +264,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.27.66" +version = "1.27.89" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -348,7 +348,7 @@ zstd = ["zstandard"] [[package]] name = "certifi" -version = "2022.6.15" +version = "2022.9.24" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -401,7 +401,7 @@ tests = ["async-generator", "async-timeout", "cryptography (>=1.3.0)", "pytest", [[package]] name = "charset-normalizer" -version = "2.1.0" +version = "2.1.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -550,7 +550,7 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] -name = "deprecated" +name = "Deprecated" version = "1.2.13" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." category = "main" @@ -564,7 +564,23 @@ wrapt = ">=1.10,<2" dev = ["PyTest", "PyTest (<5)", "PyTest-Cov", "PyTest-Cov (<2.6)", "bump2version (<1)", "configparser (<5)", "importlib-metadata (<3)", "importlib-resources (<4)", "sphinx (<2)", "sphinxcontrib-websupport (<2)", "tox", "zipp (<2)"] [[package]] -name = "django" +name = "dj-rest-auth" +version = "2.2.5" +description = "Authentication and Registration in Django Rest Framework" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +Django = ">=2.0" +django-allauth = {version = ">=0.40.0,<0.51.0", optional = true, markers = "extra == \"with_social\""} +djangorestframework = ">=3.7.0" + +[package.extras] +with_social = ["django-allauth (>=0.40.0,<0.51.0)"] + +[[package]] +name = "Django" version = "3.2.15" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." category = "main" @@ -664,27 +680,27 @@ six = ">=1.4.0" [[package]] name = "django-debug-toolbar" -version = "3.5.0" +version = "3.6.0" description = "A configurable set of panels that display various debug information about the current request/response." category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -Django = ">=3.2" +Django = ">=3.2.4" sqlparse = ">=0.2.0" [[package]] name = "django-dynamic-preferences" -version = "1.13.0" +version = "1.14.0" description = "Dynamic global and instance settings for your django project" category = "main" optional = false python-versions = "*" [package.dependencies] -django = ">=2.2" -persisting-theory = "1.0" +django = ">=3.2" +persisting_theory = "1.0" six = "*" [[package]] @@ -740,22 +756,6 @@ redis = ">=3,<4.0.0 || >4.0.0,<4.0.1 || >4.0.1" [package.extras] hiredis = ["redis[hiredis] (>=3,!=4.0.0,!=4.0.1)"] -[[package]] -name = "django-rest-auth" -version = "0.9.5" -description = "Create a set of REST API endpoints for Authentication and Registration" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -Django = ">=1.8.0" -djangorestframework = ">=3.1.3" -six = ">=1.9.0" - -[package.extras] -with_social = ["django-allauth (>=0.25.0)"] - [[package]] name = "django-storages" version = "1.13.1" @@ -836,8 +836,8 @@ dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoen doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] -name = "faker" -version = "14.0.0" +name = "Faker" +version = "14.2.0" description = "Faker is a Python package that generates fake data for you." category = "dev" optional = false @@ -845,7 +845,7 @@ python-versions = ">=3.6" [package.dependencies] python-dateutil = ">=2.4" -typing-extensions = {version = ">=3.10.0.2", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} [[package]] name = "feedparser" @@ -915,11 +915,11 @@ tornado = ["tornado (>=0.2)"] [[package]] name = "h11" -version = "0.13.0" +version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] typing-extensions = {version = "*", markers = "python_version < \"3.8\""} @@ -934,7 +934,7 @@ python-versions = ">=3.6" [[package]] name = "httptools" -version = "0.4.0" +version = "0.5.0" description = "A collection of framework independent HTTP protocol utils." category = "main" optional = false @@ -956,7 +956,7 @@ idna = ">=2.5" [[package]] name = "idna" -version = "3.3" +version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false @@ -964,7 +964,7 @@ python-versions = ">=3.5" [[package]] name = "importlib-metadata" -version = "4.12.0" +version = "4.13.0" description = "Read metadata from Python packages" category = "main" optional = false @@ -975,13 +975,13 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "importlib-resources" -version = "5.9.0" +version = "5.10.0" description = "Read resources from Python packages" category = "main" optional = false @@ -991,8 +991,8 @@ python-versions = ">=3.7" zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} [package.extras] -docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] -testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [[package]] name = "incremental" @@ -1093,7 +1093,7 @@ python-versions = ">=3.7" [[package]] name = "jsonschema" -version = "4.9.1" +version = "4.16.0" description = "An implementation of JSON Schema validation for Python" category = "main" optional = false @@ -1113,7 +1113,7 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- [[package]] name = "jwcrypto" -version = "1.3.1" +version = "1.4.2" description = "Implementation of JOSE Web standards" category = "main" optional = false @@ -1168,7 +1168,7 @@ htmlsoup = ["BeautifulSoup4"] source = ["Cython (>=0.29.7)"] [[package]] -name = "markdown" +name = "Markdown" version = "3.4.1" description = "Python implementation of Markdown." category = "main" @@ -1183,7 +1183,7 @@ testing = ["coverage", "pyyaml"] [[package]] name = "matplotlib-inline" -version = "0.1.3" +version = "0.1.6" description = "Inline Matplotlib backend for Jupyter" category = "main" optional = false @@ -1242,7 +1242,7 @@ python-versions = "*" [[package]] name = "oauthlib" -version = "3.2.0" +version = "3.2.1" description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" category = "main" optional = false @@ -1278,11 +1278,11 @@ testing = ["docopt", "pytest (<6.0.0)"] [[package]] name = "pathspec" -version = "0.9.0" +version = "0.10.1" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.7" [[package]] name = "persisting-theory" @@ -1312,7 +1312,7 @@ optional = false python-versions = "*" [[package]] -name = "pillow" +name = "Pillow" version = "9.2.0" description = "Python Imaging Library (Fork)" category = "main" @@ -1324,7 +1324,7 @@ docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] [[package]] -name = "pkgutil-resolve-name" +name = "pkgutil_resolve_name" version = "1.3.10" description = "Resolve a name to an object." category = "main" @@ -1358,6 +1358,14 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pluralizer" +version = "1.2.0" +description = "Singularize or pluralize a given word useing a pre-defined list of rules" +category = "main" +optional = false +python-versions = ">=3.6" + [[package]] name = "prompt-toolkit" version = "3.0.31" @@ -1445,15 +1453,18 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] -name = "pygments" -version = "2.12.0" +name = "Pygments" +version = "2.13.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false python-versions = ">=3.6" +[package.extras] +plugins = ["importlib-metadata"] + [[package]] -name = "pyld" +name = "PyLD" version = "2.0.3" description = "Python implementation of the JSON-LD API" category = "main" @@ -1472,7 +1483,7 @@ frozendict = ["frozendict"] requests = ["requests"] [[package]] -name = "pyopenssl" +name = "pyOpenSSL" version = "21.0.0" description = "Python wrapper module around the OpenSSL library" category = "main" @@ -1635,11 +1646,11 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "0.20.0" +version = "0.21.0" description = "Read key-value pairs from a .env file and set them as environment variables" category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" [package.extras] cli = ["click (>=5.0)"] @@ -1688,7 +1699,7 @@ optional = false python-versions = "*" [[package]] -name = "pyyaml" +name = "PyYAML" version = "6.0" description = "YAML parser and emitter for Python" category = "main" @@ -1791,7 +1802,7 @@ crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] [[package]] name = "sentry-sdk" -version = "1.9.5" +version = "1.9.8" description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false @@ -1799,10 +1810,7 @@ python-versions = "*" [package.dependencies] certifi = "*" -urllib3 = [ - {version = ">=1.26.9", markers = "python_version >= \"3.5\""}, - {version = ">=1.26.11", markers = "python_version >= \"3.6\""}, -] +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} [package.extras] aiohttp = ["aiohttp (>=3.5)"] @@ -1847,14 +1855,14 @@ tests = ["coverage[toml] (>=5.0.2)", "pytest"] [[package]] name = "setuptools" -version = "65.0.2" +version = "65.4.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] @@ -1876,15 +1884,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "sniffio" -version = "1.2.0" +version = "1.3.0" description = "Sniff out which async library your code is running under" category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" [[package]] name = "sqlparse" -version = "0.4.2" +version = "0.4.3" description = "A non-validating SQL parser." category = "main" optional = false @@ -1892,11 +1900,14 @@ python-versions = ">=3.5" [[package]] name = "termcolor" -version = "1.1.0" -description = "ANSII Color formatting for output in terminal." +version = "2.0.1" +description = "ANSI color formatting for output in terminal" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest", "pytest-cov"] [[package]] name = "toml" @@ -1916,7 +1927,7 @@ python-versions = ">=3.7" [[package]] name = "traitlets" -version = "5.3.0" +version = "5.4.0" description = "" category = "main" optional = false @@ -1926,7 +1937,7 @@ python-versions = ">=3.7" test = ["pre-commit", "pytest"] [[package]] -name = "twisted" +name = "Twisted" version = "22.4.0" description = "An asynchronous networking framework written in Python" category = "main" @@ -1993,7 +2004,7 @@ python-versions = ">=3.6" [[package]] name = "typing-extensions" -version = "4.3.0" +version = "4.4.0" description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false @@ -2012,8 +2023,8 @@ six = "*" unidecode = "*" [[package]] -name = "unidecode" -version = "1.3.4" +name = "Unidecode" +version = "1.3.6" description = "ASCII transliterations of Unicode text" category = "main" optional = false @@ -2029,7 +2040,7 @@ python-versions = ">=3.6" [[package]] name = "urllib3" -version = "1.26.11" +version = "1.26.12" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false @@ -2037,12 +2048,12 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "uvicorn" -version = "0.18.2" +version = "0.18.3" description = "The lightning-fast ASGI server." category = "main" optional = false @@ -2054,27 +2065,27 @@ colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win h11 = ">=0.8" httptools = {version = ">=0.4.0", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} -PyYAML = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} typing-extensions = {version = "*", markers = "python_version < \"3.8\""} uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.0", optional = true, markers = "extra == \"standard\""} [package.extras] -standard = ["PyYAML (>=5.1)", "colorama (>=0.4)", "httptools (>=0.4.0)", "python-dotenv (>=0.13)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.0)"] +standard = ["colorama (>=0.4)", "httptools (>=0.4.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.0)"] [[package]] name = "uvloop" -version = "0.16.0" +version = "0.17.0" description = "Fast implementation of asyncio event loop on top of libuv" category = "main" optional = false python-versions = ">=3.7" [package.extras] -dev = ["Cython (>=0.29.24,<0.30.0)", "Sphinx (>=4.1.2,<4.2.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=19.0.0,<19.1.0)", "pycodestyle (>=2.7.0,<2.8.0)", "pytest (>=3.6.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +dev = ["Cython (>=0.29.32,<0.30.0)", "Sphinx (>=4.1.2,<4.2.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=22.0.0,<22.1.0)", "pycodestyle (>=2.7.0,<2.8.0)", "pytest (>=3.6.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] -test = ["aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=19.0.0,<19.1.0)", "pycodestyle (>=2.7.0,<2.8.0)"] +test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=22.0.0,<22.1.0)", "pycodestyle (>=2.7.0,<2.8.0)"] [[package]] name = "vine" @@ -2097,7 +2108,7 @@ watchmedo = ["PyYAML (>=3.10)"] [[package]] name = "watchfiles" -version = "0.16.1" +version = "0.17.0" description = "Simple, modern and high performance file watching and code reload in python." category = "main" optional = false @@ -2153,19 +2164,19 @@ typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [[package]] name = "zipp" -version = "3.8.1" +version = "3.9.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] -testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [[package]] name = "zope.interface" -version = "5.4.0" +version = "5.5.0" description = "Interfaces for Python" category = "main" optional = false @@ -2182,82 +2193,97 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "ccc104a19b5aee0e2d6df9fdcd5bd457d704536bdc38e09631fd46d4b689b11b" +content-hash = "5693594f712a0498e5b87f697a5802fe14cd7c865118df4abce46b35653723f3" [metadata.files] aiohttp = [ - {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"}, - {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"}, - {file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"}, - {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"}, - {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"}, - {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"}, - {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"}, - {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516"}, - {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"}, - {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"}, - {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"}, - {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"}, - {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"}, - {file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"}, - {file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"}, - {file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"}, - {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"}, - {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"}, - {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"}, - {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"}, - {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440"}, - {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"}, - {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"}, - {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"}, - {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"}, - {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"}, - {file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"}, - {file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"}, - {file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"}, - {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"}, - {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"}, - {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"}, - {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"}, - {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4"}, - {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"}, - {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"}, - {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"}, - {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"}, - {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"}, - {file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"}, - {file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"}, - {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"}, - {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"}, - {file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"}, - {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"}, - {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"}, - {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"}, - {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"}, - {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf"}, - {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"}, - {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"}, - {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"}, - {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"}, - {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"}, - {file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"}, - {file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"}, - {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"}, - {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"}, - {file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"}, - {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"}, - {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"}, - {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"}, - {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"}, - {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd"}, - {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"}, - {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"}, - {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"}, - {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"}, - {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"}, - {file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"}, - {file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"}, - {file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"}, + {file = "aiohttp-3.8.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ba71c9b4dcbb16212f334126cc3d8beb6af377f6703d9dc2d9fb3874fd667ee9"}, + {file = "aiohttp-3.8.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d24b8bb40d5c61ef2d9b6a8f4528c2f17f1c5d2d31fed62ec860f6006142e83e"}, + {file = "aiohttp-3.8.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f88df3a83cf9df566f171adba39d5bd52814ac0b94778d2448652fc77f9eb491"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97decbb3372d4b69e4d4c8117f44632551c692bb1361b356a02b97b69e18a62"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:309aa21c1d54b8ef0723181d430347d7452daaff93e8e2363db8e75c72c2fb2d"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad5383a67514e8e76906a06741febd9126fc7c7ff0f599d6fcce3e82b80d026f"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20acae4f268317bb975671e375493dbdbc67cddb5f6c71eebdb85b34444ac46b"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05a3c31c6d7cd08c149e50dc7aa2568317f5844acd745621983380597f027a18"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6f76310355e9fae637c3162936e9504b4767d5c52ca268331e2756e54fd4ca5"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:256deb4b29fe5e47893fa32e1de2d73c3afe7407738bd3c63829874661d4822d"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5c59fcd80b9049b49acd29bd3598cada4afc8d8d69bd4160cd613246912535d7"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:059a91e88f2c00fe40aed9031b3606c3f311414f86a90d696dd982e7aec48142"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2feebbb6074cdbd1ac276dbd737b40e890a1361b3cc30b74ac2f5e24aab41f7b"}, + {file = "aiohttp-3.8.3-cp310-cp310-win32.whl", hash = "sha256:5bf651afd22d5f0c4be16cf39d0482ea494f5c88f03e75e5fef3a85177fecdeb"}, + {file = "aiohttp-3.8.3-cp310-cp310-win_amd64.whl", hash = "sha256:653acc3880459f82a65e27bd6526e47ddf19e643457d36a2250b85b41a564715"}, + {file = "aiohttp-3.8.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:86fc24e58ecb32aee09f864cb11bb91bc4c1086615001647dbfc4dc8c32f4008"}, + {file = "aiohttp-3.8.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75e14eac916f024305db517e00a9252714fce0abcb10ad327fb6dcdc0d060f1d"}, + {file = "aiohttp-3.8.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1fde0f44029e02d02d3993ad55ce93ead9bb9b15c6b7ccd580f90bd7e3de476"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ab94426ddb1ecc6a0b601d832d5d9d421820989b8caa929114811369673235c"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89d2e02167fa95172c017732ed7725bc8523c598757f08d13c5acca308e1a061"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:02f9a2c72fc95d59b881cf38a4b2be9381b9527f9d328771e90f72ac76f31ad8"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c7149272fb5834fc186328e2c1fa01dda3e1fa940ce18fded6d412e8f2cf76d"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:512bd5ab136b8dc0ffe3fdf2dfb0c4b4f49c8577f6cae55dca862cd37a4564e2"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7018ecc5fe97027214556afbc7c502fbd718d0740e87eb1217b17efd05b3d276"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:88c70ed9da9963d5496d38320160e8eb7e5f1886f9290475a881db12f351ab5d"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:da22885266bbfb3f78218dc40205fed2671909fbd0720aedba39b4515c038091"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:e65bc19919c910127c06759a63747ebe14f386cda573d95bcc62b427ca1afc73"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:08c78317e950e0762c2983f4dd58dc5e6c9ff75c8a0efeae299d363d439c8e34"}, + {file = "aiohttp-3.8.3-cp311-cp311-win32.whl", hash = "sha256:45d88b016c849d74ebc6f2b6e8bc17cabf26e7e40c0661ddd8fae4c00f015697"}, + {file = "aiohttp-3.8.3-cp311-cp311-win_amd64.whl", hash = "sha256:96372fc29471646b9b106ee918c8eeb4cca423fcbf9a34daa1b93767a88a2290"}, + {file = "aiohttp-3.8.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c971bf3786b5fad82ce5ad570dc6ee420f5b12527157929e830f51c55dc8af77"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff25f48fc8e623d95eca0670b8cc1469a83783c924a602e0fbd47363bb54aaca"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e381581b37db1db7597b62a2e6b8b57c3deec95d93b6d6407c5b61ddc98aca6d"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db19d60d846283ee275d0416e2a23493f4e6b6028825b51290ac05afc87a6f97"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25892c92bee6d9449ffac82c2fe257f3a6f297792cdb18ad784737d61e7a9a85"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398701865e7a9565d49189f6c90868efaca21be65c725fc87fc305906be915da"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:4a4fbc769ea9b6bd97f4ad0b430a6807f92f0e5eb020f1e42ece59f3ecfc4585"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:b29bfd650ed8e148f9c515474a6ef0ba1090b7a8faeee26b74a8ff3b33617502"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:1e56b9cafcd6531bab5d9b2e890bb4937f4165109fe98e2b98ef0dcfcb06ee9d"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ec40170327d4a404b0d91855d41bfe1fe4b699222b2b93e3d833a27330a87a6d"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2df5f139233060578d8c2c975128fb231a89ca0a462b35d4b5fcf7c501ebdbe1"}, + {file = "aiohttp-3.8.3-cp36-cp36m-win32.whl", hash = "sha256:f973157ffeab5459eefe7b97a804987876dd0a55570b8fa56b4e1954bf11329b"}, + {file = "aiohttp-3.8.3-cp36-cp36m-win_amd64.whl", hash = "sha256:437399385f2abcd634865705bdc180c8314124b98299d54fe1d4c8990f2f9494"}, + {file = "aiohttp-3.8.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:09e28f572b21642128ef31f4e8372adb6888846f32fecb288c8b0457597ba61a"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f3553510abdbec67c043ca85727396ceed1272eef029b050677046d3387be8d"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e168a7560b7c61342ae0412997b069753f27ac4862ec7867eff74f0fe4ea2ad9"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db4c979b0b3e0fa7e9e69ecd11b2b3174c6963cebadeecfb7ad24532ffcdd11a"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e164e0a98e92d06da343d17d4e9c4da4654f4a4588a20d6c73548a29f176abe2"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8a78079d9a39ca9ca99a8b0ac2fdc0c4d25fc80c8a8a82e5c8211509c523363"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:21b30885a63c3f4ff5b77a5d6caf008b037cb521a5f33eab445dc566f6d092cc"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4b0f30372cef3fdc262f33d06e7b411cd59058ce9174ef159ad938c4a34a89da"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:8135fa153a20d82ffb64f70a1b5c2738684afa197839b34cc3e3c72fa88d302c"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ad61a9639792fd790523ba072c0555cd6be5a0baf03a49a5dd8cfcf20d56df48"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:978b046ca728073070e9abc074b6299ebf3501e8dee5e26efacb13cec2b2dea0"}, + {file = "aiohttp-3.8.3-cp37-cp37m-win32.whl", hash = "sha256:0d2c6d8c6872df4a6ec37d2ede71eff62395b9e337b4e18efd2177de883a5033"}, + {file = "aiohttp-3.8.3-cp37-cp37m-win_amd64.whl", hash = "sha256:21d69797eb951f155026651f7e9362877334508d39c2fc37bd04ff55b2007091"}, + {file = "aiohttp-3.8.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ca9af5f8f5812d475c5259393f52d712f6d5f0d7fdad9acdb1107dd9e3cb7eb"}, + {file = "aiohttp-3.8.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d90043c1882067f1bd26196d5d2db9aa6d268def3293ed5fb317e13c9413ea4"}, + {file = "aiohttp-3.8.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d737fc67b9a970f3234754974531dc9afeea11c70791dcb7db53b0cf81b79784"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebf909ea0a3fc9596e40d55d8000702a85e27fd578ff41a5500f68f20fd32e6c"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5835f258ca9f7c455493a57ee707b76d2d9634d84d5d7f62e77be984ea80b849"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da37dcfbf4b7f45d80ee386a5f81122501ec75672f475da34784196690762f4b"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87f44875f2804bc0511a69ce44a9595d5944837a62caecc8490bbdb0e18b1342"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:527b3b87b24844ea7865284aabfab08eb0faf599b385b03c2aa91fc6edd6e4b6"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d5ba88df9aa5e2f806650fcbeedbe4f6e8736e92fc0e73b0400538fd25a4dd96"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e7b8813be97cab8cb52b1375f41f8e6804f6507fe4660152e8ca5c48f0436017"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:2dea10edfa1a54098703cb7acaa665c07b4e7568472a47f4e64e6319d3821ccf"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:713d22cd9643ba9025d33c4af43943c7a1eb8547729228de18d3e02e278472b6"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2d252771fc85e0cf8da0b823157962d70639e63cb9b578b1dec9868dd1f4f937"}, + {file = "aiohttp-3.8.3-cp38-cp38-win32.whl", hash = "sha256:66bd5f950344fb2b3dbdd421aaa4e84f4411a1a13fca3aeb2bcbe667f80c9f76"}, + {file = "aiohttp-3.8.3-cp38-cp38-win_amd64.whl", hash = "sha256:84b14f36e85295fe69c6b9789b51a0903b774046d5f7df538176516c3e422446"}, + {file = "aiohttp-3.8.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16c121ba0b1ec2b44b73e3a8a171c4f999b33929cd2397124a8c7fcfc8cd9e06"}, + {file = "aiohttp-3.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8d6aaa4e7155afaf994d7924eb290abbe81a6905b303d8cb61310a2aba1c68ba"}, + {file = "aiohttp-3.8.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43046a319664a04b146f81b40e1545d4c8ac7b7dd04c47e40bf09f65f2437346"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599418aaaf88a6d02a8c515e656f6faf3d10618d3dd95866eb4436520096c84b"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a2964319d359f494f16011e23434f6f8ef0434acd3cf154a6b7bec511e2fb7"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73a4131962e6d91109bca6536416aa067cf6c4efb871975df734f8d2fd821b37"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:598adde339d2cf7d67beaccda3f2ce7c57b3b412702f29c946708f69cf8222aa"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75880ed07be39beff1881d81e4a907cafb802f306efd6d2d15f2b3c69935f6fb"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0239da9fbafd9ff82fd67c16704a7d1bccf0d107a300e790587ad05547681c8"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4e3a23ec214e95c9fe85a58470b660efe6534b83e6cbe38b3ed52b053d7cb6ad"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:47841407cc89a4b80b0c52276f3cc8138bbbfba4b179ee3acbd7d77ae33f7ac4"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:54d107c89a3ebcd13228278d68f1436d3f33f2dd2af5415e3feaeb1156e1a62c"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c37c5cce780349d4d51739ae682dec63573847a2a8dcb44381b174c3d9c8d403"}, + {file = "aiohttp-3.8.3-cp39-cp39-win32.whl", hash = "sha256:f178d2aadf0166be4df834c4953da2d7eef24719e8aec9a65289483eeea9d618"}, + {file = "aiohttp-3.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:88e5be56c231981428f4f506c68b6a46fa25c4123a2e86d156c58a8369d31ab7"}, + {file = "aiohttp-3.8.3.tar.gz", hash = "sha256:3828fb41b7203176b82fe5d699e0d845435f2374750a44b480ea6b930f6be269"}, ] aioredis = [ {file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"}, @@ -2306,7 +2332,7 @@ attrs = [ autobahn = [ {file = "autobahn-22.7.1.tar.gz", hash = "sha256:8b462ea2e6aad6b4dc0ed45fb800b6cbfeb0325e7fe6983907f122f2be4a1fe9"}, ] -automat = [ +Automat = [ {file = "Automat-20.2.0-py2.py3-none-any.whl", hash = "sha256:b6feb6455337df834f6c9962d6ccf771515b7d939bca142b29c20c2376bc6111"}, {file = "Automat-20.2.0.tar.gz", hash = "sha256:7979803c74610e11ef0c0d68a2942b152df52da55336e0c9d58daf1831cbdf33"}, ] @@ -2319,41 +2345,41 @@ billiard = [ {file = "billiard-3.6.4.0.tar.gz", hash = "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547"}, ] black = [ - {file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"}, - {file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"}, - {file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"}, - {file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"}, - {file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"}, - {file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"}, - {file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"}, - {file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"}, - {file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"}, - {file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"}, - {file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"}, - {file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"}, - {file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"}, - {file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"}, - {file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"}, - {file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"}, - {file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"}, - {file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"}, - {file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"}, - {file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"}, - {file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"}, - {file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"}, - {file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"}, + {file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"}, + {file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"}, + {file = "black-22.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8166b7bfe5dcb56d325385bd1d1e0f635f24aae14b3ae437102dedc0c186747"}, + {file = "black-22.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd82842bb272297503cbec1a2600b6bfb338dae017186f8f215c8958f8acf869"}, + {file = "black-22.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d839150f61d09e7217f52917259831fe2b689f5c8e5e32611736351b89bb2a90"}, + {file = "black-22.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a05da0430bd5ced89176db098567973be52ce175a55677436a271102d7eaa3fe"}, + {file = "black-22.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a098a69a02596e1f2a58a2a1c8d5a05d5a74461af552b371e82f9fa4ada8342"}, + {file = "black-22.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5594efbdc35426e35a7defa1ea1a1cb97c7dbd34c0e49af7fb593a36bd45edab"}, + {file = "black-22.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a983526af1bea1e4cf6768e649990f28ee4f4137266921c2c3cee8116ae42ec3"}, + {file = "black-22.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b2c25f8dea5e8444bdc6788a2f543e1fb01494e144480bc17f806178378005e"}, + {file = "black-22.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:78dd85caaab7c3153054756b9fe8c611efa63d9e7aecfa33e533060cb14b6d16"}, + {file = "black-22.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cea1b2542d4e2c02c332e83150e41e3ca80dc0fb8de20df3c5e98e242156222c"}, + {file = "black-22.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b879eb439094751185d1cfdca43023bc6786bd3c60372462b6f051efa6281a5"}, + {file = "black-22.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a12e4e1353819af41df998b02c6742643cfef58282915f781d0e4dd7a200411"}, + {file = "black-22.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3a73f66b6d5ba7288cd5d6dad9b4c9b43f4e8a4b789a94bf5abfb878c663eb3"}, + {file = "black-22.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:e981e20ec152dfb3e77418fb616077937378b322d7b26aa1ff87717fb18b4875"}, + {file = "black-22.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8ce13ffed7e66dda0da3e0b2eb1bdfc83f5812f66e09aca2b0978593ed636b6c"}, + {file = "black-22.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:32a4b17f644fc288c6ee2bafdf5e3b045f4eff84693ac069d87b1a347d861497"}, + {file = "black-22.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad827325a3a634bae88ae7747db1a395d5ee02cf05d9aa7a9bd77dfb10e940c"}, + {file = "black-22.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53198e28a1fb865e9fe97f88220da2e44df6da82b18833b588b1883b16bb5d41"}, + {file = "black-22.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc4d4123830a2d190e9cc42a2e43570f82ace35c3aeb26a512a2102bce5af7ec"}, + {file = "black-22.8.0-py3-none-any.whl", hash = "sha256:d2c21d439b2baf7aa80d6dd4e3659259be64c6f49dfd0f32091063db0e006db4"}, + {file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"}, ] bleach = [ {file = "bleach-5.0.1-py3-none-any.whl", hash = "sha256:085f7f33c15bd408dd9b17a4ad77c577db66d76203e5984b1bd59baeee948b2a"}, {file = "bleach-5.0.1.tar.gz", hash = "sha256:0d03255c47eb9bd2f26aa9bb7f2107732e7e8fe195ca2f64709fcf3b0a4a085c"}, ] boto3 = [ - {file = "boto3-1.24.66-py3-none-any.whl", hash = "sha256:8b866aa5f51f66663a3d7fb24450a6f7e84da1c996e0b0aa738d0e12c34fc92b"}, - {file = "boto3-1.24.66.tar.gz", hash = "sha256:60003d2b83268a303cf61b78a0b59ebe2abe87e2f21308b55a99f25fd9bca4db"}, + {file = "boto3-1.24.73-py3-none-any.whl", hash = "sha256:f7ca88a76c8e31c19fef3bad2dee3c2ee0e77a0bced151fa3922cf021d55755e"}, + {file = "boto3-1.24.73.tar.gz", hash = "sha256:a8911d55f1497dc55d69d2029c3a5120887af4846ef6b9fe3ef0c777dc1b394e"}, ] botocore = [ - {file = "botocore-1.27.66-py3-none-any.whl", hash = "sha256:6293d1cb392a4779cf0a44055cae9ac0728809c14a11f2d91e679a00a9beae20"}, - {file = "botocore-1.27.66.tar.gz", hash = "sha256:6c8c8c82b38ba2353bd3bc071019ab44d8a160b9d17f3ab166f0ceaf1ca38c12"}, + {file = "botocore-1.27.89-py3-none-any.whl", hash = "sha256:238f1dfdb8d8d017c2aea082609a3764f3161d32745900f41bcdcf290d95a048"}, + {file = "botocore-1.27.89.tar.gz", hash = "sha256:621f5413be8f97712b7e36c1b075a8791d1d1b9971a7ee060cdcdf5e2debf6c1"}, ] cached-property = [ {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, @@ -2368,8 +2394,8 @@ celery = [ {file = "celery-5.2.7.tar.gz", hash = "sha256:fafbd82934d30f8a004f81e8f7a062e31413a23d444be8ee3326553915958c6d"}, ] certifi = [ - {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, - {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, ] cffi = [ {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, @@ -2446,8 +2472,8 @@ channels-redis = [ {file = "channels_redis-3.4.1.tar.gz", hash = "sha256:78e4a2f2b2a744fe5a87848ec36b5ee49f522c6808cefe6c583663d0d531faa8"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"}, - {file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"}, + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, ] click = [ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, @@ -2581,11 +2607,14 @@ defusedxml = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] -deprecated = [ +Deprecated = [ {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, ] -django = [ +dj-rest-auth = [ + {file = "dj-rest-auth-2.2.5.tar.gz", hash = "sha256:c9a0dd9c79c33a2d4c723d1e4c1a60ab4657b6333aa2539b6781fe25a01c41b6"}, +] +Django = [ {file = "Django-3.2.15-py3-none-any.whl", hash = "sha256:115baf5049d5cf4163e43492cdc7139c306ed6d451e7d3571fe9612903903713"}, {file = "Django-3.2.15.tar.gz", hash = "sha256:f71934b1a822f14a86c9ac9634053689279cd04ae69cb6ade4a59471b886582b"}, ] @@ -2616,12 +2645,11 @@ django-coverage-plugin = [ {file = "django_coverage_plugin-2.0.3.tar.gz", hash = "sha256:e73231e3bfddb2ac836fd43cb0e6ec2ab3a97457b1d9ca2468085e506beb7935"}, ] django-debug-toolbar = [ - {file = "django-debug-toolbar-3.5.0.tar.gz", hash = "sha256:97965f2630692de316ea0c1ca5bfa81660d7ba13146dbc6be2059cf55b35d0e5"}, - {file = "django_debug_toolbar-3.5.0-py3-none-any.whl", hash = "sha256:89a52128309eb4da12738801ff0c202d2ff8730d1c3225fac6acf630c303e661"}, + {file = "django-debug-toolbar-3.6.0.tar.gz", hash = "sha256:95fc2fd29c56cc86678aae9f6919ececefe892f2a78c4004b193a223a8380c3d"}, + {file = "django_debug_toolbar-3.6.0-py3-none-any.whl", hash = "sha256:fe7fe3f21865218827e2162ecc06eba386dfe8cffe4f3501c49bb4359e06a0e6"}, ] django-dynamic-preferences = [ - {file = "django-dynamic-preferences-1.13.0.tar.gz", hash = "sha256:b7b13c913b5b6f6e05c880afeae1a91b147a5bc11fb95079151d9cc88b606a50"}, - {file = "django_dynamic_preferences-1.13.0-py2.py3-none-any.whl", hash = "sha256:0f13d3c05df70366dcf7199fa285c36fdd11c88c99af750b270285ad149ec6d5"}, + {file = "django-dynamic-preferences-1.14.0.tar.gz", hash = "sha256:c00abcb8d524067390a66518cfcd32683b87ad3cc620d5913649fc7707b80833"}, ] django-environ = [ {file = "django-environ-0.9.0.tar.gz", hash = "sha256:bff5381533056328c9ac02f71790bd5bf1cea81b1beeb648f28b81c9e83e0a21"}, @@ -2639,9 +2667,6 @@ django-redis = [ {file = "django-redis-5.2.0.tar.gz", hash = "sha256:8a99e5582c79f894168f5865c52bd921213253b7fd64d16733ae4591564465de"}, {file = "django_redis-5.2.0-py3-none-any.whl", hash = "sha256:1d037dc02b11ad7aa11f655d26dac3fb1af32630f61ef4428860a2e29ff92026"}, ] -django-rest-auth = [ - {file = "django-rest-auth-0.9.5.tar.gz", hash = "sha256:f11e12175dafeed772f50d740d22caeab27e99a3caca24ec65e66a8d6de16571"}, -] django-storages = [ {file = "django-storages-1.13.1.tar.gz", hash = "sha256:b3d98ecc09f1b1627c2b2cf430964322ce4e08617dbf9b4236c16a32875a1e0b"}, {file = "django_storages-1.13.1-py3-none-any.whl", hash = "sha256:3540b45618b04be2c867c0982e8d2bd8e34f84dae922267fcebe4691fb93daf0"}, @@ -2662,9 +2687,9 @@ factory-boy = [ {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, ] -faker = [ - {file = "Faker-14.0.0-py3-none-any.whl", hash = "sha256:f1558ecb1770d8c871ea01cc2edc7b5e86148b0fa0466731f0e1e8953165d179"}, - {file = "Faker-14.0.0.tar.gz", hash = "sha256:0c7d283a96c49af64fe319f70d2b68927873c9173e922f8eda6001e7757cb63b"}, +Faker = [ + {file = "Faker-14.2.0-py3-none-any.whl", hash = "sha256:e02c55a5b0586caaf913cc6c254b3de178e08b031c5922e590fd033ebbdbfd02"}, + {file = "Faker-14.2.0.tar.gz", hash = "sha256:6db56e2c43a2b74250d1c332ef25fef7dc07dcb6c5fab5329dd7b4467b8ed7b9"}, ] feedparser = [ {file = "feedparser-6.0.10-py3-none-any.whl", hash = "sha256:79c257d526d13b944e965f6095700587f27388e50ea16fd245babe4dfae7024f"}, @@ -2763,8 +2788,8 @@ gunicorn = [ {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, ] h11 = [ - {file = "h11-0.13.0-py3-none-any.whl", hash = "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442"}, - {file = "h11-0.13.0.tar.gz", hash = "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06"}, + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] hiredis = [ {file = "hiredis-2.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048"}, @@ -2810,56 +2835,63 @@ hiredis = [ {file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"}, ] httptools = [ - {file = "httptools-0.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcddfe70553be717d9745990dfdb194e22ee0f60eb8f48c0794e7bfeda30d2d5"}, - {file = "httptools-0.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ee0b459257e222b878a6c09ccf233957d3a4dcb883b0847640af98d2d9aac23"}, - {file = "httptools-0.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceafd5e960b39c7e0d160a1936b68eb87c5e79b3979d66e774f0c77d4d8faaed"}, - {file = "httptools-0.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fdb9f9ed79bc6f46b021b3319184699ba1a22410a82204e6e89c774530069683"}, - {file = "httptools-0.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:abe829275cdd4174b4c4e65ad718715d449e308d59793bf3a931ee1bf7e7b86c"}, - {file = "httptools-0.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7af6bdbd21a2a25d6784f6d67f44f5df33ef39b6159543b9f9064d365c01f919"}, - {file = "httptools-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5d1fe6b6661022fd6cac541f54a4237496b246e6f1c0a6b41998ee08a1135afe"}, - {file = "httptools-0.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:48e48530d9b995a84d1d89ae6b3ec4e59ea7d494b150ac3bbc5e2ac4acce92cd"}, - {file = "httptools-0.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a113789e53ac1fa26edf99856a61e4c493868e125ae0dd6354cf518948fbbd5c"}, - {file = "httptools-0.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8e2eb957787cbb614a0f006bfc5798ff1d90ac7c4dd24854c84edbdc8c02369e"}, - {file = "httptools-0.4.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:7ee9f226acab9085037582c059d66769862706e8e8cd2340470ceb8b3850873d"}, - {file = "httptools-0.4.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:701e66b59dd21a32a274771238025d58db7e2b6ecebbab64ceff51b8e31527ae"}, - {file = "httptools-0.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6a1a7dfc1f9c78a833e2c4904757a0f47ce25d08634dd2a52af394eefe5f9777"}, - {file = "httptools-0.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:903f739c9fb78dab8970b0f3ea51f21955b24b45afa77b22ff0e172fc11ef111"}, - {file = "httptools-0.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54bbd295f031b866b9799dd39cb45deee81aca036c9bff9f58ca06726f6494f1"}, - {file = "httptools-0.4.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3194f6d6443befa8d4db16c1946b2fc428a3ceb8ab32eb6f09a59f86104dc1a0"}, - {file = "httptools-0.4.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cd1295f52971097f757edfbfce827b6dbbfb0f7a74901ee7d4933dff5ad4c9af"}, - {file = "httptools-0.4.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:20a45bcf22452a10fa8d58b7dbdb474381f6946bf5b8933e3662d572bc61bae4"}, - {file = "httptools-0.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d1f27bb0f75bef722d6e22dc609612bfa2f994541621cd2163f8c943b6463dfe"}, - {file = "httptools-0.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7f7bfb74718f52d5ed47d608d507bf66d3bc01d4a8b3e6dd7134daaae129357b"}, - {file = "httptools-0.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a522d12e2ddbc2e91842ffb454a1aeb0d47607972c7d8fc88bd0838d97fb8a2a"}, - {file = "httptools-0.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2db44a0b294d317199e9f80123e72c6b005c55b625b57fae36de68670090fa48"}, - {file = "httptools-0.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c286985b5e194ca0ebb2908d71464b9be8f17cc66d6d3e330e8d5407248f56ad"}, - {file = "httptools-0.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3a4e165ca6204f34856b765d515d558dc84f1352033b8721e8d06c3e44930c3"}, - {file = "httptools-0.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:72aa3fbe636b16d22e04b5a9d24711b043495e0ecfe58080addf23a1a37f3409"}, - {file = "httptools-0.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9967d9758df505975913304c434cb9ab21e2c609ad859eb921f2f615a038c8de"}, - {file = "httptools-0.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f72b5d24d6730035128b238decdc4c0f2104b7056a7ca55cf047c106842ec890"}, - {file = "httptools-0.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:29bf97a5c532da9c7a04de2c7a9c31d1d54f3abd65a464119b680206bbbb1055"}, - {file = "httptools-0.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98993805f1e3cdb53de4eed02b55dcc953cdf017ba7bbb2fd89226c086a6d855"}, - {file = "httptools-0.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d9b90bf58f3ba04e60321a23a8723a1ff2a9377502535e70495e5ada8e6e6722"}, - {file = "httptools-0.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a99346ebcb801b213c591540837340bdf6fd060a8687518d01c607d338b7424"}, - {file = "httptools-0.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:645373c070080e632480a3d251d892cb795be3d3a15f86975d0f1aca56fd230d"}, - {file = "httptools-0.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:34d2903dd2a3dd85d33705b6fde40bf91fc44411661283763fd0746723963c83"}, - {file = "httptools-0.4.0.tar.gz", hash = "sha256:2c9a930c378b3d15d6b695fb95ebcff81a7395b4f9775c4f10a076beb0b2c1ff"}, + {file = "httptools-0.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f470c79061599a126d74385623ff4744c4e0f4a0997a353a44923c0b561ee51"}, + {file = "httptools-0.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e90491a4d77d0cb82e0e7a9cb35d86284c677402e4ce7ba6b448ccc7325c5421"}, + {file = "httptools-0.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1d2357f791b12d86faced7b5736dea9ef4f5ecdc6c3f253e445ee82da579449"}, + {file = "httptools-0.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f90cd6fd97c9a1b7fe9215e60c3bd97336742a0857f00a4cb31547bc22560c2"}, + {file = "httptools-0.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5230a99e724a1bdbbf236a1b58d6e8504b912b0552721c7c6b8570925ee0ccde"}, + {file = "httptools-0.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3a47a34f6015dd52c9eb629c0f5a8a5193e47bf2a12d9a3194d231eaf1bc451a"}, + {file = "httptools-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:24bb4bb8ac3882f90aa95403a1cb48465de877e2d5298ad6ddcfdebec060787d"}, + {file = "httptools-0.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e67d4f8734f8054d2c4858570cc4b233bf753f56e85217de4dfb2495904cf02e"}, + {file = "httptools-0.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7e5eefc58d20e4c2da82c78d91b2906f1a947ef42bd668db05f4ab4201a99f49"}, + {file = "httptools-0.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0297822cea9f90a38df29f48e40b42ac3d48a28637368f3ec6d15eebefd182f9"}, + {file = "httptools-0.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:557be7fbf2bfa4a2ec65192c254e151684545ebab45eca5d50477d562c40f986"}, + {file = "httptools-0.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:54465401dbbec9a6a42cf737627fb0f014d50dc7365a6b6cd57753f151a86ff0"}, + {file = "httptools-0.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4d9ebac23d2de960726ce45f49d70eb5466725c0087a078866043dad115f850f"}, + {file = "httptools-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e8a34e4c0ab7b1ca17b8763613783e2458e77938092c18ac919420ab8655c8c1"}, + {file = "httptools-0.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f659d7a48401158c59933904040085c200b4be631cb5f23a7d561fbae593ec1f"}, + {file = "httptools-0.5.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1616b3ba965cd68e6f759eeb5d34fbf596a79e84215eeceebf34ba3f61fdc7"}, + {file = "httptools-0.5.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3625a55886257755cb15194efbf209584754e31d336e09e2ffe0685a76cb4b60"}, + {file = "httptools-0.5.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:72ad589ba5e4a87e1d404cc1cb1b5780bfcb16e2aec957b88ce15fe879cc08ca"}, + {file = "httptools-0.5.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:850fec36c48df5a790aa735417dca8ce7d4b48d59b3ebd6f83e88a8125cde324"}, + {file = "httptools-0.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f222e1e9d3f13b68ff8a835574eda02e67277d51631d69d7cf7f8e07df678c86"}, + {file = "httptools-0.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3cb8acf8f951363b617a8420768a9f249099b92e703c052f9a51b66342eea89b"}, + {file = "httptools-0.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550059885dc9c19a072ca6d6735739d879be3b5959ec218ba3e013fd2255a11b"}, + {file = "httptools-0.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a04fe458a4597aa559b79c7f48fe3dceabef0f69f562daf5c5e926b153817281"}, + {file = "httptools-0.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d0c1044bce274ec6711f0770fd2d5544fe392591d204c68328e60a46f88843b"}, + {file = "httptools-0.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c6eeefd4435055a8ebb6c5cc36111b8591c192c56a95b45fe2af22d9881eee25"}, + {file = "httptools-0.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5b65be160adcd9de7a7e6413a4966665756e263f0d5ddeffde277ffeee0576a5"}, + {file = "httptools-0.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fe9c766a0c35b7e3d6b6939393c8dfdd5da3ac5dec7f971ec9134f284c6c36d6"}, + {file = "httptools-0.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:85b392aba273566c3d5596a0a490978c085b79700814fb22bfd537d381dd230c"}, + {file = "httptools-0.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5e3088f4ed33947e16fd865b8200f9cfae1144f41b64a8cf19b599508e096bc"}, + {file = "httptools-0.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c2a56b6aad7cc8f5551d8e04ff5a319d203f9d870398b94702300de50190f63"}, + {file = "httptools-0.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b571b281a19762adb3f48a7731f6842f920fa71108aff9be49888320ac3e24d"}, + {file = "httptools-0.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa47ffcf70ba6f7848349b8a6f9b481ee0f7637931d91a9860a1838bfc586901"}, + {file = "httptools-0.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:bede7ee075e54b9a5bde695b4fc8f569f30185891796b2e4e09e2226801d09bd"}, + {file = "httptools-0.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:64eba6f168803a7469866a9c9b5263a7463fa8b7a25b35e547492aa7322036b6"}, + {file = "httptools-0.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4b098e4bb1174096a93f48f6193e7d9aa7071506a5877da09a783509ca5fff42"}, + {file = "httptools-0.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9423a2de923820c7e82e18980b937893f4aa8251c43684fa1772e341f6e06887"}, + {file = "httptools-0.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca1b7becf7d9d3ccdbb2f038f665c0f4857e08e1d8481cbcc1a86a0afcfb62b2"}, + {file = "httptools-0.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:50d4613025f15f4b11f1c54bbed4761c0020f7f921b95143ad6d58c151198142"}, + {file = "httptools-0.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8ffce9d81c825ac1deaa13bc9694c0562e2840a48ba21cfc9f3b4c922c16f372"}, + {file = "httptools-0.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:1af91b3650ce518d226466f30bbba5b6376dbd3ddb1b2be8b0658c6799dd450b"}, + {file = "httptools-0.5.0.tar.gz", hash = "sha256:295874861c173f9101960bba332429bb77ed4dcd8cdf5cee9922eb00e4f6bc09"}, ] hyperlink = [ {file = "hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4"}, {file = "hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b"}, ] idna = [ - {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, - {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"}, - {file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"}, + {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"}, + {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"}, ] importlib-resources = [ - {file = "importlib_resources-5.9.0-py3-none-any.whl", hash = "sha256:f78a8df21a79bcc30cfd400bdc38f314333de7c0fb619763f6b9dabab8268bb7"}, - {file = "importlib_resources-5.9.0.tar.gz", hash = "sha256:5481e97fb45af8dcf2f798952625591c58fe599d0735d86b10f54de086a61681"}, + {file = "importlib_resources-5.10.0-py3-none-any.whl", hash = "sha256:ee17ec648f85480d523596ce49eae8ead87d5631ae1551f913c0100b5edd3437"}, + {file = "importlib_resources-5.10.0.tar.gz", hash = "sha256:c01b1b94210d9849f286b86bb51bcea7cd56dde0600d8db721d7b81330711668"}, ] incremental = [ {file = "incremental-21.3.0-py2.py3-none-any.whl", hash = "sha256:92014aebc6a20b78a8084cdd5645eeaa7f74b8933f70fa3ada2cfbd1e3b54321"}, @@ -2889,11 +2921,11 @@ jmespath = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] jsonschema = [ - {file = "jsonschema-4.9.1-py3-none-any.whl", hash = "sha256:8ebad55894c002585271af2d327d99339ef566fb085d9129b69e2623867c4106"}, - {file = "jsonschema-4.9.1.tar.gz", hash = "sha256:408c4c8ed0dede3b268f7a441784f74206380b04f93eb2d537c7befb3df3099f"}, + {file = "jsonschema-4.16.0-py3-none-any.whl", hash = "sha256:9e74b8f9738d6a946d70705dc692b74b5429cd0960d58e79ffecfc43b2221eb9"}, + {file = "jsonschema-4.16.0.tar.gz", hash = "sha256:165059f076eff6971bae5b742fc029a7b4ef3f9bcf04c14e4776a7605de14b23"}, ] jwcrypto = [ - {file = "jwcrypto-1.3.1.tar.gz", hash = "sha256:54b551b115ffb4d12b1f1ee93b8ba2a71bb8556ba3d85d62f707549613da877c"}, + {file = "jwcrypto-1.4.2.tar.gz", hash = "sha256:80a35e9ed1b3b2c43ce03d92c5d48e6d0b6647e2aa2618e4963448923d78a37b"}, ] kombu = [ {file = "kombu-5.2.4-py3-none-any.whl", hash = "sha256:8b213b24293d3417bcf0d2f5537b7f756079e3ea232a8386dcc89a59fd2361a4"}, @@ -2971,13 +3003,13 @@ lxml = [ {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9"}, {file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"}, ] -markdown = [ +Markdown = [ {file = "Markdown-3.4.1-py3-none-any.whl", hash = "sha256:08fb8465cffd03d10b9dd34a5c3fea908e20391a2a90b88d66362cb05beed186"}, {file = "Markdown-3.4.1.tar.gz", hash = "sha256:3b809086bb6efad416156e00a0da66fe47618a5d6918dd688f53f40c8e4cfeff"}, ] matplotlib-inline = [ - {file = "matplotlib-inline-0.1.3.tar.gz", hash = "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee"}, - {file = "matplotlib_inline-0.1.3-py3-none-any.whl", hash = "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c"}, + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -3111,8 +3143,8 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] oauthlib = [ - {file = "oauthlib-3.2.0-py3-none-any.whl", hash = "sha256:6db33440354787f9b7f3a6dbd4febf5d0f93758354060e802f6c06cb493022fe"}, - {file = "oauthlib-3.2.0.tar.gz", hash = "sha256:23a8208d75b902797ea29fd31fa80a15ed9dc2c6c16fe73f5d346f83f6fa27a2"}, + {file = "oauthlib-3.2.1-py3-none-any.whl", hash = "sha256:88e912ca1ad915e1dcc1c06fc9259d19de8deacd6fd17cc2df266decc2e49066"}, + {file = "oauthlib-3.2.1.tar.gz", hash = "sha256:1565237372795bf6ee3e5aba5e2a85bd5a65d0e2aa5c628b9a97b7d7a0da3721"}, ] packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, @@ -3123,8 +3155,8 @@ parso = [ {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, ] pathspec = [ - {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, - {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, + {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, + {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, ] persisting-theory = [ {file = "persisting-theory-1.0.tar.gz", hash = "sha256:0f840fa22247bcaa514094da7f3b26c602359429ab12d2cd88be06b49a336290"}, @@ -3138,7 +3170,7 @@ pickleshare = [ {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] -pillow = [ +Pillow = [ {file = "Pillow-9.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb"}, {file = "Pillow-9.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f"}, {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5"}, @@ -3198,7 +3230,7 @@ pillow = [ {file = "Pillow-9.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927"}, {file = "Pillow-9.2.0.tar.gz", hash = "sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04"}, ] -pkgutil-resolve-name = [ +pkgutil_resolve_name = [ {file = "pkgutil_resolve_name-1.3.10-py3-none-any.whl", hash = "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e"}, {file = "pkgutil_resolve_name-1.3.10.tar.gz", hash = "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174"}, ] @@ -3210,6 +3242,10 @@ pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] +pluralizer = [ + {file = "pluralizer-1.2.0-py3-none-any.whl", hash = "sha256:d8f92ffa787661d9e704d1e0d8abc6c6c4bbaae9e790d7c709707eafbe17ed12"}, + {file = "pluralizer-1.2.0.tar.gz", hash = "sha256:fe3fb8e1e53fabf372e77d8cbebe04b0f8fc7db853aeff50095dbd5628ac39c5"}, +] prompt-toolkit = [ {file = "prompt_toolkit-3.0.31-py3-none-any.whl", hash = "sha256:9696f386133df0fc8ca5af4895afe5d78f5fcfe5258111c2a79a1c3e41ffa96d"}, {file = "prompt_toolkit-3.0.31.tar.gz", hash = "sha256:9ada952c9d1787f52ff6d5f3484d0b4df8952787c087edf6a1f7c2cb1ea88148"}, @@ -3281,14 +3317,14 @@ pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] -pygments = [ - {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, - {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, +Pygments = [ + {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, + {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, ] -pyld = [ +PyLD = [ {file = "PyLD-2.0.3.tar.gz", hash = "sha256:287445f888c3a332ccbd20a14844c66c2fcbaeab3c99acd506a0788e2ebb2f82"}, ] -pyopenssl = [ +pyOpenSSL = [ {file = "pyOpenSSL-21.0.0-py2.py3-none-any.whl", hash = "sha256:8935bd4920ab9abfebb07c41a4f58296407ed77f04bd1a92914044b848ba1ed6"}, {file = "pyOpenSSL-21.0.0.tar.gz", hash = "sha256:5e2d8c5e46d0d865ae933bef5230090bdaf5506281e9eec60fa250ee80600cb3"}, ] @@ -3355,8 +3391,8 @@ python-dateutil = [ {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] python-dotenv = [ - {file = "python-dotenv-0.20.0.tar.gz", hash = "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f"}, - {file = "python_dotenv-0.20.0-py3-none-any.whl", hash = "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"}, + {file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"}, + {file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"}, ] python-ldap = [ {file = "python-ldap-3.4.2.tar.gz", hash = "sha256:b16470a0983aaf09a00ffb8f40b69a2446f3d0be639a229256bce381fcb268f7"}, @@ -3373,7 +3409,7 @@ pytz = [ {file = "pytz-2022.2.1-py2.py3-none-any.whl", hash = "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197"}, {file = "pytz-2022.2.1.tar.gz", hash = "sha256:cea221417204f2d1a2aa03ddae3e867921971d0d76f14d87abb4414415bbdcf5"}, ] -pyyaml = [ +PyYAML = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, @@ -3381,6 +3417,13 @@ pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, + {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, + {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, @@ -3433,16 +3476,16 @@ s3transfer = [ {file = "s3transfer-0.6.0.tar.gz", hash = "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"}, ] sentry-sdk = [ - {file = "sentry-sdk-1.9.5.tar.gz", hash = "sha256:2d7ec7bc88ebbdf2c4b6b2650b3257893d386325a96c9b723adcd31033469b63"}, - {file = "sentry_sdk-1.9.5-py2.py3-none-any.whl", hash = "sha256:b4b41f90951ed83e7b4c176eef021b19ecba39da5b73aca106c97a9b7e279a90"}, + {file = "sentry-sdk-1.9.8.tar.gz", hash = "sha256:ef4b4d57631662ff81e15c22bf007f7ce07c47321648c8e601fbd22950ed1a79"}, + {file = "sentry_sdk-1.9.8-py2.py3-none-any.whl", hash = "sha256:7378c08d81da78a4860da7b4900ac51fef38be841d01bc5b7cb249ac51e070c6"}, ] service-identity = [ {file = "service-identity-21.1.0.tar.gz", hash = "sha256:6e6c6086ca271dc11b033d17c3a8bea9f24ebff920c587da090afc9519419d34"}, {file = "service_identity-21.1.0-py2.py3-none-any.whl", hash = "sha256:f0b0caac3d40627c3c04d7a51b6e06721857a0e10a8775f2d1d7e72901b3a7db"}, ] setuptools = [ - {file = "setuptools-65.0.2-py3-none-any.whl", hash = "sha256:39275e7aafa4a4f0f4308f2302c6ee384dcdacdbaacc1e30dcbb6fd824c625bb"}, - {file = "setuptools-65.0.2.tar.gz", hash = "sha256:101bf15ca723beef42c8db91a761f3748d4d697e17fae904db60c0b619d8d094"}, + {file = "setuptools-65.4.1-py3-none-any.whl", hash = "sha256:1b6bdc6161661409c5f21508763dc63ab20a9ac2f8ba20029aaaa7fdb9118012"}, + {file = "setuptools-65.4.1.tar.gz", hash = "sha256:3050e338e5871e70c72983072fe34f6032ae1cdeeeb67338199c2f74e083a80e"}, ] sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, @@ -3452,15 +3495,16 @@ six = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] sniffio = [ - {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, - {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] sqlparse = [ - {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, - {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, + {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, + {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, ] termcolor = [ - {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, + {file = "termcolor-2.0.1-py3-none-any.whl", hash = "sha256:7e597f9de8e001a3208c4132938597413b9da45382b6f1d150cff8d062b7aaa3"}, + {file = "termcolor-2.0.1.tar.gz", hash = "sha256:6b2cf769e93364a2676e1de56a7c0cff2cf5bd07f37e9cc80b0dd6320ebfe388"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -3471,10 +3515,10 @@ tomli = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] traitlets = [ - {file = "traitlets-5.3.0-py3-none-any.whl", hash = "sha256:65fa18961659635933100db8ca120ef6220555286949774b9cfc106f941d1c7a"}, - {file = "traitlets-5.3.0.tar.gz", hash = "sha256:0bb9f1f9f017aa8ec187d8b1b2a7a6626a2a1d877116baba52a129bfa124f8e2"}, + {file = "traitlets-5.4.0-py3-none-any.whl", hash = "sha256:93663cc8236093d48150e2af5e2ed30fc7904a11a6195e21bab0408af4e6d6c8"}, + {file = "traitlets-5.4.0.tar.gz", hash = "sha256:3f2c4e435e271592fe4390f1746ea56836e3a080f84e7833f0f801d9613fec39"}, ] -twisted = [ +Twisted = [ {file = "Twisted-22.4.0-py3-none-any.whl", hash = "sha256:f9f7a91f94932477a9fc3b169d57f54f96c6e74a23d78d9ce54039a7f48928a2"}, {file = "Twisted-22.4.0.tar.gz", hash = "sha256:a047990f57dfae1e0bd2b7df2526d4f16dcdc843774dc108b78c52f2a5f13680"}, ] @@ -3523,46 +3567,60 @@ typed-ast = [ {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, ] typing-extensions = [ - {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, - {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, ] unicode-slugify = [ {file = "unicode-slugify-0.1.5.tar.gz", hash = "sha256:25f424258317e4cb41093e2953374b3af1f23097297664731cdb3ae46f6bd6c3"}, {file = "unicode_slugify-0.1.5-py3-none-any.whl", hash = "sha256:33a11c0ac901f7220659dd0dd6f232cf39637dfd1b9f5f35ef5ead9fef696879"}, ] -unidecode = [ - {file = "Unidecode-1.3.4-py3-none-any.whl", hash = "sha256:afa04efcdd818a93237574791be9b2817d7077c25a068b00f8cff7baa4e59257"}, - {file = "Unidecode-1.3.4.tar.gz", hash = "sha256:8e4352fb93d5a735c788110d2e7ac8e8031eb06ccbfe8d324ab71735015f9342"}, +Unidecode = [ + {file = "Unidecode-1.3.6-py3-none-any.whl", hash = "sha256:547d7c479e4f377b430dd91ac1275d593308dce0fc464fb2ab7d41f82ec653be"}, + {file = "Unidecode-1.3.6.tar.gz", hash = "sha256:fed09cf0be8cf415b391642c2a5addfc72194407caee4f98719e40ec2a72b830"}, ] uritemplate = [ {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, ] urllib3 = [ - {file = "urllib3-1.26.11-py2.py3-none-any.whl", hash = "sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc"}, - {file = "urllib3-1.26.11.tar.gz", hash = "sha256:ea6e8fb210b19d950fab93b60c9009226c63a28808bc8386e05301e25883ac0a"}, + {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, + {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, ] uvicorn = [ - {file = "uvicorn-0.18.2-py3-none-any.whl", hash = "sha256:c19a057deb1c5bb060946e2e5c262fc01590c6529c0af2c3d9ce941e89bc30e0"}, - {file = "uvicorn-0.18.2.tar.gz", hash = "sha256:cade07c403c397f9fe275492a48c1b869efd175d5d8a692df649e6e7e2ed8f4e"}, + {file = "uvicorn-0.18.3-py3-none-any.whl", hash = "sha256:0abd429ebb41e604ed8d2be6c60530de3408f250e8d2d84967d85ba9e86fe3af"}, + {file = "uvicorn-0.18.3.tar.gz", hash = "sha256:9a66e7c42a2a95222f76ec24a4b754c158261c4696e683b9dadc72b590e0311b"}, ] uvloop = [ - {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d"}, - {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c"}, - {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64"}, - {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9"}, - {file = "uvloop-0.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638"}, - {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450"}, - {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805"}, - {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382"}, - {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee"}, - {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464"}, - {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab"}, - {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f"}, - {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897"}, - {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f"}, - {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861"}, - {file = "uvloop-0.16.0.tar.gz", hash = "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228"}, + {file = "uvloop-0.17.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce9f61938d7155f79d3cb2ffa663147d4a76d16e08f65e2c66b77bd41b356718"}, + {file = "uvloop-0.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:68532f4349fd3900b839f588972b3392ee56042e440dd5873dfbbcd2cc67617c"}, + {file = "uvloop-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0949caf774b9fcefc7c5756bacbbbd3fc4c05a6b7eebc7c7ad6f825b23998d6d"}, + {file = "uvloop-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff3d00b70ce95adce264462c930fbaecb29718ba6563db354608f37e49e09024"}, + {file = "uvloop-0.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a5abddb3558d3f0a78949c750644a67be31e47936042d4f6c888dd6f3c95f4aa"}, + {file = "uvloop-0.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8efcadc5a0003d3a6e887ccc1fb44dec25594f117a94e3127954c05cf144d811"}, + {file = "uvloop-0.17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3378eb62c63bf336ae2070599e49089005771cc651c8769aaad72d1bd9385a7c"}, + {file = "uvloop-0.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6aafa5a78b9e62493539456f8b646f85abc7093dd997f4976bb105537cf2635e"}, + {file = "uvloop-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c686a47d57ca910a2572fddfe9912819880b8765e2f01dc0dd12a9bf8573e539"}, + {file = "uvloop-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:864e1197139d651a76c81757db5eb199db8866e13acb0dfe96e6fc5d1cf45fc4"}, + {file = "uvloop-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2a6149e1defac0faf505406259561bc14b034cdf1d4711a3ddcdfbaa8d825a05"}, + {file = "uvloop-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6708f30db9117f115eadc4f125c2a10c1a50d711461699a0cbfaa45b9a78e376"}, + {file = "uvloop-0.17.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:23609ca361a7fc587031429fa25ad2ed7242941adec948f9d10c045bfecab06b"}, + {file = "uvloop-0.17.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2deae0b0fb00a6af41fe60a675cec079615b01d68beb4cc7b722424406b126a8"}, + {file = "uvloop-0.17.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45cea33b208971e87a31c17622e4b440cac231766ec11e5d22c76fab3bf9df62"}, + {file = "uvloop-0.17.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9b09e0f0ac29eee0451d71798878eae5a4e6a91aa275e114037b27f7db72702d"}, + {file = "uvloop-0.17.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dbbaf9da2ee98ee2531e0c780455f2841e4675ff580ecf93fe5c48fe733b5667"}, + {file = "uvloop-0.17.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a4aee22ece20958888eedbad20e4dbb03c37533e010fb824161b4f05e641f738"}, + {file = "uvloop-0.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:307958f9fc5c8bb01fad752d1345168c0abc5d62c1b72a4a8c6c06f042b45b20"}, + {file = "uvloop-0.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ebeeec6a6641d0adb2ea71dcfb76017602ee2bfd8213e3fcc18d8f699c5104f"}, + {file = "uvloop-0.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1436c8673c1563422213ac6907789ecb2b070f5939b9cbff9ef7113f2b531595"}, + {file = "uvloop-0.17.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8887d675a64cfc59f4ecd34382e5b4f0ef4ae1da37ed665adba0c2badf0d6578"}, + {file = "uvloop-0.17.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3db8de10ed684995a7f34a001f15b374c230f7655ae840964d51496e2f8a8474"}, + {file = "uvloop-0.17.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7d37dccc7ae63e61f7b96ee2e19c40f153ba6ce730d8ba4d3b4e9738c1dccc1b"}, + {file = "uvloop-0.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cbbe908fda687e39afd6ea2a2f14c2c3e43f2ca88e3a11964b297822358d0e6c"}, + {file = "uvloop-0.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d97672dc709fa4447ab83276f344a165075fd9f366a97b712bdd3fee05efae8"}, + {file = "uvloop-0.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1e507c9ee39c61bfddd79714e4f85900656db1aec4d40c6de55648e85c2799c"}, + {file = "uvloop-0.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c092a2c1e736086d59ac8e41f9c98f26bbf9b9222a76f21af9dfe949b99b2eb9"}, + {file = "uvloop-0.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:30babd84706115626ea78ea5dbc7dd8d0d01a2e9f9b306d24ca4ed5796c66ded"}, + {file = "uvloop-0.17.0.tar.gz", hash = "sha256:0ddf6baf9cf11a1a22c71487f39f15b2cf78eb5bde7e5b45fbb99e8a9d91b9e1"}, ] vine = [ {file = "vine-5.0.0-py2.py3-none-any.whl", hash = "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30"}, @@ -3596,24 +3654,24 @@ watchdog = [ {file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"}, ] watchfiles = [ - {file = "watchfiles-0.16.1-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:1e41c8b4bf3e07c18aa51775b36b718830fa727929529a7d6e5b38cf845a06b4"}, - {file = "watchfiles-0.16.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b2c7ad91a867dd688b9a12097dd6a4f89397b43fccee871152aa67197cc94398"}, - {file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:75a4b9cec1b1c337ea77d4428b29861553d6bf8179923b1bc7e825e217460e2c"}, - {file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2a3debb19912072799d7ca53e99fc5f090f77948f5601392623b2a416b4c86be"}, - {file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35f3e411822e14a35f2ef656535aad4e6e79670d6b6ef8e53db958e28916b1fe"}, - {file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9a7a6dc63684ff5ba11f0be0e64f744112c3c7a0baf4ec8f6794f9a6257d21e"}, - {file = "watchfiles-0.16.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e939a2693404ac11e055f9d1237db8ad7635e2185a6143bde00116e691ea2983"}, - {file = "watchfiles-0.16.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cd7d2fd9a8f28066edc8db5278f3632eb94d10596af760fa0601631f32b1a41e"}, - {file = "watchfiles-0.16.1-cp37-abi3-win32.whl", hash = "sha256:f91035a273001390093a09e52274a34695b0d15ee8736183b640bbc3b8a432ab"}, - {file = "watchfiles-0.16.1-cp37-abi3-win_amd64.whl", hash = "sha256:a8a1809bf910672aa0b7ed6e6045d4fc2cf1e0718b99bc443ef17faa5697b68a"}, - {file = "watchfiles-0.16.1-cp37-abi3-win_arm64.whl", hash = "sha256:baa6d0c1c5140e1dcf6ff802dd7b09fcd95b358e50d42fabc83d83f719451c54"}, - {file = "watchfiles-0.16.1-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:5741246ae399a03395aa5ee35480083a4f29d58ffd41dd3395594f8805f8cdbc"}, - {file = "watchfiles-0.16.1-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:44c6aff58b8a70a26431737e483a54e8e224279b21873388571ed184fe7c91a7"}, - {file = "watchfiles-0.16.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d1b2d0cf060e5222a930a3e2f40f6577da1d18c085c32741b98a128dc1e72c"}, - {file = "watchfiles-0.16.1-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:70159e759f52b65a50c498182dece80364bfd721e839c254c328cbc7a1716616"}, - {file = "watchfiles-0.16.1-pp39-pypy39_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22af3b915f928ef59d427d7228668f87ac8054ed8200808c73fbcaa4f82d5572"}, - {file = "watchfiles-0.16.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a6a1ac96edf5bc3f8e36f4462fc1daad0ec3769ff2adb920571e120e37c91c5"}, - {file = "watchfiles-0.16.1.tar.gz", hash = "sha256:aed7575e24434c8fec2f2bbb0cecb1521ea1240234d9108db7915a3424d92394"}, + {file = "watchfiles-0.17.0-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:c7e1ffbd03cbcb46d1b7833e10e7d6b678ab083b4e4b80db06cfff5baca3c93f"}, + {file = "watchfiles-0.17.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:539bcdb55a487126776c9d8c011094214d1df3f9a2321a6c0b1583197309405a"}, + {file = "watchfiles-0.17.0-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:00e5f307a58752ec1478eeb738863544bde21cc7a2728bd1c216060406bde9c1"}, + {file = "watchfiles-0.17.0-cp37-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:92675f379a9d5adbc6a52179f3e39aa56944c6eecb80384608fff2ed2619103a"}, + {file = "watchfiles-0.17.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1dd1e3181ad5d83ca35e9147c72e24f39437fcdf570c9cdc532016399fb62957"}, + {file = "watchfiles-0.17.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:204950f1d6083539af5c8b7d4f5f8039c3ce36fa692da12d9743448f3199cb15"}, + {file = "watchfiles-0.17.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:4056398d8f6d4972fe0918707b59d4cb84470c91d3c37f0e11e5a66c2a598760"}, + {file = "watchfiles-0.17.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ffff3418dc753a2aed2d00200a4daeaac295c40458f8012836a65555f288be8b"}, + {file = "watchfiles-0.17.0-cp37-abi3-win32.whl", hash = "sha256:b5c334cd3bc88aa4a8a1e08ec9f702b63c947211275defdc2dd79dc037fcb500"}, + {file = "watchfiles-0.17.0-cp37-abi3-win_amd64.whl", hash = "sha256:53a2faeb121bc51bb6b960984f46901227e2e2475acc5a8d4c905a600436752d"}, + {file = "watchfiles-0.17.0-cp37-abi3-win_arm64.whl", hash = "sha256:58dc3140dcf02a8aa76464a77a093016f10e89306fec21a4814922a64f3e8b9f"}, + {file = "watchfiles-0.17.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:adcf15ecc2182ea9d2358c1a8c2b53203c3909484918776929b7bbe205522c0e"}, + {file = "watchfiles-0.17.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:afd35a1bd3b9e68efe384ae7538481ae725597feb66f56f4bd23ecdbda726da0"}, + {file = "watchfiles-0.17.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad2bdcae4c0f07ca6c090f5a2c30188cc6edba011b45e7c96eb1896648092367"}, + {file = "watchfiles-0.17.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:a53cb6c06e5c1f216c792fbb432ce315239d432cb8b68d508547100939ec0399"}, + {file = "watchfiles-0.17.0-pp39-pypy39_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6a3d6c699f3ce238dfa90bcef501f331a69b0d9b076f14459ed8eab26ba2f4cf"}, + {file = "watchfiles-0.17.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f4271af86569bdbf131dd5c7c121c45d0ed194f3c88b88326e48a3b6a2db12"}, + {file = "watchfiles-0.17.0.tar.gz", hash = "sha256:ae7c57ef920589a40270d5ef3216d693f4e6f8864d8fc8b6cb7885ca98ad2a61"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, @@ -3801,59 +3859,45 @@ yarl = [ {file = "yarl-1.8.1.tar.gz", hash = "sha256:af887845b8c2e060eb5605ff72b6f2dd2aab7a761379373fd89d314f4752abbf"}, ] zipp = [ - {file = "zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"}, - {file = "zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"}, + {file = "zipp-3.9.0-py3-none-any.whl", hash = "sha256:972cfa31bc2fedd3fa838a51e9bc7e64b7fb725a8c00e7431554311f180e9980"}, + {file = "zipp-3.9.0.tar.gz", hash = "sha256:3a7af91c3db40ec72dd9d154ae18e008c69efe8ca88dde4f9a731bb82fe2f9eb"}, ] "zope.interface" = [ - {file = "zope.interface-5.4.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:7df1e1c05304f26faa49fa752a8c690126cf98b40b91d54e6e9cc3b7d6ffe8b7"}, - {file = "zope.interface-5.4.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2c98384b254b37ce50eddd55db8d381a5c53b4c10ee66e1e7fe749824f894021"}, - {file = "zope.interface-5.4.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:08f9636e99a9d5410181ba0729e0408d3d8748026ea938f3b970a0249daa8192"}, - {file = "zope.interface-5.4.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0ea1d73b7c9dcbc5080bb8aaffb776f1c68e807767069b9ccdd06f27a161914a"}, - {file = "zope.interface-5.4.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:273f158fabc5ea33cbc936da0ab3d4ba80ede5351babc4f577d768e057651531"}, - {file = "zope.interface-5.4.0-cp27-cp27m-win32.whl", hash = "sha256:a1e6e96217a0f72e2b8629e271e1b280c6fa3fe6e59fa8f6701bec14e3354325"}, - {file = "zope.interface-5.4.0-cp27-cp27m-win_amd64.whl", hash = "sha256:877473e675fdcc113c138813a5dd440da0769a2d81f4d86614e5d62b69497155"}, - {file = "zope.interface-5.4.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:f7ee479e96f7ee350db1cf24afa5685a5899e2b34992fb99e1f7c1b0b758d263"}, - {file = "zope.interface-5.4.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:b0297b1e05fd128d26cc2460c810d42e205d16d76799526dfa8c8ccd50e74959"}, - {file = "zope.interface-5.4.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:af310ec8335016b5e52cae60cda4a4f2a60a788cbb949a4fbea13d441aa5a09e"}, - {file = "zope.interface-5.4.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:9a9845c4c6bb56e508651f005c4aeb0404e518c6f000d5a1123ab077ab769f5c"}, - {file = "zope.interface-5.4.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0b465ae0962d49c68aa9733ba92a001b2a0933c317780435f00be7ecb959c702"}, - {file = "zope.interface-5.4.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:5dd9ca406499444f4c8299f803d4a14edf7890ecc595c8b1c7115c2342cadc5f"}, - {file = "zope.interface-5.4.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:469e2407e0fe9880ac690a3666f03eb4c3c444411a5a5fddfdabc5d184a79f05"}, - {file = "zope.interface-5.4.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:52de7fc6c21b419078008f697fd4103dbc763288b1406b4562554bd47514c004"}, - {file = "zope.interface-5.4.0-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:3dd4952748521205697bc2802e4afac5ed4b02909bb799ba1fe239f77fd4e117"}, - {file = "zope.interface-5.4.0-cp35-cp35m-win32.whl", hash = "sha256:dd93ea5c0c7f3e25335ab7d22a507b1dc43976e1345508f845efc573d3d779d8"}, - {file = "zope.interface-5.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:3748fac0d0f6a304e674955ab1365d515993b3a0a865e16a11ec9d86fb307f63"}, - {file = "zope.interface-5.4.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:66c0061c91b3b9cf542131148ef7ecbecb2690d48d1612ec386de9d36766058f"}, - {file = "zope.interface-5.4.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:d0c1bc2fa9a7285719e5678584f6b92572a5b639d0e471bb8d4b650a1a910920"}, - {file = "zope.interface-5.4.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2876246527c91e101184f63ccd1d716ec9c46519cc5f3d5375a3351c46467c46"}, - {file = "zope.interface-5.4.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:334701327f37c47fa628fc8b8d28c7d7730ce7daaf4bda1efb741679c2b087fc"}, - {file = "zope.interface-5.4.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:71aace0c42d53abe6fc7f726c5d3b60d90f3c5c055a447950ad6ea9cec2e37d9"}, - {file = "zope.interface-5.4.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5bb3489b4558e49ad2c5118137cfeaf59434f9737fa9c5deefc72d22c23822e2"}, - {file = "zope.interface-5.4.0-cp36-cp36m-win32.whl", hash = "sha256:1c0e316c9add0db48a5b703833881351444398b04111188069a26a61cfb4df78"}, - {file = "zope.interface-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f0c02cbb9691b7c91d5009108f975f8ffeab5dff8f26d62e21c493060eff2a1"}, - {file = "zope.interface-5.4.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:7d97a4306898b05404a0dcdc32d9709b7d8832c0c542b861d9a826301719794e"}, - {file = "zope.interface-5.4.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:867a5ad16892bf20e6c4ea2aab1971f45645ff3102ad29bd84c86027fa99997b"}, - {file = "zope.interface-5.4.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5f931a1c21dfa7a9c573ec1f50a31135ccce84e32507c54e1ea404894c5eb96f"}, - {file = "zope.interface-5.4.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:194d0bcb1374ac3e1e023961610dc8f2c78a0f5f634d0c737691e215569e640d"}, - {file = "zope.interface-5.4.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8270252effc60b9642b423189a2fe90eb6b59e87cbee54549db3f5562ff8d1b8"}, - {file = "zope.interface-5.4.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:15e7d1f7a6ee16572e21e3576d2012b2778cbacf75eb4b7400be37455f5ca8bf"}, - {file = "zope.interface-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:8892f89999ffd992208754851e5a052f6b5db70a1e3f7d54b17c5211e37a98c7"}, - {file = "zope.interface-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2e5a26f16503be6c826abca904e45f1a44ff275fdb7e9d1b75c10671c26f8b94"}, - {file = "zope.interface-5.4.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:0f91b5b948686659a8e28b728ff5e74b1be6bf40cb04704453617e5f1e945ef3"}, - {file = "zope.interface-5.4.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:4de4bc9b6d35c5af65b454d3e9bc98c50eb3960d5a3762c9438df57427134b8e"}, - {file = "zope.interface-5.4.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bf68f4b2b6683e52bec69273562df15af352e5ed25d1b6641e7efddc5951d1a7"}, - {file = "zope.interface-5.4.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:63b82bb63de7c821428d513607e84c6d97d58afd1fe2eb645030bdc185440120"}, - {file = "zope.interface-5.4.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:db1fa631737dab9fa0b37f3979d8d2631e348c3b4e8325d6873c2541d0ae5a48"}, - {file = "zope.interface-5.4.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:f44e517131a98f7a76696a7b21b164bcb85291cee106a23beccce454e1f433a4"}, - {file = "zope.interface-5.4.0-cp38-cp38-win32.whl", hash = "sha256:a9506a7e80bcf6eacfff7f804c0ad5350c8c95b9010e4356a4b36f5322f09abb"}, - {file = "zope.interface-5.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:3c02411a3b62668200910090a0dff17c0b25aaa36145082a5a6adf08fa281e54"}, - {file = "zope.interface-5.4.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:0cee5187b60ed26d56eb2960136288ce91bcf61e2a9405660d271d1f122a69a4"}, - {file = "zope.interface-5.4.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:a8156e6a7f5e2a0ff0c5b21d6bcb45145efece1909efcbbbf48c56f8da68221d"}, - {file = "zope.interface-5.4.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:205e40ccde0f37496904572035deea747390a8b7dc65146d30b96e2dd1359a83"}, - {file = "zope.interface-5.4.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:3f24df7124c323fceb53ff6168da70dbfbae1442b4f3da439cd441681f54fe25"}, - {file = "zope.interface-5.4.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5208ebd5152e040640518a77827bdfcc73773a15a33d6644015b763b9c9febc1"}, - {file = "zope.interface-5.4.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:17776ecd3a1fdd2b2cd5373e5ef8b307162f581c693575ec62e7c5399d80794c"}, - {file = "zope.interface-5.4.0-cp39-cp39-win32.whl", hash = "sha256:d4d9d6c1a455d4babd320203b918ccc7fcbefe308615c521062bc2ba1aa4d26e"}, - {file = "zope.interface-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:0cba8477e300d64a11a9789ed40ee8932b59f9ee05f85276dbb4b59acee5dd09"}, - {file = "zope.interface-5.4.0.tar.gz", hash = "sha256:5dba5f530fec3f0988d83b78cc591b58c0b6eb8431a85edd1569a0539a8a5a0e"}, + {file = "zope.interface-5.5.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:2cb3003941f5f4fa577479ac6d5db2b940acb600096dd9ea9bf07007f5cab46f"}, + {file = "zope.interface-5.5.0-cp27-cp27m-win32.whl", hash = "sha256:8c791f4c203ccdbcda588ea4c8a6e4353e10435ea48ddd3d8734a26fe9714cba"}, + {file = "zope.interface-5.5.0-cp27-cp27m-win_amd64.whl", hash = "sha256:3eedf3d04179774d750e8bb4463e6da350956a50ed44d7b86098e452d7ec385e"}, + {file = "zope.interface-5.5.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:58a66c2020a347973168a4a9d64317bac52f9fdfd3e6b80b252be30da881a64e"}, + {file = "zope.interface-5.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da7912ae76e1df6a1fb841b619110b1be4c86dfb36699d7fd2f177105cdea885"}, + {file = "zope.interface-5.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:423c074e404f13e6fa07f4454f47fdbb38d358be22945bc812b94289d9142374"}, + {file = "zope.interface-5.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7bdcec93f152e0e1942102537eed7b166d6661ae57835b20a52a2a3d6a3e1bf3"}, + {file = "zope.interface-5.5.0-cp310-cp310-win32.whl", hash = "sha256:03f5ae315db0d0de668125d983e2a819a554f3fdb2d53b7e934e3eb3c3c7375d"}, + {file = "zope.interface-5.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:8b9f153208d74ccfa25449a0c6cb756ab792ce0dc99d9d771d935f039b38740c"}, + {file = "zope.interface-5.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeac590cce44e68ee8ad0b8ecf4d7bf15801f102d564ca1b0eb1f12f584ee656"}, + {file = "zope.interface-5.5.0-cp35-cp35m-win32.whl", hash = "sha256:7d9ec1e6694af39b687045712a8ad14ddcb568670d5eb1b66b48b98b9312afba"}, + {file = "zope.interface-5.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:d18fb0f6c8169d26044128a2e7d3c39377a8a151c564e87b875d379dbafd3930"}, + {file = "zope.interface-5.5.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0eb2b3e84f48dd9cfc8621c80fba905d7e228615c67f76c7df7c716065669bb6"}, + {file = "zope.interface-5.5.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df6593e150d13cfcce69b0aec5df7bc248cb91e4258a7374c129bb6d56b4e5ca"}, + {file = "zope.interface-5.5.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9dc4493aa3d87591e3d2bf1453e25b98038c839ca8e499df3d7106631b66fe83"}, + {file = "zope.interface-5.5.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5c6023ae7defd052cf76986ce77922177b0c2f3913bea31b5b28fbdf6cb7099e"}, + {file = "zope.interface-5.5.0-cp36-cp36m-win32.whl", hash = "sha256:a69c28d85bb7cf557751a5214cb3f657b2b035c8c96d71080c1253b75b79b69b"}, + {file = "zope.interface-5.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:85dd6dd9aaae7a176948d8bb62e20e2968588fd787c29c5d0d964ab475168d3d"}, + {file = "zope.interface-5.5.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:970661ece2029915b8f7f70892e88404340fbdefd64728380cad41c8dce14ff4"}, + {file = "zope.interface-5.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e3495bb0cdcea212154e558082c256f11b18031f05193ae2fb85d048848db14"}, + {file = "zope.interface-5.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3f68404edb1a4fb6aa8a94675521ca26c83ebbdbb90e894f749ae0dc4ca98418"}, + {file = "zope.interface-5.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:740f3c1b44380658777669bcc42f650f5348e53797f2cee0d93dc9b0f9d7cc69"}, + {file = "zope.interface-5.5.0-cp37-cp37m-win32.whl", hash = "sha256:006f8dd81fae28027fc28ada214855166712bf4f0bfbc5a8788f9b70982b9437"}, + {file = "zope.interface-5.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:43490ad65d4c64e45a30e51a2beb7a6b63e1ff395302ad22392224eb618476d6"}, + {file = "zope.interface-5.5.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f70726b60009433111fe9928f5d89cbb18962411d33c45fb19eb81b9bbd26fcd"}, + {file = "zope.interface-5.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfa614d049667bed1c737435c609c0956c5dc0dbafdc1145ee7935e4658582cb"}, + {file = "zope.interface-5.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:58a975f89e4584d0223ab813c5ba4787064c68feef4b30d600f5e01de90ae9ce"}, + {file = "zope.interface-5.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:37ec9ade9902f412cc7e7a32d71f79dec3035bad9bd0170226252eed88763c48"}, + {file = "zope.interface-5.5.0-cp38-cp38-win32.whl", hash = "sha256:be11fce0e6af6c0e8d93c10ef17b25aa7c4acb7ec644bff2596c0d639c49e20f"}, + {file = "zope.interface-5.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:cbbf83914b9a883ab324f728de869f4e406e0cbcd92df7e0a88decf6f9ab7d5a"}, + {file = "zope.interface-5.5.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:26c1456520fdcafecc5765bec4783eeafd2e893eabc636908f50ee31fe5c738c"}, + {file = "zope.interface-5.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47ff078734a1030c48103422a99e71a7662d20258c00306546441adf689416f7"}, + {file = "zope.interface-5.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:687cab7f9ae18d2c146f315d0ca81e5ffe89a139b88277afa70d52f632515854"}, + {file = "zope.interface-5.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d80f6236b57a95eb19d5e47eb68d0296119e1eff6deaa2971ab8abe3af918420"}, + {file = "zope.interface-5.5.0-cp39-cp39-win32.whl", hash = "sha256:9cdc4e898d3b1547d018829fd4a9f403e52e51bba24be0fbfa37f3174e1ef797"}, + {file = "zope.interface-5.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:6566b3d2657e7609cd8751bcb1eab1202b1692a7af223035a5887d64bb3a2f3b"}, + {file = "zope.interface-5.5.0.tar.gz", hash = "sha256:700ebf9662cf8df70e2f0cb4988e078c53f65ee3eefd5c9d80cf988c4175c8e3"}, ] diff --git a/api/pyproject.toml b/api/pyproject.toml index 4450058cf6411e477f7307c7513b93f3dcae4949..2e10dc87fbbf5f63b2413cf3f444be9312879f60 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -8,7 +8,6 @@ license = "GNU AGPLv3" [tool.poetry.dependencies] python = "^3.7" Django = "==3.2.15" -setuptools = "==65.0.2" django-environ = "==0.9.0" Pillow = "==9.2.0" django-allauth = "==0.42.0" @@ -25,9 +24,8 @@ arrow = "==1.2.3" persisting-theory = "==1.0" django-versatileimagefield = "==2.2" django-filter = "==22.1" -django-rest-auth = "==0.9.5" mutagen = "==1.45.1" -django-dynamic-preferences = "==1.13.0" +django-dynamic-preferences = "==1.14.0" python-magic = "==0.4.27" channels = "==3.0.5" channels-redis = "==3.4.1" @@ -38,10 +36,10 @@ requests = "==2.28.1" pyOpenSSL = "21.0.0" pydub = "==0.25.1" PyLD = "==2.0.3" -aiohttp = "==3.8.1" +aiohttp = "==3.8.3" django-oauth-toolkit = "==1.7.1" django-storages = "==1.13.1" -boto3 = "==1.24.66" +boto3 = "==1.24.73" unicode-slugify = "==0.1.5" django-cacheops = "==6.1" service-identity = "==21.1.0" @@ -54,11 +52,14 @@ asgiref = "==3.5.2" #1516 ipython = "==7.34.0" python-ldap = "==3.4.2" django-auth-ldap = "==4.1.0" -uvicorn = {version = "==0.18.2", extras = ["standard"]} +uvicorn = {version = "==0.18.3", extras = ["standard"]} django-cache-memoize = "0.1.10" requests-http-message-signatures = "==0.3.1" drf-spectacular = "==0.23.1" -sentry-sdk = "==1.9.5" +sentry-sdk = "==1.9.8" +pluralizer = "==1.2.0" +importlib_metadata = { version = "==4.13.0", python = "<=3.7" } # Keep support python 3.7, can be removed if we drop support +dj-rest-auth = {extras = ["with_social"], version = "^2.2.5"} [tool.poetry.dev-dependencies] flake8 = "==3.9.2" @@ -71,15 +72,15 @@ pytest-randomly = "==3.12.0" pytest-sugar = "==0.9.5" pytest-asyncio = "==0.19.0" requests-mock = "==1.9.3" -Faker = "==14.0.0" +Faker = "==14.2.0" coverage = "==6.4.4" django-coverage-plugin = "==2.0.3" factory-boy = "==3.2.1" -django-debug-toolbar = "==3.5.0" +django-debug-toolbar = "==3.6.0" asynctest = "==0.13.0" aioresponses = "==0.7.3" prompt-toolkit = "==3.0.31" -black = "==22.6.0" +black = "==22.8.0" ipdb = "==0.13.9" debugpy = "==1.6.3" diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py index fc5809b77d3f2bbc04b7b15b0e1919549dacb4a2..4ee216fa9b0fb244f0da6f570b66ee0437ca77be 100644 --- a/api/tests/audio/test_views.py +++ b/api/tests/audio/test_views.py @@ -1,4 +1,3 @@ -import uuid import pytest from django.urls import reverse @@ -282,7 +281,7 @@ def test_subscriptions_all(factories, logged_in_api_client): assert response.status_code == 200 assert response.data == { - "results": [{"uuid": subscription.uuid, "channel": uuid.UUID(channel.uuid)}], + "results": [{"uuid": subscription.uuid, "channel": channel.uuid}], "count": 1, } diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py index 3e1255fcc3ad133acd08caa285af52b8c7cf75ab..cb7f37811f5181dc59627b27329c8db730a48a2d 100644 --- a/api/tests/common/test_views.py +++ b/api/tests/common/test_views.py @@ -273,6 +273,16 @@ def test_attachment_destroy_not_owner(factories, logged_in_api_client): attachment.refresh_from_db() +def test_render_fails_for_no_text(api_client): + payload = {} + url = reverse("api:v1:text-preview") + response = api_client.post(url, payload) + + expected = {"detail": "Invalid input"} + assert response.status_code == 400 + assert response.data == expected + + def test_can_render_text_preview(api_client, db): payload = {"text": "Hello world"} url = reverse("api:v1:text-preview") diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py index beea844b6831bb5db5afcee250af47d44d9094d4..ebff4e4aff97aa269ae04b30078494f1f7f70fc3 100644 --- a/api/tests/federation/test_api_serializers.py +++ b/api/tests/federation/test_api_serializers.py @@ -36,29 +36,6 @@ def test_library_serializer_latest_scan(factories): assert serializer.data["latest_scan"] == expected -def test_library_serializer_with_follow(factories, to_api_date): - library = factories["music.Library"](uploads_count=5678) - follow = factories["federation.LibraryFollow"](target=library) - - setattr(library, "_follows", [follow]) - expected = { - "fid": library.fid, - "uuid": str(library.uuid), - "actor": serializers.APIActorSerializer(library.actor).data, - "name": library.name, - "description": library.description, - "creation_date": to_api_date(library.creation_date), - "uploads_count": library.uploads_count, - "privacy_level": library.privacy_level, - "follow": api_serializers.NestedLibraryFollowSerializer(follow).data, - "latest_scan": None, - } - - serializer = api_serializers.LibrarySerializer(library) - - assert serializer.data == expected - - def test_library_follow_serializer_validates_existing_follow(factories): follow = factories["federation.LibraryFollow"]() serializer = api_serializers.LibraryFollowSerializer( diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py index 0f820e691fd50953f67b5885dcfbfea9daed08b7..2539a23611fa5897697e6635b00d5cafe75d8530 100644 --- a/api/tests/instance/test_nodeinfo.py +++ b/api/tests/instance/test_nodeinfo.py @@ -1,194 +1,90 @@ -import pytest - from django.urls import reverse +from funkwhale_api import __version__ as api_version +from funkwhale_api.music.utils import SUPPORTED_EXTENSIONS -import funkwhale_api -from funkwhale_api.instance import nodeinfo -from funkwhale_api.federation import actors -from funkwhale_api.federation import utils as federation_utils -from funkwhale_api.music import utils as music_utils - +from collections import OrderedDict -def test_nodeinfo_dump(preferences, mocker, avatar): - preferences["instance__banner"] = avatar - preferences["instance__nodeinfo_stats_enabled"] = True - preferences["common__api_authentication_required"] = False - preferences["moderation__unauthenticated_report_types"] = [ - "takedown_request", - "other", - "other_category_that_doesnt_exist", - ] - stats = { - "users": {"total": 1, "active_halfyear": 12, "active_month": 13}, - "tracks": 2, - "albums": 3, - "artists": 4, - "track_favorites": 5, - "music_duration": 6, - "listenings": 7, - "downloads": 42, - } - mocker.patch("funkwhale_api.instance.stats.get", return_value=stats) +def test_nodeinfo_default(api_client): + url = reverse("api:v1:instance:nodeinfo-2.0") + response = api_client.get(url) expected = { "version": "2.0", - "software": {"name": "funkwhale", "version": funkwhale_api.__version__}, + "software": OrderedDict([("name", "funkwhale"), ("version", api_version)]), "protocols": ["activitypub"], - "services": {"inbound": [], "outbound": []}, - "openRegistrations": preferences["users__registration_enabled"], - "usage": {"users": {"total": 1, "activeHalfyear": 12, "activeMonth": 13}}, + "services": OrderedDict([("inbound", []), ("outbound", [])]), + "openRegistrations": False, + "usage": { + "users": OrderedDict( + [("total", 0), ("activeHalfyear", 0), ("activeMonth", 0)] + ) + }, "metadata": { - "actorId": actors.get_service_actor().fid, - "private": preferences["instance__nodeinfo_private"], - "shortDescription": preferences["instance__short_description"], - "longDescription": preferences["instance__long_description"], - "nodeName": preferences["instance__name"], - "rules": preferences["instance__rules"], - "contactEmail": preferences["instance__contact_email"], - "defaultUploadQuota": preferences["users__upload_quota"], - "terms": preferences["instance__terms"], - "banner": federation_utils.full_url(preferences["instance__banner"].url), + "actorId": "https://test.federation/federation/actors/service", + "private": False, + "shortDescription": "", + "longDescription": "", + "rules": "", + "contactEmail": "", + "terms": "", + "nodeName": "", + "banner": None, + "defaultUploadQuota": 1000, "library": { - "federationEnabled": preferences["federation__enabled"], - "anonymousCanListen": not preferences[ - "common__api_authentication_required" - ], - "tracks": {"total": stats["tracks"]}, - "artists": {"total": stats["artists"]}, - "albums": {"total": stats["albums"]}, - "music": {"hours": stats["music_duration"]}, - }, - "usage": { - "favorites": {"tracks": {"total": stats["track_favorites"]}}, - "listenings": {"total": stats["listenings"]}, - "downloads": {"total": stats["downloads"]}, + "federationEnabled": True, + "anonymousCanListen": False, + "tracks": OrderedDict([("total", 0)]), + "artists": OrderedDict([("total", 0)]), + "albums": OrderedDict([("total", 0)]), + "music": OrderedDict([("hours", 0)]), }, - "supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS, + "supportedUploadExtensions": SUPPORTED_EXTENSIONS, "allowList": {"enabled": False, "domains": None}, "reportTypes": [ - { - "type": "takedown_request", - "label": "Takedown request", - "anonymous": True, - }, - { - "type": "invalid_metadata", - "label": "Invalid metadata", - "anonymous": False, - }, - { - "type": "illegal_content", - "label": "Illegal content", - "anonymous": False, - }, - { - "type": "offensive_content", - "label": "Offensive content", - "anonymous": False, - }, - {"type": "other", "label": "Other", "anonymous": True}, - ], - "funkwhaleSupportMessageEnabled": preferences[ - "instance__funkwhale_support_message_enabled" - ], - "instanceSupportMessage": preferences["instance__support_message"], - "endpoints": { - "knownNodes": federation_utils.full_url( - reverse("api:v1:federation:domains-list") + OrderedDict( + [ + ("type", "takedown_request"), + ("label", "Takedown request"), + ("anonymous", True), + ] ), - "libraries": federation_utils.full_url( - reverse("federation:index:index-libraries") + OrderedDict( + [ + ("type", "invalid_metadata"), + ("label", "Invalid metadata"), + ("anonymous", False), + ] ), - "channels": federation_utils.full_url( - reverse("federation:index:index-channels") + OrderedDict( + [ + ("type", "illegal_content"), + ("label", "Illegal content"), + ("anonymous", True), + ] + ), + OrderedDict( + [ + ("type", "offensive_content"), + ("label", "Offensive content"), + ("anonymous", False), + ] + ), + OrderedDict( + [("type", "other"), ("label", "Other"), ("anonymous", False)] ), - }, - }, - } - assert nodeinfo.get() == expected - - -def test_nodeinfo_dump_stats_disabled(preferences, mocker): - preferences["instance__nodeinfo_stats_enabled"] = False - preferences["federation__public_index"] = False - preferences["moderation__unauthenticated_report_types"] = [ - "takedown_request", - "other", - ] - - expected = { - "version": "2.0", - "software": {"name": "funkwhale", "version": funkwhale_api.__version__}, - "protocols": ["activitypub"], - "services": {"inbound": [], "outbound": []}, - "openRegistrations": preferences["users__registration_enabled"], - "usage": {"users": {"total": 0, "activeHalfyear": 0, "activeMonth": 0}}, - "metadata": { - "actorId": actors.get_service_actor().fid, - "private": preferences["instance__nodeinfo_private"], - "shortDescription": preferences["instance__short_description"], - "longDescription": preferences["instance__long_description"], - "nodeName": preferences["instance__name"], - "rules": preferences["instance__rules"], - "contactEmail": preferences["instance__contact_email"], - "defaultUploadQuota": preferences["users__upload_quota"], - "terms": preferences["instance__terms"], - "banner": None, - "library": { - "federationEnabled": preferences["federation__enabled"], - "anonymousCanListen": not preferences[ - "common__api_authentication_required" - ], - }, - "supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS, - "allowList": {"enabled": False, "domains": None}, - "reportTypes": [ - { - "type": "takedown_request", - "label": "Takedown request", - "anonymous": True, - }, - { - "type": "invalid_metadata", - "label": "Invalid metadata", - "anonymous": False, - }, - { - "type": "illegal_content", - "label": "Illegal content", - "anonymous": False, - }, - { - "type": "offensive_content", - "label": "Offensive content", - "anonymous": False, - }, - {"type": "other", "label": "Other", "anonymous": True}, - ], - "funkwhaleSupportMessageEnabled": preferences[ - "instance__funkwhale_support_message_enabled" ], - "instanceSupportMessage": preferences["instance__support_message"], - "endpoints": {"knownNodes": None, "libraries": None, "channels": None}, + "funkwhaleSupportMessageEnabled": True, + "instanceSupportMessage": "", + "endpoints": OrderedDict( + [("knownNodes", None), ("channels", None), ("libraries", None)] + ), + "usage": { + "favorites": OrderedDict([("tracks", {"total": 0})]), + "listenings": OrderedDict([("total", 0)]), + "downloads": OrderedDict([("total", 0)]), + }, }, } - assert nodeinfo.get() == expected - - -@pytest.mark.parametrize( - "enabled, public, expected", - [ - (True, True, {"enabled": True, "domains": ["allowed.example"]}), - (True, False, {"enabled": True, "domains": None}), - (False, False, {"enabled": False, "domains": None}), - ], -) -def test_nodeinfo_allow_list_enabled(preferences, factories, enabled, public, expected): - preferences["moderation__allow_list_enabled"] = enabled - preferences["moderation__allow_list_public"] = public - factories["federation.Domain"](name="allowed.example", allowed=True) - factories["federation.Domain"](allowed=False) - factories["federation.Domain"](allowed=None) - assert nodeinfo.get()["metadata"]["allowList"] == expected + assert response.data == expected diff --git a/api/tests/instance/test_views.py b/api/tests/instance/test_views.py index 4bc9c296a4d77d019b7c12bea4cdc4006ad37842..005924990d342b6a1d933f5d7d83ba87e757d47f 100644 --- a/api/tests/instance/test_views.py +++ b/api/tests/instance/test_views.py @@ -5,15 +5,12 @@ from django.urls import reverse from funkwhale_api.federation import utils as federation_utils -def test_nodeinfo_endpoint(db, api_client, mocker): - payload = {"test": "test"} - mocker.patch("funkwhale_api.instance.nodeinfo.get", return_value=payload) +def test_nodeinfo_endpoint(db, api_client): url = reverse("api:v1:instance:nodeinfo-2.0") response = api_client.get(url) ct = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" # noqa assert response.status_code == 200 assert response["Content-Type"] == ct - assert response.data == payload def test_settings_only_list_public_settings(db, api_client, preferences): diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index 13522e338c13e4fdebfdcb1fe68b4b6a8c89c7ae..15cecae33e7b46cbead3060d2f05a1bc79da550e 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -452,7 +452,7 @@ def test_upload_import_skip_existing_track_in_own_library(factories, temp_signal assert duplicate.import_status == "skipped" assert duplicate.import_details == { "code": "already_imported_in_owned_libraries", - "duplicates": [str(existing.uuid)], + "duplicates": str(existing.uuid), } handler.assert_called_once_with( diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 4c4d407a2f3ceb131e98d69b9e5d572adb8d9b6d..3a069cb163e261c2ebad72f3ded778e8b49b873f 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -1346,12 +1346,14 @@ def test_search_get(logged_in_api_client, factories): factories["tags.Tag"]() url = reverse("api:v1:search") - expected = { - "artists": [serializers.ArtistWithAlbumsSerializer(artist).data], - "albums": [serializers.AlbumSerializer(album).data], - "tracks": [serializers.TrackSerializer(track).data], - "tags": [views.TagSerializer(tag).data], - } + expected = serializers.SearchResultSerializer( + { + "artists": [artist], + "albums": [album], + "tracks": [track], + "tags": [tag], + } + ).data response = logged_in_api_client.get(url, {"q": "foo"}) diff --git a/api/tests/radios/test_filters.py b/api/tests/radios/test_filters.py new file mode 100644 index 0000000000000000000000000000000000000000..d63ef7940a4f2d98d7efd1d77955048829dc828a --- /dev/null +++ b/api/tests/radios/test_filters.py @@ -0,0 +1,37 @@ +from funkwhale_api.radios import filters + + +def test_clean_config_artist_name_sorting(factories): + + artist3 = factories["music.Artist"](name="The Green Eyes") + artist2 = factories["music.Artist"](name="The Green Eyed Machine") + artist1 = factories["music.Artist"](name="The Green Seed") + factories["music.Artist"]() + filter_config = {"type": "artist", "ids": [artist3.pk, artist1.pk, artist2.pk]} + artist_filter = filters.ArtistFilter() + config = artist_filter.clean_config(filter_config) + # list of names whose artists have been sorted by name then by id + sorted_names = [ + a.name + for a in list( + sorted([artist2, artist1, artist3], key=lambda x: (len(x.name), x.id)) + ) + ] + assert config["names"] == sorted_names + + +def test_clean_config_tag_name_sorting(factories): + + tag3 = factories["tags.Tag"](name="Rock") + tag2 = factories["tags.Tag"](name="Classic") + tag1 = factories["tags.Tag"](name="Punk") + factories["tags.Tag"]() + filter_config = {"type": "tag", "names": [tag3.name, tag1.name, tag2.name]} + tag_filter = filters.TagFilter() + config = tag_filter.clean_config(filter_config) + # list of names whose tags have been sorted by name then by id + sorted_names = [ + a.name + for a in list(sorted([tag2, tag1, tag3], key=lambda x: (len(x.name), x.id))) + ] + assert config["names"] == sorted_names diff --git a/changes/changelog.d/1362.bugfix b/changes/changelog.d/1362.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..461606312ba3b3beab5394413d2bac2173872fbd --- /dev/null +++ b/changes/changelog.d/1362.bugfix @@ -0,0 +1 @@ +Fix editing playlist tracks (#1362) diff --git a/changes/changelog.d/1392.enhancement b/changes/changelog.d/1392.enhancement deleted file mode 100644 index 4b7d9be44b9baf8efa3e0a527dd339e133d6456d..0000000000000000000000000000000000000000 --- a/changes/changelog.d/1392.enhancement +++ /dev/null @@ -1 +0,0 @@ -Add task to refresh actor data in the cache (#1392) diff --git a/changes/changelog.d/1526.enhancement b/changes/changelog.d/1526.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..30e865a9fcf5a66d11da7c73ea44cf9f589e69cd --- /dev/null +++ b/changes/changelog.d/1526.enhancement @@ -0,0 +1 @@ +Automatically fetch next page of tracks (#1526) diff --git a/changes/changelog.d/1768.enhancement b/changes/changelog.d/1768.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..e597dc0f1a2446309a90c8345fada78c7188ae9e --- /dev/null +++ b/changes/changelog.d/1768.enhancement @@ -0,0 +1 @@ +Migrate to Vue 3 diff --git a/changes/changelog.d/1769.bugfix b/changes/changelog.d/1769.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..c209cf176169a2c7e851a3470e41682289df9951 --- /dev/null +++ b/changes/changelog.d/1769.bugfix @@ -0,0 +1 @@ +Fixes service worker (#1634) diff --git a/changes/changelog.d/1769.enhancement b/changes/changelog.d/1769.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..a212d50bae38750dcacf74c64e43a9f0104eef79 --- /dev/null +++ b/changes/changelog.d/1769.enhancement @@ -0,0 +1 @@ +Handle PWA correctly and provide better cache strategy for album covers (#1721) diff --git a/changes/changelog.d/1876.bugfix b/changes/changelog.d/1876.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..68f86c19c75d116f1cdab7967ad4cfb23c0bb85c --- /dev/null +++ b/changes/changelog.d/1876.bugfix @@ -0,0 +1 @@ +Fix global keyboard shortcuts firing when input is focused (#1876) diff --git a/changes/changelog.d/1877.refactoring b/changes/changelog.d/1877.refactoring new file mode 100644 index 0000000000000000000000000000000000000000..9e970de63265fc82467cf4659608b1c579a90c18 --- /dev/null +++ b/changes/changelog.d/1877.refactoring @@ -0,0 +1 @@ +Replace django-rest-auth with dj-rest-auth (#1877) diff --git a/changes/changelog.d/1890.bugfix b/changes/changelog.d/1890.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..d68c08d89ee61d429b9cc7347ac9a64bbcfcdf72 --- /dev/null +++ b/changes/changelog.d/1890.bugfix @@ -0,0 +1 @@ +Fix OAuth login (#1890) diff --git a/changes/changelog.d/1892.enhancement b/changes/changelog.d/1892.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..527c9d8f4e87175be6255c1e74373e7cc2ccaf58 --- /dev/null +++ b/changes/changelog.d/1892.enhancement @@ -0,0 +1 @@ +Fix openapi specs for user endpoints (#1892, #1894) diff --git a/changes/changelog.d/1895.enhancement b/changes/changelog.d/1895.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..44794672941a7d80be46f1f4f78e724dcf96f86a --- /dev/null +++ b/changes/changelog.d/1895.enhancement @@ -0,0 +1 @@ +Make sure ChannelViewSet always has a serializer (#1895) diff --git a/changes/changelog.d/1896.enhancement b/changes/changelog.d/1896.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..2f384d2b6cfd87feabdaa3c40f669bd2d0bb8436 --- /dev/null +++ b/changes/changelog.d/1896.enhancement @@ -0,0 +1 @@ +Improve specification of LibraryFollowViewSet (#1896) diff --git a/changes/changelog.d/1898.enhancement b/changes/changelog.d/1898.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..10c683c4724429beff08ae3337c4256ed54c605c --- /dev/null +++ b/changes/changelog.d/1898.enhancement @@ -0,0 +1 @@ +Fix specs for ListenViewSet (#1898) diff --git a/changes/changelog.d/1899.enhancement b/changes/changelog.d/1899.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..458a98dbf2b8343acf07839c01eb90682a4a8449 --- /dev/null +++ b/changes/changelog.d/1899.enhancement @@ -0,0 +1 @@ +Exclude /api/v1/oauth/authorize from the specs since its not supported yet (#1899) diff --git a/changes/changelog.d/1901.enhancement b/changes/changelog.d/1901.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..f97f1d8ef66bd5424a31162fa9614f3735764bbe --- /dev/null +++ b/changes/changelog.d/1901.enhancement @@ -0,0 +1 @@ +Add hint which serializer is used for OembedView (#1901) diff --git a/changes/changelog.d/1902.enhancement b/changes/changelog.d/1902.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..1d73bd24d68dbe6d3513bf60cd521f4fbe8a6c4e --- /dev/null +++ b/changes/changelog.d/1902.enhancement @@ -0,0 +1 @@ +Use proper serializer for Search endpoint (#1902) diff --git a/changes/changelog.d/1903.enhancement b/changes/changelog.d/1903.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..eff5f045ed241384dce1d5e449483bb8cd0ec5e6 --- /dev/null +++ b/changes/changelog.d/1903.enhancement @@ -0,0 +1 @@ +Add proper serialization for TextPreviewView (#1903) diff --git a/changes/changelog.d/1975.bugfix b/changes/changelog.d/1975.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..11d00ce665f1994444354c4fa8b5aff87a4f03ed --- /dev/null +++ b/changes/changelog.d/1975.bugfix @@ -0,0 +1 @@ +Fix search by text in affected views (#1858) diff --git a/changes/changelog.d/1976.bugfix b/changes/changelog.d/1976.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..f82a110ceb813446c2f5b05cd4fe7411c49b8e18 --- /dev/null +++ b/changes/changelog.d/1976.bugfix @@ -0,0 +1 @@ +Fix remote search (#1857) diff --git a/changes/changelog.d/1977.bugfix b/changes/changelog.d/1977.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..e05e03648379bdaac30615dd3a97c5ef8ba7b741 --- /dev/null +++ b/changes/changelog.d/1977.bugfix @@ -0,0 +1 @@ +Fix CSP header issues diff --git a/changes/changelog.d/1997.bugfix b/changes/changelog.d/1997.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..c35128083c9db8ca66d6836fc04e23f30c84ad82 --- /dev/null +++ b/changes/changelog.d/1997.bugfix @@ -0,0 +1 @@ +Fixes track listenings not being sent when tab is not focused diff --git a/changes/changelog.d/coverage.enhancement b/changes/changelog.d/coverage.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..bb66a780e4d39099e15f8f457ff5e84a0a9d31a5 --- /dev/null +++ b/changes/changelog.d/coverage.enhancement @@ -0,0 +1 @@ +Add coverage report for Frontend Tests diff --git a/changes/changelog.d/mutations.enhancement b/changes/changelog.d/mutations.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..711281961df3b3c2988fdff10f4bd96c0da1d266 --- /dev/null +++ b/changes/changelog.d/mutations.enhancement @@ -0,0 +1 @@ +Make mutations endpoint appear in openapi specs diff --git a/changes/changelog.d/nodeinfo.enhancement b/changes/changelog.d/nodeinfo.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..d125fedf69f68e611da9898a8eb3b7a973ed5d7c --- /dev/null +++ b/changes/changelog.d/nodeinfo.enhancement @@ -0,0 +1 @@ +Refactor node info endpoint to use proper serializers \ No newline at end of file diff --git a/changes/changelog.d/openapi-alignment.misc b/changes/changelog.d/openapi-alignment.misc new file mode 100644 index 0000000000000000000000000000000000000000..2de67abc719414ec8cb8213e227829c28a1279c8 --- /dev/null +++ b/changes/changelog.d/openapi-alignment.misc @@ -0,0 +1 @@ +Align the openapi spec to the actual API wherever possible diff --git a/changes/changelog.d/pipeline.enhancement b/changes/changelog.d/pipeline.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..2cba9db4ef5cab043ab180b11ad2fbf7fa9af582 --- /dev/null +++ b/changes/changelog.d/pipeline.enhancement @@ -0,0 +1 @@ +Prevent running two pipelines for MRs diff --git a/changes/changelog.d/python3.11.enhancement b/changes/changelog.d/python3.11.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..3ac1eaf2c97e0f9ee0246ddc5f7e9b2df1ab0a96 --- /dev/null +++ b/changes/changelog.d/python3.11.enhancement @@ -0,0 +1 @@ +Add support for python 3.11 diff --git a/changes/changelog.d/rename_operation_id_for_api_client.enhancement b/changes/changelog.d/rename_operation_id_for_api_client.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..9ddb886c9e4c1cf3906a857c74e57a6aca15491d --- /dev/null +++ b/changes/changelog.d/rename_operation_id_for_api_client.enhancement @@ -0,0 +1 @@ +Rename OpenAPI schema's operation ids for nicer API client method names. diff --git a/changes/changelog.d/rewrite-embedded-player.enhancement b/changes/changelog.d/rewrite-embedded-player.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..f22eb7b063f71d082eb001ed0536fe3ecfee88be --- /dev/null +++ b/changes/changelog.d/rewrite-embedded-player.enhancement @@ -0,0 +1 @@ +Rewrite embedded player to petite-vue diff --git a/changes/changelog.d/sentry.feature b/changes/changelog.d/sentry.feature index eda8170f50abd0e29d9c7cbeadd530315f68f8ca..c559f62bb3f9c1e08f96931b5a5bee26089fbd52 100644 --- a/changes/changelog.d/sentry.feature +++ b/changes/changelog.d/sentry.feature @@ -1 +1 @@ -Add Sentry SDK to collect errors at the backend +Add Sentry SDK to collect #1479 diff --git a/changes/changelog.d/settingsview.enhancement b/changes/changelog.d/settingsview.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..41ea43c590cb3e12c0675d1d56809d051bef97f1 --- /dev/null +++ b/changes/changelog.d/settingsview.enhancement @@ -0,0 +1 @@ +Refactor SettingsView to use a proper serializer diff --git a/deploy/nginx.template b/deploy/nginx.template index 2e6c81081a53cd5ada937ac93544a87baabfebf7..a1e7177295c0d320d5080230dfcc77c2bca63946 100644 --- a/deploy/nginx.template +++ b/deploy/nginx.template @@ -94,7 +94,7 @@ server { add_header Cache-Control "public, must-revalidate, proxy-revalidate"; } location = /front/embed.html { - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:; worker-src 'self'"; + add_header Content-Security-Policy "connect-src https: http: 'self'; default-src 'self'; script-src 'self' unpkg.com 'unsafe-inline' 'unsafe-eval'; style-src https: http: 'self' 'unsafe-inline'; img-src https: http: 'self' data:; font-src https: http: 'self' data:; object-src 'none'; media-src https: http: 'self' data:"; add_header Referrer-Policy "strict-origin-when-cross-origin"; add_header X-Frame-Options "" always; diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 6d27ed4a1258822cd4102dfd2137bd945a13b5ed..928f7e6828379418328e772e576bd97d432346d4 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -69,12 +69,12 @@ http { text/x-component text/x-cross-domain-policy; - add_header Content-Security-Policy "default-src 'self' 'unsafe-eval'; connect-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:"; + add_header Content-Security-Policy "connect-src https: wss: http: ws: 'self' 'unsafe-eval'; style-src https: http: 'self' 'unsafe-inline'; img-src https: http: 'self' data:; font-src https: http: 'self' data:; object-src 'none'; media-src https: http: 'self' data:"; add_header Referrer-Policy "strict-origin-when-cross-origin"; add_header X-Frame-Options "SAMEORIGIN" always; location /front/ { - add_header Content-Security-Policy "default-src 'self' 'unsafe-eval'; connect-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:"; + add_header Content-Security-Policy "connect-src https: wss: http: ws: 'self' 'unsafe-eval'; style-src https: http: 'self' 'unsafe-inline'; img-src https: http: 'self' data:; font-src https: http: 'self' data:; object-src 'none'; media-src https: http: 'self' data:"; add_header Referrer-Policy "strict-origin-when-cross-origin"; add_header Service-Worker-Allowed "/"; # uncomment the following line and comment the proxy-pass one @@ -83,7 +83,7 @@ http { proxy_pass http://funkwhale-front/front/; } location /front/embed.html { - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:"; + add_header Content-Security-Policy "connect-src https: http: 'self'; default-src 'self'; script-src 'self' unpkg.com 'unsafe-inline' 'unsafe-eval'; style-src https: http: 'self' 'unsafe-inline'; img-src https: http: 'self' data:; font-src https: http: 'self' data:; object-src 'none'; media-src https: http: 'self' data:"; add_header Referrer-Policy "strict-origin-when-cross-origin"; add_header X-Frame-Options "" always; proxy_pass http://funkwhale-front/front/embed.html; diff --git a/docs/poetry.lock b/docs/poetry.lock index 5e159ad827402d33280bcfebe686b7514cea8540..b569d45629772ff7c723c949bda889ad8b67347a 100644 --- a/docs/poetry.lock +++ b/docs/poetry.lock @@ -15,10 +15,10 @@ optional = false python-versions = ">=3.7" [package.extras] -tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] -name = "babel" +name = "Babel" version = "2.10.3" description = "Internationalization utilities" category = "main" @@ -30,7 +30,7 @@ pytz = ">=2015.7" [[package]] name = "certifi" -version = "2022.6.15" +version = "2022.6.15.1" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -38,7 +38,7 @@ python-versions = ">=3.6" [[package]] name = "charset-normalizer" -version = "2.1.0" +version = "2.1.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -67,8 +67,8 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] -name = "django" -version = "4.0.5" +name = "Django" +version = "4.0.6" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." category = "main" optional = false @@ -121,7 +121,7 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] -name = "jinja2" +name = "Jinja2" version = "3.1.2" description = "A very fast and expressive template engine." category = "main" @@ -156,7 +156,7 @@ code_style = ["pre-commit (==2.6)"] benchmarking = ["pytest-benchmark (>=3.2,<4.0)", "pytest", "psutil"] [[package]] -name = "markupsafe" +name = "MarkupSafe" version = "2.1.1" description = "Safely add untrusted strings to HTML/XML markup." category = "main" @@ -181,7 +181,7 @@ code_style = ["pre-commit (==2.6)"] [[package]] name = "mdurl" -version = "0.1.1" +version = "0.1.2" description = "Markdown URL utilities" category = "main" optional = false @@ -222,13 +222,16 @@ python-versions = ">=3.6" pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] -name = "pygments" -version = "2.12.0" +name = "Pygments" +version = "2.13.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false python-versions = ">=3.6" +[package.extras] +plugins = ["importlib-metadata"] + [[package]] name = "pyparsing" version = "3.0.9" @@ -238,18 +241,18 @@ optional = false python-versions = ">=3.6.8" [package.extras] -diagrams = ["railroad-diagrams", "jinja2"] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytz" -version = "2022.1" +version = "2022.2.1" description = "World timezone definitions, modern and historical" category = "main" optional = false python-versions = "*" [[package]] -name = "pyyaml" +name = "PyYAML" version = "6.0" description = "YAML parser and emitter for Python" category = "main" @@ -444,7 +447,7 @@ optional = false python-versions = ">=3.5" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] +lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] [[package]] @@ -465,7 +468,7 @@ python-versions = ">=3.7" [[package]] name = "tzdata" -version = "2022.1" +version = "2022.2" description = "Provider of IANA time zone data" category = "main" optional = false @@ -477,11 +480,11 @@ version = "1.26.11" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.5.*, <4" [package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] @@ -498,13 +501,13 @@ asgiref = [ {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, ] -babel = [ +Babel = [ {file = "Babel-2.10.3-py3-none-any.whl", hash = "sha256:ff56f4892c1c4bf0d814575ea23471c230d544203c7748e8c68f0089478d48eb"}, {file = "Babel-2.10.3.tar.gz", hash = "sha256:7614553711ee97490f732126dc077f8d0ae084ebc6a96e23db1482afabdb2c51"}, ] certifi = [ - {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, - {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, + {file = "certifi-2022.6.15.1-py3-none-any.whl", hash = "sha256:43dadad18a7f168740e66944e4fa82c6611848ff9056ad910f8f7a3e46ab89e0"}, + {file = "certifi-2022.6.15.1.tar.gz", hash = "sha256:cffdcd380919da6137f76633531a5817e3a9f268575c128249fb637e4f9e73fb"}, ] charset-normalizer = [ {file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"}, @@ -518,9 +521,9 @@ colorama = [ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] -django = [ - {file = "Django-4.0.5-py3-none-any.whl", hash = "sha256:502ae42b6ab1b612c933fb50d5ff850facf858a4c212f76946ecd8ea5b3bf2d9"}, - {file = "Django-4.0.5.tar.gz", hash = "sha256:f7431a5de7277966f3785557c3928433347d998c1e6459324501378a291e5aab"}, +Django = [ + {file = "Django-4.0.6-py3-none-any.whl", hash = "sha256:ca54ebedfcbc60d191391efbf02ba68fb52165b8bf6ccd6fe71f098cac1fe59e"}, + {file = "Django-4.0.6.tar.gz", hash = "sha256:a67a793ff6827fd373555537dca0da293a63a316fe34cb7f367f898ccca3c3ae"}, ] django-environ = [ {file = "django-environ-0.9.0.tar.gz", hash = "sha256:bff5381533056328c9ac02f71790bd5bf1cea81b1beeb648f28b81c9e83e0a21"}, @@ -538,7 +541,7 @@ imagesize = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] -jinja2 = [ +Jinja2 = [ {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, ] @@ -546,7 +549,7 @@ markdown-it-py = [ {file = "markdown-it-py-2.1.0.tar.gz", hash = "sha256:cf7e59fed14b5ae17c0006eff14a2d9a00ed5f3a846148153899a0224e2c07da"}, {file = "markdown_it_py-2.1.0-py3-none-any.whl", hash = "sha256:93de681e5c021a432c63147656fe21790bc01231e0cd2da73626f1aa3ac0fe27"}, ] -markupsafe = [ +MarkupSafe = [ {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, @@ -593,8 +596,8 @@ mdit-py-plugins = [ {file = "mdit_py_plugins-0.3.0-py3-none-any.whl", hash = "sha256:b1279701cee2dbf50e188d3da5f51fee8d78d038cdf99be57c6b9d1aa93b4073"}, ] mdurl = [ - {file = "mdurl-0.1.1-py3-none-any.whl", hash = "sha256:6a8f6804087b7128040b2fb2ebe242bdc2affaeaa034d5fc9feeed30b443651b"}, - {file = "mdurl-0.1.1.tar.gz", hash = "sha256:f79c9709944df218a4cdb0fcc0b0c7ead2f44594e3e84dc566606f04ad749c20"}, + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] myst-parser = [ {file = "myst-parser-0.18.0.tar.gz", hash = "sha256:739a4d96773a8e55a2cacd3941ce46a446ee23dcd6b37e06f73f551ad7821d86"}, @@ -604,19 +607,19 @@ packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] -pygments = [ - {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, - {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, +Pygments = [ + {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, + {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, ] pyparsing = [ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] pytz = [ - {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, - {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, + {file = "pytz-2022.2.1-py2.py3-none-any.whl", hash = "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197"}, + {file = "pytz-2022.2.1.tar.gz", hash = "sha256:cea221417204f2d1a2aa03ddae3e867921971d0d76f14d87abb4414415bbdcf5"}, ] -pyyaml = [ +PyYAML = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, @@ -712,8 +715,8 @@ typing-extensions = [ {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, ] tzdata = [ - {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, - {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"}, + {file = "tzdata-2022.2-py2.py3-none-any.whl", hash = "sha256:c3119520447d68ef3eb8187a55a4f44fa455f30eb1b4238fa5691ba094f2b05b"}, + {file = "tzdata-2022.2.tar.gz", hash = "sha256:21f4f0d7241572efa7f7a4fdabb052e61b55dc48274e6842697ccdf5253e5451"}, ] urllib3 = [ {file = "urllib3-1.26.11-py2.py3-none-any.whl", hash = "sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc"}, diff --git a/front/.eslintrc.js b/front/.eslintrc.js index 4a27a4a420256cae4ca717f8f2d6cc970efde28d..343c62e278c8ca42cefe8f1987ca38db6379ea34 100644 --- a/front/.eslintrc.js +++ b/front/.eslintrc.js @@ -4,22 +4,52 @@ module.exports = { es6: true }, extends: [ - 'plugin:vue/recommended', - 'standard' + 'plugin:vue/vue3-recommended', + '@vue/typescript/recommended', + '@vue/standard' ], globals: { - Atomics: 'readonly', - SharedArrayBuffer: 'readonly' + SharedArrayBuffer: 'readonly', + Atomics: 'readonly' }, + parser: 'vue-eslint-parser', parserOptions: { - ecmaVersion: 2020, + parser: '@typescript-eslint/parser', sourceType: 'module', + ecmaVersion: 2020 }, plugins: [ 'vue' ], rules: { - "vue/no-v-html": "off", // TODO: tackle this properly - "vue/no-use-v-if-with-v-for": "off" - } + // NOTE: Nicer for the eye + 'operator-linebreak': ['error', 'before'], + + // NOTE: Handled by typescript + '@typescript-eslint/no-unused-vars': 'off', + 'no-use-before-define': 'off', + 'no-unused-vars': 'off', + 'no-redeclare': 'off', + 'no-undef': 'off', + + // TODO (wvffle): Remove after VUI and #1618 + 'vue/multi-word-component-names': 'off', + 'import/extensions': 'off', + + // TODO (wvffle): Remove after embeded player migration + '@typescript-eslint/no-this-alias': 'off', + + // TODO (wvffle): Remove after API Client + '@typescript-eslint/no-explicit-any': 'off' + }, + overrides: [ + { + files: ['public/embed.html'], + rules: { + // NOTE: It is broken for some reason. It's safe to disable in a .html file as this rule only + // brings <!-- eslint-disable --> comments support for the <template> tag in SFCs + 'vue/comment-directive': 'off' + } + } + ] } diff --git a/front/Dockerfile b/front/Dockerfile index d74b230bd0759159c45b255563daa01617a4e3ea..13ce70f00fceda62d11b69c30baa7b882bd5936f 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -5,7 +5,7 @@ COPY package.json yarn.lock /app/ COPY src /app/src/ COPY scripts /app/scripts COPY public /app/public -COPY vite.config.js index.html embed.html /app/ +COPY vite.config.ts index.html /app/ RUN apk add --no-cache jq bash coreutils python3 build-base RUN yarn install diff --git a/front/babel.config.js b/front/babel.config.js deleted file mode 100644 index d6d12ff7211e50e6ba07676a7aad45db29662473..0000000000000000000000000000000000000000 --- a/front/babel.config.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - presets: [ - '@babel/preset-env', - ], - plugins: [ - '@babel/plugin-transform-runtime', - function () { - return { - visitor: { - MetaProperty(path) { - path.replaceWithSourceString('process') - }, - }, - } - }, - ], -} diff --git a/front/embed.html b/front/embed.html deleted file mode 100644 index 1f7f6c51a7b918fe1898531a249927cf220907c0..0000000000000000000000000000000000000000 --- a/front/embed.html +++ /dev/null @@ -1,22 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - -<head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width,initial-scale=1.0"> - <meta name="generator" content="Funkwhale"> - <link rel="icon" href="/favicon.png"> - <title>Funkwhale Widget</title> - <script type="module" src="/src/embed/embed.js"></script> -</head> - -<body> - <noscript> - <strong>We're sorry but this widget doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> - </noscript> - <div id="app"></div> - <!-- built files will be auto injected --> -</body> - -</html> diff --git a/front/index.html b/front/index.html index ace99da15d5ed0a9626df02bc4eb5c1b702d940a..de34034ffab9ef742ba42bb49447179a9cf34153 100644 --- a/front/index.html +++ b/front/index.html @@ -8,7 +8,6 @@ <meta name="generator" content="Funkwhale"> <link rel="icon" href="/favicon.png"> <title>Funkwhale</title> - <script type="module" src="/src/main.js"></script> <style> #fake-app { width: 100vw; @@ -60,7 +59,7 @@ </style> </head> -<body class="theme-light" id="body"> +<body id="body"> <div id="fake-app"> <div id="fake-sidebar"> <div id="orange-square"></div> @@ -86,9 +85,8 @@ </div> </div> </div> - <div id="app"> - </div> - <!-- built files will be auto injected --> + <div id="app"></div> + <script type="module" src="/src/main.ts"></script> </body> </html> diff --git a/front/locales/app.pot b/front/locales/app.pot index 2f2905e11f05799d6883695fee582c8be75dedba..837619aba0f4539b54975945db14a96104d637e5 100644 --- a/front/locales/app.pot +++ b/front/locales/app.pot @@ -1,8056 +1,8062 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the front package. -# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. -# -#, fuzzy msgid "" msgstr "" -"Project-Id-Version: front 0.1.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-12-08 18:17+0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" -"Language-Team: LANGUAGE <LL@li.org>\n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" +"Generated-By: easygettext\n" +"Project-Id-Version: \n" -#: front/src/components/playlists/PlaylistModal.vue:8 -#: front/src/components/playlists/PlaylistModal.vue:5 +#: src/components/playlists/PlaylistModal.vue:8 +#: src/components/playlists/PlaylistModal.vue:5 msgctxt "Popup/Playlist/Paragraph" msgid "\"%{ title }\", by %{ artist }" msgstr "" -#: front/src/components/RemoteSearchForm.vue:132 +#: src/components/RemoteSearchForm.vue:34 msgctxt "Head/Fetch/Field.Placeholder" msgid "@username@example.com" msgstr "" -#: front/src/components/auth/Authorize.vue:31 +#: src/components/auth/Authorize.vue:31 msgctxt "Content/Auth/Title" msgid "%{ app } wants to access your Funkwhale account" msgstr "" -#: front/src/components/Home.vue:65 src/components/Home.vue:7 +#: src/components/Home.vue:65 +#: src/components/Home.vue:7 msgctxt "Content/Home/Stat" msgid "%{ count } active user" msgid_plural "%{ count } active users" msgstr[0] "" msgstr[1] "" -#: front/src/components/audio/artist/Card.vue:18 +#: src/components/audio/artist/Card.vue:18 msgctxt "*/*/*" msgid "%{ count } episode" msgid_plural "%{ count } episodes" msgstr[0] "" msgstr[1] "" -#: front/src/components/audio/ChannelCard.vue:12 -#: front/src/components/audio/ChannelSerieCard.vue:15 -#: front/src/components/library/AlbumBase.vue:25 -#: front/src/components/library/AlbumBase.vue:59 -#: front/src/components/library/AlbumBase.vue:20 -#: front/src/components/library/AlbumBase.vue:54 -#: front/src/components/library/AlbumBase.vue:2 -#: front/src/components/library/AlbumBase.vue:1 src/views/channels/DetailBase.vue:19 -#: front/src/views/channels/DetailBase.vue:14 src/views/channels/DetailBase.vue:2 +#: src/components/audio/ChannelCard.vue:12 +#: src/components/audio/ChannelSerieCard.vue:15 +#: src/components/library/AlbumBase.vue:25 +#: src/components/library/AlbumBase.vue:59 +#: src/components/library/AlbumBase.vue:20 +#: src/components/library/AlbumBase.vue:54 +#: src/components/library/AlbumBase.vue:2 +#: src/components/library/AlbumBase.vue:1 +#: src/views/channels/DetailBase.vue:19 +#: src/views/channels/DetailBase.vue:14 +#: src/views/channels/DetailBase.vue:2 msgctxt "Content/Channel/Paragraph" msgid "%{ count } episode" msgid_plural "%{ count } episodes" msgstr[0] "" msgstr[1] "" -#: front/src/components/favorites/List.vue:12 +#: src/components/favorites/List.vue:12 msgctxt "Content/Favorites/Title" msgid "%{ count } favorite" msgid_plural "%{ count } favorites" msgstr[0] "" msgstr[1] "" -#: front/src/components/channels/UploadModal.vue:175 +#: src/components/channels/UploadModal.vue:31 msgctxt "*/*/*" msgid "%{ count } file" msgid_plural "%{ count } files" msgstr[0] "" msgstr[1] "" -#: front/src/components/Home.vue:70 src/components/Home.vue:12 +#: src/components/Home.vue:70 +#: src/components/Home.vue:12 msgctxt "Content/Home/Stat" msgid "%{ count } hour of music" msgid_plural "%{ count } hours of music" msgstr[0] "" msgstr[1] "" -#: front/src/views/channels/DetailBase.vue:30 src/views/channels/DetailBase.vue:25 -#: front/src/views/channels/DetailBase.vue:4 +#: src/views/channels/DetailBase.vue:30 +#: src/views/channels/DetailBase.vue:25 +#: src/views/channels/DetailBase.vue:4 msgctxt "Content/Channel/Paragraph" msgid "%{ count } listening" msgid_plural "%{ count } listenings" msgstr[0] "" msgstr[1] "" -#: front/src/components/common/ActionTable.vue:59 +#: src/components/common/ActionTable.vue:59 msgctxt "Content/*/Paragraph" msgid "%{ count } on %{ total } selected" msgid_plural "%{ count } on %{ total } selected" msgstr[0] "" msgstr[1] "" -#: front/src/views/channels/DetailBase.vue:27 src/views/channels/DetailBase.vue:22 -#: front/src/views/channels/DetailBase.vue:1 +#: src/views/channels/DetailBase.vue:27 +#: src/views/channels/DetailBase.vue:22 +#: src/views/channels/DetailBase.vue:1 msgctxt "Content/Channel/Paragraph" msgid "%{ count } subscriber" msgid_plural "%{ count } subscribers" msgstr[0] "" msgstr[1] "" -#: front/src/components/audio/ChannelCard.vue:15 -#: front/src/components/audio/album/Card.vue:21 -#: front/src/components/audio/artist/Card.vue:15 -#: front/src/components/channels/AlbumSelect.vue:13 -#: front/src/components/library/AlbumBase.vue:28 -#: front/src/components/library/AlbumBase.vue:62 -#: front/src/components/library/AlbumBase.vue:23 -#: front/src/components/library/AlbumBase.vue:57 -#: front/src/components/library/AlbumBase.vue:5 -#: front/src/components/library/AlbumBase.vue:4 src/components/playlists/Card.vue:17 -#: front/src/views/channels/DetailBase.vue:22 src/views/channels/DetailBase.vue:17 -#: front/src/views/channels/DetailBase.vue:5 src/views/content/libraries/Card.vue:31 -#: front/src/views/content/remote/Card.vue:34 src/views/library/DetailBase.vue:55 +#: src/components/audio/ChannelCard.vue:15 +#: src/components/audio/album/Card.vue:21 +#: src/components/audio/artist/Card.vue:15 +#: src/components/channels/AlbumSelect.vue:13 +#: src/components/library/AlbumBase.vue:28 +#: src/components/library/AlbumBase.vue:62 +#: src/components/library/AlbumBase.vue:23 +#: src/components/library/AlbumBase.vue:57 +#: src/components/library/AlbumBase.vue:5 +#: src/components/library/AlbumBase.vue:4 +#: src/components/playlists/Card.vue:17 +#: src/views/channels/DetailBase.vue:22 +#: src/views/channels/DetailBase.vue:17 +#: src/views/channels/DetailBase.vue:5 +#: src/views/content/libraries/Card.vue:31 +#: src/views/content/remote/Card.vue:34 +#: src/views/library/DetailBase.vue:55 msgctxt "*/*/*" msgid "%{ count } track" msgid_plural "%{ count } tracks" msgstr[0] "" msgstr[1] "" -#: front/src/components/library/ArtistBase.vue:13 -#: front/src/components/library/ArtistBase.vue:8 +#: src/components/library/ArtistBase.vue:13 +#: src/components/library/ArtistBase.vue:8 msgctxt "Content/Artist/Paragraph" msgid "%{ count } track in %{ albumsCount } albums" msgid_plural "%{ count } tracks in %{ albumsCount } albums" msgstr[0] "" msgstr[1] "" -#: front/src/components/library/radios/Builder.vue:109 -#: front/src/components/library/radios/Builder.vue:1 +#: src/components/library/radios/Builder.vue:109 +#: src/components/library/radios/Builder.vue:1 msgctxt "Content/Radio/Table.Paragraph/Short" msgid "%{ count } track matching combined filters" msgid_plural "%{ count } tracks matching combined filters" msgstr[0] "" msgstr[1] "" -#: front/src/components/mixins/PlayOptions.vue:177 -#: front/src/components/mixins/PlayOptions.vue:178 +#: src/components/mixins/PlayOptions.vue:175 msgctxt "*/Queue/Message" msgid "%{ count } track was added to your queue" msgid_plural "%{ count } tracks were added to your queue" msgstr[0] "" msgstr[1] "" -#: front/src/views/content/libraries/Quota.vue:21 +#: src/views/content/libraries/Quota.vue:21 msgctxt "Content/Library/Paragraph" msgid "%{ current } used on %{ max } allowed" msgstr "" -#: front/src/components/common/Duration.vue:2 +#: src/components/common/Duration.vue:2 msgctxt "Content/*/Paragraph" msgid "%{ hours } h %{ minutes } min" msgstr "" -#: front/src/components/audio/Player.vue:113 src/components/audio/Player.vue:119 +#: src/components/audio/Player.vue:113 +#: src/components/audio/Player.vue:119 msgctxt "Sidebar/Queue/Text" msgid "%{ index } of %{ length }" msgstr "" -#: front/src/components/common/Duration.vue:3 +#: src/components/common/Duration.vue:3 msgctxt "Content/*/Paragraph" msgid "%{ minutes } min" msgstr "" -#: front/src/components/audio/ChannelCard.vue:22 +#: src/components/audio/ChannelCard.vue:22 msgid "%{ updatedAgo }" msgstr "" -#: front/src/components/notifications/NotificationRow.vue:86 +#: src/components/notifications/NotificationRow.vue:16 msgctxt "Content/Notifications/Paragraph" msgid "%{ username } accepted your follow on library \"%{ library }\"" msgstr "" -#: front/src/components/notifications/NotificationRow.vue:85 +#: src/components/notifications/NotificationRow.vue:15 msgctxt "Content/Notifications/Paragraph" msgid "%{ username } followed your library \"%{ library }\"" msgstr "" -#: front/src/components/notifications/NotificationRow.vue:88 +#: src/components/notifications/NotificationRow.vue:18 msgctxt "Content/Notifications/Paragraph" msgid "%{ username } wants to follow your library \"%{ library }\"" msgstr "" -#: front/src/views/auth/ProfileBase.vue:162 +#: src/views/auth/ProfileBase.vue:23 msgctxt "Head/Profile/Title" msgid "%{ username }'s profile" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:22 +#: src/components/playlists/PlaylistModal.vue:22 msgctxt "Popup/Playlist/Paragraph" msgid "<strong>%{ track }</strong> is already in <strong>%{ playlist }</strong>." msgstr "" -#: front/src/views/Notifications.vue:28 src/views/Notifications.vue:84 +#: src/views/Notifications.vue:28 +#: src/views/Notifications.vue:84 msgctxt "*/*/*" msgid "30 days" msgstr "" -#: front/src/views/Notifications.vue:33 src/views/Notifications.vue:89 +#: src/views/Notifications.vue:33 +#: src/views/Notifications.vue:89 msgctxt "*/*/*" msgid "60 days" msgstr "" -#: front/src/views/Notifications.vue:38 src/views/Notifications.vue:94 +#: src/views/Notifications.vue:38 +#: src/views/Notifications.vue:94 msgctxt "*/*/*" msgid "90 days" msgstr "" -#: front/src/components/library/FileUpload.vue:370 -#: front/src/components/library/FileUpload.vue:371 +#: src/components/library/FileUpload.vue:62 msgctxt "Content/Library/Help text" msgid "A network error occurred while uploading this file" msgstr "" -#: front/src/App.vue:206 +#: src/AppOld.vue:182 msgctxt "App/Message/Paragraph" msgid "A new version of the app is available." msgstr "" -#: front/src/components/library/EditForm.vue:281 +#: src/components/library/EditForm.vue:40 msgctxt "*/*/Placeholder" msgid "A short summary describing your changes." msgstr "" -#: front/src/components/About.vue:19 +#: src/components/About.vue:19 msgctxt "Content/About/Heading" msgid "A social platform to enjoy and share music" msgstr "" -#: front/src/components/Footer.vue:18 -msgctxt "Footer/About/List item.Link" -msgid "About" -msgstr "" - -#: front/src/components/About.vue:271 src/components/AboutPod.vue:454 +#: src/components/About.vue:27 +#: src/components/AboutPod.vue:19 msgctxt "Head/About/Title" msgid "About" msgstr "" -#: front/src/components/common/UserMenu.vue:165 -#: front/src/components/common/UserModal.vue:194 +#: src/components/common/UserMenu.vue:18 +#: src/components/common/UserModal.vue:26 msgctxt "Sidebar/About/List item.Link" msgid "About" msgstr "" -#: front/src/components/Footer.vue:11 -msgctxt "Footer/About/Title" -msgid "About %{instanceName}" -msgstr "" - -#: front/src/components/Footer.vue:14 -msgctxt "Footer/About/Title" -msgid "About %{instanceUrl}" -msgstr "" - -#: front/src/components/Footer.vue:81 src/components/Home.vue:98 +#: src/components/Home.vue:98 msgctxt "Footer/*/Title/Short" msgid "About Funkwhale" msgstr "" -#: front/src/components/Home.vue:18 +#: src/components/Home.vue:18 msgctxt "Content/Home/Header" msgid "About this Funkwhale pod" msgstr "" -#: front/src/components/channels/LicenseSelect.vue:18 +#: src/components/channels/LicenseSelect.vue:18 msgctxt "Content/*/*" msgid "About this license" msgstr "" -#: front/src/components/About.vue:89 src/components/AboutPod.vue:18 -#: front/src/components/AboutPod.vue:47 +#: src/components/About.vue:94 +#: src/components/AboutPod.vue:18 +#: src/components/AboutPod.vue:47 msgctxt "Content/About/Header" msgid "About this pod" msgstr "" -#: front/src/components/About.vue:171 +#: src/components/About.vue:176 msgctxt "Content/About/Paragraph" msgid "About this pod" msgstr "" -#: front/src/components/Sidebar.vue:240 +#: src/components/Sidebar.vue:240 msgctxt "Sidebar/*/List item.Link" msgid "About this pod" msgstr "" -#: front/src/views/library/Edit.vue:65 +#: src/views/library/Edit.vue:65 msgctxt "Content/Library/Button.Label" msgid "Accept" msgstr "" -#: front/src/views/library/Edit.vue:57 +#: src/views/library/Edit.vue:57 msgctxt "Content/Library/Table/Short" msgid "Accepted" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:210 +#: src/components/auth/SubsonicTokenForm.vue:64 msgctxt "Content/Settings/Message" msgid "Access disabled" msgstr "" -#: front/src/components/mixins/Translations.vue:100 -#: front/src/components/mixins/Translations.vue:101 +#: src/components/mixins/Translations.vue:100 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to audio files, libraries, artists, albums and tracks" msgstr "" -#: front/src/components/mixins/Translations.vue:124 -#: front/src/components/mixins/Translations.vue:125 +#: src/components/mixins/Translations.vue:124 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to content filters" msgstr "" -#: front/src/components/mixins/Translations.vue:96 -#: front/src/components/mixins/Translations.vue:97 +#: src/components/mixins/Translations.vue:96 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to e-mail, username, and profile information" msgstr "" -#: front/src/components/mixins/Translations.vue:132 -#: front/src/components/mixins/Translations.vue:133 +#: src/components/mixins/Translations.vue:132 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to edits" msgstr "" -#: front/src/components/mixins/Translations.vue:104 -#: front/src/components/mixins/Translations.vue:105 +#: src/components/mixins/Translations.vue:104 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to favorites" msgstr "" -#: front/src/components/mixins/Translations.vue:112 -#: front/src/components/mixins/Translations.vue:113 +#: src/components/mixins/Translations.vue:112 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to follows" msgstr "" -#: front/src/components/mixins/Translations.vue:108 -#: front/src/components/mixins/Translations.vue:109 +#: src/components/mixins/Translations.vue:108 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to listening history" msgstr "" -#: front/src/components/mixins/Translations.vue:140 -#: front/src/components/mixins/Translations.vue:141 +#: src/components/mixins/Translations.vue:140 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to moderation reports" msgstr "" -#: front/src/components/mixins/Translations.vue:128 -#: front/src/components/mixins/Translations.vue:129 +#: src/components/mixins/Translations.vue:128 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to notifications" msgstr "" -#: front/src/components/mixins/Translations.vue:116 -#: front/src/components/mixins/Translations.vue:117 +#: src/components/mixins/Translations.vue:116 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to playlists" msgstr "" -#: front/src/components/mixins/Translations.vue:120 -#: front/src/components/mixins/Translations.vue:121 +#: src/components/mixins/Translations.vue:120 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to radios" msgstr "" -#: front/src/components/mixins/Translations.vue:136 -#: front/src/components/mixins/Translations.vue:137 +#: src/components/mixins/Translations.vue:136 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to security settings such as password and authorization" msgstr "" -#: front/src/components/auth/ApplicationEdit.vue:33 -#: front/src/components/auth/ApplicationEdit.vue:26 +#: src/components/auth/ApplicationEdit.vue:33 +#: src/components/auth/ApplicationEdit.vue:26 msgctxt "Content/Applications/Label" msgid "Access token" msgstr "" -#: front/src/components/manage/library/UploadsTable.vue:132 -#: front/src/components/manage/library/UploadsTable.vue:42 -#: front/src/components/mixins/Translations.vue:70 -#: front/src/views/admin/library/UploadDetail.vue:196 -#: front/src/views/admin/library/UploadDetail.vue:191 -#: front/src/components/mixins/Translations.vue:71 +#: src/components/manage/library/UploadsTable.vue:132 +#: src/components/manage/library/UploadsTable.vue:42 +#: src/components/mixins/Translations.vue:70 +#: src/views/admin/library/UploadDetail.vue:196 +#: src/views/admin/library/UploadDetail.vue:191 msgctxt "Content/*/*/Noun" msgid "Accessed date" msgstr "" -#: front/src/components/manage/ChannelsTable.vue:66 -#: front/src/components/manage/ChannelsTable.vue:7 -#: front/src/components/manage/library/LibrariesTable.vue:66 -#: front/src/components/manage/library/LibrariesTable.vue:7 -#: front/src/components/manage/library/UploadsTable.vue:102 -#: front/src/components/manage/library/UploadsTable.vue:12 -#: front/src/components/manage/moderation/ReportCard.vue:188 -#: front/src/components/mixins/Report.vue:14 src/views/admin/ChannelDetail.vue:127 -#: front/src/views/admin/ChannelDetail.vue:122 -#: front/src/views/admin/library/LibraryDetail.vue:120 -#: front/src/views/admin/library/LibraryDetail.vue:115 -#: front/src/views/admin/library/UploadDetail.vue:120 -#: front/src/views/admin/library/UploadDetail.vue:115 -#: front/src/components/mixins/Report.vue:15 +#: src/components/manage/ChannelsTable.vue:66 +#: src/components/manage/ChannelsTable.vue:7 +#: src/components/manage/library/LibrariesTable.vue:66 +#: src/components/manage/library/LibrariesTable.vue:7 +#: src/components/manage/library/UploadsTable.vue:102 +#: src/components/manage/library/UploadsTable.vue:12 +#: src/components/manage/moderation/ReportCard.vue:188 +#: src/components/mixins/Report.vue:14 +#: src/views/admin/ChannelDetail.vue:127 +#: src/views/admin/ChannelDetail.vue:122 +#: src/views/admin/library/LibraryDetail.vue:120 +#: src/views/admin/library/LibraryDetail.vue:115 +#: src/views/admin/library/UploadDetail.vue:120 +#: src/views/admin/library/UploadDetail.vue:115 msgctxt "*/*/*/Noun" msgid "Account" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:104 -#: front/src/views/admin/moderation/AccountsDetail.vue:99 +#: src/views/admin/moderation/AccountsDetail.vue:104 +#: src/views/admin/moderation/AccountsDetail.vue:99 msgctxt "Content/Moderation/Title" msgid "Account data" msgstr "" -#: front/src/components/auth/Settings.vue:5 +#: src/components/auth/Settings.vue:5 msgctxt "Content/Settings/Title" msgid "Account settings" msgstr "" -#: front/src/components/auth/Settings.vue:761 +#: src/components/auth/Settings.vue:63 msgctxt "Head/Settings/Title" msgid "Account Settings" msgstr "" -#: front/src/components/manage/users/UsersTable.vue:50 -#: front/src/components/manage/users/UsersTable.vue:12 +#: src/components/manage/users/UsersTable.vue:50 +#: src/components/manage/users/UsersTable.vue:12 msgctxt "Content/Admin/Table.Label/Short, Noun" msgid "Account status" msgstr "" -#: front/src/views/auth/PasswordReset.vue:28 +#: src/views/auth/PasswordReset.vue:28 msgctxt "Content/Signup/Input.Label" msgid "Account's e-mail address" msgstr "" -#: front/src/views/admin/moderation/AccountsList.vue:4 -#: front/src/views/admin/moderation/AccountsList.vue:31 -#: front/src/views/admin/moderation/Base.vue:25 +#: src/views/admin/moderation/AccountsList.vue:4 +#: src/views/admin/moderation/AccountsList.vue:13 +#: src/views/admin/moderation/Base.vue:25 msgctxt "*/Moderation/Title" msgid "Accounts" msgstr "" -#: front/src/views/library/Edit.vue:43 +#: src/views/library/Edit.vue:43 msgctxt "Content/Library/Table.Label" msgid "Action" msgstr "" -#: front/src/components/common/ActionTable.vue:88 +#: src/components/common/ActionTable.vue:88 msgctxt "Content/*/Paragraph" msgid "Action %{ action } was launched successfully on %{ count } element" msgid_plural "Action %{ action } was launched successfully on %{ count } elements" msgstr[0] "" msgstr[1] "" -#: front/src/components/library/FileUpload.vue:102 +#: src/components/library/FileUpload.vue:102 msgctxt "*/*/*" msgid "Actions" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:57 +#: src/components/admin/SignupFormBuilder.vue:57 msgctxt "*/*/Form-builder,Help" msgid "Actions" msgstr "" -#: front/src/components/common/ActionTable.vue:18 -#: front/src/components/library/radios/Builder.vue:98 -#: front/src/components/manage/moderation/ReportCard.vue:254 -#: front/src/components/manage/moderation/UserRequestCard.vue:145 +#: src/components/common/ActionTable.vue:18 +#: src/components/library/radios/Builder.vue:98 +#: src/components/manage/moderation/ReportCard.vue:254 +#: src/components/manage/moderation/UserRequestCard.vue:145 msgctxt "Content/*/*/Noun" msgid "Actions" msgstr "" -#: front/src/components/manage/users/UsersTable.vue:88 -#: front/src/components/manage/users/UsersTable.vue:13 +#: src/components/manage/users/UsersTable.vue:88 +#: src/components/manage/users/UsersTable.vue:13 msgctxt "Content/Admin/Table" msgid "Active" msgstr "" -#: front/src/components/About.vue:109 src/components/About.vue:7 -#: front/src/components/AboutPod.vue:251 src/components/AboutPod.vue:39 +#: src/components/About.vue:114 +#: src/components/About.vue:7 +#: src/components/AboutPod.vue:251 +#: src/components/AboutPod.vue:39 msgctxt "Content/About/*" msgid "active user" msgid_plural "active users" msgstr[0] "" msgstr[1] "" -#: front/src/views/admin/ChannelDetail.vue:185 src/views/admin/ChannelDetail.vue:180 -#: front/src/views/admin/library/AlbumDetail.vue:166 -#: front/src/views/admin/library/AlbumDetail.vue:161 -#: front/src/views/admin/library/ArtistDetail.vue:165 -#: front/src/views/admin/library/ArtistDetail.vue:160 -#: front/src/views/admin/library/LibraryDetail.vue:160 -#: front/src/views/admin/library/LibraryDetail.vue:155 -#: front/src/views/admin/library/TagDetail.vue:96 -#: front/src/views/admin/library/TagDetail.vue:91 -#: front/src/views/admin/library/TrackDetail.vue:231 -#: front/src/views/admin/library/TrackDetail.vue:226 -#: front/src/views/admin/library/UploadDetail.vue:177 -#: front/src/views/admin/library/UploadDetail.vue:172 -#: front/src/views/admin/moderation/AccountsDetail.vue:242 -#: front/src/views/admin/moderation/AccountsDetail.vue:237 -#: front/src/views/admin/moderation/DomainsDetail.vue:194 -#: front/src/views/admin/moderation/DomainsDetail.vue:189 +#: src/views/admin/ChannelDetail.vue:185 +#: src/views/admin/ChannelDetail.vue:180 +#: src/views/admin/library/AlbumDetail.vue:166 +#: src/views/admin/library/AlbumDetail.vue:161 +#: src/views/admin/library/ArtistDetail.vue:165 +#: src/views/admin/library/ArtistDetail.vue:160 +#: src/views/admin/library/LibraryDetail.vue:160 +#: src/views/admin/library/LibraryDetail.vue:155 +#: src/views/admin/library/TagDetail.vue:96 +#: src/views/admin/library/TagDetail.vue:91 +#: src/views/admin/library/TrackDetail.vue:231 +#: src/views/admin/library/TrackDetail.vue:226 +#: src/views/admin/library/UploadDetail.vue:177 +#: src/views/admin/library/UploadDetail.vue:172 +#: src/views/admin/moderation/AccountsDetail.vue:242 +#: src/views/admin/moderation/AccountsDetail.vue:237 +#: src/views/admin/moderation/DomainsDetail.vue:194 +#: src/views/admin/moderation/DomainsDetail.vue:189 msgctxt "Content/Moderation/Title" msgid "Activity" msgstr "" -#: front/src/views/auth/ProfileBase.vue:63 +#: src/views/auth/ProfileBase.vue:63 msgctxt "Content/Profile/*" msgid "Activity" msgstr "" -#: front/src/components/mixins/Translations.vue:7 -#: front/src/components/mixins/Translations.vue:8 +#: src/components/mixins/Translations.vue:7 msgctxt "Content/Settings/Dropdown.Label/Noun" msgid "Activity visibility" msgstr "" -#: front/src/views/admin/moderation/DomainsList.vue:32 +#: src/views/admin/moderation/DomainsList.vue:32 msgctxt "Content/Moderation/Button/Verb" msgid "Add" msgstr "" -#: front/src/components/library/AlbumBase.vue:82 -#: front/src/components/library/AlbumBase.vue:93 -#: front/src/components/library/AlbumBase.vue:77 -#: front/src/components/library/AlbumBase.vue:88 -#: front/src/components/library/AlbumBase.vue:5 +#: src/components/library/AlbumBase.vue:82 +#: src/components/library/AlbumBase.vue:93 +#: src/components/library/AlbumBase.vue:77 +#: src/components/library/AlbumBase.vue:88 +#: src/components/library/AlbumBase.vue:5 msgctxt "Content/*/Button.Label/Verb" msgid "Add a description…" msgstr "" -#: front/src/views/admin/moderation/DomainsList.vue:23 +#: src/views/admin/moderation/DomainsList.vue:23 msgctxt "Content/Moderation/Form.Label/Verb" msgid "Add a domain" msgstr "" -#: front/src/components/channels/UploadForm.vue:29 +#: src/components/channels/UploadForm.vue:29 msgctxt "Content/Channels/Popup.Paragraph" msgid "Add a license to your upload to ensure some freedoms to your public." msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:78 -#: front/src/views/admin/moderation/AccountsDetail.vue:73 -#: front/src/views/admin/moderation/AccountsDetail.vue:15 +#: src/views/admin/moderation/AccountsDetail.vue:78 +#: src/views/admin/moderation/AccountsDetail.vue:73 +#: src/views/admin/moderation/AccountsDetail.vue:15 msgctxt "Content/Moderation/Button/Verb" msgid "Add a moderation policy" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:103 +#: src/components/admin/SignupFormBuilder.vue:103 msgctxt "*/*/Form-builder" msgid "Add a new field" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:6 +#: src/components/manage/moderation/InstancePolicyForm.vue:6 msgctxt "Content/Moderation/Card.Button.Label/Verb" msgid "Add a new moderation rule" msgstr "" -#: front/src/views/content/Home.vue:91 +#: src/views/content/Home.vue:7 msgctxt "Content/Library/Title/Verb" msgid "Add and manage content" msgstr "" -#: front/src/components/playlists/Editor.vue:38 -#: front/src/components/playlists/PlaylistModal.vue:31 +#: src/components/playlists/Editor.vue:38 +#: src/components/playlists/PlaylistModal.vue:31 msgctxt "*/Playlist/Button.Label/Verb" msgid "Add anyways" msgstr "" -#: front/src/components/Sidebar.vue:540 src/views/content/Base.vue:35 +#: src/components/Sidebar.vue:74 +#: src/views/content/Base.vue:4 msgctxt "*/Library/*/Verb" msgid "Add content" msgstr "" -#: front/src/components/library/radios/Builder.vue:65 +#: src/components/library/radios/Builder.vue:65 msgctxt "Content/Radio/Button.Label/Verb" msgid "Add filter" msgstr "" -#: front/src/components/library/radios/Builder.vue:53 +#: src/components/library/radios/Builder.vue:53 msgctxt "Content/Radio/Paragraph" msgid "Add filters to customize your radio" msgstr "" -#: front/src/views/auth/ProfileOverview.vue:14 src/views/auth/ProfileOverview.vue:26 -#: front/src/views/channels/DetailOverview.vue:79 -#: front/src/views/channels/SubscriptionsList.vue:8 +#: src/views/auth/ProfileOverview.vue:14 +#: src/views/auth/ProfileOverview.vue:26 +#: src/views/channels/DetailOverview.vue:79 +#: src/views/channels/SubscriptionsList.vue:8 msgctxt "Content/Profile/Button" msgid "Add new" msgstr "" -#: front/src/components/manage/moderation/NoteForm.vue:18 +#: src/components/manage/moderation/NoteForm.vue:18 msgctxt "Content/Moderation/Button.Label/Verb" msgid "Add note" msgstr "" -#: front/src/components/library/Albums.vue:78 src/components/library/Artists.vue:87 +#: src/components/library/Albums.vue:78 +#: src/components/library/Artists.vue:87 msgctxt "Content/*/Verb" msgid "Add some music" msgstr "" -#: front/src/components/manage/moderation/DomainsTable.vue:250 -#: front/src/views/admin/moderation/DomainsDetail.vue:38 -#: front/src/views/admin/moderation/DomainsDetail.vue:33 -#: front/src/views/admin/moderation/DomainsList.vue:28 +#: src/components/manage/moderation/DomainsTable.vue:62 +#: src/views/admin/moderation/DomainsDetail.vue:38 +#: src/views/admin/moderation/DomainsDetail.vue:33 +#: src/views/admin/moderation/DomainsList.vue:28 msgctxt "Content/Moderation/Action/Verb" msgid "Add to allow-list" msgstr "" -#: front/src/components/audio/PlayButton.vue:177 +#: src/components/audio/PlayButton.vue:51 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Add to current queue" msgstr "" -#: front/src/components/audio/podcast/Modal.vue:264 -#: front/src/components/audio/track/Modal.vue:264 -#: front/src/components/favorites/TrackFavoriteIcon.vue:6 -#: front/src/components/favorites/TrackFavoriteIcon.vue:44 +#: src/components/audio/podcast/Modal.vue:36 +#: src/components/audio/track/Modal.vue:36 +#: src/components/favorites/TrackFavoriteIcon.vue:6 +#: src/components/favorites/TrackFavoriteIcon.vue:12 msgctxt "Content/Track/*/Verb" msgid "Add to favorites" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:5 -#: front/src/components/playlists/PlaylistModal.vue:2 +#: src/components/playlists/PlaylistModal.vue:5 +#: src/components/playlists/PlaylistModal.vue:2 msgctxt "Popup/Playlist/Title/Verb" msgid "Add to playlist" msgstr "" -#: front/src/components/audio/PlayButton.vue:24 -#: front/src/components/audio/PlayButton.vue:181 -#: front/src/components/audio/podcast/Modal.vue:300 -#: front/src/components/audio/track/Modal.vue:300 -#: front/src/components/playlists/TrackPlaylistIcon.vue:3 -#: front/src/components/playlists/TrackPlaylistIcon.vue:39 -#: front/src/components/audio/podcast/Modal.vue:302 -#: front/src/components/audio/track/Modal.vue:302 +#: src/components/audio/PlayButton.vue:24 +#: src/components/audio/PlayButton.vue:55 +#: src/components/audio/podcast/Modal.vue:72 +#: src/components/audio/track/Modal.vue:72 +#: src/components/playlists/TrackPlaylistIcon.vue:3 +#: src/components/playlists/TrackPlaylistIcon.vue:15 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Add to playlist…" msgstr "" -#: front/src/components/audio/PlayButton.vue:11 +#: src/components/audio/PlayButton.vue:11 msgctxt "*/Queue/Dropdown/Button/Label/Short" msgid "Add to queue" msgstr "" -#: front/src/components/audio/podcast/Modal.vue:295 -#: front/src/components/audio/track/Modal.vue:295 -#: front/src/components/audio/podcast/Modal.vue:297 -#: front/src/components/audio/track/Modal.vue:297 +#: src/components/audio/podcast/Modal.vue:67 +#: src/components/audio/track/Modal.vue:67 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Add to queue" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:228 +#: src/components/playlists/PlaylistModal.vue:32 msgctxt "Popup/Playlist/Table.Button.Tooltip/Verb" msgid "Add to this playlist" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:99 +#: src/components/playlists/PlaylistModal.vue:99 msgctxt "Popup/Playlist/Table.Button.Label/Verb" msgid "Add track" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:213 +#: src/components/admin/SignupFormBuilder.vue:50 msgctxt "*/*/Form-builder" msgid "Additional field" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:32 +#: src/components/admin/SignupFormBuilder.vue:32 msgctxt "*/*/Label" msgid "Additional fields" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:35 +#: src/components/admin/SignupFormBuilder.vue:35 msgctxt "*/*/Help" msgid "Additional form fields to be displayed in the form. Only shown if manual sign-up validation is enabled." msgstr "" -#: front/src/components/audio/VolumeControl.vue:76 +#: src/components/audio/VolumeControl.vue:24 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Adjust volume" msgstr "" -#: front/src/components/manage/users/UsersTable.vue:108 -#: front/src/components/manage/users/UsersTable.vue:33 +#: src/components/manage/users/UsersTable.vue:108 +#: src/components/manage/users/UsersTable.vue:33 msgctxt "Content/Admin/Table.User role" msgid "Admin" msgstr "" -#: front/src/components/Sidebar.vue:19 src/components/Sidebar.vue:541 +#: src/components/Sidebar.vue:19 +#: src/components/Sidebar.vue:75 msgctxt "Sidebar/Admin/Title/Noun" msgid "Administration" msgstr "" -#: front/src/components/AboutPod.vue:237 src/components/AboutPod.vue:25 +#: src/components/AboutPod.vue:237 +#: src/components/AboutPod.vue:25 msgctxt "Content/About/*" msgid "album" msgid_plural "albums" msgstr[0] "" msgstr[1] "" -#: front/src/components/audio/SearchBar.vue:43 -#: front/src/components/channels/AlbumSelect.vue:4 -#: front/src/components/library/AlbumBase.vue:312 -#: front/src/components/library/ArtistBase.vue:269 -#: front/src/components/manage/library/TracksTable.vue:47 -#: front/src/components/manage/library/TracksTable.vue:7 -#: front/src/components/mixins/Report.vue:44 -#: front/src/views/admin/library/TrackDetail.vue:128 -#: front/src/views/admin/library/TrackDetail.vue:123 -#: front/src/views/content/libraries/FilesTable.vue:104 -#: front/src/views/content/libraries/FilesTable.vue:12 -#: front/src/components/mixins/Report.vue:45 +#: src/components/audio/SearchBar.vue:20 +#: src/components/channels/AlbumSelect.vue:4 +#: src/components/library/AlbumBase.vue:64 +#: src/components/library/ArtistBase.vue:53 +#: src/components/manage/library/TracksTable.vue:47 +#: src/components/manage/library/TracksTable.vue:7 +#: src/components/mixins/Report.vue:44 +#: src/views/admin/library/TrackDetail.vue:128 +#: src/views/admin/library/TrackDetail.vue:123 +#: src/views/content/libraries/FilesTable.vue:104 +#: src/views/content/libraries/FilesTable.vue:12 msgctxt "*/*/*" msgid "Album" msgstr "" -#: front/src/components/audio/podcast/Table.vue:122 -#: front/src/components/audio/track/Table.vue:210 -#: front/src/components/library/TrackDetail.vue:120 +#: src/components/audio/podcast/Table.vue:43 +#: src/components/audio/track/Table.vue:54 +#: src/components/library/TrackDetail.vue:121 msgctxt "*/*/*/Noun" msgid "Album" msgstr "" -#: front/src/views/admin/library/TrackDetail.vue:153 -#: front/src/views/admin/library/TrackDetail.vue:148 +#: src/views/admin/library/TrackDetail.vue:153 +#: src/views/admin/library/TrackDetail.vue:148 msgctxt "*/*/*/Noun" msgid "Album artist" msgstr "" -#: front/src/views/admin/library/AlbumDetail.vue:108 -#: front/src/views/admin/library/AlbumDetail.vue:103 +#: src/views/admin/library/AlbumDetail.vue:108 +#: src/views/admin/library/AlbumDetail.vue:103 msgctxt "Content/Moderation/Title" msgid "Album data" msgstr "" -#: front/src/components/mixins/Translations.vue:76 -#: front/src/components/mixins/Translations.vue:77 +#: src/components/mixins/Translations.vue:76 msgctxt "Content/*/Dropdown/Noun" msgid "Album name" msgstr "" -#: front/src/components/Sidebar.vue:166 src/components/Sidebar.vue:201 -#: front/src/components/audio/Search.vue:32 src/components/audio/Search.vue:2 -#: front/src/components/library/Albums.vue:190 -#: front/src/components/library/TagDetail.vue:40 -#: front/src/components/library/TagDetail.vue:2 -#: front/src/components/manage/ChannelsTable.vue:76 -#: front/src/components/manage/ChannelsTable.vue:17 -#: front/src/components/manage/library/ArtistsTable.vue:71 -#: front/src/components/manage/library/ArtistsTable.vue:12 -#: front/src/components/manage/library/TagsTable.vue:53 -#: front/src/components/manage/library/TagsTable.vue:12 src/views/Search.vue:221 -#: front/src/views/admin/ChannelDetail.vue:321 src/views/admin/ChannelDetail.vue:316 -#: front/src/views/admin/library/AlbumsList.vue:29 -#: front/src/views/admin/library/ArtistDetail.vue:314 -#: front/src/views/admin/library/ArtistDetail.vue:309 -#: front/src/views/admin/library/Base.vue:19 -#: front/src/views/admin/library/LibraryDetail.vue:264 -#: front/src/views/admin/library/LibraryDetail.vue:259 -#: front/src/views/admin/library/TagDetail.vue:152 -#: front/src/views/admin/library/TagDetail.vue:147 -#: front/src/views/admin/moderation/AccountsDetail.vue:430 -#: front/src/views/admin/moderation/AccountsDetail.vue:425 -#: front/src/views/admin/moderation/DomainsDetail.vue:354 -#: front/src/views/admin/moderation/DomainsDetail.vue:349 -#: front/src/views/channels/DetailOverview.vue:73 -#: front/src/views/library/DetailBase.vue:101 +#: src/components/Sidebar.vue:166 +#: src/components/Sidebar.vue:201 +#: src/components/audio/Search.vue:32 +#: src/components/audio/Search.vue:2 +#: src/components/library/Albums.vue:41 +#: src/components/library/TagDetail.vue:40 +#: src/components/library/TagDetail.vue:2 +#: src/components/manage/ChannelsTable.vue:76 +#: src/components/manage/ChannelsTable.vue:17 +#: src/components/manage/library/ArtistsTable.vue:71 +#: src/components/manage/library/ArtistsTable.vue:12 +#: src/components/manage/library/TagsTable.vue:53 +#: src/components/manage/library/TagsTable.vue:12 +#: src/views/Search.vue:82 +#: src/views/admin/ChannelDetail.vue:321 +#: src/views/admin/ChannelDetail.vue:316 +#: src/views/admin/library/AlbumsList.vue:13 +#: src/views/admin/library/ArtistDetail.vue:314 +#: src/views/admin/library/ArtistDetail.vue:309 +#: src/views/admin/library/Base.vue:19 +#: src/views/admin/library/LibraryDetail.vue:264 +#: src/views/admin/library/LibraryDetail.vue:259 +#: src/views/admin/library/TagDetail.vue:152 +#: src/views/admin/library/TagDetail.vue:147 +#: src/views/admin/moderation/AccountsDetail.vue:430 +#: src/views/admin/moderation/AccountsDetail.vue:425 +#: src/views/admin/moderation/DomainsDetail.vue:354 +#: src/views/admin/moderation/DomainsDetail.vue:349 +#: src/views/channels/DetailOverview.vue:73 +#: src/views/library/DetailBase.vue:101 msgctxt "*/*/*" msgid "Albums" msgstr "" -#: front/src/components/library/ArtistDetail.vue:39 +#: src/components/library/ArtistDetail.vue:27 msgctxt "Content/Artist/Title" msgid "Albums by this artist" msgstr "" -#: front/src/components/manage/ChannelsTable.vue:14 -#: front/src/components/manage/library/ArtistsTable.vue:14 -#: front/src/components/manage/library/EditsCardList.vue:15 -#: front/src/components/manage/library/LibrariesTable.vue:14 -#: front/src/components/manage/library/UploadsTable.vue:14 -#: front/src/components/manage/library/UploadsTable.vue:33 -#: front/src/components/manage/moderation/DomainsTable.vue:12 -#: front/src/components/manage/users/InvitationsTable.vue:20 -#: front/src/components/moderation/ReportCategoryDropdown.vue:47 -#: front/src/views/admin/moderation/ReportsList.vue:21 -#: front/src/views/admin/moderation/RequestsList.vue:21 -#: front/src/views/content/libraries/FilesTable.vue:18 +#: src/components/manage/ChannelsTable.vue:14 +#: src/components/manage/library/ArtistsTable.vue:14 +#: src/components/manage/library/EditsCardList.vue:15 +#: src/components/manage/library/LibrariesTable.vue:14 +#: src/components/manage/library/UploadsTable.vue:14 +#: src/components/manage/library/UploadsTable.vue:33 +#: src/components/manage/moderation/DomainsTable.vue:12 +#: src/components/manage/users/InvitationsTable.vue:20 +#: src/components/moderation/ReportCategoryDropdown.vue:19 +#: src/views/admin/moderation/ReportsList.vue:21 +#: src/views/admin/moderation/RequestsList.vue:21 +#: src/views/content/libraries/FilesTable.vue:18 msgctxt "Content/*/Dropdown" msgid "All" msgstr "" -#: front/src/components/common/ActionTable.vue:56 +#: src/components/common/ActionTable.vue:56 msgctxt "Content/*/Paragraph" msgid "All %{ count } element selected" msgid_plural "All %{ count } elements selected" msgstr[0] "" msgstr[1] "" -#: front/src/views/channels/DetailBase.vue:243 src/views/channels/DetailBase.vue:238 +#: src/views/channels/DetailBase.vue:245 +#: src/views/channels/DetailBase.vue:240 msgctxt "Content/Channels/*" msgid "All Episodes" msgstr "" -#: front/src/components/auth/Authorize.vue:185 +#: src/components/auth/Authorize.vue:41 msgctxt "Head/Authorize/Title" msgid "Allow application" msgstr "" -#: front/src/components/AboutPod.vue:129 +#: src/components/AboutPod.vue:129 msgctxt "*/*/*" msgid "Allow-list" msgstr "" -#: front/src/components/library/ImportStatusModal.vue:25 +#: src/components/library/ImportStatusModal.vue:25 msgctxt "Popup/Import/Message" msgid "An error occurred during upload processing. You will find more information below." msgstr "" -#: front/src/components/playlists/Editor.vue:17 src/components/playlists/Editor.vue:2 +#: src/components/playlists/Editor.vue:17 +#: src/components/playlists/Editor.vue:2 msgctxt "Content/Playlist/Error message.Title" msgid "An error occurred while saving your changes" msgstr "" -#: front/src/components/federation/FetchButton.vue:44 -#: front/src/components/federation/FetchButton.vue:32 +#: src/components/federation/FetchButton.vue:44 +#: src/components/federation/FetchButton.vue:32 msgctxt "Popup/*/Message.Content" msgid "An error occurred while trying to refresh data:" msgstr "" -#: front/src/components/federation/FetchButton.vue:70 -#: front/src/components/federation/FetchButton.vue:58 +#: src/components/federation/FetchButton.vue:70 +#: src/components/federation/FetchButton.vue:58 msgctxt "*/*/Error" msgid "An HTTP error occurred while contacting the remote server" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:24 +#: src/components/admin/SignupFormBuilder.vue:24 msgctxt "*/*/Help" msgid "An optional text to be displayed at the start of the sign-up form." msgstr "" -#: front/src/components/library/ImportStatusModal.vue:204 +#: src/components/library/ImportStatusModal.vue:70 msgctxt "Popup/Import/Error.Label" msgid "An unknown error occurred" msgstr "" -#: front/src/components/AboutPod.vue:154 +#: src/components/AboutPod.vue:154 msgctxt "*/*/*" msgid "Anonymous access" msgstr "" -#: front/src/components/moderation/ReportModal.vue:74 +#: src/components/moderation/ReportModal.vue:74 msgctxt "Popup/Moderation/Error message" msgid "Anonymous reports are disabled, please sign-in to submit a report." msgstr "" -#: front/src/components/auth/Settings.vue:242 src/components/auth/Settings.vue:318 +#: src/components/auth/Settings.vue:242 +#: src/components/auth/Settings.vue:318 msgctxt "*/*/*/Noun" msgid "Application" msgstr "" -#: front/src/components/auth/ApplicationEdit.vue:14 -#: front/src/components/auth/ApplicationEdit.vue:7 +#: src/components/auth/ApplicationEdit.vue:14 +#: src/components/auth/ApplicationEdit.vue:7 msgctxt "Content/Applications/Title" msgid "Application details" msgstr "" -#: front/src/components/Footer.vue:3 -msgctxt "*/*/*" -msgid "Application footer" -msgstr "" - -#: front/src/components/auth/ApplicationEdit.vue:25 -#: front/src/components/auth/ApplicationEdit.vue:18 +#: src/components/auth/ApplicationEdit.vue:25 +#: src/components/auth/ApplicationEdit.vue:18 msgctxt "Content/Applications/Label" msgid "Application ID" msgstr "" -#: front/src/components/auth/ApplicationEdit.vue:20 -#: front/src/components/auth/ApplicationEdit.vue:13 +#: src/components/auth/ApplicationEdit.vue:20 +#: src/components/auth/ApplicationEdit.vue:13 msgctxt "Content/Application/Paragraph/" msgid "Application ID and secret are really sensitive values and must be treated like passwords. Do not share those with anyone else." msgstr "" -#: front/src/components/auth/ApplicationEdit.vue:29 -#: front/src/components/auth/ApplicationEdit.vue:22 +#: src/components/auth/ApplicationEdit.vue:29 +#: src/components/auth/ApplicationEdit.vue:22 msgctxt "Content/Applications/Label" msgid "Application secret" msgstr "" -#: front/src/components/library/EditCard.vue:111 -#: front/src/components/manage/moderation/UserRequestCard.vue:152 -#: front/src/components/notifications/NotificationRow.vue:115 +#: src/components/library/EditCard.vue:111 +#: src/components/manage/moderation/UserRequestCard.vue:152 +#: src/components/notifications/NotificationRow.vue:45 msgctxt "Content/*/Button.Label/Verb" msgid "Approve" msgstr "" -#: front/src/components/library/EditCard.vue:27 -#: front/src/components/manage/library/EditsCardList.vue:25 -#: front/src/components/manage/moderation/UserRequestCard.vue:64 -#: front/src/components/manage/moderation/UserRequestCard.vue:2 -#: front/src/views/admin/moderation/RequestsList.vue:31 +#: src/components/library/EditCard.vue:27 +#: src/components/manage/library/EditsCardList.vue:25 +#: src/components/manage/moderation/UserRequestCard.vue:64 +#: src/components/manage/moderation/UserRequestCard.vue:2 +#: src/views/admin/moderation/RequestsList.vue:31 msgctxt "Content/*/*/Short" msgid "Approved" msgstr "" -#: front/src/components/library/EditCard.vue:23 +#: src/components/library/EditCard.vue:23 msgctxt "Content/Library/Card/Short" msgid "Approved and applied" msgstr "" -#: front/src/components/auth/Logout.vue:5 +#: src/components/auth/Logout.vue:5 msgctxt "Content/Login/Title" msgid "Are you sure you want to log out?" msgstr "" -#: front/src/components/AboutPod.vue:230 src/components/AboutPod.vue:18 +#: src/components/AboutPod.vue:230 +#: src/components/AboutPod.vue:18 msgctxt "Content/About/*" msgid "artist" msgid_plural "artists" msgstr[0] "" msgstr[1] "" -#: front/src/components/audio/SearchBar.vue:42 -#: front/src/components/audio/podcast/Table.vue:123 -#: front/src/components/audio/track/Table.vue:211 -#: front/src/components/library/TrackDetail.vue:108 -#: front/src/components/manage/library/AlbumsTable.vue:47 -#: front/src/components/manage/library/AlbumsTable.vue:7 -#: front/src/components/manage/library/TracksTable.vue:52 -#: front/src/components/manage/library/TracksTable.vue:12 -#: front/src/components/mixins/Report.vue:71 -#: front/src/views/admin/library/AlbumDetail.vue:128 -#: front/src/views/admin/library/AlbumDetail.vue:123 -#: front/src/views/admin/library/TrackDetail.vue:141 -#: front/src/views/admin/library/TrackDetail.vue:136 -#: front/src/views/content/libraries/FilesTable.vue:99 -#: front/src/views/content/libraries/FilesTable.vue:7 -#: front/src/components/mixins/Report.vue:72 src/entities.js:12 +#: src/components/audio/SearchBar.vue:19 +#: src/components/audio/podcast/Table.vue:44 +#: src/components/audio/track/Table.vue:55 +#: src/components/library/TrackDetail.vue:109 +#: src/components/manage/library/AlbumsTable.vue:47 +#: src/components/manage/library/AlbumsTable.vue:7 +#: src/components/manage/library/TracksTable.vue:52 +#: src/components/manage/library/TracksTable.vue:12 +#: src/components/mixins/Report.vue:71 +#: src/views/admin/library/AlbumDetail.vue:128 +#: src/views/admin/library/AlbumDetail.vue:123 +#: src/views/admin/library/TrackDetail.vue:141 +#: src/views/admin/library/TrackDetail.vue:136 +#: src/views/content/libraries/FilesTable.vue:99 +#: src/views/content/libraries/FilesTable.vue:7 msgctxt "*/*/*/Noun" msgid "Artist" msgstr "" -#: front/src/views/auth/ProfileOverview.vue:47 src/views/channels/DetailBase.vue:209 -#: front/src/views/channels/DetailBase.vue:204 +#: src/views/auth/ProfileOverview.vue:47 +#: src/views/channels/DetailBase.vue:211 +#: src/views/channels/DetailBase.vue:206 msgctxt "Content/Channel/*" msgid "Artist channel" msgstr "" -#: front/src/views/admin/library/ArtistDetail.vue:107 -#: front/src/views/admin/library/ArtistDetail.vue:102 +#: src/views/admin/library/ArtistDetail.vue:107 +#: src/views/admin/library/ArtistDetail.vue:102 msgctxt "Content/Moderation/Title" msgid "Artist data" msgstr "" -#: front/src/components/audio/ChannelForm.vue:309 +#: src/components/audio/ChannelForm.vue:60 msgctxt "*/*/*" msgid "Artist discography" msgstr "" -#: front/src/components/mixins/Translations.vue:77 -#: front/src/components/mixins/Translations.vue:78 +#: src/components/mixins/Translations.vue:77 msgctxt "Content/*/Dropdown/Noun" msgid "Artist name" msgstr "" -#: front/src/components/library/Artists.vue:12 +#: src/components/library/Artists.vue:12 msgctxt "Content/Search/Input.Label/Noun" msgid "Artist name" msgstr "" -#: front/src/components/audio/Search.vue:99 +#: src/components/audio/Search.vue:28 msgctxt "*/Search/Input.Placeholder" msgid "Artist, album, track…" msgstr "" -#: front/src/components/Sidebar.vue:171 src/components/Sidebar.vue:206 -#: front/src/views/library/DetailBase.vue:96 +#: src/components/Sidebar.vue:171 +#: src/components/Sidebar.vue:206 +#: src/views/library/DetailBase.vue:96 msgctxt "*/*/*" msgid "Artists" msgstr "" -#: front/src/components/audio/Search.vue:15 src/components/audio/Search.vue:2 -#: front/src/components/library/Artists.vue:207 -#: front/src/components/library/TagDetail.vue:21 -#: front/src/components/library/TagDetail.vue:2 -#: front/src/components/manage/library/TagsTable.vue:48 -#: front/src/components/manage/library/TagsTable.vue:7 src/views/Search.vue:215 -#: front/src/views/admin/library/ArtistsList.vue:29 -#: front/src/views/admin/library/Base.vue:14 -#: front/src/views/admin/library/LibraryDetail.vue:252 -#: front/src/views/admin/library/LibraryDetail.vue:247 -#: front/src/views/admin/library/TagDetail.vue:140 -#: front/src/views/admin/library/TagDetail.vue:135 -#: front/src/views/admin/moderation/AccountsDetail.vue:420 -#: front/src/views/admin/moderation/AccountsDetail.vue:415 -#: front/src/views/admin/moderation/DomainsDetail.vue:342 -#: front/src/views/admin/moderation/DomainsDetail.vue:337 +#: src/components/audio/Search.vue:15 +#: src/components/audio/Search.vue:2 +#: src/components/library/Artists.vue:42 +#: src/components/library/TagDetail.vue:21 +#: src/components/library/TagDetail.vue:2 +#: src/components/manage/library/TagsTable.vue:48 +#: src/components/manage/library/TagsTable.vue:7 +#: src/views/Search.vue:76 +#: src/views/admin/library/ArtistsList.vue:13 +#: src/views/admin/library/Base.vue:14 +#: src/views/admin/library/LibraryDetail.vue:252 +#: src/views/admin/library/LibraryDetail.vue:247 +#: src/views/admin/library/TagDetail.vue:140 +#: src/views/admin/library/TagDetail.vue:135 +#: src/views/admin/moderation/AccountsDetail.vue:420 +#: src/views/admin/moderation/AccountsDetail.vue:415 +#: src/views/admin/moderation/DomainsDetail.vue:342 +#: src/views/admin/moderation/DomainsDetail.vue:337 msgctxt "*/*/*/Noun" msgid "Artists" msgstr "" -#: front/src/components/favorites/List.vue:33 src/components/library/Albums.vue:37 -#: front/src/components/library/Artists.vue:37 src/components/library/Podcasts.vue:37 -#: front/src/components/library/Radios.vue:59 -#: front/src/components/manage/ChannelsTable.vue:41 -#: front/src/components/manage/library/AlbumsTable.vue:22 -#: front/src/components/manage/library/ArtistsTable.vue:41 -#: front/src/components/manage/library/EditsCardList.vue:48 -#: front/src/components/manage/library/LibrariesTable.vue:41 -#: front/src/components/manage/library/TagsTable.vue:22 -#: front/src/components/manage/library/TracksTable.vue:22 -#: front/src/components/manage/library/UploadsTable.vue:71 -#: front/src/components/manage/moderation/AccountsTable.vue:22 -#: front/src/components/manage/moderation/DomainsTable.vue:40 -#: front/src/components/manage/users/UsersTable.vue:20 -#: front/src/views/admin/moderation/ReportsList.vue:50 -#: front/src/views/admin/moderation/RequestsList.vue:54 -#: front/src/views/content/libraries/FilesTable.vue:65 -#: front/src/views/playlists/List.vue:39 +#: src/components/favorites/List.vue:33 +#: src/components/library/Albums.vue:37 +#: src/components/library/Artists.vue:37 +#: src/components/library/Podcasts.vue:37 +#: src/components/library/Radios.vue:59 +#: src/components/manage/ChannelsTable.vue:41 +#: src/components/manage/library/AlbumsTable.vue:22 +#: src/components/manage/library/ArtistsTable.vue:41 +#: src/components/manage/library/EditsCardList.vue:48 +#: src/components/manage/library/LibrariesTable.vue:41 +#: src/components/manage/library/TagsTable.vue:22 +#: src/components/manage/library/TracksTable.vue:22 +#: src/components/manage/library/UploadsTable.vue:71 +#: src/components/manage/moderation/AccountsTable.vue:22 +#: src/components/manage/moderation/DomainsTable.vue:40 +#: src/components/manage/users/UsersTable.vue:20 +#: src/views/admin/moderation/ReportsList.vue:50 +#: src/views/admin/moderation/RequestsList.vue:54 +#: src/views/content/libraries/FilesTable.vue:65 +#: src/views/playlists/List.vue:39 msgctxt "Content/Search/Dropdown" msgid "Ascending" msgstr "" -#: front/src/views/auth/PasswordReset.vue:37 +#: src/views/auth/PasswordReset.vue:37 msgctxt "Content/Signup/Button.Label/Verb" msgid "Ask for a password reset" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:82 -#: front/src/components/manage/moderation/UserRequestCard.vue:72 +#: src/components/manage/moderation/ReportCard.vue:82 +#: src/components/manage/moderation/UserRequestCard.vue:72 msgctxt "Content/Moderation/*" msgid "Assigned to" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:272 src/views/admin/ChannelDetail.vue:267 -#: front/src/views/admin/library/AlbumDetail.vue:253 -#: front/src/views/admin/library/AlbumDetail.vue:248 -#: front/src/views/admin/library/ArtistDetail.vue:252 -#: front/src/views/admin/library/ArtistDetail.vue:247 -#: front/src/views/admin/library/LibraryDetail.vue:215 -#: front/src/views/admin/library/LibraryDetail.vue:210 -#: front/src/views/admin/library/TagDetail.vue:129 -#: front/src/views/admin/library/TagDetail.vue:124 -#: front/src/views/admin/library/TrackDetail.vue:318 -#: front/src/views/admin/library/TrackDetail.vue:313 -#: front/src/views/admin/library/UploadDetail.vue:216 -#: front/src/views/admin/library/UploadDetail.vue:211 -#: front/src/views/admin/moderation/AccountsDetail.vue:329 -#: front/src/views/admin/moderation/AccountsDetail.vue:324 -#: front/src/views/admin/moderation/DomainsDetail.vue:269 -#: front/src/views/admin/moderation/DomainsDetail.vue:264 +#: src/views/admin/ChannelDetail.vue:272 +#: src/views/admin/ChannelDetail.vue:267 +#: src/views/admin/library/AlbumDetail.vue:253 +#: src/views/admin/library/AlbumDetail.vue:248 +#: src/views/admin/library/ArtistDetail.vue:252 +#: src/views/admin/library/ArtistDetail.vue:247 +#: src/views/admin/library/LibraryDetail.vue:215 +#: src/views/admin/library/LibraryDetail.vue:210 +#: src/views/admin/library/TagDetail.vue:129 +#: src/views/admin/library/TagDetail.vue:124 +#: src/views/admin/library/TrackDetail.vue:318 +#: src/views/admin/library/TrackDetail.vue:313 +#: src/views/admin/library/UploadDetail.vue:216 +#: src/views/admin/library/UploadDetail.vue:211 +#: src/views/admin/moderation/AccountsDetail.vue:329 +#: src/views/admin/moderation/AccountsDetail.vue:324 +#: src/views/admin/moderation/DomainsDetail.vue:269 +#: src/views/admin/moderation/DomainsDetail.vue:264 msgctxt "Content/Moderation/Title" msgid "Audio content" msgstr "" -#: front/src/components/audio/Player.vue:3 +#: src/components/audio/Player.vue:3 msgctxt "*/*/*" msgid "Audio player and controls" msgstr "" -#: front/src/components/ShortcutsModal.vue:94 +#: src/components/ShortcutsModal.vue:34 msgctxt "Popup/Keyboard shortcuts/Title" msgid "Audio player shortcuts" msgstr "" -#: front/src/components/auth/Authorize.vue:64 +#: src/components/auth/Authorize.vue:64 msgctxt "Content/Signup/Button.Label/Verb" msgid "Authorize %{ app }" msgstr "" -#: front/src/components/auth/Authorize.vue:5 +#: src/components/auth/Authorize.vue:5 msgctxt "Content/Auth/Title/Verb" msgid "Authorize third-party app" msgstr "" -#: front/src/components/auth/Settings.vue:222 +#: src/components/auth/Settings.vue:222 msgctxt "Content/Settings/Title/Noun" msgid "Authorized apps" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:49 +#: src/components/playlists/PlaylistModal.vue:49 msgctxt "Popup/Playlist/Title" msgid "Available playlists" msgstr "" -#: front/src/components/auth/Settings.vue:70 +#: src/components/auth/Settings.vue:70 msgctxt "Content/Channel/*" msgid "Avatar" msgstr "" -#: front/src/components/auth/Settings.vue:51 +#: src/components/auth/Settings.vue:51 msgctxt "Content/Settings/Title" msgid "Avatar" msgstr "" -#: front/src/components/audio/ChannelForm.vue:325 +#: src/components/audio/ChannelForm.vue:76 msgctxt "Content/Channel/Form.Field.Placeholder" msgid "Awesome channel name" msgstr "" -#: front/src/components/audio/ChannelForm.vue:326 +#: src/components/audio/ChannelForm.vue:77 msgctxt "Content/Channel/Form.Field.Placeholder" msgid "awesomechannelname" msgstr "" -#: front/src/views/auth/PasswordReset.vue:32 -#: front/src/views/auth/PasswordResetConfirm.vue:24 -#: front/src/views/auth/PasswordResetConfirm.vue:6 +#: src/views/auth/PasswordReset.vue:32 +#: src/views/auth/PasswordResetConfirm.vue:24 +#: src/views/auth/PasswordResetConfirm.vue:6 msgctxt "Content/Signup/Link" msgid "Back to login" msgstr "" -#: front/src/components/auth/ApplicationEdit.vue:9 -#: front/src/components/auth/ApplicationEdit.vue:2 -#: front/src/components/auth/ApplicationNew.vue:5 +#: src/components/auth/ApplicationEdit.vue:9 +#: src/components/auth/ApplicationEdit.vue:2 +#: src/components/auth/ApplicationNew.vue:5 msgctxt "Content/Applications/Link" msgid "Back to settings" msgstr "" -#: front/src/components/mixins/Translations.vue:56 -#: front/src/components/mixins/Translations.vue:57 +#: src/components/mixins/Translations.vue:56 msgctxt "Content/Account/*" msgid "Bio" msgstr "" -#: front/src/components/library/TrackDetail.vue:65 -#: front/src/components/library/TrackDetail.vue:60 -#: front/src/components/mixins/Translations.vue:82 -#: front/src/views/admin/library/UploadDetail.vue:262 -#: front/src/views/admin/library/UploadDetail.vue:257 -#: front/src/components/mixins/Translations.vue:83 +#: src/components/library/TrackDetail.vue:66 +#: src/components/library/TrackDetail.vue:61 +#: src/components/mixins/Translations.vue:82 +#: src/views/admin/library/UploadDetail.vue:262 +#: src/views/admin/library/UploadDetail.vue:257 msgctxt "Content/Track/*/Noun" msgid "Bitrate" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyCard.vue:23 -#: front/src/components/manage/moderation/InstancePolicyForm.vue:44 +#: src/components/manage/moderation/InstancePolicyCard.vue:23 +#: src/components/manage/moderation/InstancePolicyForm.vue:44 msgctxt "Content/Moderation/*/Verb" msgid "Block everything" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:206 +#: src/components/manage/moderation/InstancePolicyForm.vue:36 msgctxt "Content/Moderation/Help text" msgid "Block everything from this account or domain. This will prevent any interaction with the entity, and purge related content (uploads, libraries, follows, etc.)" msgstr "" -#: front/src/components/Sidebar.vue:156 src/components/Sidebar.vue:196 +#: src/components/Sidebar.vue:156 +#: src/components/Sidebar.vue:196 msgctxt "Sidebar/Navigation/List item.Link/Verb" msgid "Browse" msgstr "" -#: front/src/components/About.vue:135 +#: src/components/About.vue:140 msgctxt "Content/About/Header" msgid "Browse public content" msgstr "" -#: front/src/components/Home.vue:163 +#: src/components/Home.vue:163 msgctxt "Content/Home/Link" msgid "Browse public content" msgstr "" -#: front/src/components/favorites/List.vue:74 +#: src/components/favorites/List.vue:74 msgctxt "Content/*/Verb" msgid "Browse the library" msgstr "" -#: front/src/components/channels/UploadForm.vue:136 -#: front/src/components/channels/UploadForm.vue:101 -#: front/src/components/channels/UploadForm.vue:90 +#: src/components/channels/UploadForm.vue:136 +#: src/components/channels/UploadForm.vue:101 +#: src/components/channels/UploadForm.vue:90 msgctxt "*/*/*" msgid "Browse…" msgstr "" -#: front/src/components/library/Albums.vue:4 +#: src/components/library/Albums.vue:4 msgctxt "Content/Album/Title" msgid "Browsing albums" msgstr "" -#: front/src/components/library/Artists.vue:4 +#: src/components/library/Artists.vue:4 msgctxt "Content/Artist/Title" msgid "Browsing artists" msgstr "" -#: front/src/views/playlists/List.vue:4 +#: src/views/playlists/List.vue:4 msgctxt "Content/Playlist/Title" msgid "Browsing playlists" msgstr "" -#: front/src/components/library/Podcasts.vue:4 +#: src/components/library/Podcasts.vue:4 msgctxt "Content/Podcasts/Title" msgid "Browsing podcasts" msgstr "" -#: front/src/components/library/Radios.vue:4 +#: src/components/library/Radios.vue:4 msgctxt "Content/Radio/Title" msgid "Browsing radios" msgstr "" -#: front/src/components/library/radios/Builder.vue:5 +#: src/components/library/radios/Builder.vue:5 msgctxt "Content/Radio/Title" msgid "Builder" msgstr "" -#: front/src/views/content/remote/Card.vue:135 src/views/content/remote/Card.vue:31 -#: front/src/views/content/remote/Card.vue:12 +#: src/views/content/remote/Card.vue:135 +#: src/views/content/remote/Card.vue:31 +#: src/views/content/remote/Card.vue:12 msgctxt "Popup/Library/Paragraph" msgid "By unfollowing this library, you loose access to its content." msgstr "" -#: front/src/views/admin/ChannelDetail.vue:288 src/views/admin/ChannelDetail.vue:283 -#: front/src/views/admin/library/AlbumDetail.vue:269 -#: front/src/views/admin/library/AlbumDetail.vue:264 -#: front/src/views/admin/library/ArtistDetail.vue:268 -#: front/src/views/admin/library/ArtistDetail.vue:263 -#: front/src/views/admin/library/LibraryDetail.vue:231 -#: front/src/views/admin/library/LibraryDetail.vue:226 -#: front/src/views/admin/library/TrackDetail.vue:334 -#: front/src/views/admin/library/TrackDetail.vue:329 -#: front/src/views/admin/library/UploadDetail.vue:237 -#: front/src/views/admin/library/UploadDetail.vue:232 -#: front/src/views/admin/moderation/AccountsDetail.vue:345 -#: front/src/views/admin/moderation/AccountsDetail.vue:340 -#: front/src/views/admin/moderation/DomainsDetail.vue:285 -#: front/src/views/admin/moderation/DomainsDetail.vue:280 +#: src/views/admin/ChannelDetail.vue:288 +#: src/views/admin/ChannelDetail.vue:283 +#: src/views/admin/library/AlbumDetail.vue:269 +#: src/views/admin/library/AlbumDetail.vue:264 +#: src/views/admin/library/ArtistDetail.vue:268 +#: src/views/admin/library/ArtistDetail.vue:263 +#: src/views/admin/library/LibraryDetail.vue:231 +#: src/views/admin/library/LibraryDetail.vue:226 +#: src/views/admin/library/TrackDetail.vue:334 +#: src/views/admin/library/TrackDetail.vue:329 +#: src/views/admin/library/UploadDetail.vue:237 +#: src/views/admin/library/UploadDetail.vue:232 +#: src/views/admin/moderation/AccountsDetail.vue:345 +#: src/views/admin/moderation/AccountsDetail.vue:340 +#: src/views/admin/moderation/DomainsDetail.vue:285 +#: src/views/admin/moderation/DomainsDetail.vue:280 msgctxt "Content/Moderation/Table.Label/Noun" msgid "Cached size" msgstr "" -#: front/src/components/SetInstanceModal.vue:64 -#: front/src/components/channels/AlbumModal.vue:15 -#: front/src/components/channels/UploadModal.vue:34 -#: front/src/components/common/DangerousButton.vue:19 -#: front/src/components/common/RenderedDescription.vue:41 -#: front/src/components/library/AlbumDropdown.vue:15 -#: front/src/components/library/ArtistBase.vue:46 -#: front/src/components/library/ArtistBase.vue:41 -#: front/src/components/library/EditForm.vue:123 -#: front/src/components/library/FileUpload.vue:194 -#: front/src/components/library/FileUpload.vue:18 -#: front/src/components/library/Podcasts.vue:108 -#: front/src/components/library/TrackBase.vue:40 -#: front/src/components/library/TrackBase.vue:35 -#: front/src/components/library/radios/Filter.vue:49 -#: front/src/components/manage/moderation/InstancePolicyForm.vue:66 -#: front/src/components/moderation/FilterModal.vue:59 -#: front/src/components/moderation/ReportModal.vue:82 -#: front/src/components/playlists/PlaylistModal.vue:26 -#: front/src/components/playlists/PlaylistModal.vue:130 -#: front/src/views/auth/ProfileOverview.vue:57 src/views/channels/DetailBase.vue:87 -#: front/src/views/channels/DetailBase.vue:198 src/views/channels/DetailBase.vue:219 -#: front/src/views/channels/DetailBase.vue:82 src/views/channels/DetailBase.vue:193 -#: front/src/views/channels/DetailBase.vue:214 -#: front/src/views/channels/SubscriptionsList.vue:23 -#: front/src/views/playlists/Detail.vue:83 +#: src/components/SetInstanceModal.vue:64 +#: src/components/channels/AlbumModal.vue:15 +#: src/components/channels/UploadModal.vue:34 +#: src/components/common/DangerousButton.vue:19 +#: src/components/common/RenderedDescription.vue:41 +#: src/components/library/AlbumDropdown.vue:15 +#: src/components/library/ArtistBase.vue:46 +#: src/components/library/ArtistBase.vue:41 +#: src/components/library/EditForm.vue:123 +#: src/components/library/FileUpload.vue:194 +#: src/components/library/FileUpload.vue:18 +#: src/components/library/Podcasts.vue:108 +#: src/components/library/TrackBase.vue:40 +#: src/components/library/TrackBase.vue:35 +#: src/components/library/radios/Filter.vue:49 +#: src/components/manage/moderation/InstancePolicyForm.vue:66 +#: src/components/moderation/FilterModal.vue:59 +#: src/components/moderation/ReportModal.vue:82 +#: src/components/playlists/PlaylistModal.vue:26 +#: src/components/playlists/PlaylistModal.vue:130 +#: src/views/auth/ProfileOverview.vue:57 +#: src/views/channels/DetailBase.vue:87 +#: src/views/channels/DetailBase.vue:200 +#: src/views/channels/DetailBase.vue:221 +#: src/views/channels/DetailBase.vue:82 +#: src/views/channels/DetailBase.vue:195 +#: src/views/channels/DetailBase.vue:216 +#: src/views/channels/SubscriptionsList.vue:23 +#: src/views/playlists/Detail.vue:83 msgctxt "*/*/Button.Label/Verb" msgid "Cancel" msgstr "" -#: front/src/components/audio/LibraryFollowButton.vue:6 +#: src/components/audio/LibraryFollowButton.vue:6 msgctxt "Content/Library/Card.Button.Label/Verb" msgid "Cancel follow request" msgstr "" -#: front/src/views/content/remote/Card.vue:118 src/views/content/remote/Card.vue:14 -#: front/src/views/content/remote/Card.vue:8 +#: src/views/content/remote/Card.vue:118 +#: src/views/content/remote/Card.vue:14 +#: src/views/content/remote/Card.vue:8 msgctxt "Content/Library/Card.Paragraph" msgid "Cancel follow request" msgstr "" -#: front/src/components/library/radios/Builder.vue:93 +#: src/components/library/radios/Builder.vue:93 msgctxt "Content/Radio/Table.Label/Noun (Value is a number of Tracks)" msgid "Candidates" msgstr "" -#: front/src/components/library/FileUpload.vue:367 -#: front/src/components/library/FileUpload.vue:368 +#: src/components/library/FileUpload.vue:59 msgctxt "Content/Library/Help text" msgid "Cannot upload this file, ensure it is not too big" msgstr "" -#: front/src/components/audio/ChannelForm.vue:100 -#: front/src/components/audio/ChannelForm.vue:86 -#: front/src/components/audio/ChannelForm.vue:66 -#: front/src/components/manage/ChannelsTable.vue:11 -#: front/src/components/manage/library/ArtistsTable.vue:11 -#: front/src/components/manage/moderation/ReportCard.vue:34 -#: front/src/components/mixins/Translations.vue:46 -#: front/src/components/moderation/ReportCategoryDropdown.vue:2 -#: front/src/views/admin/ChannelDetail.vue:115 src/views/admin/ChannelDetail.vue:110 -#: front/src/views/admin/library/ArtistDetail.vue:127 -#: front/src/views/admin/library/ArtistDetail.vue:122 -#: front/src/components/mixins/Translations.vue:47 +#: src/components/audio/ChannelForm.vue:100 +#: src/components/audio/ChannelForm.vue:86 +#: src/components/audio/ChannelForm.vue:66 +#: src/components/manage/ChannelsTable.vue:11 +#: src/components/manage/library/ArtistsTable.vue:11 +#: src/components/manage/moderation/ReportCard.vue:34 +#: src/components/mixins/Translations.vue:46 +#: src/components/moderation/ReportCategoryDropdown.vue:2 +#: src/views/admin/ChannelDetail.vue:115 +#: src/views/admin/ChannelDetail.vue:110 +#: src/views/admin/library/ArtistDetail.vue:127 +#: src/views/admin/library/ArtistDetail.vue:122 msgctxt "*/*/*" msgid "Category" msgstr "" -#: front/src/components/Footer.vue:41 src/components/common/UserMenu.vue:170 +#: src/components/common/UserMenu.vue:23 msgctxt "Footer/Settings/Dropdown.Label/Short, Verb" msgid "Change language" msgstr "" -#: front/src/components/auth/Settings.vue:412 +#: src/components/auth/Settings.vue:412 msgctxt "*/*/Button.Label" msgid "Change my e-mail address" msgstr "" -#: front/src/components/auth/Settings.vue:80 +#: src/components/auth/Settings.vue:80 msgctxt "Content/Settings/Title/Verb" msgid "Change my password" msgstr "" -#: front/src/components/auth/Settings.vue:115 +#: src/components/auth/Settings.vue:115 msgctxt "Content/Settings/Button.Label" msgid "Change password" msgstr "" -#: front/src/components/auth/Settings.vue:418 +#: src/components/auth/Settings.vue:418 msgctxt "Content/Settings/Paragraph'" msgid "Change the e-mail address associated with your account. We will send a confirmation to the new address." msgstr "" -#: front/src/components/Footer.vue:61 src/components/common/UserMenu.vue:171 +#: src/components/common/UserMenu.vue:24 msgctxt "Footer/Settings/Dropdown.Label/Short, Verb" msgid "Change theme" msgstr "" -#: front/src/views/auth/PasswordResetConfirm.vue:113 +#: src/views/auth/PasswordResetConfirm.vue:25 msgctxt "*/Signup/Title" msgid "Change your password" msgstr "" -#: front/src/components/auth/Settings.vue:119 +#: src/components/auth/Settings.vue:119 msgctxt "Popup/Settings/Title" msgid "Change your password?" msgstr "" -#: front/src/components/playlists/Editor.vue:44 src/components/playlists/Editor.vue:1 +#: src/components/playlists/Editor.vue:44 +#: src/components/playlists/Editor.vue:1 msgctxt "Content/Playlist/Paragraph" msgid "Changes synced with server" msgstr "" -#: front/src/components/auth/Settings.vue:85 +#: src/components/auth/Settings.vue:85 msgctxt "Content/Settings/Paragraph'" msgid "Changing your password will also change your Subsonic API password if you have requested one." msgstr "" -#: front/src/components/auth/Settings.vue:125 +#: src/components/auth/Settings.vue:125 msgctxt "Popup/Settings/Paragraph" msgid "Changing your password will have the following consequences:" msgstr "" -#: front/src/components/channels/UploadForm.vue:16 -#: front/src/components/mixins/Report.vue:60 src/views/channels/DetailBase.vue:493 -#: front/src/components/mixins/Report.vue:61 +#: src/components/channels/UploadForm.vue:16 +#: src/components/mixins/Report.vue:60 +#: src/views/channels/DetailBase.vue:56 msgctxt "*/*/*" msgid "Channel" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:95 src/views/admin/ChannelDetail.vue:90 +#: src/views/admin/ChannelDetail.vue:95 +#: src/views/admin/ChannelDetail.vue:90 msgctxt "Content/Moderation/Title" msgid "Channel data" msgstr "" -#: front/src/components/audio/ChannelForm.vue:62 -#: front/src/components/audio/ChannelForm.vue:48 -#: front/src/components/audio/ChannelForm.vue:28 +#: src/components/audio/ChannelForm.vue:62 +#: src/components/audio/ChannelForm.vue:48 +#: src/components/audio/ChannelForm.vue:28 msgctxt "Content/Channel/*" msgid "Channel Picture" msgstr "" -#: front/src/components/Sidebar.vue:228 src/components/library/TagDetail.vue:30 -#: front/src/views/admin/ChannelsList.vue:29 src/views/admin/Settings.vue:73 -#: front/src/views/admin/library/Base.vue:9 -#: front/src/views/admin/moderation/AccountsDetail.vue:385 -#: front/src/views/admin/moderation/AccountsDetail.vue:380 -#: front/src/views/admin/moderation/DomainsDetail.vue:306 -#: front/src/views/admin/moderation/DomainsDetail.vue:301 -#: front/src/views/auth/ProfileOverview.vue:8 +#: src/components/Sidebar.vue:228 +#: src/components/library/TagDetail.vue:30 +#: src/views/admin/ChannelsList.vue:13 +#: src/views/admin/Settings.vue:29 +#: src/views/admin/library/Base.vue:9 +#: src/views/admin/moderation/AccountsDetail.vue:385 +#: src/views/admin/moderation/AccountsDetail.vue:380 +#: src/views/admin/moderation/DomainsDetail.vue:306 +#: src/views/admin/moderation/DomainsDetail.vue:301 +#: src/views/auth/ProfileOverview.vue:8 msgctxt "*/*/*" msgid "Channels" msgstr "" -#: front/src/components/Footer.vue:76 -msgctxt "Footer/*/List item.Link" -msgid "Chat room" -msgstr "" - -#: front/src/components/common/UserMenu.vue:172 -#: front/src/components/common/UserModal.vue:208 +#: src/components/common/UserMenu.vue:25 +#: src/components/common/UserModal.vue:40 msgctxt "Sidebar/*/Listitem.Link" msgid "Chat room" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:30 +#: src/components/auth/ApplicationForm.vue:30 msgctxt "Content/Applications/Paragraph/" msgid "Checking the parent \"Read\" or \"Write\" scopes implies access to all the corresponding children scopes." msgstr "" -#: front/src/components/SetInstanceModal.vue:3 +#: src/components/SetInstanceModal.vue:3 msgctxt "Popup/Instance/Title" msgid "Choose your instance" msgstr "" -#: front/src/components/Queue.vue:106 +#: src/components/Queue.vue:106 msgctxt "*/Queue/*/Verb" msgid "Clear" msgstr "" -#: front/src/components/common/InlineSearchBar.vue:46 -#: front/src/components/library/EditForm.vue:85 -#: front/src/components/library/EditForm.vue:104 -#: front/src/components/library/EditForm.vue:15 src/components/library/EditForm.vue:5 -#: front/src/components/manage/users/InvitationForm.vue:56 +#: src/components/common/InlineSearchBar.vue:10 +#: src/components/library/EditForm.vue:85 +#: src/components/library/EditForm.vue:104 +#: src/components/library/EditForm.vue:15 +#: src/components/library/EditForm.vue:5 +#: src/components/manage/users/InvitationForm.vue:56 msgctxt "Content/Library/Button.Label" msgid "Clear" msgstr "" -#: front/src/components/playlists/Editor.vue:58 -#: front/src/components/playlists/Editor.vue:70 +#: src/components/playlists/Editor.vue:58 +#: src/components/playlists/Editor.vue:70 msgctxt "*/Playlist/Button.Label/Verb" msgid "Clear playlist" msgstr "" -#: front/src/components/ShortcutsModal.vue:150 +#: src/components/ShortcutsModal.vue:90 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Clear queue" msgstr "" -#: front/src/components/audio/Player.vue:423 +#: src/components/audio/Player.vue:83 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Clear your queue" msgstr "" -#: front/src/components/mixins/Translations.vue:21 -#: front/src/components/mixins/Translations.vue:22 +#: src/components/mixins/Translations.vue:21 msgctxt "Content/Library/Link.Title" msgid "Click to display more information about the import process for this upload" msgstr "" -#: front/src/components/library/FileUpload.vue:73 +#: src/components/library/FileUpload.vue:73 msgctxt "Content/Library/Paragraph/Call to action" msgid "Click to select files to upload or drag and drop files or directories" msgstr "" -#: front/src/components/ShortcutsModal.vue:35 -#: front/src/components/channels/UploadModal.vue:71 -#: front/src/components/federation/FetchButton.vue:138 -#: front/src/components/library/ImportStatusModal.vue:97 -#: front/src/components/manage/moderation/InstancePolicyModal.vue:33 +#: src/components/ShortcutsModal.vue:35 +#: src/components/channels/UploadModal.vue:71 +#: src/components/federation/FetchButton.vue:138 +#: src/components/library/ImportStatusModal.vue:97 +#: src/components/manage/moderation/InstancePolicyModal.vue:33 msgctxt "*/*/Button.Label/Verb" msgid "Close" msgstr "" -#: front/src/components/Queue.vue:101 +#: src/components/Queue.vue:101 msgctxt "*/Queue/*/Verb" msgid "Close" msgstr "" -#: front/src/components/federation/FetchButton.vue:143 +#: src/components/federation/FetchButton.vue:143 msgctxt "*/*/Button.Label/Verb" msgid "Close and reload page" msgstr "" -#: front/src/components/AboutPod.vue:186 +#: src/components/AboutPod.vue:186 msgctxt "*/*/*/State of registrations" msgid "Closed" msgstr "" -#: front/src/components/manage/users/InvitationForm.vue:35 -#: front/src/components/manage/users/InvitationsTable.vue:65 -#: front/src/components/manage/users/InvitationsTable.vue:22 +#: src/components/manage/users/InvitationForm.vue:35 +#: src/components/manage/users/InvitationsTable.vue:65 +#: src/components/manage/users/InvitationsTable.vue:22 msgctxt "Content/Admin/Table.Label/Noun" msgid "Code" msgstr "" -#: front/src/components/library/TrackDetail.vue:50 -#: front/src/components/library/TrackDetail.vue:45 +#: src/components/library/TrackDetail.vue:51 +#: src/components/library/TrackDetail.vue:46 msgctxt "Content/*/*/Noun" msgid "Codec" msgstr "" -#: front/src/components/common/CollapseLink.vue:3 +#: src/components/common/CollapseLink.vue:3 msgctxt "*/*/Button,Label" msgid "Collapse" msgstr "" -#: front/src/components/library/radios/Builder.vue:88 +#: src/components/library/radios/Builder.vue:88 msgctxt "Content/Radio/Table.Label/Verb (Value is a List of Parameters)" msgid "Config" msgstr "" -#: front/src/components/common/DangerousButton.vue:25 +#: src/components/common/DangerousButton.vue:25 msgctxt "Modal/*/Button.Label/Short, Verb" msgid "Confirm" msgstr "" -#: front/src/views/auth/EmailConfirm.vue:96 +#: src/views/auth/EmailConfirm.vue:16 msgctxt "Head/Signup/Title" msgid "Confirm your e-mail address" msgstr "" -#: front/src/views/auth/EmailConfirm.vue:19 +#: src/views/auth/EmailConfirm.vue:19 msgctxt "Content/Signup/Form.Label" msgid "Confirmation code" msgstr "" -#: front/src/components/AboutPod.vue:266 src/components/AboutPod.vue:2 +#: src/components/AboutPod.vue:266 +#: src/components/AboutPod.vue:2 msgctxt "Content/About/Header" msgid "Contact" msgstr "" -#: front/src/components/Home.vue:77 src/components/Home.vue:2 +#: src/components/Home.vue:77 +#: src/components/Home.vue:2 msgctxt "Content/Home/Header/Name" msgid "Contact" msgstr "" -#: front/src/components/mixins/Translations.vue:59 -#: front/src/components/mixins/Translations.vue:60 +#: src/components/mixins/Translations.vue:59 msgctxt "Content/*/Dropdown.Label/Noun" msgid "Content category" msgstr "" -#: front/src/components/moderation/FilterModal.vue:134 +#: src/components/moderation/FilterModal.vue:43 msgctxt "*/Moderation/Message" msgid "Content filter successfully added" msgstr "" -#: front/src/components/auth/Settings.vue:158 -#: front/src/components/mixins/Translations.vue:123 -#: front/src/components/mixins/Translations.vue:124 +#: src/components/auth/Settings.vue:158 +#: src/components/mixins/Translations.vue:123 msgctxt "Content/Settings/Title/Noun" msgid "Content filters" msgstr "" -#: front/src/components/auth/Settings.vue:164 +#: src/components/auth/Settings.vue:164 msgctxt "Content/Settings/Paragraph" msgid "Content filters help you hide content you don't want to see on the service." msgstr "" -#: front/src/components/common/ActionTable.vue:8 +#: src/components/common/ActionTable.vue:8 msgctxt "Content/*/Button.Help text.Paragraph" msgid "Content has been updated, click refresh to see up-to-date content" msgstr "" -#: front/src/components/Footer.vue:86 -msgctxt "Footer/*/List item.Link" -msgid "Contribute" -msgstr "" - -#: front/src/components/audio/EmbedWizard.vue:36 -#: front/src/components/common/CopyInput.vue:10 -#: front/src/components/forms/PasswordInput.vue:50 +#: src/components/audio/EmbedWizard.vue:36 +#: src/components/common/CopyInput.vue:10 +#: src/components/forms/PasswordInput.vue:20 msgctxt "*/*/Button.Label/Short, Verb" msgid "Copy" msgstr "" -#: front/src/components/playlists/Editor.vue:207 +#: src/components/playlists/Editor.vue:31 msgctxt "Content/Playlist/Button.Tooltip/Verb" msgid "Copy the current queue to this playlist" msgstr "" -#: front/src/components/auth/Authorize.vue:76 +#: src/components/auth/Authorize.vue:76 msgctxt "Content/Auth/Paragraph" msgid "Copy-paste the following code in the application:" msgstr "" -#: front/src/views/channels/DetailBase.vue:63 src/views/channels/DetailBase.vue:58 -#: front/src/views/channels/DetailBase.vue:8 +#: src/views/channels/DetailBase.vue:63 +#: src/views/channels/DetailBase.vue:58 +#: src/views/channels/DetailBase.vue:8 msgctxt "Content/Channels/Label" msgid "Copy-paste the following URL in your favorite podcatcher:" msgstr "" -#: front/src/components/audio/EmbedWizard.vue:42 +#: src/components/audio/EmbedWizard.vue:42 msgctxt "Popup/Embed/Paragraph" msgid "Copy/paste this code in your website HTML" msgstr "" -#: front/src/components/library/TrackDetail.vue:152 -#: front/src/views/admin/library/TrackDetail.vue:184 -#: front/src/views/admin/library/TrackDetail.vue:179 src/edits.js:108 +#: src/components/library/TrackDetail.vue:153 +#: src/views/admin/library/TrackDetail.vue:184 +#: src/views/admin/library/TrackDetail.vue:179 msgctxt "Content/Track/*/Noun" msgid "Copyright" msgstr "" -#: front/src/views/auth/EmailConfirm.vue:8 +#: src/views/auth/EmailConfirm.vue:8 msgctxt "Content/Signup/Paragraph" msgid "Could not confirm your e-mail address" msgstr "" -#: front/src/views/content/remote/ScanForm.vue:4 +#: src/views/content/remote/ScanForm.vue:4 msgctxt "Content/Library/Error message.Title" msgid "Could not fetch remote library" msgstr "" -#: front/src/components/channels/AlbumModal.vue:20 +#: src/components/channels/AlbumModal.vue:20 msgctxt "*/*/Button.Label" msgid "Create" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:74 +#: src/components/manage/moderation/InstancePolicyForm.vue:74 msgctxt "Content/Moderation/Card.Button.Label/Verb" msgid "Create" msgstr "" -#: front/src/components/library/Podcasts.vue:80 +#: src/components/library/Podcasts.vue:80 msgctxt "Content/*/Verb" msgid "Create a Channel" msgstr "" -#: front/src/views/auth/Signup.vue:5 +#: src/views/auth/Signup.vue:5 msgctxt "Content/Signup/Title" msgid "Create a Funkwhale account" msgstr "" -#: front/src/components/auth/ApplicationNew.vue:10 -#: front/src/components/auth/ApplicationNew.vue:53 +#: src/components/auth/ApplicationNew.vue:10 +#: src/components/auth/ApplicationNew.vue:26 msgctxt "Content/Settings/Button.Label" msgid "Create a new application" msgstr "" -#: front/src/views/content/libraries/Home.vue:24 +#: src/views/content/libraries/Home.vue:24 msgctxt "Content/Library/Link/Verb" msgid "Create a new library" msgstr "" -#: front/src/components/playlists/Form.vue:3 +#: src/components/playlists/Form.vue:3 msgctxt "Popup/Playlist/Title/Verb" msgid "Create a new playlist" msgstr "" -#: front/src/views/playlists/List.vue:77 +#: src/views/playlists/List.vue:77 msgctxt "Content/*/Verb" msgid "Create a playlist" msgstr "" -#: front/src/components/library/Radios.vue:96 +#: src/components/library/Radios.vue:96 msgctxt "Content/*/Verb" msgid "Create a radio" msgstr "" -#: front/src/components/Sidebar.vue:128 src/components/auth/LoginForm.vue:31 -#: front/src/components/auth/LoginForm.vue:7 src/components/auth/LoginForm.vue:3 +#: src/components/Sidebar.vue:128 +#: src/components/auth/LoginForm.vue:31 +#: src/components/auth/LoginForm.vue:7 +#: src/components/auth/LoginForm.vue:3 msgctxt "*/Signup/Link/Verb" msgid "Create an account" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:64 +#: src/components/auth/ApplicationForm.vue:64 msgctxt "Content/Applications/Button.Label/Verb" msgid "Create application" msgstr "" -#: front/src/views/auth/ProfileOverview.vue:72 +#: src/views/auth/ProfileOverview.vue:72 msgctxt "*/Channels/Button.Label" msgid "Create channel" msgstr "" -#: front/src/views/auth/ProfileOverview.vue:41 +#: src/views/auth/ProfileOverview.vue:41 msgctxt "Content/Channel/*/Verb" msgid "Create channel" msgstr "" -#: front/src/views/content/libraries/Form.vue:44 +#: src/views/content/libraries/Form.vue:44 msgctxt "Content/Library/Button.Label/Verb" msgid "Create library" msgstr "" -#: front/src/components/auth/SignupForm.vue:72 +#: src/components/auth/SignupForm.vue:72 msgctxt "Content/Signup/Button.Label" msgid "Create my account" msgstr "" -#: front/src/components/playlists/Form.vue:55 src/components/playlists/Form.vue:1 +#: src/components/playlists/Form.vue:55 +#: src/components/playlists/Form.vue:1 msgctxt "Content/Playlist/Button.Label/Verb" msgid "Create playlist" msgstr "" -#: front/src/components/playlists/Widget.vue:20 +#: src/components/playlists/Widget.vue:20 msgctxt "Content/Home/CreatePlaylist" msgid "Create Playlist" msgstr "" -#: front/src/components/library/Radios.vue:31 +#: src/components/library/Radios.vue:31 msgctxt "Content/Radio/Button.Label/Verb" msgid "Create your own radio" msgstr "" -#: front/src/components/auth/Settings.vue:189 src/components/auth/Settings.vue:328 -#: front/src/components/manage/ChannelsTable.vue:86 -#: front/src/components/manage/ChannelsTable.vue:27 -#: front/src/components/manage/library/AlbumsTable.vue:67 -#: front/src/components/manage/library/AlbumsTable.vue:27 -#: front/src/components/manage/library/ArtistsTable.vue:81 -#: front/src/components/manage/library/ArtistsTable.vue:22 -#: front/src/components/manage/library/LibrariesTable.vue:91 -#: front/src/components/manage/library/LibrariesTable.vue:32 -#: front/src/components/manage/library/TagsTable.vue:63 -#: front/src/components/manage/library/TagsTable.vue:22 -#: front/src/components/manage/library/TracksTable.vue:67 -#: front/src/components/manage/library/TracksTable.vue:27 -#: front/src/components/manage/library/UploadsTable.vue:127 -#: front/src/components/manage/library/UploadsTable.vue:37 -#: front/src/components/manage/moderation/ReportCard.vue:47 -#: front/src/components/manage/moderation/UserRequestCard.vue:29 -#: front/src/components/manage/users/InvitationsTable.vue:55 -#: front/src/components/manage/users/InvitationsTable.vue:12 -#: front/src/components/mixins/Translations.vue:68 -#: front/src/components/mixins/Translations.vue:69 +#: src/components/auth/Settings.vue:189 +#: src/components/auth/Settings.vue:328 +#: src/components/manage/ChannelsTable.vue:86 +#: src/components/manage/ChannelsTable.vue:27 +#: src/components/manage/library/AlbumsTable.vue:67 +#: src/components/manage/library/AlbumsTable.vue:27 +#: src/components/manage/library/ArtistsTable.vue:81 +#: src/components/manage/library/ArtistsTable.vue:22 +#: src/components/manage/library/LibrariesTable.vue:91 +#: src/components/manage/library/LibrariesTable.vue:32 +#: src/components/manage/library/TagsTable.vue:63 +#: src/components/manage/library/TagsTable.vue:22 +#: src/components/manage/library/TracksTable.vue:67 +#: src/components/manage/library/TracksTable.vue:27 +#: src/components/manage/library/UploadsTable.vue:127 +#: src/components/manage/library/UploadsTable.vue:37 +#: src/components/manage/moderation/ReportCard.vue:47 +#: src/components/manage/moderation/UserRequestCard.vue:29 +#: src/components/manage/users/InvitationsTable.vue:55 +#: src/components/manage/users/InvitationsTable.vue:12 +#: src/components/mixins/Translations.vue:68 msgctxt "Content/*/*/Noun" msgid "Creation date" msgstr "" -#: front/src/components/admin/SettingsGroup.vue:56 +#: src/components/admin/SettingsGroup.vue:56 msgctxt "Content/Settings/Title/Noun" msgid "Current image" msgstr "" -#: front/src/components/auth/Settings.vue:107 +#: src/components/auth/Settings.vue:107 msgctxt "Content/Settings/Input.Label" msgid "Current password" msgstr "" -#: front/src/views/content/libraries/Quota.vue:3 +#: src/views/content/libraries/Quota.vue:3 msgctxt "Content/Library/Title" msgid "Current usage" msgstr "" -#: front/src/components/Footer.vue:240 src/components/common/UserMenu.vue:188 -#: front/src/components/common/UserModal.vue:231 -#: front/src/components/common/UserModal.vue:233 -msgctxt "Footer/Settings/Dropdown.Label/Theme name" -msgid "Dark" -msgstr "" - -#: front/src/components/Sidebar.vue:600 -msgctxt "Sidebar/Settings/Dropdown.Label/Theme name" -msgid "Dark" -msgstr "" - -#: front/src/components/federation/FetchButton.vue:82 -#: front/src/components/federation/FetchButton.vue:70 +#: src/components/federation/FetchButton.vue:82 +#: src/components/federation/FetchButton.vue:70 msgctxt "*/*/Error" msgid "Data returned by the remote server had invalid or missing attributes" msgstr "" -#: front/src/components/federation/FetchButton.vue:32 -#: front/src/components/federation/FetchButton.vue:20 +#: src/components/federation/FetchButton.vue:32 +#: src/components/federation/FetchButton.vue:20 msgctxt "Popup/*/Message.Content" msgid "Data was refreshed successfully from remote server." msgstr "" -#: front/src/views/library/Edit.vue:33 +#: src/views/library/Edit.vue:33 msgctxt "Content/Library/Table.Label" msgid "Date" msgstr "" -#: front/src/components/library/ImportStatusModal.vue:80 -#: front/src/components/library/ImportStatusModal.vue:51 +#: src/components/library/ImportStatusModal.vue:80 +#: src/components/library/ImportStatusModal.vue:51 msgctxt "Popup/Import/Table.Label/Noun" msgid "Debug information" msgstr "" -#: front/src/components/ShortcutsModal.vue:130 +#: src/components/ShortcutsModal.vue:70 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Decrease volume" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:198 +#: src/components/admin/SignupFormBuilder.vue:35 msgctxt "*/*/*" msgid "Delete" msgstr "" -#: front/src/components/auth/Settings.vue:208 -#: front/src/components/library/AlbumDropdown.vue:50 -#: front/src/components/library/EditCard.vue:121 -#: front/src/components/library/EditCard.vue:137 -#: front/src/components/library/TrackBase.vue:91 -#: front/src/components/library/TrackBase.vue:86 -#: front/src/components/manage/library/AlbumsTable.vue:246 -#: front/src/components/manage/library/ArtistsTable.vue:243 -#: front/src/components/manage/library/LibrariesTable.vue:275 -#: front/src/components/manage/library/TagsTable.vue:211 -#: front/src/components/manage/library/TracksTable.vue:256 -#: front/src/components/manage/library/UploadsTable.vue:374 -#: front/src/components/manage/moderation/InstancePolicyForm.vue:79 -#: front/src/components/manage/moderation/NotesThread.vue:21 -#: front/src/components/manage/moderation/NotesThread.vue:37 -#: front/src/components/manage/moderation/ReportCard.vue:498 -#: front/src/components/manage/users/InvitationsTable.vue:212 -#: front/src/views/admin/ChannelDetail.vue:61 src/views/admin/ChannelDetail.vue:77 -#: front/src/views/admin/ChannelDetail.vue:56 src/views/admin/ChannelDetail.vue:72 -#: front/src/views/admin/library/AlbumDetail.vue:74 -#: front/src/views/admin/library/AlbumDetail.vue:90 -#: front/src/views/admin/library/AlbumDetail.vue:69 -#: front/src/views/admin/library/AlbumDetail.vue:85 -#: front/src/views/admin/library/ArtistDetail.vue:73 -#: front/src/views/admin/library/ArtistDetail.vue:89 -#: front/src/views/admin/library/ArtistDetail.vue:68 -#: front/src/views/admin/library/ArtistDetail.vue:84 -#: front/src/views/admin/library/LibraryDetail.vue:47 -#: front/src/views/admin/library/LibraryDetail.vue:63 -#: front/src/views/admin/library/LibraryDetail.vue:42 -#: front/src/views/admin/library/LibraryDetail.vue:58 -#: front/src/views/admin/library/TagDetail.vue:36 -#: front/src/views/admin/library/TagDetail.vue:52 -#: front/src/views/admin/library/TagDetail.vue:31 -#: front/src/views/admin/library/TagDetail.vue:47 -#: front/src/views/admin/library/TrackDetail.vue:74 -#: front/src/views/admin/library/TrackDetail.vue:90 -#: front/src/views/admin/library/TrackDetail.vue:69 -#: front/src/views/admin/library/TrackDetail.vue:85 -#: front/src/views/admin/library/UploadDetail.vue:54 -#: front/src/views/admin/library/UploadDetail.vue:70 -#: front/src/views/admin/library/UploadDetail.vue:49 -#: front/src/views/admin/library/UploadDetail.vue:65 -#: front/src/views/channels/DetailBase.vue:131 src/views/channels/DetailBase.vue:126 -#: front/src/views/channels/DetailBase.vue:22 -#: front/src/views/content/libraries/FilesTable.vue:344 -#: front/src/views/content/libraries/Form.vue:49 src/views/playlists/Detail.vue:51 +#: src/components/auth/Settings.vue:208 +#: src/components/library/AlbumDropdown.vue:50 +#: src/components/library/EditCard.vue:121 +#: src/components/library/EditCard.vue:137 +#: src/components/library/TrackBase.vue:91 +#: src/components/library/TrackBase.vue:86 +#: src/components/manage/library/AlbumsTable.vue:55 +#: src/components/manage/library/ArtistsTable.vue:53 +#: src/components/manage/library/LibrariesTable.vue:54 +#: src/components/manage/library/TagsTable.vue:59 +#: src/components/manage/library/TracksTable.vue:52 +#: src/components/manage/library/UploadsTable.vue:61 +#: src/components/manage/moderation/InstancePolicyForm.vue:79 +#: src/components/manage/moderation/NotesThread.vue:21 +#: src/components/manage/moderation/NotesThread.vue:37 +#: src/components/manage/moderation/ReportCard.vue:107 +#: src/components/manage/users/InvitationsTable.vue:50 +#: src/views/admin/ChannelDetail.vue:61 +#: src/views/admin/ChannelDetail.vue:77 +#: src/views/admin/ChannelDetail.vue:56 +#: src/views/admin/ChannelDetail.vue:72 +#: src/views/admin/library/AlbumDetail.vue:74 +#: src/views/admin/library/AlbumDetail.vue:90 +#: src/views/admin/library/AlbumDetail.vue:69 +#: src/views/admin/library/AlbumDetail.vue:85 +#: src/views/admin/library/ArtistDetail.vue:73 +#: src/views/admin/library/ArtistDetail.vue:89 +#: src/views/admin/library/ArtistDetail.vue:68 +#: src/views/admin/library/ArtistDetail.vue:84 +#: src/views/admin/library/LibraryDetail.vue:47 +#: src/views/admin/library/LibraryDetail.vue:63 +#: src/views/admin/library/LibraryDetail.vue:42 +#: src/views/admin/library/LibraryDetail.vue:58 +#: src/views/admin/library/TagDetail.vue:36 +#: src/views/admin/library/TagDetail.vue:52 +#: src/views/admin/library/TagDetail.vue:31 +#: src/views/admin/library/TagDetail.vue:47 +#: src/views/admin/library/TrackDetail.vue:74 +#: src/views/admin/library/TrackDetail.vue:90 +#: src/views/admin/library/TrackDetail.vue:69 +#: src/views/admin/library/TrackDetail.vue:85 +#: src/views/admin/library/UploadDetail.vue:54 +#: src/views/admin/library/UploadDetail.vue:70 +#: src/views/admin/library/UploadDetail.vue:49 +#: src/views/admin/library/UploadDetail.vue:65 +#: src/views/channels/DetailBase.vue:133 +#: src/views/channels/DetailBase.vue:128 +#: src/views/channels/DetailBase.vue:24 +#: src/views/content/libraries/FilesTable.vue:76 +#: src/views/content/libraries/Form.vue:49 +#: src/views/playlists/Detail.vue:51 msgctxt "*/*/*/Verb" msgid "Delete" msgstr "" -#: front/src/views/content/libraries/Form.vue:63 +#: src/views/content/libraries/Form.vue:63 msgctxt "Popup/Library/Button.Label/Verb" msgid "Delete library" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:93 +#: src/components/manage/moderation/InstancePolicyForm.vue:93 msgctxt "Popup/Moderation/Button.Label/Verb" msgid "Delete moderation rule" msgstr "" -#: front/src/components/auth/Settings.vue:460 src/components/auth/Settings.vue:509 +#: src/components/auth/Settings.vue:460 +#: src/components/auth/Settings.vue:509 msgctxt "*/*/Button.Label" msgid "Delete my account" msgstr "" -#: front/src/components/auth/Settings.vue:493 +#: src/components/auth/Settings.vue:493 msgctxt "*/*/Button.Label" msgid "Delete my account…" msgstr "" -#: front/src/views/playlists/Detail.vue:63 +#: src/views/playlists/Detail.vue:63 msgctxt "Popup/Playlist/Button.Label/Verb" msgid "Delete playlist" msgstr "" -#: front/src/views/radios/Detail.vue:35 src/views/radios/Detail.vue:16 +#: src/views/radios/Detail.vue:35 +#: src/views/radios/Detail.vue:16 msgctxt "Popup/Radio/Button.Label/Verb" msgid "Delete radio" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:495 +#: src/components/manage/moderation/ReportCard.vue:104 msgctxt "Content/Moderation/Button/Verb" msgid "Delete reported object" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:496 +#: src/components/manage/moderation/ReportCard.vue:105 msgctxt "Content/Moderation/Popup/Header" msgid "Delete reported object?" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:46 +#: src/components/library/AlbumDropdown.vue:46 msgctxt "Popup/Channel/Title" msgid "Delete this album?" msgstr "" -#: front/src/views/admin/library/AlbumDetail.vue:78 -#: front/src/views/admin/library/AlbumDetail.vue:73 +#: src/views/admin/library/AlbumDetail.vue:78 +#: src/views/admin/library/AlbumDetail.vue:73 msgctxt "Popup/Library/Title" msgid "Delete this album?" msgstr "" -#: front/src/views/admin/library/ArtistDetail.vue:77 -#: front/src/views/admin/library/ArtistDetail.vue:72 +#: src/views/admin/library/ArtistDetail.vue:77 +#: src/views/admin/library/ArtistDetail.vue:72 msgctxt "Popup/Library/Title" msgid "Delete this artist?" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:65 src/views/admin/ChannelDetail.vue:60 +#: src/views/admin/ChannelDetail.vue:65 +#: src/views/admin/ChannelDetail.vue:60 msgctxt "Popup/Library/Title" msgid "Delete this channel?" msgstr "" -#: front/src/views/channels/DetailBase.vue:119 src/views/channels/DetailBase.vue:114 -#: front/src/views/channels/DetailBase.vue:10 +#: src/views/channels/DetailBase.vue:121 +#: src/views/channels/DetailBase.vue:116 +#: src/views/channels/DetailBase.vue:12 msgctxt "Popup/Channel/Title" msgid "Delete this Channel?" msgstr "" -#: front/src/views/admin/library/LibraryDetail.vue:51 -#: front/src/views/admin/library/LibraryDetail.vue:46 -#: front/src/views/content/libraries/Form.vue:53 +#: src/views/admin/library/LibraryDetail.vue:51 +#: src/views/admin/library/LibraryDetail.vue:46 +#: src/views/content/libraries/Form.vue:53 msgctxt "Popup/Library/Title" msgid "Delete this library?" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:83 +#: src/components/manage/moderation/InstancePolicyForm.vue:83 msgctxt "Popup/Moderation/Title" msgid "Delete this moderation rule?" msgstr "" -#: front/src/components/manage/moderation/NotesThread.vue:25 +#: src/components/manage/moderation/NotesThread.vue:25 msgctxt "Popup/Moderation/Title" msgid "Delete this note?" msgstr "" -#: front/src/components/library/EditCard.vue:125 +#: src/components/library/EditCard.vue:125 msgctxt "Popup/Library/Title" msgid "Delete this suggestion?" msgstr "" -#: front/src/views/admin/library/TagDetail.vue:40 -#: front/src/views/admin/library/TagDetail.vue:35 +#: src/views/admin/library/TagDetail.vue:40 +#: src/views/admin/library/TagDetail.vue:35 msgctxt "Popup/Library/Title" msgid "Delete this tag?" msgstr "" -#: front/src/components/library/TrackBase.vue:79 -#: front/src/components/library/TrackBase.vue:74 +#: src/components/library/TrackBase.vue:79 +#: src/components/library/TrackBase.vue:74 msgctxt "Popup/Channel/Title" msgid "Delete this track?" msgstr "" -#: front/src/views/admin/library/TrackDetail.vue:78 -#: front/src/views/admin/library/TrackDetail.vue:73 +#: src/views/admin/library/TrackDetail.vue:78 +#: src/views/admin/library/TrackDetail.vue:73 msgctxt "Popup/Library/Title" msgid "Delete this track?" msgstr "" -#: front/src/views/admin/library/UploadDetail.vue:58 -#: front/src/views/admin/library/UploadDetail.vue:53 +#: src/views/admin/library/UploadDetail.vue:58 +#: src/views/admin/library/UploadDetail.vue:53 msgctxt "Popup/Library/Title" msgid "Delete this upload?" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:45 -#: front/src/components/library/TrackBase.vue:75 -#: front/src/components/library/TrackBase.vue:70 -#: front/src/views/channels/DetailBase.vue:115 src/views/channels/DetailBase.vue:110 -#: front/src/views/channels/DetailBase.vue:6 +#: src/components/library/AlbumDropdown.vue:45 +#: src/components/library/TrackBase.vue:75 +#: src/components/library/TrackBase.vue:70 +#: src/views/channels/DetailBase.vue:117 +#: src/views/channels/DetailBase.vue:112 +#: src/views/channels/DetailBase.vue:8 msgctxt "*/*/*/Verb" msgid "Delete…" msgstr "" -#: front/src/components/favorites/List.vue:38 src/components/library/Albums.vue:42 -#: front/src/components/library/Artists.vue:42 src/components/library/Podcasts.vue:42 -#: front/src/components/library/Radios.vue:64 -#: front/src/components/manage/ChannelsTable.vue:46 -#: front/src/components/manage/library/AlbumsTable.vue:27 -#: front/src/components/manage/library/ArtistsTable.vue:46 -#: front/src/components/manage/library/EditsCardList.vue:53 -#: front/src/components/manage/library/LibrariesTable.vue:46 -#: front/src/components/manage/library/TagsTable.vue:27 -#: front/src/components/manage/library/TracksTable.vue:27 -#: front/src/components/manage/library/UploadsTable.vue:76 -#: front/src/components/manage/moderation/AccountsTable.vue:27 -#: front/src/components/manage/moderation/DomainsTable.vue:45 -#: front/src/components/manage/users/UsersTable.vue:25 -#: front/src/views/admin/moderation/ReportsList.vue:55 -#: front/src/views/admin/moderation/RequestsList.vue:59 -#: front/src/views/content/libraries/FilesTable.vue:70 -#: front/src/views/playlists/List.vue:44 +#: src/components/favorites/List.vue:38 +#: src/components/library/Albums.vue:42 +#: src/components/library/Artists.vue:42 +#: src/components/library/Podcasts.vue:42 +#: src/components/library/Radios.vue:64 +#: src/components/manage/ChannelsTable.vue:46 +#: src/components/manage/library/AlbumsTable.vue:27 +#: src/components/manage/library/ArtistsTable.vue:46 +#: src/components/manage/library/EditsCardList.vue:53 +#: src/components/manage/library/LibrariesTable.vue:46 +#: src/components/manage/library/TagsTable.vue:27 +#: src/components/manage/library/TracksTable.vue:27 +#: src/components/manage/library/UploadsTable.vue:76 +#: src/components/manage/moderation/AccountsTable.vue:27 +#: src/components/manage/moderation/DomainsTable.vue:45 +#: src/components/manage/users/UsersTable.vue:25 +#: src/views/admin/moderation/ReportsList.vue:55 +#: src/views/admin/moderation/RequestsList.vue:59 +#: src/views/content/libraries/FilesTable.vue:70 +#: src/views/playlists/List.vue:44 msgctxt "Content/Search/Dropdown" msgid "Descending" msgstr "" -#: front/src/components/manage/moderation/NoteForm.vue:65 +#: src/components/manage/moderation/NoteForm.vue:19 msgctxt "Content/Moderation/Placeholder" msgid "Describe what actions have been taken, or any other related updates…" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:150 src/views/admin/ChannelDetail.vue:145 -#: front/src/views/admin/library/AlbumDetail.vue:151 -#: front/src/views/admin/library/AlbumDetail.vue:146 -#: front/src/views/admin/library/ArtistDetail.vue:150 -#: front/src/views/admin/library/ArtistDetail.vue:145 -#: front/src/views/admin/library/TrackDetail.vue:216 -#: front/src/views/admin/library/TrackDetail.vue:211 +#: src/views/admin/ChannelDetail.vue:150 +#: src/views/admin/ChannelDetail.vue:145 +#: src/views/admin/library/AlbumDetail.vue:151 +#: src/views/admin/library/AlbumDetail.vue:146 +#: src/views/admin/library/ArtistDetail.vue:150 +#: src/views/admin/library/ArtistDetail.vue:145 +#: src/views/admin/library/TrackDetail.vue:216 +#: src/views/admin/library/TrackDetail.vue:211 msgctxt "'*/*/*/Noun" msgid "Description" msgstr "" -#: front/src/components/audio/ChannelForm.vue:93 -#: front/src/components/audio/ChannelForm.vue:79 -#: front/src/components/audio/ChannelForm.vue:59 -#: front/src/components/channels/UploadMetadataForm.vue:30 +#: src/components/audio/ChannelForm.vue:93 +#: src/components/audio/ChannelForm.vue:79 +#: src/components/audio/ChannelForm.vue:59 +#: src/components/channels/UploadMetadataForm.vue:30 msgctxt "*/*/*" msgid "Description" msgstr "" -#: front/src/components/library/radios/Builder.vue:35 -#: front/src/views/admin/library/LibraryDetail.vue:143 -#: front/src/views/admin/library/LibraryDetail.vue:138 -#: front/src/views/content/libraries/Form.vue:24 src/edits.js:18 +#: src/components/library/radios/Builder.vue:35 +#: src/views/admin/library/LibraryDetail.vue:143 +#: src/views/admin/library/LibraryDetail.vue:138 +#: src/views/content/libraries/Form.vue:24 msgctxt "*/*/*/Noun" msgid "Description" msgstr "" -#: front/src/views/content/remote/Card.vue:70 +#: src/views/content/remote/Card.vue:70 msgctxt "Content/Library/Card.Button.Label/Noun" msgid "Details" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:603 +#: src/views/admin/moderation/AccountsDetail.vue:33 msgctxt "Content/Moderation/Help text" msgid "Determine how much content the user can upload. Leave empty to use the default value of the instance." msgstr "" -#: front/src/components/mixins/Translations.vue:8 -#: front/src/components/mixins/Translations.vue:9 +#: src/components/mixins/Translations.vue:8 msgctxt "Content/Settings/Dropdown.Help text" msgid "Determine the visibility level of your activity" msgstr "" -#: front/src/components/auth/Settings.vue:143 -#: front/src/components/auth/SubsonicTokenForm.vue:91 -#: front/src/components/auth/SubsonicTokenForm.vue:45 +#: src/components/auth/Settings.vue:143 +#: src/components/auth/SubsonicTokenForm.vue:91 +#: src/components/auth/SubsonicTokenForm.vue:45 msgctxt "Popup/Settings/Button.Label" msgid "Disable access" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:77 -#: front/src/components/auth/SubsonicTokenForm.vue:31 +#: src/components/auth/SubsonicTokenForm.vue:77 +#: src/components/auth/SubsonicTokenForm.vue:31 msgctxt "Content/Settings/Button.Label/Verb" msgid "Disable Subsonic access" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:81 -#: front/src/components/auth/SubsonicTokenForm.vue:35 +#: src/components/auth/SubsonicTokenForm.vue:81 +#: src/components/auth/SubsonicTokenForm.vue:35 msgctxt "Popup/Settings/Title" msgid "Disable Subsonic API access?" msgstr "" -#: front/src/components/AboutPod.vue:123 src/components/AboutPod.vue:142 -#: front/src/components/AboutPod.vue:167 -#: front/src/components/manage/moderation/InstancePolicyForm.vue:28 -#: front/src/views/admin/moderation/AccountsDetail.vue:164 -#: front/src/views/admin/moderation/AccountsDetail.vue:170 -#: front/src/views/admin/moderation/AccountsDetail.vue:159 -#: front/src/views/admin/moderation/AccountsDetail.vue:165 +#: src/components/AboutPod.vue:123 +#: src/components/AboutPod.vue:142 +#: src/components/AboutPod.vue:167 +#: src/components/manage/moderation/InstancePolicyForm.vue:28 +#: src/views/admin/moderation/AccountsDetail.vue:164 +#: src/views/admin/moderation/AccountsDetail.vue:170 +#: src/views/admin/moderation/AccountsDetail.vue:159 +#: src/views/admin/moderation/AccountsDetail.vue:165 msgctxt "*/*/*/State of feature" msgid "Disabled" msgstr "" -#: front/src/views/admin/library/TrackDetail.vue:174 -#: front/src/views/admin/library/TrackDetail.vue:169 +#: src/views/admin/library/TrackDetail.vue:174 +#: src/views/admin/library/TrackDetail.vue:169 msgctxt "*/*/*/Noun" msgid "Disc number" msgstr "" -#: front/src/components/Home.vue:194 +#: src/components/Home.vue:194 msgctxt "Content/Home/Link" msgid "Discover everything you need to know about Funkwhale and its features" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:26 +#: src/components/auth/SubsonicTokenForm.vue:26 msgctxt "Content/Settings/Link" msgid "Discover how to use Funkwhale from other apps" msgstr "" -#: front/src/views/Notifications.vue:73 +#: src/views/Notifications.vue:73 msgctxt "Content/Notifications/Button.Label/Verb" msgid "Discover other ways to help" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:135 -#: front/src/views/admin/moderation/AccountsDetail.vue:130 +#: src/views/admin/moderation/AccountsDetail.vue:135 +#: src/views/admin/moderation/AccountsDetail.vue:130 msgctxt "'Content/*/*/Noun'" msgid "Display name" msgstr "" -#: front/src/components/library/radios/Builder.vue:40 +#: src/components/library/radios/Builder.vue:40 msgctxt "Content/Radio/Checkbox.Label/Verb" msgid "Display publicly" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:216 +#: src/components/manage/moderation/InstancePolicyForm.vue:46 msgctxt "Content/Moderation/Help text" msgid "Do not download any media file (audio, album cover, account avatar…) from this account or domain. This will purge existing content as well." msgstr "" -#: front/src/views/Notifications.vue:60 +#: src/views/Notifications.vue:60 msgctxt "Content/Notifications/Header" msgid "Do you like Funkwhale?" msgstr "" -#: front/src/components/playlists/Editor.vue:61 +#: src/components/playlists/Editor.vue:61 msgctxt "Popup/Playlist/Title" msgid "Do you want to clear the playlist \"%{ playlist }\"?" msgstr "" -#: front/src/components/common/DangerousButton.vue:7 +#: src/components/common/DangerousButton.vue:7 msgctxt "Modal/*/Title" msgid "Do you want to confirm this action?" msgstr "" -#: front/src/views/playlists/Detail.vue:54 +#: src/views/playlists/Detail.vue:54 msgctxt "Popup/Playlist/Title/Call to action" msgid "Do you want to delete the playlist \"%{ playlist }\"?" msgstr "" -#: front/src/views/radios/Detail.vue:26 src/views/radios/Detail.vue:7 +#: src/views/radios/Detail.vue:26 +#: src/views/radios/Detail.vue:7 msgctxt "Popup/Radio/Title" msgid "Do you want to delete the radio \"%{ radio }\"?" msgstr "" -#: front/src/components/auth/Settings.vue:497 +#: src/components/auth/Settings.vue:497 msgctxt "Popup/Settings/Title" msgid "Do you want to delete your account?" msgstr "" -#: front/src/components/moderation/FilterModal.vue:3 +#: src/components/moderation/FilterModal.vue:3 msgctxt "Popup/Moderation/Title/Verb" msgid "Do you want to hide content from artist \"%{ name }\"?" msgstr "" -#: front/src/components/common/ActionTable.vue:31 +#: src/components/common/ActionTable.vue:31 msgctxt "Modal/*/Title" msgid "Do you want to launch %{ action } on %{ count } element?" msgid_plural "Do you want to launch %{ action } on %{ count } elements?" msgstr[0] "" msgstr[1] "" -#: front/src/components/moderation/ReportModal.vue:3 +#: src/components/moderation/ReportModal.vue:3 msgctxt "Popup/Moderation/Title/Verb" msgid "Do you want to report this object?" msgstr "" -#: front/src/components/Footer.vue:55 src/components/auth/Plugin.vue:8 -#: front/src/components/auth/Plugin.vue:4 +#: src/components/auth/Plugin.vue:8 +#: src/components/auth/Plugin.vue:4 msgctxt "Footer/*/List item.Link/Short, Noun" msgid "Documentation" msgstr "" -#: front/src/components/common/UserMenu.vue:169 -#: front/src/components/common/UserModal.vue:198 +#: src/components/common/UserMenu.vue:22 +#: src/components/common/UserModal.vue:30 msgctxt "Sidebar/*/Listitem.Link" msgid "Documentation" msgstr "" -#: front/src/components/manage/ChannelsTable.vue:71 -#: front/src/components/manage/ChannelsTable.vue:12 -#: front/src/components/manage/library/AlbumsTable.vue:52 -#: front/src/components/manage/library/AlbumsTable.vue:12 -#: front/src/components/manage/library/ArtistsTable.vue:66 -#: front/src/components/manage/library/ArtistsTable.vue:7 -#: front/src/components/manage/library/LibrariesTable.vue:71 -#: front/src/components/manage/library/LibrariesTable.vue:12 -#: front/src/components/manage/library/TracksTable.vue:57 -#: front/src/components/manage/library/TracksTable.vue:17 -#: front/src/components/manage/library/UploadsTable.vue:107 -#: front/src/components/manage/library/UploadsTable.vue:17 -#: front/src/components/manage/moderation/AccountsTable.vue:47 -#: front/src/components/manage/moderation/AccountsTable.vue:7 -#: front/src/components/manage/moderation/ReportCard.vue:201 -#: front/src/components/manage/moderation/ReportCard.vue:215 -#: front/src/components/mixins/Translations.vue:87 -#: front/src/views/admin/ChannelDetail.vue:139 src/views/admin/ChannelDetail.vue:134 -#: front/src/views/admin/library/AlbumDetail.vue:140 -#: front/src/views/admin/library/AlbumDetail.vue:135 -#: front/src/views/admin/library/ArtistDetail.vue:139 -#: front/src/views/admin/library/ArtistDetail.vue:134 -#: front/src/views/admin/library/LibraryDetail.vue:132 -#: front/src/views/admin/library/LibraryDetail.vue:127 -#: front/src/views/admin/library/TrackDetail.vue:205 -#: front/src/views/admin/library/TrackDetail.vue:200 -#: front/src/views/admin/library/UploadDetail.vue:132 -#: front/src/views/admin/library/UploadDetail.vue:127 -#: front/src/views/admin/moderation/AccountsDetail.vue:124 -#: front/src/views/admin/moderation/AccountsDetail.vue:119 -#: front/src/components/mixins/Translations.vue:88 +#: src/components/manage/ChannelsTable.vue:71 +#: src/components/manage/ChannelsTable.vue:12 +#: src/components/manage/library/AlbumsTable.vue:52 +#: src/components/manage/library/AlbumsTable.vue:12 +#: src/components/manage/library/ArtistsTable.vue:66 +#: src/components/manage/library/ArtistsTable.vue:7 +#: src/components/manage/library/LibrariesTable.vue:71 +#: src/components/manage/library/LibrariesTable.vue:12 +#: src/components/manage/library/TracksTable.vue:57 +#: src/components/manage/library/TracksTable.vue:17 +#: src/components/manage/library/UploadsTable.vue:107 +#: src/components/manage/library/UploadsTable.vue:17 +#: src/components/manage/moderation/AccountsTable.vue:47 +#: src/components/manage/moderation/AccountsTable.vue:7 +#: src/components/manage/moderation/ReportCard.vue:201 +#: src/components/manage/moderation/ReportCard.vue:215 +#: src/components/mixins/Translations.vue:87 +#: src/views/admin/ChannelDetail.vue:139 +#: src/views/admin/ChannelDetail.vue:134 +#: src/views/admin/library/AlbumDetail.vue:140 +#: src/views/admin/library/AlbumDetail.vue:135 +#: src/views/admin/library/ArtistDetail.vue:139 +#: src/views/admin/library/ArtistDetail.vue:134 +#: src/views/admin/library/LibraryDetail.vue:132 +#: src/views/admin/library/LibraryDetail.vue:127 +#: src/views/admin/library/TrackDetail.vue:205 +#: src/views/admin/library/TrackDetail.vue:200 +#: src/views/admin/library/UploadDetail.vue:132 +#: src/views/admin/library/UploadDetail.vue:127 +#: src/views/admin/moderation/AccountsDetail.vue:124 +#: src/views/admin/moderation/AccountsDetail.vue:119 msgctxt "Content/Moderation/*/Noun" msgid "Domain" msgstr "" -#: front/src/views/admin/moderation/Base.vue:20 -#: front/src/views/admin/moderation/DomainsList.vue:4 -#: front/src/views/admin/moderation/DomainsList.vue:93 +#: src/views/admin/moderation/Base.vue:20 +#: src/views/admin/moderation/DomainsList.vue:4 +#: src/views/admin/moderation/DomainsList.vue:20 msgctxt "*/Moderation/*/Noun" msgid "Domains" msgstr "" -#: front/src/views/Notifications.vue:70 +#: src/views/Notifications.vue:70 msgctxt "Content/Notifications/Button.Label/Verb" msgid "Donate" msgstr "" -#: front/src/components/library/TrackBase.vue:291 -#: front/src/views/admin/library/UploadDetail.vue:49 -#: front/src/views/admin/library/UploadDetail.vue:44 +#: src/components/library/TrackBase.vue:70 +#: src/views/admin/library/UploadDetail.vue:49 +#: src/views/admin/library/UploadDetail.vue:44 msgctxt "Content/Track/Link/Verb" msgid "Download" msgstr "" -#: front/src/components/library/TrackDetail.vue:80 -#: front/src/components/library/TrackDetail.vue:75 +#: src/components/library/TrackDetail.vue:81 +#: src/components/library/TrackDetail.vue:76 msgctxt "Content/*/*" msgid "Downloads" msgstr "" -#: front/src/components/mixins/Translations.vue:28 -#: front/src/views/content/libraries/FilesTable.vue:23 -#: front/src/components/mixins/Translations.vue:29 +#: src/components/mixins/Translations.vue:28 +#: src/views/content/libraries/FilesTable.vue:23 msgctxt "Content/Library/*/Short" msgid "Draft" msgstr "" -#: front/src/components/playlists/Editor.vue:78 src/components/playlists/Editor.vue:2 +#: src/components/playlists/Editor.vue:78 +#: src/components/playlists/Editor.vue:2 msgctxt "Content/Playlist/Paragraph/Call to action" msgid "Drag and drop rows to reorder tracks in the playlist" msgstr "" -#: front/src/components/channels/UploadForm.vue:130 -#: front/src/components/channels/UploadForm.vue:95 -#: front/src/components/channels/UploadForm.vue:84 +#: src/components/channels/UploadForm.vue:130 +#: src/components/channels/UploadForm.vue:95 +#: src/components/channels/UploadForm.vue:84 msgctxt "Content/Channels/Paragraph" msgid "Drag and drop your files here or open the browser to upload your files" msgstr "" -#: front/src/components/Queue.vue:399 +#: src/components/Queue.vue:58 msgctxt "*/*/*" msgid "Duration" msgstr "" -#: front/src/components/mixins/Translations.vue:79 -#: front/src/components/mixins/Translations.vue:80 +#: src/components/mixins/Translations.vue:79 msgctxt "*/*/*/Noun" msgid "Duration" msgstr "" -#: front/src/components/library/TrackDetail.vue:20 -#: front/src/components/library/TrackDetail.vue:15 -#: front/src/components/mixins/Translations.vue:83 -#: front/src/views/admin/library/UploadDetail.vue:277 -#: front/src/views/admin/library/UploadDetail.vue:272 -#: front/src/views/content/libraries/FilesTable.vue:119 -#: front/src/views/content/libraries/FilesTable.vue:27 -#: front/src/components/mixins/Translations.vue:84 +#: src/components/library/TrackDetail.vue:21 +#: src/components/library/TrackDetail.vue:16 +#: src/components/mixins/Translations.vue:83 +#: src/views/admin/library/UploadDetail.vue:277 +#: src/views/admin/library/UploadDetail.vue:272 +#: src/views/content/libraries/FilesTable.vue:119 +#: src/views/content/libraries/FilesTable.vue:27 msgctxt "Content/*/*" msgid "Duration" msgstr "" -#: front/src/components/auth/SignupForm.vue:53 +#: src/components/auth/SignupForm.vue:53 msgctxt "Content/*/*/Noun" msgid "E-mail address" msgstr "" -#: front/src/views/auth/EmailConfirm.vue:33 +#: src/views/auth/EmailConfirm.vue:33 msgctxt "Content/Signup/Message" msgid "E-mail address confirmed" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:64 -#: front/src/components/playlists/PlaylistModal.vue:87 +#: src/components/playlists/PlaylistModal.vue:64 +#: src/components/playlists/PlaylistModal.vue:87 msgctxt "*/*/*/Verb" msgid "Edit" msgstr "" -#: front/src/components/auth/Settings.vue:350 -#: front/src/components/channels/UploadForm.vue:293 -#: front/src/components/common/RenderedDescription.vue:23 -#: front/src/components/common/RenderedDescription.vue:4 -#: front/src/components/library/AlbumDropdown.vue:41 -#: front/src/components/library/ArtistBase.vue:86 -#: front/src/components/library/ArtistBase.vue:81 -#: front/src/components/library/TrackBase.vue:69 -#: front/src/components/library/TrackBase.vue:64 -#: front/src/components/manage/moderation/InstancePolicyCard.vue:62 -#: front/src/components/radios/Card.vue:20 src/views/admin/library/AlbumDetail.vue:67 -#: front/src/views/admin/library/AlbumDetail.vue:62 -#: front/src/views/admin/library/ArtistDetail.vue:66 -#: front/src/views/admin/library/ArtistDetail.vue:61 -#: front/src/views/admin/library/TrackDetail.vue:67 -#: front/src/views/admin/library/TrackDetail.vue:62 -#: front/src/views/library/DetailBase.vue:118 src/views/playlists/Detail.vue:37 -#: front/src/views/playlists/Detail.vue:1 +#: src/components/auth/Settings.vue:350 +#: src/components/channels/UploadForm.vue:55 +#: src/components/common/RenderedDescription.vue:23 +#: src/components/common/RenderedDescription.vue:4 +#: src/components/library/AlbumDropdown.vue:41 +#: src/components/library/ArtistBase.vue:86 +#: src/components/library/ArtistBase.vue:81 +#: src/components/library/TrackBase.vue:69 +#: src/components/library/TrackBase.vue:64 +#: src/components/manage/moderation/InstancePolicyCard.vue:62 +#: src/components/radios/Card.vue:20 +#: src/views/admin/library/AlbumDetail.vue:67 +#: src/views/admin/library/AlbumDetail.vue:62 +#: src/views/admin/library/ArtistDetail.vue:66 +#: src/views/admin/library/ArtistDetail.vue:61 +#: src/views/admin/library/TrackDetail.vue:67 +#: src/views/admin/library/TrackDetail.vue:62 +#: src/views/library/DetailBase.vue:118 +#: src/views/playlists/Detail.vue:37 +#: src/views/playlists/Detail.vue:1 msgctxt "Content/*/Button.Label/Verb" msgid "Edit" msgstr "" -#: front/src/views/playlists/Detail.vue:112 +#: src/views/playlists/Detail.vue:112 msgctxt "Content/Home/CreatePlaylist" msgid "Edit" msgstr "" -#: front/src/components/auth/ApplicationEdit.vue:42 -#: front/src/components/auth/ApplicationEdit.vue:35 -#: front/src/components/auth/ApplicationEdit.vue:97 +#: src/components/auth/ApplicationEdit.vue:42 +#: src/components/auth/ApplicationEdit.vue:35 +#: src/components/auth/ApplicationEdit.vue:19 msgctxt "Content/Applications/Title" msgid "Edit application" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:4 +#: src/components/admin/SignupFormBuilder.vue:4 msgctxt "Content/*/Button.Label/Verb" msgid "Edit form" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:3 +#: src/components/manage/moderation/InstancePolicyForm.vue:3 msgctxt "Content/Moderation/Card.Title/Verb" msgid "Edit moderation rule" msgstr "" -#: front/src/components/library/AlbumEdit.vue:4 +#: src/components/library/AlbumEdit.vue:4 msgctxt "Content/*/Title" msgid "Edit this album" msgstr "" -#: front/src/components/library/ArtistEdit.vue:4 +#: src/components/library/ArtistEdit.vue:4 msgctxt "Content/*/Title" msgid "Edit this artist" msgstr "" -#: front/src/components/library/TrackEdit.vue:4 +#: src/components/library/TrackEdit.vue:4 msgctxt "Content/*/Title" msgid "Edit this track" msgstr "" -#: front/src/views/channels/DetailBase.vue:112 src/views/channels/DetailBase.vue:107 -#: front/src/views/channels/DetailBase.vue:3 +#: src/views/channels/DetailBase.vue:113 +#: src/views/channels/DetailBase.vue:108 +#: src/views/channels/DetailBase.vue:4 msgctxt "*/*/*/Verb" msgid "Edit…" msgstr "" -#: front/src/components/mixins/Translations.vue:131 -#: front/src/views/admin/ChannelDetail.vue:254 src/views/admin/ChannelDetail.vue:249 -#: front/src/views/admin/library/AlbumDetail.vue:235 -#: front/src/views/admin/library/AlbumDetail.vue:230 -#: front/src/views/admin/library/ArtistDetail.vue:234 -#: front/src/views/admin/library/ArtistDetail.vue:229 -#: front/src/views/admin/library/Base.vue:4 src/views/admin/library/EditsList.vue:31 -#: front/src/views/admin/library/TrackDetail.vue:300 -#: front/src/views/admin/library/TrackDetail.vue:295 -#: front/src/components/mixins/Translations.vue:132 +#: src/components/mixins/Translations.vue:131 +#: src/views/admin/ChannelDetail.vue:254 +#: src/views/admin/ChannelDetail.vue:249 +#: src/views/admin/library/AlbumDetail.vue:235 +#: src/views/admin/library/AlbumDetail.vue:230 +#: src/views/admin/library/ArtistDetail.vue:234 +#: src/views/admin/library/ArtistDetail.vue:229 +#: src/views/admin/library/Base.vue:4 +#: src/views/admin/library/EditsList.vue:13 +#: src/views/admin/library/TrackDetail.vue:300 +#: src/views/admin/library/TrackDetail.vue:295 msgctxt "*/Admin/*/Noun" msgid "Edits" msgstr "" -#: front/src/components/manage/users/UsersTable.vue:45 -#: front/src/components/manage/users/UsersTable.vue:7 -#: front/src/components/moderation/ReportModal.vue:35 +#: src/components/manage/users/UsersTable.vue:45 +#: src/components/manage/users/UsersTable.vue:7 +#: src/components/moderation/ReportModal.vue:35 msgctxt "Content/*/*/Noun" msgid "Email" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:145 -#: front/src/views/admin/moderation/AccountsDetail.vue:140 +#: src/views/admin/moderation/AccountsDetail.vue:145 +#: src/views/admin/moderation/AccountsDetail.vue:140 msgctxt "Content/*/*" msgid "Email address" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:29 -#: front/src/components/library/ArtistBase.vue:68 -#: front/src/components/library/ArtistBase.vue:63 -#: front/src/components/library/TrackBase.vue:55 -#: front/src/components/library/TrackBase.vue:50 src/views/channels/DetailBase.vue:98 -#: front/src/views/channels/DetailBase.vue:93 src/views/playlists/Detail.vue:46 +#: src/components/library/AlbumDropdown.vue:29 +#: src/components/library/ArtistBase.vue:68 +#: src/components/library/ArtistBase.vue:63 +#: src/components/library/TrackBase.vue:55 +#: src/components/library/TrackBase.vue:50 +#: src/views/channels/DetailBase.vue:98 +#: src/views/channels/DetailBase.vue:93 +#: src/views/playlists/Detail.vue:46 msgctxt "Content/*/Button.Label/Verb" msgid "Embed" msgstr "" -#: front/src/components/audio/EmbedWizard.vue:40 +#: src/components/audio/EmbedWizard.vue:40 msgctxt "Popup/Embed/Input.Label/Noun" msgid "Embed code" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:5 +#: src/components/library/AlbumDropdown.vue:5 msgctxt "Popup/Album/Title/Verb" msgid "Embed this album on your website" msgstr "" -#: front/src/components/library/ArtistBase.vue:35 -#: front/src/components/library/ArtistBase.vue:30 -#: front/src/views/channels/DetailBase.vue:187 src/views/channels/DetailBase.vue:182 +#: src/components/library/ArtistBase.vue:35 +#: src/components/library/ArtistBase.vue:30 +#: src/views/channels/DetailBase.vue:189 +#: src/views/channels/DetailBase.vue:184 msgctxt "Popup/Artist/Title/Verb" msgid "Embed this artist work on your website" msgstr "" -#: front/src/views/playlists/Detail.vue:72 +#: src/views/playlists/Detail.vue:72 msgctxt "Popup/Album/Title/Verb" msgid "Embed this playlist on your website" msgstr "" -#: front/src/components/library/TrackBase.vue:29 -#: front/src/components/library/TrackBase.vue:24 +#: src/components/library/TrackBase.vue:29 +#: src/components/library/TrackBase.vue:24 msgctxt "Popup/Track/Title" msgid "Embed this track on your website" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:288 -#: front/src/views/admin/moderation/AccountsDetail.vue:283 -#: front/src/views/admin/moderation/DomainsDetail.vue:252 -#: front/src/views/admin/moderation/DomainsDetail.vue:247 +#: src/views/admin/moderation/AccountsDetail.vue:288 +#: src/views/admin/moderation/AccountsDetail.vue:283 +#: src/views/admin/moderation/DomainsDetail.vue:252 +#: src/views/admin/moderation/DomainsDetail.vue:247 msgctxt "Content/Moderation/Table.Label/Noun" msgid "Emitted library follows" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:268 -#: front/src/views/admin/moderation/AccountsDetail.vue:263 -#: front/src/views/admin/moderation/DomainsDetail.vue:232 -#: front/src/views/admin/moderation/DomainsDetail.vue:227 +#: src/views/admin/moderation/AccountsDetail.vue:268 +#: src/views/admin/moderation/AccountsDetail.vue:263 +#: src/views/admin/moderation/DomainsDetail.vue:232 +#: src/views/admin/moderation/DomainsDetail.vue:227 msgctxt "Content/Moderation/Table.Label/Noun" msgid "Emitted messages" msgstr "" -#: front/src/components/auth/Plugin.vue:27 +#: src/components/auth/Plugin.vue:27 msgctxt "*/*/*" msgid "Enabled" msgstr "" -#: front/src/components/AboutPod.vue:117 src/components/AboutPod.vue:136 -#: front/src/components/AboutPod.vue:161 -#: front/src/components/manage/moderation/InstancePolicyCard.vue:8 -#: front/src/components/manage/moderation/InstancePolicyCard.vue:2 -#: front/src/components/manage/moderation/InstancePolicyForm.vue:27 -#: front/src/views/admin/moderation/AccountsDetail.vue:163 -#: front/src/views/admin/moderation/AccountsDetail.vue:167 -#: front/src/views/admin/moderation/AccountsDetail.vue:158 -#: front/src/views/admin/moderation/AccountsDetail.vue:162 +#: src/components/AboutPod.vue:117 +#: src/components/AboutPod.vue:136 +#: src/components/AboutPod.vue:161 +#: src/components/manage/moderation/InstancePolicyCard.vue:8 +#: src/components/manage/moderation/InstancePolicyCard.vue:2 +#: src/components/manage/moderation/InstancePolicyForm.vue:27 +#: src/views/admin/moderation/AccountsDetail.vue:163 +#: src/views/admin/moderation/AccountsDetail.vue:167 +#: src/views/admin/moderation/AccountsDetail.vue:158 +#: src/views/admin/moderation/AccountsDetail.vue:162 msgctxt "*/*/*/State of feature" msgid "Enabled" msgstr "" -#: front/src/views/content/remote/ScanForm.vue:60 +#: src/views/content/remote/ScanForm.vue:14 msgctxt "Content/Library/Input.Placeholder" msgid "Enter a library URL" msgstr "" -#: front/src/components/library/Radios.vue:211 +#: src/components/library/Radios.vue:35 msgctxt "Content/Search/Input.Placeholder" msgid "Enter a radio name…" msgstr "" -#: front/src/components/library/Albums.vue:189 +#: src/components/library/Albums.vue:40 msgctxt "Content/Search/Input.Placeholder" msgid "Enter album title…" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:229 +#: src/components/playlists/PlaylistModal.vue:33 msgctxt "Popup/Playlist/Form/Placeholder" msgid "Enter playlist name" msgstr "" -#: front/src/views/playlists/List.vue:177 +#: src/views/playlists/List.vue:38 msgctxt "Content/Playlist/Placeholder/Call to action" msgid "Enter playlist name…" msgstr "" -#: front/src/views/auth/PasswordReset.vue:88 +#: src/views/auth/PasswordReset.vue:15 msgctxt "Content/Signup/Input.Placeholder" msgid "Enter the e-mail address linked to your account" msgstr "" -#: front/src/components/auth/SignupForm.vue:195 +#: src/components/auth/SignupForm.vue:40 msgctxt "Content/Signup/Form/Placeholder" msgid "Enter your e-mail address" msgstr "" -#: front/src/components/auth/SignupForm.vue:190 -#: front/src/components/auth/SignupForm.vue:192 +#: src/components/auth/SignupForm.vue:35 msgctxt "Content/Signup/Form/Placeholder" msgid "Enter your invitation code (case insensitive)" msgstr "" -#: front/src/components/auth/SignupForm.vue:194 +#: src/components/auth/SignupForm.vue:39 msgctxt "Content/Signup/Form/Placeholder" msgid "Enter your username" msgstr "" -#: front/src/components/auth/LoginForm.vue:115 +#: src/components/auth/LoginForm.vue:26 msgctxt "Content/Login/Input.Placeholder" msgid "Enter your username or e-mail address" msgstr "" -#: front/src/components/audio/PlayButton.vue:28 -#: front/src/components/audio/podcast/Modal.vue:269 -#: front/src/components/audio/track/Modal.vue:269 +#: src/components/audio/PlayButton.vue:28 +#: src/components/audio/podcast/Modal.vue:41 +#: src/components/audio/track/Modal.vue:41 msgctxt "*/Queue/Dropdown/Button/Label/Short" msgid "Episode details" msgstr "" -#: front/src/components/library/TrackDetail.vue:12 -#: front/src/components/library/TrackDetail.vue:7 +#: src/components/library/TrackDetail.vue:13 +#: src/components/library/TrackDetail.vue:8 msgctxt "Content/*/*" msgid "Episode Details" msgstr "" -#: front/src/components/library/AlbumDetail.vue:3 +#: src/components/library/AlbumDetail.vue:3 msgctxt "Content/Channels/*" msgid "Episodes" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:36 -#: front/src/views/content/libraries/Form.vue:9 +#: src/components/auth/SubsonicTokenForm.vue:36 +#: src/views/content/libraries/Form.vue:9 msgctxt "Content/*/Error message.Title" msgid "Error" msgstr "" -#: front/src/components/federation/FetchButton.vue:62 -#: front/src/components/federation/FetchButton.vue:50 -#: front/src/components/library/ImportStatusModal.vue:44 -#: front/src/components/library/ImportStatusModal.vue:15 +#: src/components/federation/FetchButton.vue:62 +#: src/components/federation/FetchButton.vue:50 +#: src/components/library/ImportStatusModal.vue:44 +#: src/components/library/ImportStatusModal.vue:15 msgctxt "Popup/Import/Table.Label/Noun" msgid "Error detail" msgstr "" -#: front/src/components/federation/FetchButton.vue:52 -#: front/src/components/federation/FetchButton.vue:40 -#: front/src/components/library/ImportStatusModal.vue:34 -#: front/src/components/library/ImportStatusModal.vue:5 +#: src/components/federation/FetchButton.vue:52 +#: src/components/federation/FetchButton.vue:40 +#: src/components/library/ImportStatusModal.vue:34 +#: src/components/library/ImportStatusModal.vue:5 msgctxt "Popup/Import/Table.Label/Noun" msgid "Error type" msgstr "" -#: front/src/components/common/ActionTable.vue:76 +#: src/components/common/ActionTable.vue:76 msgctxt "Content/*/Error message/Header" msgid "Error while applying action" msgstr "" -#: front/src/views/auth/PasswordReset.vue:12 +#: src/views/auth/PasswordReset.vue:12 msgctxt "Content/Signup/Card.Title" msgid "Error while asking for a password reset" msgstr "" -#: front/src/components/auth/Authorize.vue:11 +#: src/components/auth/Authorize.vue:11 msgctxt "Popup/Moderation/Error message" msgid "Error while authorizing application" msgstr "" -#: front/src/views/auth/PasswordResetConfirm.vue:8 +#: src/views/auth/PasswordResetConfirm.vue:8 msgctxt "Content/Signup/Card.Title" msgid "Error while changing your password" msgstr "" -#: front/src/components/channels/AlbumForm.vue:4 +#: src/components/channels/AlbumForm.vue:4 msgctxt "Content/*/Error message.Title" msgid "Error while creating" msgstr "" -#: front/src/views/admin/moderation/DomainsList.vue:11 +#: src/views/admin/moderation/DomainsList.vue:11 msgctxt "Content/Moderation/Message.Title" msgid "Error while creating domain" msgstr "" -#: front/src/components/moderation/FilterModal.vue:11 +#: src/components/moderation/FilterModal.vue:11 msgctxt "Popup/Moderation/Error message" msgid "Error while creating filter" msgstr "" -#: front/src/components/manage/users/InvitationForm.vue:5 +#: src/components/manage/users/InvitationForm.vue:5 msgctxt "Content/Admin/Error message.Title" msgid "Error while creating invitation" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:12 +#: src/components/manage/moderation/InstancePolicyForm.vue:12 msgctxt "Content/Moderation/Error message.Title" msgid "Error while creating rule" msgstr "" -#: front/src/components/auth/Authorize.vue:16 +#: src/components/auth/Authorize.vue:16 msgctxt "Popup/Moderation/Error message" msgid "Error while fetching application data" msgstr "" -#: front/src/views/admin/moderation/DomainsDetail.vue:172 -#: front/src/views/admin/moderation/DomainsDetail.vue:167 -#: front/src/views/admin/moderation/DomainsDetail.vue:8 +#: src/views/admin/moderation/DomainsDetail.vue:172 +#: src/views/admin/moderation/DomainsDetail.vue:167 +#: src/views/admin/moderation/DomainsDetail.vue:8 msgctxt "Content/Moderation/Table" msgid "Error while fetching node info" msgstr "" -#: front/src/components/RemoteSearchForm.vue:20 +#: src/components/RemoteSearchForm.vue:20 msgctxt "Content/*/Error message.Title" msgid "Error while fetching object" msgstr "" -#: front/src/components/library/FileUpload.vue:165 +#: src/components/library/FileUpload.vue:165 msgctxt "Content/*/Error message.Title" msgid "Error while launching import" msgstr "" -#: front/src/components/channels/UploadForm.vue:4 +#: src/components/channels/UploadForm.vue:4 msgctxt "Content/*/Error message.Title" msgid "Error while publishing" msgstr "" -#: front/src/components/audio/ChannelForm.vue:4 +#: src/components/audio/ChannelForm.vue:4 msgctxt "Content/*/Error message.Title" msgid "Error while saving channel" msgstr "" -#: front/src/components/auth/Plugin.vue:14 +#: src/components/auth/Plugin.vue:14 msgctxt "Content/*/Error message.Title" msgid "Error while saving plugin" msgstr "" -#: front/src/components/admin/SettingsGroup.vue:8 -#: front/src/components/federation/FetchButton.vue:113 +#: src/components/admin/SettingsGroup.vue:8 +#: src/components/federation/FetchButton.vue:113 msgctxt "Content/*/Error message.Title" msgid "Error while saving settings" msgstr "" -#: front/src/components/library/EditForm.vue:50 +#: src/components/library/EditForm.vue:50 msgctxt "Content/Library/Error message.Title" msgid "Error while submitting edit" msgstr "" -#: front/src/components/manage/moderation/NoteForm.vue:4 +#: src/components/manage/moderation/NoteForm.vue:4 msgctxt "Content/Moderation/Error message.Title" msgid "Error while submitting note" msgstr "" -#: front/src/components/moderation/ReportModal.vue:14 +#: src/components/moderation/ReportModal.vue:14 msgctxt "Popup/Moderation/Error message" msgid "Error while submitting report" msgstr "" -#: front/src/components/common/RenderedDescription.vue:29 +#: src/components/common/RenderedDescription.vue:29 msgctxt "Content/Channels/Error message.Title" msgid "Error while updating description" msgstr "" -#: front/src/components/channels/UploadForm.vue:94 -#: front/src/components/channels/UploadForm.vue:59 -#: front/src/components/channels/UploadForm.vue:48 -#: front/src/components/channels/UploadForm.vue:4 +#: src/components/channels/UploadForm.vue:94 +#: src/components/channels/UploadForm.vue:59 +#: src/components/channels/UploadForm.vue:48 +#: src/components/channels/UploadForm.vue:4 msgctxt "Channels/*/*" msgid "Errored" msgstr "" -#: front/src/components/mixins/Translations.vue:36 -#: front/src/components/mixins/Translations.vue:37 +#: src/components/mixins/Translations.vue:36 msgctxt "Content/Library/Table/Short" msgid "Errored" msgstr "" -#: front/src/views/content/libraries/Quota.vue:113 +#: src/views/content/libraries/Quota.vue:113 msgctxt "Content/Library/Label" msgid "Errored files" msgstr "" -#: front/src/components/mixins/Translations.vue:17 -#: front/src/components/mixins/Translations.vue:18 +#: src/components/mixins/Translations.vue:17 msgctxt "Content/Settings/Dropdown/Short" msgid "Everyone" msgstr "" -#: front/src/components/mixins/Translations.vue:11 -#: front/src/components/mixins/Translations.vue:12 +#: src/components/mixins/Translations.vue:11 msgctxt "Content/Settings/Dropdown" msgid "Everyone on this instance" msgstr "" -#: front/src/components/mixins/Translations.vue:12 -#: front/src/components/mixins/Translations.vue:13 +#: src/components/mixins/Translations.vue:12 msgctxt "Content/Settings/Dropdown" msgid "Everyone, across all instances" msgstr "" -#: front/src/components/library/radios/Builder.vue:83 +#: src/components/library/radios/Builder.vue:83 msgctxt "Content/Radio/Table.Label/Verb" msgid "Exclude" msgstr "" -#: front/src/components/library/radios/Filter.vue:7 +#: src/components/library/radios/Filter.vue:7 msgctxt "Popup/Radio/Title/Noun" msgid "Exclude" msgstr "" -#: front/src/components/library/Artists.vue:66 +#: src/components/library/Artists.vue:66 msgctxt "Content/Search/Checkbox/Noun" msgid "Exclude Compilation Artists" msgstr "" -#: front/src/components/common/CollapseLink.vue:2 +#: src/components/common/CollapseLink.vue:2 msgctxt "*/*/Button,Label" msgid "Expand" msgstr "" -#: front/src/App.vue:101 src/components/audio/Player.vue:412 +#: src/AppOld.vue:87 +#: src/components/audio/Player.vue:72 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Expand queue" msgstr "" -#: front/src/components/ShortcutsModal.vue:138 +#: src/components/ShortcutsModal.vue:78 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Expand queue/player view" msgstr "" -#: front/src/components/manage/users/InvitationsTable.vue:60 -#: front/src/components/manage/users/InvitationsTable.vue:17 -#: front/src/components/mixins/Translations.vue:74 -#: front/src/components/mixins/Translations.vue:75 +#: src/components/manage/users/InvitationsTable.vue:60 +#: src/components/manage/users/InvitationsTable.vue:17 +#: src/components/mixins/Translations.vue:74 msgctxt "Content/Admin/Table.Label/Noun" msgid "Expiration date" msgstr "" -#: front/src/components/manage/users/InvitationsTable.vue:78 -#: front/src/components/manage/users/InvitationsTable.vue:8 +#: src/components/manage/users/InvitationsTable.vue:78 +#: src/components/manage/users/InvitationsTable.vue:8 msgctxt "Content/Admin/Table" msgid "Expired" msgstr "" -#: front/src/components/manage/users/InvitationsTable.vue:30 +#: src/components/manage/users/InvitationsTable.vue:30 msgctxt "Content/Admin/Dropdown/Adjective" msgid "Expired/used" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:204 +#: src/components/manage/moderation/InstancePolicyForm.vue:34 msgctxt "Content/Moderation/Help text" msgid "Explain why you're applying this policy: this will help you remember why you added this rule. Depending on your pod configuration, this may be displayed publicly to help users understand the moderation rules in place." msgstr "" -#: front/src/components/Sidebar.vue:144 +#: src/components/Sidebar.vue:144 msgctxt "*/*/*/Verb" msgid "Explore" msgstr "" -#: front/src/components/manage/library/UploadsTable.vue:48 -#: front/src/views/content/libraries/FilesTable.vue:38 +#: src/components/manage/library/UploadsTable.vue:48 +#: src/views/content/libraries/FilesTable.vue:38 msgctxt "Content/Library/Dropdown" msgid "Failed" msgstr "" -#: front/src/views/content/remote/Card.vue:80 +#: src/views/content/remote/Card.vue:80 msgctxt "Content/Library/Card.List item/Noun" msgid "Failed tracks:" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:221 src/views/admin/ChannelDetail.vue:216 -#: front/src/views/admin/library/AlbumDetail.vue:202 -#: front/src/views/admin/library/AlbumDetail.vue:197 -#: front/src/views/admin/library/ArtistDetail.vue:201 -#: front/src/views/admin/library/ArtistDetail.vue:196 -#: front/src/views/admin/library/TrackDetail.vue:267 -#: front/src/views/admin/library/TrackDetail.vue:262 +#: src/views/admin/ChannelDetail.vue:221 +#: src/views/admin/ChannelDetail.vue:216 +#: src/views/admin/library/AlbumDetail.vue:202 +#: src/views/admin/library/AlbumDetail.vue:197 +#: src/views/admin/library/ArtistDetail.vue:201 +#: src/views/admin/library/ArtistDetail.vue:196 +#: src/views/admin/library/TrackDetail.vue:267 +#: src/views/admin/library/TrackDetail.vue:262 msgctxt "*/*/*" msgid "Favorited tracks" msgstr "" -#: front/src/components/Sidebar.vue:221 src/components/mixins/Translations.vue:103 -#: front/src/components/mixins/Translations.vue:104 +#: src/components/Sidebar.vue:221 +#: src/components/mixins/Translations.vue:103 msgctxt "Sidebar/Favorites/List item.Link/Noun" msgid "Favorites" msgstr "" -#: front/src/components/AboutPod.vue:33 +#: src/components/AboutPod.vue:33 msgctxt "Content/About/Header" msgid "Features" msgstr "" -#: front/src/components/AboutPod.vue:83 +#: src/components/AboutPod.vue:83 msgctxt "Content/About/Header/Name" msgid "Features" msgstr "" -#: front/src/components/AboutPod.vue:110 src/components/audio/SearchBar.vue:94 -#: front/src/components/audio/SearchBar.vue:175 src/views/admin/Settings.vue:75 +#: src/components/AboutPod.vue:110 +#: src/components/audio/SearchBar.vue:71 +#: src/components/audio/SearchBar.vue:152 +#: src/views/admin/Settings.vue:31 msgctxt "*/*/*" msgid "Federation" msgstr "" -#: front/src/components/RemoteSearchForm.vue:11 +#: src/components/RemoteSearchForm.vue:11 msgctxt "Content/Search/Input.Label/Noun" msgid "Fediverse" msgstr "" -#: front/src/components/audio/ChannelForm.vue:43 -#: front/src/components/audio/ChannelForm.vue:29 -#: front/src/components/audio/ChannelForm.vue:9 +#: src/components/audio/ChannelForm.vue:43 +#: src/components/audio/ChannelForm.vue:29 +#: src/components/audio/ChannelForm.vue:9 msgctxt "Content/Channel/*" msgid "Fediverse handle" msgstr "" -#: front/src/components/RemoteSearchForm.vue:131 +#: src/components/RemoteSearchForm.vue:33 msgctxt "*/*/*" msgid "Fediverse object" msgstr "" -#: front/src/components/library/EditCard.vue:48 +#: src/components/library/EditCard.vue:48 msgctxt "Content/Library/Card.Table.Header/Short" msgid "Field" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:43 +#: src/components/admin/SignupFormBuilder.vue:43 msgctxt "*/*/Form-builder,Help" msgid "Field label" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:48 +#: src/components/admin/SignupFormBuilder.vue:48 msgctxt "*/*/Form-builder,Help" msgid "Field type" msgstr "" -#: front/src/components/library/FileUpload.vue:87 +#: src/components/library/FileUpload.vue:87 msgctxt "Content/Library/Table.Label" msgid "Filename" msgstr "" -#: front/src/components/channels/UploadModal.vue:6 +#: src/components/channels/UploadModal.vue:6 msgctxt "Popup/Channels/Title" msgid "Files to upload" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:56 +#: src/components/playlists/PlaylistModal.vue:56 msgctxt "Popup/Playlist/Label" msgid "Filter" msgstr "" -#: front/src/views/channels/SubscriptionsList.vue:103 +#: src/views/channels/SubscriptionsList.vue:31 msgctxt "Content/Subscriptions/Form.Placeholder" msgid "Filter by name…" msgstr "" -#: front/src/components/library/radios/Builder.vue:78 +#: src/components/library/radios/Builder.vue:78 msgctxt "Content/Radio/Table.Label/Noun" msgid "Filter name" msgstr "" -#: front/src/components/About.vue:160 +#: src/components/About.vue:165 msgctxt "Content/About/Header" msgid "Find an app" msgstr "" -#: front/src/components/About.vue:149 +#: src/components/About.vue:154 msgctxt "Content/About/Header" msgid "Find another pod" msgstr "" -#: front/src/components/About.vue:60 +#: src/components/About.vue:60 msgctxt "Content/About/Link" msgid "Find another pod" msgstr "" -#: front/src/components/Home.vue:147 +#: src/components/Home.vue:147 msgctxt "Content/Home/Link" msgid "Find another pod" msgstr "" -#: front/src/components/channels/UploadModal.vue:63 +#: src/components/channels/UploadModal.vue:63 msgctxt "Content/*/Button.Label/Verb" msgid "Finish later" msgstr "" -#: front/src/components/manage/library/UploadsTable.vue:53 -#: front/src/components/mixins/Translations.vue:40 -#: front/src/views/content/libraries/FilesTable.vue:43 -#: front/src/components/mixins/Translations.vue:41 +#: src/components/manage/library/UploadsTable.vue:53 +#: src/components/mixins/Translations.vue:40 +#: src/views/content/libraries/FilesTable.vue:43 msgctxt "Content/Library/*" msgid "Finished" msgstr "" -#: front/src/components/manage/moderation/AccountsTable.vue:57 -#: front/src/components/manage/moderation/AccountsTable.vue:17 -#: front/src/components/manage/moderation/DomainsTable.vue:75 -#: front/src/components/manage/moderation/DomainsTable.vue:17 -#: front/src/views/admin/ChannelDetail.vue:201 src/views/admin/ChannelDetail.vue:196 -#: front/src/views/admin/library/AlbumDetail.vue:182 -#: front/src/views/admin/library/AlbumDetail.vue:177 -#: front/src/views/admin/library/ArtistDetail.vue:181 -#: front/src/views/admin/library/ArtistDetail.vue:176 -#: front/src/views/admin/library/LibraryDetail.vue:176 -#: front/src/views/admin/library/LibraryDetail.vue:171 -#: front/src/views/admin/library/TagDetail.vue:112 -#: front/src/views/admin/library/TagDetail.vue:107 -#: front/src/views/admin/library/TrackDetail.vue:247 -#: front/src/views/admin/library/TrackDetail.vue:242 -#: front/src/views/admin/library/UploadDetail.vue:186 -#: front/src/views/admin/library/UploadDetail.vue:181 -#: front/src/views/admin/moderation/AccountsDetail.vue:258 -#: front/src/views/admin/moderation/AccountsDetail.vue:253 -#: front/src/views/admin/moderation/DomainsDetail.vue:210 -#: front/src/views/admin/moderation/DomainsDetail.vue:205 +#: src/components/manage/moderation/AccountsTable.vue:57 +#: src/components/manage/moderation/AccountsTable.vue:17 +#: src/components/manage/moderation/DomainsTable.vue:75 +#: src/components/manage/moderation/DomainsTable.vue:17 +#: src/views/admin/ChannelDetail.vue:201 +#: src/views/admin/ChannelDetail.vue:196 +#: src/views/admin/library/AlbumDetail.vue:182 +#: src/views/admin/library/AlbumDetail.vue:177 +#: src/views/admin/library/ArtistDetail.vue:181 +#: src/views/admin/library/ArtistDetail.vue:176 +#: src/views/admin/library/LibraryDetail.vue:176 +#: src/views/admin/library/LibraryDetail.vue:171 +#: src/views/admin/library/TagDetail.vue:112 +#: src/views/admin/library/TagDetail.vue:107 +#: src/views/admin/library/TrackDetail.vue:247 +#: src/views/admin/library/TrackDetail.vue:242 +#: src/views/admin/library/UploadDetail.vue:186 +#: src/views/admin/library/UploadDetail.vue:181 +#: src/views/admin/moderation/AccountsDetail.vue:258 +#: src/views/admin/moderation/AccountsDetail.vue:253 +#: src/views/admin/moderation/DomainsDetail.vue:210 +#: src/views/admin/moderation/DomainsDetail.vue:205 msgctxt "Content/Moderation/Table.Label/Short (Value is a date)" msgid "First seen" msgstr "" -#: front/src/components/mixins/Translations.vue:71 -#: front/src/components/mixins/Translations.vue:72 +#: src/components/mixins/Translations.vue:71 msgctxt "Content/Moderation/Dropdown/Noun" msgid "First seen date" msgstr "" -#: front/src/components/ShortcutsModal.vue:80 +#: src/components/ShortcutsModal.vue:20 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Focus searchbar" msgstr "" -#: front/src/components/audio/LibraryFollowButton.vue:9 -#: front/src/views/content/remote/Card.vue:106 src/views/content/remote/Card.vue:2 +#: src/components/audio/LibraryFollowButton.vue:9 +#: src/views/content/remote/Card.vue:106 +#: src/views/content/remote/Card.vue:2 msgctxt "Content/Library/Card.Button.Label/Verb" msgid "Follow" msgstr "" -#: front/src/views/content/Home.vue:54 +#: src/views/content/Home.vue:54 msgctxt "Content/Library/Paragraph" msgid "Follow libraries from other users to get access to new music. Public libraries can be followed immediately, while following a private library requires approval from its owner." msgstr "" -#: front/src/views/content/Home.vue:49 +#: src/views/content/Home.vue:49 msgctxt "Content/Library/Title/Verb" msgid "Follow remote libraries" msgstr "" -#: front/src/views/content/remote/Card.vue:113 src/views/content/remote/Card.vue:9 -#: front/src/views/content/remote/Card.vue:3 +#: src/views/content/remote/Card.vue:113 +#: src/views/content/remote/Card.vue:9 +#: src/views/content/remote/Card.vue:3 msgctxt "Content/Library/Card.Paragraph" msgid "Follow request pending approval" msgstr "" -#: front/src/components/manage/library/LibrariesTable.vue:86 -#: front/src/components/manage/library/LibrariesTable.vue:27 -#: front/src/components/mixins/Translations.vue:91 -#: front/src/views/admin/library/LibraryDetail.vue:186 -#: front/src/views/admin/library/LibraryDetail.vue:181 src/views/library/Edit.vue:13 -#: front/src/components/mixins/Translations.vue:92 +#: src/components/manage/library/LibrariesTable.vue:86 +#: src/components/manage/library/LibrariesTable.vue:27 +#: src/components/mixins/Translations.vue:91 +#: src/views/admin/library/LibraryDetail.vue:186 +#: src/views/admin/library/LibraryDetail.vue:181 +#: src/views/library/Edit.vue:13 msgctxt "Content/Federation/*/Noun" msgid "Followers" msgstr "" -#: front/src/components/mixins/Translations.vue:111 -#: front/src/components/mixins/Translations.vue:112 +#: src/components/mixins/Translations.vue:111 msgctxt "Content/OAuth Scopes/Label" msgid "Follows" msgstr "" -#: front/src/components/common/UserMenu.vue:168 -#: front/src/components/common/UserModal.vue:197 +#: src/components/common/UserMenu.vue:21 +#: src/components/common/UserModal.vue:29 msgctxt "Sidebar/*/Listitem.Link" msgid "Forum" msgstr "" -#: front/src/components/moderation/ReportModal.vue:64 +#: src/components/moderation/ReportModal.vue:64 msgctxt "*/*/Field,Help" msgid "Forward an anonymized copy of your report to the server hosting this element." msgstr "" -#: front/src/components/moderation/ReportModal.vue:61 +#: src/components/moderation/ReportModal.vue:61 msgctxt "*/*/Field.Label/Verb" msgid "Forward to %{ domain}" msgstr "" -#: front/src/components/auth/Authorize.vue:46 +#: src/components/auth/Authorize.vue:46 msgctxt "Content/Auth/Label/Noun" msgid "Full access" msgstr "" -#: front/src/components/About.vue:24 +#: src/components/About.vue:24 msgctxt "Content/About/Paragraph" msgid "Funkwhale is a community-driven project that lets you listen and share music and audio within a decentralized, open network." msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:13 +#: src/components/auth/SubsonicTokenForm.vue:13 msgctxt "Content/Settings/Paragraph'" msgid "Funkwhale is compatible with other music players that support the Subsonic API." msgstr "" -#: front/src/components/Home.vue:105 +#: src/components/Home.vue:105 msgctxt "Content/Home/Paragraph" msgid "Funkwhale is free and developed by a friendly community of volunteers." msgstr "" -#: front/src/components/AboutPod.vue:93 +#: src/components/AboutPod.vue:93 msgctxt "*/*/*" msgid "Funkwhale version" msgstr "" -#: front/src/components/ShortcutsModal.vue:72 +#: src/components/ShortcutsModal.vue:12 msgctxt "Popup/Keyboard shortcuts/Title" msgid "General shortcuts" msgstr "" -#: front/src/components/manage/users/InvitationForm.vue:22 +#: src/components/manage/users/InvitationForm.vue:22 msgctxt "Content/Admin/Button.Label/Verb" msgid "Get a new invitation" msgstr "" -#: front/src/views/content/Home.vue:23 src/views/content/Home.vue:41 -#: front/src/views/content/Home.vue:59 +#: src/views/content/Home.vue:23 +#: src/views/content/Home.vue:41 +#: src/views/content/Home.vue:59 msgctxt "Content/Library/Button.Label/Verb" msgid "Get started" msgstr "" -#: front/src/components/Footer.vue:71 src/components/library/ImportStatusModal.vue:59 -#: front/src/components/library/ImportStatusModal.vue:30 +#: src/components/library/ImportStatusModal.vue:59 +#: src/components/library/ImportStatusModal.vue:30 msgctxt "Footer/*/Link" msgid "Getting help" msgstr "" -#: front/src/components/common/ActionTable.vue:27 -#: front/src/components/common/ActionTable.vue:50 +#: src/components/common/ActionTable.vue:27 +#: src/components/common/ActionTable.vue:50 msgctxt "Content/*/Button.Label/Short, Verb" msgid "Go" msgstr "" -#: front/src/components/PageNotFound.vue:20 +#: src/components/PageNotFound.vue:20 msgctxt "Content/*/Button.Label/Verb" msgid "Go to home page" msgstr "" -#: front/src/components/Footer.vue:23 -msgctxt "Footer/*/List item.Link" -msgid "Go to Library" -msgstr "" - -#: front/src/views/Notifications.vue:49 src/views/Notifications.vue:105 +#: src/views/Notifications.vue:49 +#: src/views/Notifications.vue:105 msgctxt "Content/Notifications/Button.Label" msgid "Got it!" msgstr "" -#: front/src/components/About.vue:73 +#: src/components/About.vue:77 msgid "Hello" msgstr "" -#: front/src/components/common/UserMenu.vue:167 -#: front/src/components/common/UserModal.vue:196 -#: front/src/components/common/UserModal.vue:199 +#: src/components/common/UserMenu.vue:20 +#: src/components/common/UserModal.vue:28 +#: src/components/common/UserModal.vue:31 msgctxt "Sidebar/*/Listitem.Link" msgid "Help" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:21 +#: src/components/admin/SignupFormBuilder.vue:21 msgctxt "*/*/Label" msgid "Help text" msgstr "" -#: front/src/components/auth/Settings.vue:176 +#: src/components/auth/Settings.vue:176 msgctxt "Content/Settings/Title" msgid "Hidden artists" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:208 +#: src/components/manage/moderation/InstancePolicyForm.vue:38 msgctxt "Content/Moderation/Help text" msgid "Hide account or domain content, except from followers." msgstr "" -#: front/src/components/moderation/FilterModal.vue:64 +#: src/components/moderation/FilterModal.vue:64 msgctxt "Popup/*/Button.Label" msgid "Hide content" msgstr "" -#: front/src/components/audio/PlayButton.vue:33 +#: src/components/audio/PlayButton.vue:33 msgctxt "*/Queue/Dropdown/Button/Label/Short" msgid "Hide content from this artist" msgstr "" -#: front/src/components/Queue.vue:400 src/components/audio/Player.vue:424 +#: src/components/Queue.vue:59 +#: src/components/audio/Player.vue:84 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Hide content from this artist…" msgstr "" -#: front/src/components/Home.vue:357 +#: src/components/Home.vue:30 msgctxt "Head/Home/Title" msgid "Home" msgstr "" -#: front/src/components/Footer.vue:28 -msgctxt "Footer/*/List item.Link" -msgid "Home Page" -msgstr "" - -#: front/src/components/audio/ChannelForm.vue:305 +#: src/components/audio/ChannelForm.vue:56 msgctxt "Content/Channels/Help" msgid "Host your episodes and keep your community updated." msgstr "" -#: front/src/components/About.vue:116 src/components/About.vue:14 -#: front/src/components/AboutPod.vue:223 src/components/AboutPod.vue:11 +#: src/components/About.vue:121 +#: src/components/About.vue:14 +#: src/components/AboutPod.vue:223 +#: src/components/AboutPod.vue:11 msgctxt "Content/About/*" msgid "hour of music" msgid_plural "hours of music" msgstr[0] "" msgstr[1] "" -#: front/src/components/auth/SubsonicTokenForm.vue:20 +#: src/components/auth/SubsonicTokenForm.vue:20 msgctxt "Content/Settings/Paragraph" msgid "However, accessing Funkwhale from those clients requires a separate password you can set below." msgstr "" -#: front/src/components/RemoteSearchForm.vue:128 +#: src/components/RemoteSearchForm.vue:30 msgctxt "Head/Fetch/Field.Placeholder" msgid "https://website.example.com/rss.xml" msgstr "" -#: front/src/views/auth/PasswordResetConfirm.vue:36 -#: front/src/views/auth/PasswordResetConfirm.vue:2 +#: src/views/auth/PasswordResetConfirm.vue:36 +#: src/views/auth/PasswordResetConfirm.vue:2 msgctxt "Content/Signup/Paragraph" msgid "If the e-mail address provided in the previous step is valid and linked to a user account, you should receive an e-mail with reset instructions in the next couple of minutes." msgstr "" -#: front/src/views/content/Home.vue:15 +#: src/views/content/Home.vue:15 msgctxt "Content/Library/Paragraph" msgid "If you are a musician or a podcaster, channels are designed for you!" msgstr "" -#: front/src/components/auth/Settings.vue:289 +#: src/components/auth/Settings.vue:289 msgctxt "Content/Applications/Paragraph" msgid "If you authorize third-party applications to access your data, those applications will be listed here." msgstr "" -#: front/src/components/auth/LoginForm.vue:10 +#: src/components/auth/LoginForm.vue:10 msgctxt "Content/Login/Error message.List item/Call to action" msgid "If you signed-up recently, you may need to wait before our moderation team review your account, or verify your e-mail address." msgstr "" -#: front/src/views/channels/DetailBase.vue:77 src/views/channels/DetailBase.vue:72 -#: front/src/views/channels/DetailBase.vue:8 +#: src/views/channels/DetailBase.vue:77 +#: src/views/channels/DetailBase.vue:72 +#: src/views/channels/DetailBase.vue:8 msgctxt "Content/Channels/Label" msgid "If you're using Mastodon or other fediverse applications, you can subscribe to this account:" msgstr "" -#: front/src/components/channels/UploadForm.vue:55 -#: front/src/components/channels/UploadForm.vue:20 -#: front/src/components/channels/UploadForm.vue:9 +#: src/components/channels/UploadForm.vue:55 +#: src/components/channels/UploadForm.vue:20 +#: src/components/channels/UploadForm.vue:9 msgctxt "*/*/*" msgid "Ignore" msgstr "" -#: front/src/components/mixins/Translations.vue:50 -#: front/src/components/mixins/Translations.vue:51 +#: src/components/mixins/Translations.vue:50 msgctxt "Content/Moderation/Dropdown" msgid "Illegal content" msgstr "" -#: front/src/components/library/FsBrowser.vue:5 +#: src/components/library/FsBrowser.vue:5 msgctxt "Content/Library/Button/Verb" msgid "Import" msgstr "" -#: front/src/components/library/ImportStatusModal.vue:3 +#: src/components/library/ImportStatusModal.vue:3 msgctxt "Popup/Import/Title" msgid "Import detail" msgstr "" -#: front/src/components/library/FsLogs.vue:4 +#: src/components/library/FsLogs.vue:4 msgctxt "Content/Library/Paragraph" msgid "Import hasn't started yet" msgstr "" -#: front/src/components/library/FileUpload.vue:159 +#: src/components/library/FileUpload.vue:159 msgctxt "Content/Library/Title/Verb" msgid "Import music from your server" msgstr "" -#: front/src/components/manage/library/UploadsTable.vue:30 -#: front/src/components/manage/library/UploadsTable.vue:117 -#: front/src/components/manage/library/UploadsTable.vue:27 -#: front/src/views/admin/library/UploadDetail.vue:144 -#: front/src/views/admin/library/UploadDetail.vue:139 -#: front/src/views/content/libraries/FilesTable.vue:14 -#: front/src/views/content/libraries/FilesTable.vue:114 -#: front/src/views/content/libraries/FilesTable.vue:22 +#: src/components/manage/library/UploadsTable.vue:30 +#: src/components/manage/library/UploadsTable.vue:117 +#: src/components/manage/library/UploadsTable.vue:27 +#: src/views/admin/library/UploadDetail.vue:144 +#: src/views/admin/library/UploadDetail.vue:139 +#: src/views/content/libraries/FilesTable.vue:14 +#: src/views/content/libraries/FilesTable.vue:114 +#: src/views/content/libraries/FilesTable.vue:22 msgctxt "Content/*/*/Noun" msgid "Import status" msgstr "" -#: front/src/components/library/FileUpload.vue:178 -#: front/src/components/library/FileUpload.vue:2 +#: src/components/library/FileUpload.vue:178 +#: src/components/library/FileUpload.vue:2 msgctxt "Content/Library/Title/Verb" msgid "Import status" msgstr "" -#: front/src/components/mixins/Translations.vue:41 -#: front/src/components/mixins/Translations.vue:42 +#: src/components/mixins/Translations.vue:41 msgctxt "Content/Library/Help text" msgid "Imported" msgstr "" -#: front/src/components/federation/FetchButton.vue:76 -#: front/src/components/federation/FetchButton.vue:64 +#: src/components/federation/FetchButton.vue:76 +#: src/components/federation/FetchButton.vue:64 msgctxt "*/*/Error" msgid "Impossible to connect to the remote server" msgstr "" -#: front/src/components/moderation/FilterModal.vue:34 -#: front/src/components/moderation/FilterModal.vue:13 +#: src/components/moderation/FilterModal.vue:34 +#: src/components/moderation/FilterModal.vue:13 msgctxt "Popup/Moderation/List item" msgid "In \"Recently added\" widget" msgstr "" -#: front/src/components/moderation/FilterModal.vue:39 -#: front/src/components/moderation/FilterModal.vue:18 +#: src/components/moderation/FilterModal.vue:39 +#: src/components/moderation/FilterModal.vue:18 msgctxt "Popup/Moderation/List item" msgid "In artists and album listings" msgstr "" -#: front/src/components/favorites/TrackFavoriteIcon.vue:3 +#: src/components/favorites/TrackFavoriteIcon.vue:3 msgctxt "Content/Track/Button.Message" msgid "In favorites" msgstr "" -#: front/src/components/moderation/FilterModal.vue:29 -#: front/src/components/moderation/FilterModal.vue:8 +#: src/components/moderation/FilterModal.vue:29 +#: src/components/moderation/FilterModal.vue:8 msgctxt "Popup/Moderation/List item" msgid "In other users favorites and listening history" msgstr "" -#: front/src/components/moderation/FilterModal.vue:44 -#: front/src/components/moderation/FilterModal.vue:23 +#: src/components/moderation/FilterModal.vue:44 +#: src/components/moderation/FilterModal.vue:23 msgctxt "Popup/Moderation/List item" msgid "In radio suggestions" msgstr "" -#: front/src/components/manage/users/UsersTable.vue:89 -#: front/src/components/manage/users/UsersTable.vue:14 +#: src/components/manage/users/UsersTable.vue:89 +#: src/components/manage/users/UsersTable.vue:14 msgctxt "Content/Admin/Table" msgid "Inactive" msgstr "" -#: front/src/components/ShortcutsModal.vue:126 +#: src/components/ShortcutsModal.vue:66 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Increase volume" msgstr "" -#: front/src/components/playlists/Editor.vue:52 +#: src/components/playlists/Editor.vue:52 msgctxt "Content/Playlist/Button.Label/Verb" msgid "Insert from queue (%{ count } track)" msgid_plural "Insert from queue (%{ count } tracks)" msgstr[0] "" msgstr[1] "" -#: front/src/components/mixins/Translations.vue:16 -#: front/src/components/mixins/Translations.vue:17 +#: src/components/mixins/Translations.vue:16 msgctxt "Content/Settings/Dropdown/Short" msgid "Instance" msgstr "" -#: front/src/views/admin/moderation/DomainsDetail.vue:96 -#: front/src/views/admin/moderation/DomainsDetail.vue:91 +#: src/views/admin/moderation/DomainsDetail.vue:96 +#: src/views/admin/moderation/DomainsDetail.vue:91 msgctxt "Content/Moderation/Title" msgid "Instance data" msgstr "" -#: front/src/views/admin/Settings.vue:69 +#: src/views/admin/Settings.vue:25 msgctxt "Content/Admin/Menu" msgid "Instance information" msgstr "" -#: front/src/components/library/Radios.vue:11 +#: src/components/library/Radios.vue:11 msgctxt "Content/Radio/Title" msgid "Instance radios" msgstr "" -#: front/src/views/admin/Settings.vue:64 +#: src/views/admin/Settings.vue:20 msgctxt "Head/Admin/Title" msgid "Instance settings" msgstr "" -#: front/src/components/SetInstanceModal.vue:37 +#: src/components/SetInstanceModal.vue:37 msgctxt "Popup/Instance/Input.Label/Noun" msgid "Instance URL" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:110 -#: front/src/components/manage/moderation/ReportCard.vue:245 -#: front/src/components/manage/moderation/UserRequestCard.vue:100 -#: front/src/components/manage/moderation/UserRequestCard.vue:165 +#: src/components/manage/moderation/ReportCard.vue:110 +#: src/components/manage/moderation/ReportCard.vue:245 +#: src/components/manage/moderation/UserRequestCard.vue:100 +#: src/components/manage/moderation/UserRequestCard.vue:165 msgctxt "Content/*/*/Noun" msgid "Internal notes" msgstr "" -#: front/src/components/AboutPod.vue:279 +#: src/components/AboutPod.vue:279 msgctxt "Content/About/Paragraph" msgid "Introduction" msgstr "" -#: front/src/components/library/FileUpload.vue:374 -#: front/src/components/library/FileUpload.vue:375 +#: src/components/library/FileUpload.vue:66 msgctxt "Content/Library/Help text" msgid "Invalid file type, ensure you are uploading an audio file. Supported file extensions are %{ extensions }" msgstr "" -#: front/src/components/library/ImportStatusModal.vue:198 -#: front/src/components/mixins/Translations.vue:49 -#: front/src/components/mixins/Translations.vue:50 +#: src/components/library/ImportStatusModal.vue:64 +#: src/components/mixins/Translations.vue:49 msgctxt "Popup/Import/Error.Label" msgid "Invalid metadata" msgstr "" -#: front/src/components/auth/SignupForm.vue:61 -#: front/src/components/manage/users/InvitationForm.vue:17 +#: src/components/auth/SignupForm.vue:61 +#: src/components/manage/users/InvitationForm.vue:17 msgctxt "Content/*/Input.Label" msgid "Invitation code" msgstr "" -#: front/src/views/admin/users/Base.vue:9 -#: front/src/views/admin/users/InvitationsList.vue:26 +#: src/views/admin/users/Base.vue:9 +#: src/views/admin/users/InvitationsList.vue:12 msgctxt "*/Admin/*/Noun" msgid "Invitations" msgstr "" -#: front/src/components/manage/moderation/DomainsTable.vue:9 -#: front/src/views/admin/moderation/DomainsDetail.vue:105 -#: front/src/views/admin/moderation/DomainsDetail.vue:100 +#: src/components/manage/moderation/DomainsTable.vue:9 +#: src/views/admin/moderation/DomainsDetail.vue:105 +#: src/views/admin/moderation/DomainsDetail.vue:100 msgctxt "Content/Moderation/*/Adjective" msgid "Is present on allow-list" msgstr "" -#: front/src/components/Footer.vue:77 src/components/common/UserMenu.vue:173 +#: src/components/common/UserMenu.vue:26 msgctxt "Footer/*/List item.Link" msgid "Issue tracker" msgstr "" -#: front/src/components/common/UserModal.vue:209 +#: src/components/common/UserModal.vue:41 msgctxt "Sidebar/*/List item.Link" msgid "Issue tracker" msgstr "" -#: front/src/components/SetInstanceModal.vue:10 +#: src/components/SetInstanceModal.vue:10 msgctxt "Popup/Instance/Error message.Title" msgid "It is not possible to connect to the given URL" msgstr "" -#: front/src/components/mixins/Translations.vue:80 -#: front/src/components/mixins/Translations.vue:81 +#: src/components/mixins/Translations.vue:80 msgctxt "*/*/*/Noun" msgid "Items" msgstr "" -#: front/src/components/Footer.vue:57 src/components/ShortcutsModal.vue:3 -#: front/src/components/common/UserMenu.vue:166 -#: front/src/components/common/UserModal.vue:195 +#: src/components/ShortcutsModal.vue:3 +#: src/components/common/UserMenu.vue:19 +#: src/components/common/UserModal.vue:27 msgctxt "*/*/*/Noun" msgid "Keyboard shortcuts" msgstr "" -#: front/src/views/admin/moderation/DomainsDetail.vue:221 -#: front/src/views/admin/moderation/DomainsDetail.vue:216 +#: src/views/admin/moderation/DomainsDetail.vue:221 +#: src/views/admin/moderation/DomainsDetail.vue:216 msgctxt "Content/Moderation/Table.Label.Link" msgid "Known accounts" msgstr "" -#: front/src/views/content/remote/Home.vue:27 src/views/content/remote/Home.vue:2 +#: src/views/content/remote/Home.vue:27 +#: src/views/content/remote/Home.vue:2 msgctxt "Content/Library/Title" msgid "Known libraries" msgstr "" -#: front/src/components/audio/ChannelForm.vue:80 -#: front/src/components/audio/ChannelForm.vue:66 -#: front/src/components/audio/ChannelForm.vue:46 +#: src/components/audio/ChannelForm.vue:80 +#: src/components/audio/ChannelForm.vue:66 +#: src/components/audio/ChannelForm.vue:46 msgctxt "*/*/*" msgid "Language" msgstr "" -#: front/src/components/Sidebar.vue:527 src/components/common/UserModal.vue:200 -#: front/src/components/common/UserModal.vue:202 src/components/Sidebar.vue:529 +#: src/components/Sidebar.vue:61 +#: src/components/common/UserModal.vue:32 msgctxt "Sidebar/Settings/Dropdown.Label/Short, Verb" msgid "Language" msgstr "" -#: front/src/components/manage/users/UsersTable.vue:60 -#: front/src/components/manage/users/UsersTable.vue:22 -#: front/src/components/mixins/Translations.vue:85 -#: front/src/views/admin/moderation/AccountsDetail.vue:225 -#: front/src/views/admin/moderation/AccountsDetail.vue:220 -#: front/src/components/mixins/Translations.vue:86 +#: src/components/manage/users/UsersTable.vue:60 +#: src/components/manage/users/UsersTable.vue:22 +#: src/components/mixins/Translations.vue:85 +#: src/views/admin/moderation/AccountsDetail.vue:225 +#: src/views/admin/moderation/AccountsDetail.vue:220 msgctxt "Content/Profile/Table.Label/Short, Noun (Value is a date)" msgid "Last activity" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:202 -#: front/src/views/admin/moderation/AccountsDetail.vue:197 -#: front/src/views/admin/moderation/DomainsDetail.vue:120 -#: front/src/views/admin/moderation/DomainsDetail.vue:115 +#: src/views/admin/moderation/AccountsDetail.vue:202 +#: src/views/admin/moderation/AccountsDetail.vue:197 +#: src/views/admin/moderation/DomainsDetail.vue:120 +#: src/views/admin/moderation/DomainsDetail.vue:115 msgctxt "Content/*/Table.Label" msgid "Last checked" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:71 +#: src/components/playlists/PlaylistModal.vue:71 msgctxt "Popup/Playlist/Table.Label/Short" msgid "Last modification" msgstr "" -#: front/src/components/manage/moderation/AccountsTable.vue:62 -#: front/src/components/manage/moderation/AccountsTable.vue:22 +#: src/components/manage/moderation/AccountsTable.vue:62 +#: src/components/manage/moderation/AccountsTable.vue:22 msgctxt "Content/Moderation/Table.Label/Noun" msgid "Last seen" msgstr "" -#: front/src/components/mixins/Translations.vue:72 -#: front/src/components/mixins/Translations.vue:73 +#: src/components/mixins/Translations.vue:72 msgctxt "Content/Moderation/Dropdown/Noun" msgid "Last seen date" msgstr "" -#: front/src/views/content/remote/Card.vue:76 src/views/content/remote/Card.vue:1 +#: src/views/content/remote/Card.vue:76 +#: src/views/content/remote/Card.vue:1 msgctxt "Content/Library/Card.List item/Noun" msgid "Last update:" msgstr "" -#: front/src/App.vue:220 +#: src/AppOld.vue:196 msgctxt "App/Message/Paragraph" msgid "Later" msgstr "" -#: front/src/views/channels/DetailOverview.vue:59 +#: src/views/channels/DetailOverview.vue:59 msgctxt "Content/Channel/Paragraph" msgid "Latest episodes" msgstr "" -#: front/src/views/channels/DetailOverview.vue:62 +#: src/views/channels/DetailOverview.vue:62 msgctxt "Content/Channel/Paragraph" msgid "Latest tracks" msgstr "" -#: front/src/components/common/ActionTable.vue:44 +#: src/components/common/ActionTable.vue:44 msgctxt "Modal/*/Button.Label/Short, Verb" msgid "Launch" msgstr "" -#: front/src/components/Home.vue:38 src/components/Home.vue:8 +#: src/components/Home.vue:38 +#: src/components/Home.vue:8 msgctxt "Content/Home/Link" msgid "Learn more" msgstr "" -#: front/src/components/About.vue:124 +#: src/components/About.vue:129 msgctxt "Content/About/Paragraph" msgid "Learn More" msgstr "" -#: front/src/components/manage/users/InvitationForm.vue:109 +#: src/components/manage/users/InvitationForm.vue:15 msgctxt "Content/Admin/Input.Placeholder" msgid "Leave empty for a random code" msgstr "" -#: front/src/components/audio/EmbedWizard.vue:20 +#: src/components/audio/EmbedWizard.vue:20 msgctxt "Popup/Embed/Paragraph" msgid "Leave empty for a responsive widget" msgstr "" -#: front/src/views/admin/library/AlbumDetail.vue:291 -#: front/src/views/admin/library/AlbumDetail.vue:286 -#: front/src/views/admin/library/ArtistDetail.vue:290 -#: front/src/views/admin/library/ArtistDetail.vue:285 -#: front/src/views/admin/library/Base.vue:29 -#: front/src/views/admin/library/LibrariesList.vue:29 -#: front/src/views/admin/library/TrackDetail.vue:356 -#: front/src/views/admin/library/TrackDetail.vue:351 -#: front/src/views/admin/moderation/AccountsDetail.vue:397 -#: front/src/views/admin/moderation/AccountsDetail.vue:392 -#: front/src/views/admin/moderation/DomainsDetail.vue:318 -#: front/src/views/admin/moderation/DomainsDetail.vue:313 -#: front/src/views/content/Base.vue:4 +#: src/views/admin/library/AlbumDetail.vue:291 +#: src/views/admin/library/AlbumDetail.vue:286 +#: src/views/admin/library/ArtistDetail.vue:290 +#: src/views/admin/library/ArtistDetail.vue:285 +#: src/views/admin/library/Base.vue:29 +#: src/views/admin/library/LibrariesList.vue:13 +#: src/views/admin/library/TrackDetail.vue:356 +#: src/views/admin/library/TrackDetail.vue:351 +#: src/views/admin/moderation/AccountsDetail.vue:397 +#: src/views/admin/moderation/AccountsDetail.vue:392 +#: src/views/admin/moderation/DomainsDetail.vue:318 +#: src/views/admin/moderation/DomainsDetail.vue:313 +#: src/views/content/Base.vue:4 msgctxt "*/*/*/Noun" msgid "Libraries" msgstr "" -#: front/src/components/mixins/Translations.vue:99 -#: front/src/components/mixins/Translations.vue:100 +#: src/components/mixins/Translations.vue:99 msgctxt "Content/OAuth Scopes/Label" msgid "Libraries and uploads" msgstr "" -#: front/src/views/content/libraries/Form.vue:3 +#: src/views/content/libraries/Form.vue:3 msgctxt "Content/Library/Paragraph" msgid "Libraries help you organize and share your music collections. You can upload your own music collection to Funkwhale and share it with your friends and family." msgstr "" -#: front/src/views/library/DetailBase.vue:258 +#: src/views/library/DetailBase.vue:30 msgctxt "*/*/*" msgid "Library" msgstr "" -#: front/src/components/Sidebar.vue:28 src/components/auth/Plugin.vue:32 -#: front/src/components/manage/library/UploadsTable.vue:97 -#: front/src/components/manage/library/UploadsTable.vue:7 -#: front/src/components/manage/users/UsersTable.vue:251 -#: front/src/components/mixins/Report.vue:95 -#: front/src/views/admin/library/UploadDetail.vue:159 -#: front/src/views/admin/library/UploadDetail.vue:154 -#: front/src/views/admin/moderation/AccountsDetail.vue:610 -#: front/src/components/mixins/Report.vue:96 src/entities.js:132 +#: src/components/Sidebar.vue:28 +#: src/components/auth/Plugin.vue:32 +#: src/components/manage/library/UploadsTable.vue:97 +#: src/components/manage/library/UploadsTable.vue:7 +#: src/components/manage/users/UsersTable.vue:46 +#: src/components/mixins/Report.vue:95 +#: src/views/admin/library/UploadDetail.vue:159 +#: src/views/admin/library/UploadDetail.vue:154 +#: src/views/admin/moderation/AccountsDetail.vue:40 msgctxt "*/*/*/Noun" msgid "Library" msgstr "" -#: front/src/components/library/Home.vue:103 +#: src/components/library/Home.vue:30 msgctxt "Head/Home/Title" msgid "Library" msgstr "" -#: front/src/views/library/Edit.vue:5 +#: src/views/library/Edit.vue:5 msgctxt "*/*/*" msgid "Library contents" msgstr "" -#: front/src/views/content/libraries/Form.vue:173 +#: src/views/content/libraries/Form.vue:57 msgctxt "Content/Library/Message" msgid "Library created" msgstr "" -#: front/src/views/admin/library/LibraryDetail.vue:81 -#: front/src/views/admin/library/LibraryDetail.vue:76 +#: src/views/admin/library/LibraryDetail.vue:81 +#: src/views/admin/library/LibraryDetail.vue:76 msgctxt "Content/Moderation/Title" msgid "Library data" msgstr "" -#: front/src/views/content/libraries/Form.vue:193 +#: src/views/content/libraries/Form.vue:77 msgctxt "Content/Library/Message" msgid "Library deleted" msgstr "" -#: front/src/views/content/libraries/Card.vue:43 +#: src/views/content/libraries/Card.vue:43 msgctxt "Content/Library/Card.Button.Label/Noun" msgid "Library Details" msgstr "" -#: front/src/views/admin/library/EditsList.vue:5 +#: src/views/admin/library/EditsList.vue:5 msgctxt "Content/Admin/Title/Noun" msgid "Library edits" msgstr "" -#: front/src/views/content/libraries/Form.vue:170 +#: src/views/content/libraries/Form.vue:54 msgctxt "Content/Library/Message" msgid "Library updated" msgstr "" -#: front/src/components/auth/Plugin.vue:39 +#: src/components/auth/Plugin.vue:39 msgctxt "*/*/Paragraph/Noun" msgid "Library where files should be imported." msgstr "" -#: front/src/components/channels/LicenseSelect.vue:3 -#: front/src/components/library/TrackDetail.vue:167 -#: front/src/components/manage/library/TracksTable.vue:62 -#: front/src/components/manage/library/TracksTable.vue:22 -#: front/src/views/admin/library/TrackDetail.vue:192 -#: front/src/views/admin/library/TrackDetail.vue:187 src/edits.js:115 -#: front/src/entities.js:115 +#: src/components/channels/LicenseSelect.vue:3 +#: src/components/library/TrackDetail.vue:168 +#: src/components/manage/library/TracksTable.vue:62 +#: src/components/manage/library/TracksTable.vue:22 +#: src/views/admin/library/TrackDetail.vue:192 +#: src/views/admin/library/TrackDetail.vue:187 msgctxt "Content/*/*/Noun" msgid "License" msgstr "" -#: front/src/components/Footer.vue:236 src/components/common/UserMenu.vue:183 -#: front/src/components/common/UserModal.vue:223 -#: front/src/components/common/UserModal.vue:225 -msgctxt "Footer/Settings/Dropdown.Label/Theme name" -msgid "Light" -msgstr "" - -#: front/src/components/Sidebar.vue:596 -msgctxt "Sidebar/Settings/Dropdown.Label/Theme name" -msgid "Light" -msgstr "" - -#: front/src/views/admin/ChannelDetail.vue:242 src/views/admin/ChannelDetail.vue:237 -#: front/src/views/admin/library/AlbumDetail.vue:223 -#: front/src/views/admin/library/AlbumDetail.vue:218 -#: front/src/views/admin/library/ArtistDetail.vue:222 -#: front/src/views/admin/library/ArtistDetail.vue:217 -#: front/src/views/admin/library/LibraryDetail.vue:197 -#: front/src/views/admin/library/LibraryDetail.vue:192 -#: front/src/views/admin/library/TrackDetail.vue:288 -#: front/src/views/admin/library/TrackDetail.vue:283 -#: front/src/views/admin/moderation/AccountsDetail.vue:299 -#: front/src/views/admin/moderation/AccountsDetail.vue:294 +#: src/views/admin/ChannelDetail.vue:242 +#: src/views/admin/ChannelDetail.vue:237 +#: src/views/admin/library/AlbumDetail.vue:223 +#: src/views/admin/library/AlbumDetail.vue:218 +#: src/views/admin/library/ArtistDetail.vue:222 +#: src/views/admin/library/ArtistDetail.vue:217 +#: src/views/admin/library/LibraryDetail.vue:197 +#: src/views/admin/library/LibraryDetail.vue:192 +#: src/views/admin/library/TrackDetail.vue:288 +#: src/views/admin/library/TrackDetail.vue:283 +#: src/views/admin/moderation/AccountsDetail.vue:299 +#: src/views/admin/moderation/AccountsDetail.vue:294 msgctxt "Content/Moderation/Table.Label/Noun" msgid "Linked reports" msgstr "" -#: front/src/components/Home.vue:168 +#: src/components/Home.vue:168 msgctxt "Content/Home/Link" msgid "Listen to public albums and playlists shared on this pod" msgstr "" -#: front/src/components/About.vue:140 src/components/About.vue:153 +#: src/components/About.vue:145 +#: src/components/About.vue:158 msgctxt "Content/About/Paragraph" msgid "Listen to public albums and playlists shared on this pod." msgstr "" -#: front/src/components/AboutPod.vue:258 src/components/AboutPod.vue:46 +#: src/components/AboutPod.vue:258 +#: src/components/AboutPod.vue:46 msgctxt "Content/About/*" msgid "listening" msgid_plural "listenings" msgstr[0] "" msgstr[1] "" -#: front/src/components/mixins/Translations.vue:107 -#: front/src/views/admin/ChannelDetail.vue:211 src/views/admin/ChannelDetail.vue:206 -#: front/src/views/admin/library/AlbumDetail.vue:192 -#: front/src/views/admin/library/AlbumDetail.vue:187 -#: front/src/views/admin/library/ArtistDetail.vue:191 -#: front/src/views/admin/library/ArtistDetail.vue:186 -#: front/src/views/admin/library/TrackDetail.vue:257 -#: front/src/views/admin/library/TrackDetail.vue:252 -#: front/src/components/mixins/Translations.vue:108 +#: src/components/mixins/Translations.vue:107 +#: src/views/admin/ChannelDetail.vue:211 +#: src/views/admin/ChannelDetail.vue:206 +#: src/views/admin/library/AlbumDetail.vue:192 +#: src/views/admin/library/AlbumDetail.vue:187 +#: src/views/admin/library/ArtistDetail.vue:191 +#: src/views/admin/library/ArtistDetail.vue:186 +#: src/views/admin/library/TrackDetail.vue:257 +#: src/views/admin/library/TrackDetail.vue:252 msgctxt "*/*/*/Noun" msgid "Listenings" msgstr "" -#: front/src/components/library/ArtistDetail.vue:48 +#: src/components/library/ArtistDetail.vue:36 msgctxt "Content/*/Button.Label" msgid "Load more…" msgstr "" -#: front/src/components/audio/ChannelForm.vue:142 +#: src/components/audio/ChannelForm.vue:142 msgctxt "*/*/*" msgid "Loading" msgstr "" -#: front/src/views/library/Edit.vue:19 +#: src/views/library/Edit.vue:19 msgctxt "Content/Library/Paragraph" msgid "Loading followers…" msgstr "" -#: front/src/views/content/libraries/Home.vue:4 +#: src/views/content/libraries/Home.vue:4 msgctxt "Content/Library/Paragraph" msgid "Loading Libraries…" msgstr "" -#: front/src/views/Notifications.vue:134 +#: src/views/Notifications.vue:134 msgctxt "Content/Notifications/Paragraph" msgid "Loading notifications…" msgstr "" -#: front/src/views/content/remote/Home.vue:4 +#: src/views/content/remote/Home.vue:4 msgctxt "Content/Library/Paragraph" msgid "Loading remote libraries…" msgstr "" -#: front/src/views/content/libraries/Quota.vue:9 +#: src/views/content/libraries/Quota.vue:9 msgctxt "Content/Library/Paragraph" msgid "Loading usage data…" msgstr "" -#: front/src/components/favorites/List.vue:5 +#: src/components/favorites/List.vue:5 msgctxt "Content/Favorites/Message" msgid "Loading your favorites…" msgstr "" -#: front/src/components/manage/ChannelsTable.vue:114 -#: front/src/components/manage/ChannelsTable.vue:23 -#: front/src/components/manage/library/AlbumsTable.vue:95 -#: front/src/components/manage/library/AlbumsTable.vue:23 -#: front/src/components/manage/library/ArtistsTable.vue:101 -#: front/src/components/manage/library/ArtistsTable.vue:15 -#: front/src/components/manage/library/LibrariesTable.vue:117 -#: front/src/components/manage/library/LibrariesTable.vue:21 -#: front/src/components/manage/library/TracksTable.vue:101 -#: front/src/components/manage/library/TracksTable.vue:29 -#: front/src/components/manage/library/UploadsTable.vue:164 -#: front/src/components/manage/library/UploadsTable.vue:27 -#: front/src/components/manage/moderation/ReportCard.vue:207 -#: front/src/views/admin/ChannelDetail.vue:19 src/views/admin/ChannelDetail.vue:14 -#: front/src/views/admin/ChannelDetail.vue:3 -#: front/src/views/admin/library/AlbumDetail.vue:19 -#: front/src/views/admin/library/AlbumDetail.vue:14 -#: front/src/views/admin/library/AlbumDetail.vue:3 -#: front/src/views/admin/library/ArtistDetail.vue:19 -#: front/src/views/admin/library/ArtistDetail.vue:14 -#: front/src/views/admin/library/ArtistDetail.vue:3 -#: front/src/views/admin/library/LibraryDetail.vue:18 -#: front/src/views/admin/library/LibraryDetail.vue:13 -#: front/src/views/admin/library/LibraryDetail.vue:3 -#: front/src/views/admin/library/TrackDetail.vue:19 -#: front/src/views/admin/library/TrackDetail.vue:14 -#: front/src/views/admin/library/TrackDetail.vue:3 -#: front/src/views/admin/library/UploadDetail.vue:19 -#: front/src/views/admin/library/UploadDetail.vue:14 -#: front/src/views/admin/library/UploadDetail.vue:3 +#: src/components/manage/ChannelsTable.vue:114 +#: src/components/manage/ChannelsTable.vue:23 +#: src/components/manage/library/AlbumsTable.vue:95 +#: src/components/manage/library/AlbumsTable.vue:23 +#: src/components/manage/library/ArtistsTable.vue:101 +#: src/components/manage/library/ArtistsTable.vue:15 +#: src/components/manage/library/LibrariesTable.vue:117 +#: src/components/manage/library/LibrariesTable.vue:21 +#: src/components/manage/library/TracksTable.vue:101 +#: src/components/manage/library/TracksTable.vue:29 +#: src/components/manage/library/UploadsTable.vue:164 +#: src/components/manage/library/UploadsTable.vue:27 +#: src/components/manage/moderation/ReportCard.vue:207 +#: src/views/admin/ChannelDetail.vue:19 +#: src/views/admin/ChannelDetail.vue:14 +#: src/views/admin/ChannelDetail.vue:3 +#: src/views/admin/library/AlbumDetail.vue:19 +#: src/views/admin/library/AlbumDetail.vue:14 +#: src/views/admin/library/AlbumDetail.vue:3 +#: src/views/admin/library/ArtistDetail.vue:19 +#: src/views/admin/library/ArtistDetail.vue:14 +#: src/views/admin/library/ArtistDetail.vue:3 +#: src/views/admin/library/LibraryDetail.vue:18 +#: src/views/admin/library/LibraryDetail.vue:13 +#: src/views/admin/library/LibraryDetail.vue:3 +#: src/views/admin/library/TrackDetail.vue:19 +#: src/views/admin/library/TrackDetail.vue:14 +#: src/views/admin/library/TrackDetail.vue:3 +#: src/views/admin/library/UploadDetail.vue:19 +#: src/views/admin/library/UploadDetail.vue:14 +#: src/views/admin/library/UploadDetail.vue:3 msgctxt "Content/Moderation/*/Short, Noun" msgid "Local" msgstr "" -#: front/src/components/manage/moderation/AccountsTable.vue:87 -#: front/src/components/manage/moderation/AccountsTable.vue:15 -#: front/src/views/admin/moderation/AccountsDetail.vue:18 -#: front/src/views/admin/moderation/AccountsDetail.vue:13 -#: front/src/views/admin/moderation/AccountsDetail.vue:3 +#: src/components/manage/moderation/AccountsTable.vue:87 +#: src/components/manage/moderation/AccountsTable.vue:15 +#: src/views/admin/moderation/AccountsDetail.vue:18 +#: src/views/admin/moderation/AccountsDetail.vue:13 +#: src/views/admin/moderation/AccountsDetail.vue:3 msgctxt "Content/Moderation/*/Short, Noun" msgid "Local account" msgstr "" -#: front/src/components/common/LoginModal.vue:74 -#: front/src/components/common/UserMenu.vue:174 -#: front/src/components/common/UserModal.vue:210 +#: src/components/common/LoginModal.vue:21 +#: src/components/common/UserMenu.vue:27 +#: src/components/common/UserModal.vue:42 msgctxt "*/*/Button.Label/Verb" msgid "Log in" msgstr "" -#: front/src/components/Home.vue:115 src/views/auth/Login.vue:36 +#: src/components/Home.vue:115 +#: src/views/auth/Login.vue:17 msgctxt "Head/Login/Title" msgid "Log In" msgstr "" -#: front/src/components/auth/SignupForm.vue:15 src/views/auth/Login.vue:5 +#: src/components/auth/SignupForm.vue:15 +#: src/views/auth/Login.vue:5 msgctxt "Content/Login/Title/Verb" msgid "Log in to your Funkwhale account" msgstr "" -#: front/src/components/auth/Logout.vue:25 +#: src/components/auth/Logout.vue:25 msgctxt "Content/Login/Button.Label" msgid "Log in!" msgstr "" -#: front/src/components/common/UserMenu.vue:164 -#: front/src/components/common/UserModal.vue:193 +#: src/components/common/UserMenu.vue:17 +#: src/components/common/UserModal.vue:25 msgctxt "Sidebar/Login/List item.Link/Verb" msgid "Log out" msgstr "" -#: front/src/components/auth/Logout.vue:58 +#: src/components/auth/Logout.vue:5 msgctxt "Head/Login/Title" msgid "Log Out" msgstr "" -#: front/src/views/auth/Callback.vue:8 +#: src/views/auth/Callback.vue:8 msgctxt "*/Login/*" msgid "Logging in…" msgstr "" -#: front/src/components/Sidebar.vue:122 src/components/auth/LoginForm.vue:55 +#: src/components/Sidebar.vue:122 +#: src/components/auth/LoginForm.vue:55 msgctxt "*/Login/*/Verb" msgid "Login" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:155 -#: front/src/views/admin/moderation/AccountsDetail.vue:150 +#: src/views/admin/moderation/AccountsDetail.vue:155 +#: src/views/admin/moderation/AccountsDetail.vue:150 msgctxt "Content/*/*/Noun" msgid "Login status" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:73 +#: src/components/admin/SignupFormBuilder.vue:73 msgctxt "*/*/Form-builder" msgid "Long text" msgstr "" -#: front/src/views/content/libraries/Home.vue:17 +#: src/views/content/libraries/Home.vue:17 msgctxt "Content/Library/Paragraph" msgid "Looks like you don't have a library, it's time to create one." msgstr "" -#: front/src/components/audio/Player.vue:413 src/components/audio/Player.vue:414 +#: src/components/audio/Player.vue:73 msgctxt "Sidebar/Player/Icon.Tooltip" msgid "Looping disabled. Click to switch to single-track looping." msgstr "" -#: front/src/components/audio/Player.vue:416 src/components/audio/Player.vue:417 +#: src/components/audio/Player.vue:76 msgctxt "Sidebar/Player/Icon.Tooltip" msgid "Looping on a single track. Click to switch to whole queue looping." msgstr "" -#: front/src/components/audio/Player.vue:419 src/components/audio/Player.vue:420 +#: src/components/audio/Player.vue:79 msgctxt "Sidebar/Player/Icon.Tooltip" msgid "Looping on whole queue. Click to disable looping." msgstr "" -#: front/src/components/Sidebar.vue:523 +#: src/components/Sidebar.vue:57 msgctxt "Sidebar/*/Hidden text" msgid "Main menu" msgstr "" -#: front/src/components/Sidebar.vue:135 +#: src/components/Sidebar.vue:135 msgctxt "*/*/*" msgid "Main navigation" msgstr "" -#: front/src/views/admin/library/Base.vue:84 +#: src/views/admin/library/Base.vue:4 msgctxt "Head/Admin/Title" msgid "Manage library" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyModal.vue:10 +#: src/components/manage/moderation/InstancePolicyModal.vue:10 msgctxt "Popup/Moderation/Title/Verb" msgid "Manage moderation rules for %{ obj }" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:13 +#: src/components/playlists/PlaylistModal.vue:13 msgctxt "Popup/Playlist/Title/Verb" msgid "Manage playlists" msgstr "" -#: front/src/components/auth/Settings.vue:402 +#: src/components/auth/Settings.vue:402 msgctxt "Content/Settings/Button.Label" msgid "Manage plugins" msgstr "" -#: front/src/views/auth/Plugins.vue:47 +#: src/views/auth/Plugins.vue:17 msgctxt "Head/Login/Title" msgid "Manage plugins" msgstr "" -#: front/src/views/admin/users/Base.vue:37 +#: src/views/admin/users/Base.vue:5 msgctxt "Head/Admin/Title" msgid "Manage users" msgstr "" -#: front/src/views/playlists/List.vue:10 src/views/playlists/List.vue:2 +#: src/views/playlists/List.vue:10 +#: src/views/playlists/List.vue:2 msgctxt "Content/Playlist/Button.Label/Verb" msgid "Manage your playlists" msgstr "" -#: front/src/views/Notifications.vue:126 +#: src/views/Notifications.vue:126 msgctxt "Content/Notifications/Button.Label/Verb" msgid "Mark all as read" msgstr "" -#: front/src/components/notifications/NotificationRow.vue:94 +#: src/components/notifications/NotificationRow.vue:24 msgctxt "Content/Notifications/Button.Tooltip/Verb" msgid "Mark as read" msgstr "" -#: front/src/components/notifications/NotificationRow.vue:95 +#: src/components/notifications/NotificationRow.vue:25 msgctxt "Content/Notifications/Button.Tooltip/Verb" msgid "Mark as unread" msgstr "" -#: front/src/components/common/ContentForm.vue:43 +#: src/components/common/ContentForm.vue:43 msgctxt "*/Form/Paragraph" msgid "Markdown syntax is supported." msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:364 -#: front/src/views/admin/moderation/AccountsDetail.vue:359 +#: src/views/admin/moderation/AccountsDetail.vue:364 +#: src/views/admin/moderation/AccountsDetail.vue:359 msgctxt "Content/*/*/Unit" msgid "MB" msgstr "" -#: front/src/components/audio/Player.vue:405 +#: src/components/audio/Player.vue:65 msgctxt "Sidebar/Player/Hidden text" msgid "Media player" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:129 -#: front/src/components/manage/moderation/UserRequestCard.vue:119 -#: front/src/components/moderation/ReportModal.vue:47 +#: src/components/manage/moderation/ReportCard.vue:129 +#: src/components/manage/moderation/UserRequestCard.vue:119 +#: src/components/moderation/ReportModal.vue:47 msgctxt "*/*/Field.Label/Noun" msgid "Message" msgstr "" -#: front/src/views/channels/DetailBase.vue:160 src/views/channels/DetailBase.vue:155 +#: src/views/channels/DetailBase.vue:162 +#: src/views/channels/DetailBase.vue:157 msgctxt "Content/Channel/Paragraph" msgid "Mirrored from %{ domain }" msgstr "" -#: front/src/components/Footer.vue:56 -msgctxt "Footer/*/List item.Link" -msgid "Mobile and desktop apps" -msgstr "" - -#: front/src/components/Home.vue:178 +#: src/components/Home.vue:178 msgctxt "Content/Home/Link" msgid "Mobile apps" msgstr "" -#: front/src/components/Sidebar.vue:36 src/components/manage/users/UsersTable.vue:255 -#: front/src/views/admin/Settings.vue:76 -#: front/src/views/admin/moderation/AccountsDetail.vue:614 -#: front/src/views/admin/moderation/Base.vue:76 +#: src/components/Sidebar.vue:36 +#: src/components/manage/users/UsersTable.vue:50 +#: src/views/admin/Settings.vue:32 +#: src/views/admin/moderation/AccountsDetail.vue:44 +#: src/views/admin/moderation/Base.vue:13 msgctxt "*/Moderation/*" msgid "Moderation" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:73 -#: front/src/views/admin/moderation/AccountsDetail.vue:68 -#: front/src/views/admin/moderation/AccountsDetail.vue:10 -#: front/src/views/admin/moderation/DomainsDetail.vue:67 -#: front/src/views/admin/moderation/DomainsDetail.vue:62 -#: front/src/views/admin/moderation/DomainsDetail.vue:10 +#: src/views/admin/moderation/AccountsDetail.vue:73 +#: src/views/admin/moderation/AccountsDetail.vue:68 +#: src/views/admin/moderation/AccountsDetail.vue:10 +#: src/views/admin/moderation/DomainsDetail.vue:67 +#: src/views/admin/moderation/DomainsDetail.vue:62 +#: src/views/admin/moderation/DomainsDetail.vue:10 msgctxt "Content/Moderation/Card.Paragraph" msgid "Moderation policies help you control how your instance interact with a given domain or account." msgstr "" -#: front/src/components/manage/moderation/InstancePolicyModal.vue:4 +#: src/components/manage/moderation/InstancePolicyModal.vue:4 msgctxt "Content/Moderation/Button.Label" msgid "Moderation rules…" msgstr "" -#: front/src/components/library/EditCard.vue:5 +#: src/components/library/EditCard.vue:5 msgctxt "Content/Library/Card/Short" msgid "Modification %{ id }" msgstr "" -#: front/src/components/mixins/Translations.vue:73 -#: front/src/components/mixins/Translations.vue:74 +#: src/components/mixins/Translations.vue:73 msgctxt "Content/Playlist/Dropdown/Noun" msgid "Modification date" msgstr "" -#: front/src/components/Sidebar.vue:234 +#: src/components/Sidebar.vue:234 msgctxt "Footer/About/List item.Link" msgid "More" msgstr "" -#: front/src/components/audio/SearchBar.vue:206 +#: src/components/audio/SearchBar.vue:183 msgctxt "Search/*/*" msgid "More results 🡒" msgstr "" -#: front/src/components/audio/PlayButton.vue:187 -#: front/src/components/library/AlbumDropdown.vue:165 -#: front/src/components/library/ArtistBase.vue:54 -#: front/src/components/library/ArtistBase.vue:49 -#: front/src/components/library/TrackBase.vue:292 +#: src/components/audio/PlayButton.vue:61 +#: src/components/library/AlbumDropdown.vue:36 +#: src/components/library/ArtistBase.vue:54 +#: src/components/library/ArtistBase.vue:49 +#: src/components/library/TrackBase.vue:71 msgctxt "*/*/Button.Label/Noun" msgid "More…" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:200 +#: src/components/admin/SignupFormBuilder.vue:37 msgctxt "*/*/*" msgid "Move down" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:199 +#: src/components/admin/SignupFormBuilder.vue:36 msgctxt "*/*/*" msgid "Move up" msgstr "" -#: front/src/components/mixins/Translations.vue:62 -#: front/src/components/mixins/Translations.vue:63 +#: src/components/mixins/Translations.vue:62 msgctxt "*/*/*" msgid "Music" msgstr "" -#: front/src/views/admin/Settings.vue:72 +#: src/views/admin/Settings.vue:28 msgctxt "*/*/*/Noun" msgid "Music" msgstr "" -#: front/src/components/audio/Player.vue:411 -#: front/src/components/audio/VolumeControl.vue:75 +#: src/components/audio/Player.vue:71 +#: src/components/audio/VolumeControl.vue:23 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Mute" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyCard.vue:31 -#: front/src/components/manage/moderation/InstancePolicyForm.vue:209 +#: src/components/manage/moderation/InstancePolicyCard.vue:31 +#: src/components/manage/moderation/InstancePolicyForm.vue:39 msgctxt "Content/Moderation/*/Verb" msgid "Mute activity" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyCard.vue:39 -#: front/src/components/manage/moderation/InstancePolicyForm.vue:213 +#: src/components/manage/moderation/InstancePolicyCard.vue:39 +#: src/components/manage/moderation/InstancePolicyForm.vue:43 msgctxt "Content/Moderation/*/Verb" msgid "Mute notifications" msgstr "" -#: front/src/components/library/radios/Builder.vue:221 +#: src/components/library/radios/Builder.vue:35 msgctxt "Content/Radio/Input.Placeholder" msgid "My awesome description" msgstr "" -#: front/src/views/content/libraries/Form.vue:142 +#: src/views/content/libraries/Form.vue:26 msgctxt "Content/Library/Input.Placeholder" msgid "My awesome library" msgstr "" -#: front/src/components/playlists/Form.vue:131 +#: src/components/playlists/Form.vue:31 msgctxt "Content/Playlist/Input.Placeholder" msgid "My awesome playlist" msgstr "" -#: front/src/components/library/radios/Builder.vue:220 +#: src/components/library/radios/Builder.vue:34 msgctxt "Content/Radio/Input.Placeholder" msgid "My awesome radio" msgstr "" -#: front/src/views/content/libraries/Home.vue:11 +#: src/views/content/libraries/Home.vue:11 msgctxt "Content/Library/Title" msgid "My libraries" msgstr "" -#: front/src/components/Sidebar.vue:189 +#: src/components/Sidebar.vue:189 msgctxt "*/*/*/Noun" msgid "My Library" msgstr "" -#: front/src/components/AboutPod.vue:104 src/components/AboutPod.vue:203 -#: front/src/components/library/EditCard.vue:79 -#: front/src/components/library/EditForm.vue:75 src/components/library/EditForm.vue:5 -#: front/src/components/library/TrackDetail.vue:28 -#: front/src/components/library/TrackDetail.vue:43 -#: front/src/components/library/TrackDetail.vue:58 -#: front/src/components/library/TrackDetail.vue:73 -#: front/src/components/library/TrackDetail.vue:144 -#: front/src/components/library/TrackDetail.vue:159 -#: front/src/components/library/TrackDetail.vue:173 -#: front/src/components/library/TrackDetail.vue:23 -#: front/src/components/library/TrackDetail.vue:38 -#: front/src/components/library/TrackDetail.vue:53 -#: front/src/components/library/TrackDetail.vue:68 -#: front/src/components/library/TrackDetail.vue:1 -#: front/src/components/manage/library/AlbumsTable.vue:103 -#: front/src/components/manage/library/AlbumsTable.vue:31 -#: front/src/components/manage/library/TracksTable.vue:106 -#: front/src/components/manage/library/TracksTable.vue:34 -#: front/src/components/manage/library/UploadsTable.vue:182 -#: front/src/components/manage/library/UploadsTable.vue:191 -#: front/src/components/manage/library/UploadsTable.vue:45 -#: front/src/components/manage/library/UploadsTable.vue:54 -#: front/src/components/manage/moderation/ReportCard.vue:90 -#: front/src/components/manage/moderation/ReportCard.vue:103 -#: front/src/components/manage/moderation/ReportCard.vue:233 -#: front/src/components/manage/moderation/UserRequestCard.vue:80 -#: front/src/components/manage/moderation/UserRequestCard.vue:93 -#: front/src/components/manage/moderation/UserRequestCard.vue:135 -#: front/src/components/manage/moderation/UserRequestCard.vue:7 -#: front/src/components/manage/users/UsersTable.vue:97 -#: front/src/components/manage/users/UsersTable.vue:22 -#: front/src/components/manage/users/UsersTable.vue:1 -#: front/src/views/admin/library/UploadDetail.vue:202 -#: front/src/views/admin/library/UploadDetail.vue:245 -#: front/src/views/admin/library/UploadDetail.vue:270 -#: front/src/views/admin/library/UploadDetail.vue:285 -#: front/src/views/admin/library/UploadDetail.vue:302 -#: front/src/views/admin/library/UploadDetail.vue:197 -#: front/src/views/admin/library/UploadDetail.vue:240 -#: front/src/views/admin/library/UploadDetail.vue:265 -#: front/src/views/admin/library/UploadDetail.vue:280 -#: front/src/views/admin/library/UploadDetail.vue:297 -#: front/src/views/admin/moderation/AccountsDetail.vue:208 -#: front/src/views/admin/moderation/AccountsDetail.vue:203 -#: front/src/views/admin/moderation/DomainsDetail.vue:126 -#: front/src/views/admin/moderation/DomainsDetail.vue:121 -#: front/src/views/admin/moderation/DomainsDetail.vue:513 -#: front/src/views/admin/moderation/DomainsDetail.vue:521 -#: front/src/views/admin/moderation/DomainsDetail.vue:557 -#: front/src/views/admin/moderation/DomainsDetail.vue:593 -#: front/src/views/content/libraries/FilesTable.vue:163 -#: front/src/views/content/libraries/FilesTable.vue:171 -#: front/src/views/content/libraries/FilesTable.vue:34 -#: front/src/views/content/libraries/FilesTable.vue:42 -#: front/src/views/admin/moderation/DomainsDetail.vue:216 +#: src/components/AboutPod.vue:104 +#: src/components/AboutPod.vue:203 +#: src/components/library/EditCard.vue:79 +#: src/components/library/EditForm.vue:75 +#: src/components/library/EditForm.vue:5 +#: src/components/library/TrackDetail.vue:29 +#: src/components/library/TrackDetail.vue:44 +#: src/components/library/TrackDetail.vue:59 +#: src/components/library/TrackDetail.vue:74 +#: src/components/library/TrackDetail.vue:145 +#: src/components/library/TrackDetail.vue:160 +#: src/components/library/TrackDetail.vue:174 +#: src/components/library/TrackDetail.vue:24 +#: src/components/library/TrackDetail.vue:39 +#: src/components/library/TrackDetail.vue:54 +#: src/components/library/TrackDetail.vue:69 +#: src/components/library/TrackDetail.vue:1 +#: src/components/manage/library/AlbumsTable.vue:103 +#: src/components/manage/library/AlbumsTable.vue:31 +#: src/components/manage/library/TracksTable.vue:106 +#: src/components/manage/library/TracksTable.vue:34 +#: src/components/manage/library/UploadsTable.vue:182 +#: src/components/manage/library/UploadsTable.vue:191 +#: src/components/manage/library/UploadsTable.vue:45 +#: src/components/manage/library/UploadsTable.vue:54 +#: src/components/manage/moderation/ReportCard.vue:90 +#: src/components/manage/moderation/ReportCard.vue:103 +#: src/components/manage/moderation/ReportCard.vue:233 +#: src/components/manage/moderation/UserRequestCard.vue:80 +#: src/components/manage/moderation/UserRequestCard.vue:93 +#: src/components/manage/moderation/UserRequestCard.vue:135 +#: src/components/manage/moderation/UserRequestCard.vue:7 +#: src/components/manage/users/UsersTable.vue:97 +#: src/components/manage/users/UsersTable.vue:22 +#: src/components/manage/users/UsersTable.vue:1 +#: src/views/admin/library/UploadDetail.vue:202 +#: src/views/admin/library/UploadDetail.vue:245 +#: src/views/admin/library/UploadDetail.vue:270 +#: src/views/admin/library/UploadDetail.vue:285 +#: src/views/admin/library/UploadDetail.vue:302 +#: src/views/admin/library/UploadDetail.vue:197 +#: src/views/admin/library/UploadDetail.vue:240 +#: src/views/admin/library/UploadDetail.vue:265 +#: src/views/admin/library/UploadDetail.vue:280 +#: src/views/admin/library/UploadDetail.vue:297 +#: src/views/admin/moderation/AccountsDetail.vue:208 +#: src/views/admin/moderation/AccountsDetail.vue:203 +#: src/views/admin/moderation/DomainsDetail.vue:126 +#: src/views/admin/moderation/DomainsDetail.vue:121 +#: src/views/admin/moderation/DomainsDetail.vue:408 +#: src/views/admin/moderation/DomainsDetail.vue:419 +#: src/views/admin/moderation/DomainsDetail.vue:430 +#: src/views/content/libraries/FilesTable.vue:163 +#: src/views/content/libraries/FilesTable.vue:171 +#: src/views/content/libraries/FilesTable.vue:34 +#: src/views/content/libraries/FilesTable.vue:42 msgctxt "*/*/*" msgid "N/A" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:15 -#: front/src/components/auth/Settings.vue:184 -#: front/src/components/manage/ChannelsTable.vue:61 -#: front/src/components/manage/ChannelsTable.vue:2 -#: front/src/components/manage/library/ArtistsTable.vue:61 -#: front/src/components/manage/library/ArtistsTable.vue:2 -#: front/src/components/manage/library/LibrariesTable.vue:61 -#: front/src/components/manage/library/LibrariesTable.vue:2 -#: front/src/components/manage/library/TagsTable.vue:43 -#: front/src/components/manage/library/TagsTable.vue:2 -#: front/src/components/manage/library/UploadsTable.vue:92 -#: front/src/components/manage/library/UploadsTable.vue:2 -#: front/src/components/manage/moderation/AccountsTable.vue:42 -#: front/src/components/manage/moderation/AccountsTable.vue:2 -#: front/src/components/manage/moderation/DomainsTable.vue:60 -#: front/src/components/manage/moderation/DomainsTable.vue:2 -#: front/src/components/mixins/Translations.vue:78 -#: front/src/components/playlists/PlaylistModal.vue:66 -#: front/src/views/admin/ChannelDetail.vue:104 src/views/admin/ChannelDetail.vue:99 -#: front/src/views/admin/library/ArtistDetail.vue:116 -#: front/src/views/admin/library/ArtistDetail.vue:111 -#: front/src/views/admin/library/LibraryDetail.vue:90 -#: front/src/views/admin/library/LibraryDetail.vue:85 -#: front/src/views/admin/library/TagDetail.vue:79 -#: front/src/views/admin/library/TagDetail.vue:74 -#: front/src/views/admin/library/UploadDetail.vue:97 -#: front/src/views/admin/library/UploadDetail.vue:92 -#: front/src/views/admin/moderation/DomainsDetail.vue:145 -#: front/src/views/admin/moderation/DomainsDetail.vue:140 -#: front/src/views/admin/moderation/DomainsDetail.vue:13 -#: front/src/views/content/libraries/Form.vue:20 src/edits.js:42 -#: front/src/components/mixins/Translations.vue:79 +#: src/components/auth/ApplicationForm.vue:15 +#: src/components/auth/Settings.vue:184 +#: src/components/manage/ChannelsTable.vue:61 +#: src/components/manage/ChannelsTable.vue:2 +#: src/components/manage/library/ArtistsTable.vue:61 +#: src/components/manage/library/ArtistsTable.vue:2 +#: src/components/manage/library/LibrariesTable.vue:61 +#: src/components/manage/library/LibrariesTable.vue:2 +#: src/components/manage/library/TagsTable.vue:43 +#: src/components/manage/library/TagsTable.vue:2 +#: src/components/manage/library/UploadsTable.vue:92 +#: src/components/manage/library/UploadsTable.vue:2 +#: src/components/manage/moderation/AccountsTable.vue:42 +#: src/components/manage/moderation/AccountsTable.vue:2 +#: src/components/manage/moderation/DomainsTable.vue:60 +#: src/components/manage/moderation/DomainsTable.vue:2 +#: src/components/mixins/Translations.vue:78 +#: src/components/playlists/PlaylistModal.vue:66 +#: src/views/admin/ChannelDetail.vue:104 +#: src/views/admin/ChannelDetail.vue:99 +#: src/views/admin/library/ArtistDetail.vue:116 +#: src/views/admin/library/ArtistDetail.vue:111 +#: src/views/admin/library/LibraryDetail.vue:90 +#: src/views/admin/library/LibraryDetail.vue:85 +#: src/views/admin/library/TagDetail.vue:79 +#: src/views/admin/library/TagDetail.vue:74 +#: src/views/admin/library/UploadDetail.vue:97 +#: src/views/admin/library/UploadDetail.vue:92 +#: src/views/admin/moderation/DomainsDetail.vue:145 +#: src/views/admin/moderation/DomainsDetail.vue:140 +#: src/views/admin/moderation/DomainsDetail.vue:13 +#: src/views/content/libraries/Form.vue:20 msgctxt "*/*/*/Noun" msgid "Name" msgstr "" -#: front/src/components/audio/ChannelForm.vue:37 -#: front/src/components/audio/ChannelForm.vue:23 -#: front/src/components/audio/ChannelForm.vue:3 +#: src/components/audio/ChannelForm.vue:37 +#: src/components/audio/ChannelForm.vue:23 +#: src/components/audio/ChannelForm.vue:3 msgctxt "Content/Channel/*" msgid "Name" msgstr "" -#: front/src/views/Notifications.vue:43 src/views/Notifications.vue:99 +#: src/views/Notifications.vue:43 +#: src/views/Notifications.vue:99 msgctxt "*/*/*" msgid "Never" msgstr "" -#: front/src/components/channels/AlbumModal.vue:6 +#: src/components/channels/AlbumModal.vue:6 msgctxt "Popup/Channels/Title" msgid "New album" msgstr "" -#: front/src/components/Home.vue:220 src/components/library/Home.vue:46 -#: front/src/components/library/Home.vue:2 +#: src/components/Home.vue:220 +#: src/components/library/Home.vue:46 +#: src/components/library/Home.vue:2 msgctxt "*/*/*" msgid "New channels" msgstr "" -#: front/src/components/auth/Settings.vue:441 +#: src/components/auth/Settings.vue:441 msgctxt "*/*/*" msgid "New e-mail address" msgstr "" -#: front/src/components/auth/Settings.vue:111 -#: front/src/views/auth/PasswordResetConfirm.vue:20 -#: front/src/views/auth/PasswordResetConfirm.vue:2 +#: src/components/auth/Settings.vue:111 +#: src/views/auth/PasswordResetConfirm.vue:20 +#: src/views/auth/PasswordResetConfirm.vue:2 msgctxt "Content/Settings/Input.Label" msgid "New password" msgstr "" -#: front/src/components/channels/AlbumModal.vue:3 +#: src/components/channels/AlbumModal.vue:3 msgctxt "Popup/Channels/Title/Verb" msgid "New series" msgstr "" -#: front/src/components/library/ArtistDetail.vue:26 -#: front/src/components/library/ArtistDetail.vue:2 +#: src/components/library/ArtistDetail.vue:45 +#: src/components/library/ArtistDetail.vue:2 msgctxt "Content/Artist/Title" msgid "New tracks by this artist" msgstr "" -#: front/src/components/Queue.vue:170 +#: src/components/Queue.vue:170 msgctxt "Sidebar/Player/Paragraph" msgid "New tracks will be appended here automatically." msgstr "" -#: front/src/components/library/EditCard.vue:58 +#: src/components/library/EditCard.vue:58 msgctxt "Content/Library/Card.Table.Header/Short" msgid "New value" msgstr "" -#: front/src/components/Pagination.vue:54 +#: src/components/Pagination.vue:15 msgctxt "Content/*/Link" msgid "Next Page" msgstr "" -#: front/src/components/channels/UploadModal.vue:49 -#: front/src/views/auth/ProfileOverview.vue:67 +#: src/components/channels/UploadModal.vue:49 +#: src/views/auth/ProfileOverview.vue:67 msgctxt "*/*/Button.Label" msgid "Next step" msgstr "" -#: front/src/App.vue:100 src/components/audio/Player.vue:409 +#: src/AppOld.vue:86 +#: src/components/audio/Player.vue:69 msgctxt "Sidebar/Player/Icon.Tooltip" msgid "Next track" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:87 -#: front/src/components/manage/moderation/DomainsTable.vue:22 -#: front/src/views/admin/moderation/DomainsDetail.vue:113 -#: front/src/views/admin/moderation/DomainsDetail.vue:108 +#: src/components/admin/SignupFormBuilder.vue:87 +#: src/components/manage/moderation/DomainsTable.vue:22 +#: src/views/admin/moderation/DomainsDetail.vue:113 +#: src/views/admin/moderation/DomainsDetail.vue:108 msgctxt "*/*/*" msgid "No" msgstr "" -#: front/src/components/audio/Search.vue:42 src/components/audio/Search.vue:12 +#: src/components/audio/Search.vue:42 +#: src/components/audio/Search.vue:12 msgctxt "Content/Search/Paragraph" msgid "No album matched your query" msgstr "" -#: front/src/components/audio/Search.vue:25 src/components/audio/Search.vue:12 +#: src/components/audio/Search.vue:25 +#: src/components/audio/Search.vue:12 msgctxt "Content/Search/Paragraph" msgid "No artist matched your query" msgstr "" -#: front/src/components/common/RenderedDescription.vue:15 +#: src/components/common/RenderedDescription.vue:15 msgctxt "*/*/Placeholder" msgid "No description available" msgstr "" -#: front/src/components/About.vue:97 src/components/AboutPod.vue:53 +#: src/components/About.vue:102 +#: src/components/AboutPod.vue:53 msgctxt "Content/About/Paragraph" msgid "No description available." msgstr "" -#: front/src/components/Home.vue:26 +#: src/components/Home.vue:26 msgctxt "Content/Home/Paragraph" msgid "No description available." msgstr "" -#: front/src/components/audio/SearchBar.vue:63 +#: src/components/audio/SearchBar.vue:40 msgctxt "Sidebar/Search/Error" msgid "No matches found" msgstr "" -#: front/src/components/federation/LibraryWidget.vue:9 +#: src/components/federation/LibraryWidget.vue:9 msgctxt "Content/Federation/Paragraph" msgid "No matching library." msgstr "" -#: front/src/views/Notifications.vue:146 +#: src/views/Notifications.vue:146 msgctxt "Content/Notifications/Paragraph" msgid "No notification to show." msgstr "" -#: front/src/components/manage/moderation/DomainsTable.vue:109 +#: src/components/manage/moderation/DomainsTable.vue:109 msgctxt "Content/Home/Placeholder" msgid "No other pods found" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:121 -#: front/src/components/playlists/PlaylistModal.vue:4 -#: front/src/components/playlists/Widget.vue:14 +#: src/components/playlists/PlaylistModal.vue:121 +#: src/components/playlists/PlaylistModal.vue:4 +#: src/components/playlists/Widget.vue:14 msgctxt "Content/Home/Placeholder" msgid "No playlists have been created yet" msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:110 -#: front/src/components/playlists/PlaylistModal.vue:3 +#: src/components/playlists/PlaylistModal.vue:110 +#: src/components/playlists/PlaylistModal.vue:3 msgctxt "Popup/Playlist/EmptyState" msgid "No results matching your filter" msgstr "" -#: front/src/components/library/Albums.vue:72 +#: src/components/library/Albums.vue:72 msgctxt "Content/Albums/Placeholder" msgid "No results matching your query" msgstr "" -#: front/src/components/library/Artists.vue:81 src/components/library/Podcasts.vue:74 +#: src/components/library/Artists.vue:81 +#: src/components/library/Podcasts.vue:74 msgctxt "Content/Artists/Placeholder" msgid "No results matching your query" msgstr "" -#: front/src/views/playlists/List.vue:71 +#: src/views/playlists/List.vue:71 msgctxt "Content/Playlists/Placeholder" msgid "No results matching your query" msgstr "" -#: front/src/components/library/Radios.vue:90 +#: src/components/library/Radios.vue:90 msgctxt "Content/Radios/Placeholder" msgid "No results matching your query" msgstr "" -#: front/src/components/common/EmptyState.vue:6 +#: src/components/common/EmptyState.vue:6 msgctxt "Content/*/Paragraph" msgid "No results were found." msgstr "" -#: front/src/components/AboutPod.vue:65 +#: src/components/AboutPod.vue:65 msgctxt "Content/About/Paragraph" msgid "No rules available." msgstr "" -#: front/src/components/AboutPod.vue:77 +#: src/components/AboutPod.vue:77 msgctxt "Content/About/Paragraph" msgid "No terms available." msgstr "" -#: front/src/views/content/libraries/FilesTable.vue:86 +#: src/views/content/libraries/FilesTable.vue:86 msgctxt "Content/Home/Placeholder" msgid "No tracks have been added to this library yet" msgstr "" -#: front/src/views/radios/Detail.vue:57 +#: src/views/radios/Detail.vue:57 msgctxt "Content/Radios/Placeholder" msgid "No tracks have been added to this radio yet" msgstr "" -#: front/src/components/favorites/List.vue:68 +#: src/components/favorites/List.vue:68 msgctxt "Content/Home/Placeholder" msgid "No tracks have been added to your favorites yet" msgstr "" -#: front/src/components/mixins/Translations.vue:10 -#: front/src/components/mixins/Translations.vue:11 +#: src/components/mixins/Translations.vue:10 msgctxt "Content/Settings/Dropdown" msgid "Nobody except me" msgstr "" -#: front/src/views/library/Edit.vue:78 +#: src/views/library/Edit.vue:78 msgctxt "Content/Library/Paragraph" msgid "Nobody is following this library" msgstr "" -#: front/src/components/channels/AlbumSelect.vue:8 -#: front/src/components/channels/LicenseSelect.vue:7 +#: src/components/channels/AlbumSelect.vue:8 +#: src/components/channels/LicenseSelect.vue:7 msgctxt "*/*/*" msgid "None" msgstr "" -#: front/src/components/manage/users/InvitationsTable.vue:79 -#: front/src/components/manage/users/InvitationsTable.vue:9 +#: src/components/manage/users/InvitationsTable.vue:79 +#: src/components/manage/users/InvitationsTable.vue:9 msgctxt "Content/Admin/Table" msgid "Not used" msgstr "" -#: front/src/components/audio/track/Widget.vue:52 +#: src/components/audio/track/Widget.vue:52 msgctxt "Content/Home/Placeholder" msgid "Nothing found" msgstr "" -#: front/src/components/common/ContentForm.vue:25 -#: front/src/components/common/ContentForm.vue:10 +#: src/components/common/ContentForm.vue:25 +#: src/components/common/ContentForm.vue:10 msgctxt "*/Form/Paragraph" msgid "Nothing to preview." msgstr "" -#: front/src/components/common/UserMenu.vue:176 -#: front/src/components/common/UserModal.vue:212 -#: front/src/components/mixins/Translations.vue:127 src/views/Notifications.vue:249 -#: front/src/components/mixins/Translations.vue:128 +#: src/components/common/UserMenu.vue:29 +#: src/components/common/UserModal.vue:44 +#: src/components/mixins/Translations.vue:127 +#: src/views/Notifications.vue:35 msgctxt "*/Notifications/*" msgid "Notifications" msgstr "" -#: front/src/components/mixins/Translations.vue:51 -#: front/src/components/mixins/Translations.vue:52 +#: src/components/mixins/Translations.vue:51 msgctxt "Content/Moderation/Dropdown" msgid "Offensive content" msgstr "" -#: front/src/components/Footer.vue:85 -msgctxt "Footer/*/List item.Link" -msgid "Official website" -msgstr "" - -#: front/src/components/library/EditCard.vue:53 +#: src/components/library/EditCard.vue:53 msgctxt "Content/Library/Card.Table.Header/Short" msgid "Old value" msgstr "" -#: front/src/components/AboutPod.vue:180 +#: src/components/AboutPod.vue:180 msgctxt "*/*/*/State of registrations" msgid "Open" msgstr "" -#: front/src/components/manage/users/InvitationsTable.vue:25 +#: src/components/manage/users/InvitationsTable.vue:25 msgctxt "Content/Admin/Dropdown/Adjective" msgid "Open" msgstr "" -#: front/src/components/library/ImportStatusModal.vue:72 -#: front/src/components/library/ImportStatusModal.vue:43 +#: src/components/library/ImportStatusModal.vue:72 +#: src/components/library/ImportStatusModal.vue:43 msgctxt "Popup/Import/Table.Label/Value" msgid "Open a support thread (include the debug information below in your message)" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:59 -#: front/src/components/library/ArtistBase.vue:98 -#: front/src/components/library/ArtistBase.vue:93 -#: front/src/components/library/TagDetail.vue:11 -#: front/src/components/library/TrackBase.vue:103 -#: front/src/components/library/TrackBase.vue:98 -#: front/src/components/manage/moderation/ReportCard.vue:156 -#: front/src/views/auth/ProfileBase.vue:22 src/views/channels/DetailBase.vue:141 -#: front/src/views/channels/DetailBase.vue:136 src/views/channels/DetailBase.vue:4 -#: front/src/views/library/DetailBase.vue:20 +#: src/components/library/AlbumDropdown.vue:59 +#: src/components/library/ArtistBase.vue:98 +#: src/components/library/ArtistBase.vue:93 +#: src/components/library/TagDetail.vue:11 +#: src/components/library/TrackBase.vue:103 +#: src/components/library/TrackBase.vue:98 +#: src/components/manage/moderation/ReportCard.vue:156 +#: src/views/auth/ProfileBase.vue:22 +#: src/views/channels/DetailBase.vue:143 +#: src/views/channels/DetailBase.vue:138 +#: src/views/channels/DetailBase.vue:4 +#: src/views/library/DetailBase.vue:20 msgctxt "Content/Moderation/Link" msgid "Open in moderation interface" msgstr "" -#: front/src/components/manage/ChannelsTable.vue:246 -#: front/src/components/manage/library/AlbumsTable.vue:232 +#: src/components/manage/ChannelsTable.vue:40 +#: src/components/manage/library/AlbumsTable.vue:41 msgctxt "Content/Moderation/Verb" msgid "Open in moderation interface" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:35 src/views/admin/ChannelDetail.vue:30 -#: front/src/views/admin/library/AlbumDetail.vue:36 -#: front/src/views/admin/library/AlbumDetail.vue:31 -#: front/src/views/admin/library/ArtistDetail.vue:35 -#: front/src/views/admin/library/ArtistDetail.vue:30 -#: front/src/views/admin/library/TagDetail.vue:20 -#: front/src/views/admin/library/TagDetail.vue:15 -#: front/src/views/admin/library/TrackDetail.vue:36 -#: front/src/views/admin/library/TrackDetail.vue:31 +#: src/views/admin/ChannelDetail.vue:35 +#: src/views/admin/ChannelDetail.vue:30 +#: src/views/admin/library/AlbumDetail.vue:36 +#: src/views/admin/library/AlbumDetail.vue:31 +#: src/views/admin/library/ArtistDetail.vue:35 +#: src/views/admin/library/ArtistDetail.vue:30 +#: src/views/admin/library/TagDetail.vue:20 +#: src/views/admin/library/TagDetail.vue:15 +#: src/views/admin/library/TrackDetail.vue:36 +#: src/views/admin/library/TrackDetail.vue:31 msgctxt "Content/Moderation/Link/Verb" msgid "Open local profile" msgstr "" -#: front/src/views/admin/library/AlbumDetail.vue:49 -#: front/src/views/admin/library/AlbumDetail.vue:44 -#: front/src/views/admin/library/ArtistDetail.vue:48 -#: front/src/views/admin/library/ArtistDetail.vue:43 -#: front/src/views/admin/library/TrackDetail.vue:49 -#: front/src/views/admin/library/TrackDetail.vue:44 +#: src/views/admin/library/AlbumDetail.vue:49 +#: src/views/admin/library/AlbumDetail.vue:44 +#: src/views/admin/library/ArtistDetail.vue:48 +#: src/views/admin/library/ArtistDetail.vue:43 +#: src/views/admin/library/TrackDetail.vue:49 +#: src/views/admin/library/TrackDetail.vue:44 msgctxt "Content/Moderation/Link/Verb" msgid "Open on MusicBrainz" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:23 -#: front/src/views/admin/moderation/AccountsDetail.vue:18 +#: src/views/admin/moderation/AccountsDetail.vue:23 +#: src/views/admin/moderation/AccountsDetail.vue:18 msgctxt "Content/Moderation/Link/Verb" msgid "Open profile" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:54 src/views/admin/ChannelDetail.vue:49 -#: front/src/views/admin/library/AlbumDetail.vue:59 -#: front/src/views/admin/library/AlbumDetail.vue:54 -#: front/src/views/admin/library/ArtistDetail.vue:58 -#: front/src/views/admin/library/ArtistDetail.vue:53 -#: front/src/views/admin/library/LibraryDetail.vue:40 -#: front/src/views/admin/library/LibraryDetail.vue:35 -#: front/src/views/admin/library/TrackDetail.vue:59 -#: front/src/views/admin/library/TrackDetail.vue:54 -#: front/src/views/admin/library/UploadDetail.vue:41 -#: front/src/views/admin/library/UploadDetail.vue:36 -#: front/src/views/admin/moderation/AccountsDetail.vue:44 -#: front/src/views/admin/moderation/AccountsDetail.vue:39 +#: src/views/admin/ChannelDetail.vue:54 +#: src/views/admin/ChannelDetail.vue:49 +#: src/views/admin/library/AlbumDetail.vue:59 +#: src/views/admin/library/AlbumDetail.vue:54 +#: src/views/admin/library/ArtistDetail.vue:58 +#: src/views/admin/library/ArtistDetail.vue:53 +#: src/views/admin/library/LibraryDetail.vue:40 +#: src/views/admin/library/LibraryDetail.vue:35 +#: src/views/admin/library/TrackDetail.vue:59 +#: src/views/admin/library/TrackDetail.vue:54 +#: src/views/admin/library/UploadDetail.vue:41 +#: src/views/admin/library/UploadDetail.vue:36 +#: src/views/admin/moderation/AccountsDetail.vue:44 +#: src/views/admin/moderation/AccountsDetail.vue:39 msgctxt "Content/Moderation/Link/Verb" msgid "Open remote profile" msgstr "" -#: front/src/views/admin/moderation/DomainsDetail.vue:16 -#: front/src/views/admin/moderation/DomainsDetail.vue:11 +#: src/views/admin/moderation/DomainsDetail.vue:16 +#: src/views/admin/moderation/DomainsDetail.vue:11 msgctxt "Content/Moderation/Link/Verb" msgid "Open website" msgstr "" -#: front/src/components/common/UserModal.vue:190 +#: src/components/common/UserModal.vue:22 msgctxt "Popup/Title/Noun" msgid "Options" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:50 +#: src/components/manage/moderation/InstancePolicyForm.vue:50 msgctxt "Content/Moderation/Card.Title" msgid "Or customize your rule" msgstr "" -#: front/src/components/favorites/List.vue:30 src/components/library/Radios.vue:56 -#: front/src/components/manage/library/EditsCardList.vue:45 -#: front/src/components/manage/users/UsersTable.vue:17 -#: front/src/views/admin/moderation/ReportsList.vue:47 -#: front/src/views/admin/moderation/RequestsList.vue:51 -#: front/src/views/playlists/List.vue:36 +#: src/components/favorites/List.vue:30 +#: src/components/library/Radios.vue:56 +#: src/components/manage/library/EditsCardList.vue:45 +#: src/components/manage/users/UsersTable.vue:17 +#: src/views/admin/moderation/ReportsList.vue:47 +#: src/views/admin/moderation/RequestsList.vue:51 +#: src/views/playlists/List.vue:36 msgctxt "Content/Search/Dropdown.Label/Noun" msgid "Order" msgstr "" -#: front/src/components/favorites/List.vue:22 src/components/library/Albums.vue:26 -#: front/src/components/library/Artists.vue:26 src/components/library/Podcasts.vue:26 -#: front/src/components/library/Radios.vue:48 -#: front/src/components/manage/ChannelsTable.vue:30 -#: front/src/components/manage/library/AlbumsTable.vue:11 -#: front/src/components/manage/library/ArtistsTable.vue:30 -#: front/src/components/manage/library/EditsCardList.vue:37 -#: front/src/components/manage/library/LibrariesTable.vue:30 -#: front/src/components/manage/library/TagsTable.vue:11 -#: front/src/components/manage/library/TracksTable.vue:11 -#: front/src/components/manage/library/UploadsTable.vue:60 -#: front/src/components/manage/moderation/AccountsTable.vue:11 -#: front/src/components/manage/moderation/DomainsTable.vue:29 -#: front/src/components/manage/users/InvitationsTable.vue:9 -#: front/src/components/manage/users/UsersTable.vue:9 -#: front/src/views/admin/moderation/ReportsList.vue:39 -#: front/src/views/admin/moderation/RequestsList.vue:43 -#: front/src/views/content/libraries/FilesTable.vue:51 -#: front/src/views/playlists/List.vue:28 +#: src/components/favorites/List.vue:22 +#: src/components/library/Albums.vue:26 +#: src/components/library/Artists.vue:26 +#: src/components/library/Podcasts.vue:26 +#: src/components/library/Radios.vue:48 +#: src/components/manage/ChannelsTable.vue:30 +#: src/components/manage/library/AlbumsTable.vue:11 +#: src/components/manage/library/ArtistsTable.vue:30 +#: src/components/manage/library/EditsCardList.vue:37 +#: src/components/manage/library/LibrariesTable.vue:30 +#: src/components/manage/library/TagsTable.vue:11 +#: src/components/manage/library/TracksTable.vue:11 +#: src/components/manage/library/UploadsTable.vue:60 +#: src/components/manage/moderation/AccountsTable.vue:11 +#: src/components/manage/moderation/DomainsTable.vue:29 +#: src/components/manage/users/InvitationsTable.vue:9 +#: src/components/manage/users/UsersTable.vue:9 +#: src/views/admin/moderation/ReportsList.vue:39 +#: src/views/admin/moderation/RequestsList.vue:43 +#: src/views/content/libraries/FilesTable.vue:51 +#: src/views/playlists/List.vue:28 msgctxt "Content/Search/Dropdown.Label/Noun" msgid "Ordering" msgstr "" -#: front/src/components/library/Albums.vue:34 src/components/library/Artists.vue:34 -#: front/src/components/library/Podcasts.vue:34 -#: front/src/components/manage/ChannelsTable.vue:38 -#: front/src/components/manage/library/AlbumsTable.vue:19 -#: front/src/components/manage/library/ArtistsTable.vue:38 -#: front/src/components/manage/library/LibrariesTable.vue:38 -#: front/src/components/manage/library/TagsTable.vue:19 -#: front/src/components/manage/library/TracksTable.vue:19 -#: front/src/components/manage/library/UploadsTable.vue:68 -#: front/src/components/manage/moderation/AccountsTable.vue:19 -#: front/src/components/manage/moderation/DomainsTable.vue:37 -#: front/src/views/content/libraries/FilesTable.vue:61 +#: src/components/library/Albums.vue:34 +#: src/components/library/Artists.vue:34 +#: src/components/library/Podcasts.vue:34 +#: src/components/manage/ChannelsTable.vue:38 +#: src/components/manage/library/AlbumsTable.vue:19 +#: src/components/manage/library/ArtistsTable.vue:38 +#: src/components/manage/library/LibrariesTable.vue:38 +#: src/components/manage/library/TagsTable.vue:19 +#: src/components/manage/library/TracksTable.vue:19 +#: src/components/manage/library/UploadsTable.vue:68 +#: src/components/manage/moderation/AccountsTable.vue:19 +#: src/components/manage/moderation/DomainsTable.vue:37 +#: src/views/content/libraries/FilesTable.vue:61 msgctxt "Content/Search/Dropdown.Label/Noun" msgid "Ordering direction" msgstr "" -#: front/src/components/mixins/Translations.vue:63 -#: front/src/components/mixins/Translations.vue:64 +#: src/components/mixins/Translations.vue:63 msgctxt "*/*/*" msgid "Other" msgstr "" -#: front/src/components/mixins/Translations.vue:52 -#: front/src/components/mixins/Translations.vue:53 +#: src/components/mixins/Translations.vue:52 msgctxt "Content/Moderation/Dropdown" msgid "Other" msgstr "" -#: front/src/views/channels/DetailBase.vue:238 src/views/channels/DetailBase.vue:233 +#: src/views/channels/DetailBase.vue:240 +#: src/views/channels/DetailBase.vue:235 msgctxt "Content/Channels/Link" msgid "Overview" msgstr "" -#: front/src/views/auth/ProfileBase.vue:58 +#: src/views/auth/ProfileBase.vue:58 msgctxt "Content/Profile/Link" msgid "Overview" msgstr "" -#: front/src/views/library/DetailBase.vue:34 +#: src/views/library/DetailBase.vue:34 msgctxt "*/*/*" msgid "Owned by %{ username }" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:175 -#: front/src/components/manage/users/InvitationsTable.vue:45 -#: front/src/components/manage/users/InvitationsTable.vue:2 +#: src/components/manage/moderation/ReportCard.vue:175 +#: src/components/manage/users/InvitationsTable.vue:45 +#: src/components/manage/users/InvitationsTable.vue:2 msgctxt "*/*/*" msgid "Owner" msgstr "" -#: front/src/components/audio/ChannelForm.vue:122 -#: front/src/components/audio/ChannelForm.vue:108 -#: front/src/components/audio/ChannelForm.vue:88 +#: src/components/audio/ChannelForm.vue:122 +#: src/components/audio/ChannelForm.vue:108 +#: src/components/audio/ChannelForm.vue:88 msgctxt "*/*/*" msgid "Owner e-mail address" msgstr "" -#: front/src/components/audio/ChannelForm.vue:128 -#: front/src/components/audio/ChannelForm.vue:114 -#: front/src/components/audio/ChannelForm.vue:94 +#: src/components/audio/ChannelForm.vue:128 +#: src/components/audio/ChannelForm.vue:114 +#: src/components/audio/ChannelForm.vue:94 msgctxt "*/*/*" msgid "Owner name" msgstr "" -#: front/src/components/PageNotFound.vue:47 +#: src/components/PageNotFound.vue:10 msgctxt "Head/*/Title" msgid "Page Not Found" msgstr "" -#: front/src/components/PageNotFound.vue:7 +#: src/components/PageNotFound.vue:7 msgctxt "Content/*/Title" msgid "Page not found!" msgstr "" -#: front/src/components/Pagination.vue:52 +#: src/components/Pagination.vue:13 msgctxt "Content/*/Hidden text/Noun" msgid "Pagination" msgstr "" -#: front/src/components/auth/LoginForm.vue:39 src/components/auth/LoginForm.vue:15 -#: front/src/components/auth/Settings.vue:445 src/components/auth/Settings.vue:489 -#: front/src/components/auth/SignupForm.vue:57 +#: src/components/auth/LoginForm.vue:39 +#: src/components/auth/LoginForm.vue:15 +#: src/components/auth/Settings.vue:445 +#: src/components/auth/Settings.vue:489 +#: src/components/auth/SignupForm.vue:57 msgctxt "*/*/*" msgid "Password" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:193 +#: src/components/auth/SubsonicTokenForm.vue:47 msgctxt "Content/Settings/Message" msgid "Password updated" msgstr "" -#: front/src/views/auth/PasswordResetConfirm.vue:44 +#: src/views/auth/PasswordResetConfirm.vue:44 msgctxt "Content/Signup/Card.Title" msgid "Password updated successfully" msgstr "" -#: front/src/components/audio/Player.vue:408 +#: src/components/audio/Player.vue:68 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Pause" msgstr "" -#: front/src/App.vue:99 +#: src/AppOld.vue:85 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Pause track" msgstr "" -#: front/src/components/ShortcutsModal.vue:98 +#: src/components/ShortcutsModal.vue:38 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Pause/play the current track" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyCard.vue:14 -#: front/src/components/manage/moderation/InstancePolicyCard.vue:2 +#: src/components/manage/moderation/InstancePolicyCard.vue:14 +#: src/components/manage/moderation/InstancePolicyCard.vue:2 msgctxt "Content/Moderation/Card.List item" msgid "Paused" msgstr "" -#: front/src/components/channels/UploadForm.vue:97 -#: front/src/components/channels/UploadForm.vue:62 -#: front/src/components/channels/UploadForm.vue:51 -#: front/src/components/channels/UploadForm.vue:7 +#: src/components/channels/UploadForm.vue:97 +#: src/components/channels/UploadForm.vue:62 +#: src/components/channels/UploadForm.vue:51 +#: src/components/channels/UploadForm.vue:7 msgctxt "Channels/*/*" msgid "Pending" msgstr "" -#: front/src/components/library/FileUpload.vue:139 -#: front/src/components/manage/library/UploadsTable.vue:38 -#: front/src/components/manage/moderation/UserRequestCard.vue:52 -#: front/src/components/manage/moderation/UserRequestCard.vue:2 -#: front/src/components/mixins/Translations.vue:32 -#: front/src/views/admin/moderation/RequestsList.vue:26 -#: front/src/views/content/libraries/FilesTable.vue:28 -#: front/src/components/mixins/Translations.vue:33 +#: src/components/library/FileUpload.vue:139 +#: src/components/manage/library/UploadsTable.vue:38 +#: src/components/manage/moderation/UserRequestCard.vue:52 +#: src/components/manage/moderation/UserRequestCard.vue:2 +#: src/components/mixins/Translations.vue:32 +#: src/views/admin/moderation/RequestsList.vue:26 +#: src/views/content/libraries/FilesTable.vue:28 msgctxt "Content/Library/*/Short" msgid "Pending" msgstr "" -#: front/src/views/library/Edit.vue:54 +#: src/views/library/Edit.vue:54 msgctxt "Content/Library/Table/Short" msgid "Pending approval" msgstr "" -#: front/src/views/content/libraries/Quota.vue:34 +#: src/views/content/libraries/Quota.vue:34 msgctxt "Content/Library/Label" msgid "Pending files" msgstr "" -#: front/src/components/Sidebar.vue:525 +#: src/components/Sidebar.vue:59 msgctxt "Sidebar/Notifications/Hidden text" msgid "Pending follow requests" msgstr "" -#: front/src/components/library/EditCard.vue:31 -#: front/src/components/manage/library/EditsCardList.vue:20 +#: src/components/library/EditCard.vue:31 +#: src/components/manage/library/EditsCardList.vue:20 msgctxt "Content/Admin/*/Noun" msgid "Pending review" msgstr "" -#: front/src/components/Sidebar.vue:526 +#: src/components/Sidebar.vue:60 msgctxt "Sidebar/Moderation/Hidden text" msgid "Pending review edits" msgstr "" -#: front/src/components/common/ActionTable.vue:301 +#: src/components/common/ActionTable.vue:67 msgctxt "Content/*/Button.Label" msgid "Perform actions" msgstr "" -#: front/src/components/auth/Settings.vue:247 -#: front/src/components/manage/users/UsersTable.vue:65 -#: front/src/components/manage/users/UsersTable.vue:27 -#: front/src/views/admin/moderation/AccountsDetail.vue:177 -#: front/src/views/admin/moderation/AccountsDetail.vue:172 +#: src/components/auth/Settings.vue:247 +#: src/components/manage/users/UsersTable.vue:65 +#: src/components/manage/users/UsersTable.vue:27 +#: src/views/admin/moderation/AccountsDetail.vue:177 +#: src/views/admin/moderation/AccountsDetail.vue:172 msgctxt "Content/*/*/Noun" msgid "Permissions" msgstr "" -#: front/src/components/audio/PlayButton.vue:5 src/components/audio/PlayButton.vue:1 -#: front/src/components/library/TrackBase.vue:17 -#: front/src/components/library/TrackBase.vue:12 +#: src/components/audio/PlayButton.vue:5 +#: src/components/audio/PlayButton.vue:1 +#: src/components/library/TrackBase.vue:17 +#: src/components/library/TrackBase.vue:12 msgctxt "*/Queue/Button.Label/Short, Verb" msgid "Play" msgstr "" -#: front/src/views/channels/DetailBase.vue:176 src/views/channels/DetailBase.vue:171 +#: src/views/channels/DetailBase.vue:178 +#: src/views/channels/DetailBase.vue:173 msgctxt "Content/Channels/Button.Label/Verb" msgid "Play" msgstr "" -#: front/src/components/audio/Player.vue:407 +#: src/components/audio/Player.vue:67 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Play" msgstr "" -#: front/src/components/audio/PlayButton.vue:166 +#: src/components/audio/PlayButton.vue:40 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Play album" msgstr "" -#: front/src/views/playlists/Detail.vue:23 +#: src/views/playlists/Detail.vue:23 msgctxt "Content/Queue/Button.Label/Short, Verb" msgid "Play all" msgstr "" -#: front/src/components/library/ArtistBase.vue:27 -#: front/src/components/library/ArtistBase.vue:22 +#: src/components/library/ArtistBase.vue:27 +#: src/components/library/ArtistBase.vue:22 msgctxt "Content/Artist/Button.Label/Verb" msgid "Play all albums" msgstr "" -#: front/src/components/audio/PlayButton.vue:168 +#: src/components/audio/PlayButton.vue:42 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Play artist" msgstr "" -#: front/src/components/audio/PlayButton.vue:178 -#: front/src/components/audio/podcast/Modal.vue:299 -#: front/src/components/audio/track/Modal.vue:299 +#: src/components/audio/PlayButton.vue:52 +#: src/components/audio/podcast/Modal.vue:71 +#: src/components/audio/track/Modal.vue:71 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Play next" msgstr "" -#: front/src/components/ShortcutsModal.vue:122 +#: src/components/ShortcutsModal.vue:62 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Play next track" msgstr "" -#: front/src/components/audio/PlayButton.vue:176 -#: front/src/components/audio/podcast/Modal.vue:294 -#: front/src/components/audio/track/Modal.vue:294 +#: src/components/audio/PlayButton.vue:50 +#: src/components/audio/podcast/Modal.vue:66 +#: src/components/audio/track/Modal.vue:66 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Play now" msgstr "" -#: front/src/components/audio/PlayButton.vue:170 +#: src/components/audio/PlayButton.vue:44 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Play playlist" msgstr "" -#: front/src/components/ShortcutsModal.vue:118 +#: src/components/ShortcutsModal.vue:58 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Play previous track" msgstr "" -#: front/src/components/audio/PlayButton.vue:20 src/components/radios/Button.vue:9 -#: front/src/components/radios/Button.vue:1 +#: src/components/audio/PlayButton.vue:20 +#: src/components/radios/Button.vue:9 +#: src/components/radios/Button.vue:1 msgctxt "*/Queue/Button.Label/Short, Verb" msgid "Play radio" msgstr "" -#: front/src/components/audio/podcast/Modal.vue:290 -#: front/src/components/audio/track/Modal.vue:290 -#: front/src/components/audio/podcast/Modal.vue:292 -#: front/src/components/audio/track/Modal.vue:292 +#: src/components/audio/podcast/Modal.vue:62 +#: src/components/audio/track/Modal.vue:62 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Play radio" msgstr "" -#: front/src/components/audio/PlayButton.vue:179 +#: src/components/audio/PlayButton.vue:53 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Play similar songs" msgstr "" -#: front/src/components/Sidebar.vue:524 +#: src/components/Sidebar.vue:58 msgctxt "Sidebar/Player/Hidden text" msgid "Play this track" msgstr "" -#: front/src/components/audio/PlayButton.vue:164 +#: src/components/audio/PlayButton.vue:38 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Play track" msgstr "" -#: front/src/App.vue:98 +#: src/AppOld.vue:84 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Play track" msgstr "" -#: front/src/components/audio/PlayButton.vue:172 +#: src/components/audio/PlayButton.vue:46 msgctxt "*/Queue/Dropdown/Button/Title" msgid "Play tracks" msgstr "" -#: front/src/components/mixins/Report.vue:83 src/views/playlists/Detail.vue:208 -#: front/src/components/mixins/Report.vue:84 +#: src/components/mixins/Report.vue:83 +#: src/views/playlists/Detail.vue:33 msgctxt "*/*/*" msgid "Playlist" msgstr "" -#: front/src/views/playlists/Detail.vue:12 +#: src/views/playlists/Detail.vue:12 msgctxt "Content/Playlist/Header.Subtitle" msgid "Playlist containing %{ count } track, by %{ username }" msgid_plural "Playlist containing %{ count } tracks, by %{ username }" msgstr[0] "" msgstr[1] "" -#: front/src/components/playlists/Form.vue:15 src/components/playlists/Form.vue:1 +#: src/components/playlists/Form.vue:15 +#: src/components/playlists/Form.vue:1 msgctxt "Content/Playlist/Message" msgid "Playlist created" msgstr "" -#: front/src/components/playlists/Editor.vue:4 +#: src/components/playlists/Editor.vue:4 msgctxt "Content/Playlist/Title" msgid "Playlist editor" msgstr "" -#: front/src/components/playlists/Form.vue:35 +#: src/components/playlists/Form.vue:35 msgctxt "Content/Playlist/Input.Label" msgid "Playlist name" msgstr "" -#: front/src/components/playlists/Form.vue:10 src/components/playlists/Form.vue:1 +#: src/components/playlists/Form.vue:10 +#: src/components/playlists/Form.vue:1 msgctxt "Content/Playlist/Message" msgid "Playlist updated" msgstr "" -#: front/src/components/playlists/Form.vue:39 +#: src/components/playlists/Form.vue:39 msgctxt "Content/Playlist/Dropdown.Label" msgid "Playlist visibility" msgstr "" -#: front/src/components/Sidebar.vue:176 src/components/Sidebar.vue:211 -#: front/src/components/library/Home.vue:25 src/components/library/Home.vue:1 -#: front/src/components/mixins/Translations.vue:115 src/views/Search.vue:231 -#: front/src/views/admin/ChannelDetail.vue:231 src/views/admin/ChannelDetail.vue:226 -#: front/src/views/admin/Settings.vue:74 src/views/admin/library/AlbumDetail.vue:212 -#: front/src/views/admin/library/AlbumDetail.vue:207 -#: front/src/views/admin/library/ArtistDetail.vue:211 -#: front/src/views/admin/library/ArtistDetail.vue:206 -#: front/src/views/admin/library/TrackDetail.vue:277 -#: front/src/views/admin/library/TrackDetail.vue:272 -#: front/src/views/auth/ProfileActivity.vue:25 src/views/playlists/List.vue:176 -#: front/src/components/mixins/Translations.vue:116 +#: src/components/Sidebar.vue:176 +#: src/components/Sidebar.vue:211 +#: src/components/library/Home.vue:25 +#: src/components/library/Home.vue:1 +#: src/components/mixins/Translations.vue:115 +#: src/views/Search.vue:92 +#: src/views/admin/ChannelDetail.vue:231 +#: src/views/admin/ChannelDetail.vue:226 +#: src/views/admin/Settings.vue:30 +#: src/views/admin/library/AlbumDetail.vue:212 +#: src/views/admin/library/AlbumDetail.vue:207 +#: src/views/admin/library/ArtistDetail.vue:211 +#: src/views/admin/library/ArtistDetail.vue:206 +#: src/views/admin/library/TrackDetail.vue:277 +#: src/views/admin/library/TrackDetail.vue:272 +#: src/views/auth/ProfileActivity.vue:25 +#: src/views/playlists/List.vue:37 msgctxt "*/*/*" msgid "Playlists" msgstr "" -#: front/src/components/audio/EmbedWizard.vue:9 +#: src/components/audio/EmbedWizard.vue:9 msgctxt "Content/Embed/Message" msgid "Please contact your admins and ask them to update the corresponding setting." msgstr "" -#: front/src/components/auth/LoginForm.vue:15 +#: src/components/auth/LoginForm.vue:15 msgctxt "Content/Login/Error message.List item/Call to action" msgid "Please double-check that your username and password combination is correct and make sure you verified your e-mail address." msgstr "" -#: front/src/components/auth/Settings.vue:100 +#: src/components/auth/Settings.vue:100 msgctxt "Content/Settings/Error message.List item/Call to action" msgid "Please double-check your password is correct" msgstr "" -#: front/src/components/auth/Settings.vue:391 +#: src/components/auth/Settings.vue:391 msgctxt "Content/Settings/Title/Noun" msgid "Plugins" msgstr "" -#: front/src/components/common/AttachmentInput.vue:33 +#: src/components/common/AttachmentInput.vue:33 msgctxt "Content/*/Paragraph" msgid "PNG or JPG. Dimensions should be between 1400x1400px and 3000x3000px. Maximum file size allowed is 5MB." msgstr "" -#: front/src/components/mixins/Translations.vue:61 -#: front/src/components/mixins/Translations.vue:62 +#: src/components/mixins/Translations.vue:61 msgctxt "Content/*/Dropdown" msgid "Podcast" msgstr "" -#: front/src/views/auth/ProfileOverview.vue:44 src/views/channels/DetailBase.vue:206 -#: front/src/views/channels/DetailBase.vue:201 +#: src/views/auth/ProfileOverview.vue:44 +#: src/views/channels/DetailBase.vue:208 +#: src/views/channels/DetailBase.vue:203 msgctxt "Content/Channel/*" msgid "Podcast channel" msgstr "" -#: front/src/components/library/Podcasts.vue:12 +#: src/components/library/Podcasts.vue:12 msgctxt "Content/Search/Input.Label/Noun" msgid "Podcast title" msgstr "" -#: front/src/components/Sidebar.vue:161 src/components/audio/ChannelForm.vue:304 -#: front/src/components/audio/SearchBar.vue:98 src/components/audio/SearchBar.vue:192 -#: front/src/views/Search.vue:244 +#: src/components/Sidebar.vue:161 +#: src/components/audio/ChannelForm.vue:55 +#: src/components/audio/SearchBar.vue:75 +#: src/components/audio/SearchBar.vue:169 +#: src/views/Search.vue:105 msgctxt "*/*/*" msgid "Podcasts" msgstr "" -#: front/src/components/library/Podcasts.vue:243 +#: src/components/library/Podcasts.vue:46 msgctxt "*/*/*/Noun" msgid "Podcasts" msgstr "" -#: front/src/components/channels/UploadMetadataForm.vue:23 -#: front/src/views/admin/library/TrackDetail.vue:164 -#: front/src/views/admin/library/TrackDetail.vue:159 src/edits.js:101 +#: src/components/channels/UploadMetadataForm.vue:23 +#: src/views/admin/library/TrackDetail.vue:164 +#: src/views/admin/library/TrackDetail.vue:159 msgctxt "*/*/*/Short, Noun" msgid "Position" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:212 +#: src/components/manage/moderation/InstancePolicyForm.vue:42 msgctxt "Content/Moderation/Help text" msgid "Prevent account or domain from triggering notifications, except from followers." msgstr "" -#: front/src/components/common/ContentForm.vue:10 +#: src/components/common/ContentForm.vue:10 msgctxt "*/Form/Menu.item" msgid "Preview" msgstr "" -#: front/src/components/audio/EmbedWizard.vue:60 +#: src/components/audio/EmbedWizard.vue:60 msgctxt "Popup/Embed/Title/Noun" msgid "Preview" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:9 +#: src/components/admin/SignupFormBuilder.vue:9 msgctxt "*/Form/Menu.item" msgid "Preview form" msgstr "" -#: front/src/components/Pagination.vue:53 +#: src/components/Pagination.vue:14 msgctxt "Content/*/Link" msgid "Previous Page" msgstr "" -#: front/src/components/channels/UploadModal.vue:39 -#: front/src/views/auth/ProfileOverview.vue:62 +#: src/components/channels/UploadModal.vue:39 +#: src/views/auth/ProfileOverview.vue:62 msgctxt "*/*/Button.Label/Verb" msgid "Previous step" msgstr "" -#: front/src/components/audio/Player.vue:406 +#: src/components/audio/Player.vue:66 msgctxt "Sidebar/Player/Icon.Tooltip" msgid "Previous track" msgstr "" -#: front/src/views/library/DetailBase.vue:260 +#: src/views/library/DetailBase.vue:32 msgctxt "Content/Library/Card.Help text" msgid "Private" msgstr "" -#: front/src/components/mixins/Translations.vue:15 -#: front/src/components/mixins/Translations.vue:16 +#: src/components/mixins/Translations.vue:15 msgctxt "Content/Settings/Dropdown/Short" msgid "Private" msgstr "" -#: front/src/views/content/remote/Card.vue:53 src/views/content/remote/Card.vue:2 +#: src/views/content/remote/Card.vue:53 +#: src/views/content/remote/Card.vue:2 msgctxt "Content/Library/Card.List item" msgid "Problem during scanning" msgstr "" -#: front/src/views/auth/EmailConfirm.vue:43 -#: front/src/views/auth/PasswordResetConfirm.vue:54 +#: src/views/auth/EmailConfirm.vue:43 +#: src/views/auth/PasswordResetConfirm.vue:54 msgctxt "Content/Signup/Link/Verb" msgid "Proceed to login" msgstr "" -#: front/src/views/channels/DetailOverview.vue:11 -#: front/src/views/channels/DetailOverview.vue:47 -#: front/src/views/channels/DetailOverview.vue:8 -#: front/src/views/channels/DetailOverview.vue:13 +#: src/views/channels/DetailOverview.vue:11 +#: src/views/channels/DetailOverview.vue:47 +#: src/views/channels/DetailOverview.vue:8 +#: src/views/channels/DetailOverview.vue:13 msgctxt "Content/Channel/Paragraph" msgid "Processed uploads:" msgstr "" -#: front/src/components/library/FileUpload.vue:16 +#: src/components/library/FileUpload.vue:16 msgctxt "Content/Library/Tab.Title/Short" msgid "Processing" msgstr "" -#: front/src/components/channels/UploadModal.vue:12 +#: src/components/channels/UploadModal.vue:12 msgctxt "Popup/Channels/Title" msgid "Processing uploads" msgstr "" -#: front/src/components/common/UserMenu.vue:162 -#: front/src/components/common/UserModal.vue:191 +#: src/components/common/UserMenu.vue:15 +#: src/components/common/UserModal.vue:23 msgctxt "*/*/*/Noun" msgid "Profile" msgstr "" -#: front/src/components/mixins/Translations.vue:95 -#: front/src/components/mixins/Translations.vue:96 +#: src/components/mixins/Translations.vue:95 msgctxt "Content/OAuth Scopes/Label" msgid "Profile" msgstr "" -#: front/src/views/library/DetailBase.vue:262 +#: src/views/library/DetailBase.vue:34 msgctxt "Content/Library/Card.Help text" msgid "Public" msgstr "" -#: front/src/components/auth/SignupForm.vue:23 +#: src/components/auth/SignupForm.vue:23 msgctxt "Content/Signup/Form/Paragraph" msgid "Public registrations are not possible on this instance. You will need an invitation code to sign up." msgstr "" -#: front/src/components/channels/UploadModal.vue:55 +#: src/components/channels/UploadModal.vue:55 msgctxt "*/Channels/Button.Label" msgid "Publish" msgstr "" -#: front/src/components/channels/UploadModal.vue:3 +#: src/components/channels/UploadModal.vue:3 msgctxt "Popup/Channels/Title/Verb" msgid "Publish audio" msgstr "" -#: front/src/components/audio/ChannelForm.vue:310 +#: src/components/audio/ChannelForm.vue:61 msgctxt "Content/Channels/Help" msgid "Publish music you make as a nice discography of albums and singles." msgstr "" -#: front/src/views/content/Home.vue:10 +#: src/views/content/Home.vue:10 msgctxt "Content/Library/Title/Verb" msgid "Publish your work in a channel" msgstr "" -#: front/src/components/manage/moderation/AccountsTable.vue:236 -#: front/src/components/manage/moderation/DomainsTable.vue:245 -#: front/src/views/content/libraries/Quota.vue:47 -#: front/src/views/content/libraries/Quota.vue:61 -#: front/src/views/content/libraries/Quota.vue:86 -#: front/src/views/content/libraries/Quota.vue:100 -#: front/src/views/content/libraries/Quota.vue:125 -#: front/src/views/content/libraries/Quota.vue:139 +#: src/components/manage/moderation/AccountsTable.vue:59 +#: src/components/manage/moderation/DomainsTable.vue:57 +#: src/views/content/libraries/Quota.vue:47 +#: src/views/content/libraries/Quota.vue:61 +#: src/views/content/libraries/Quota.vue:86 +#: src/views/content/libraries/Quota.vue:100 +#: src/views/content/libraries/Quota.vue:125 +#: src/views/content/libraries/Quota.vue:139 msgctxt "*/*/*/Verb" msgid "Purge" msgstr "" -#: front/src/views/content/libraries/Quota.vue:129 +#: src/views/content/libraries/Quota.vue:129 msgctxt "Popup/Library/Title" msgid "Purge errored files?" msgstr "" -#: front/src/views/content/libraries/Quota.vue:51 +#: src/views/content/libraries/Quota.vue:51 msgctxt "Popup/Library/Title" msgid "Purge pending files?" msgstr "" -#: front/src/views/content/libraries/Quota.vue:90 +#: src/views/content/libraries/Quota.vue:90 msgctxt "Popup/Library/Title" msgid "Purge skipped files?" msgstr "" -#: front/src/components/Queue.vue:398 +#: src/components/Queue.vue:57 msgctxt "*/*/*" msgid "Queue" msgstr "" -#: front/src/components/Queue.vue:504 src/components/audio/Player.vue:558 +#: src/components/Queue.vue:163 +#: src/components/audio/Player.vue:218 msgctxt "Content/Queue/Message" msgid "Queue shuffled!" msgstr "" -#: front/src/views/radios/Detail.vue:139 +#: src/views/radios/Detail.vue:27 msgctxt "Head/Radio/Title" msgid "Radio" msgstr "" -#: front/src/components/library/radios/Builder.vue:218 +#: src/components/library/radios/Builder.vue:32 msgctxt "Head/Radio/Title" msgid "Radio Builder" msgstr "" -#: front/src/components/library/radios/Builder.vue:23 -#: front/src/components/library/radios/Builder.vue:1 +#: src/components/library/radios/Builder.vue:23 +#: src/components/library/radios/Builder.vue:1 msgctxt "Content/Radio/Message" msgid "Radio created" msgstr "" -#: front/src/components/library/radios/Builder.vue:31 +#: src/components/library/radios/Builder.vue:31 msgctxt "Content/Radio/Input.Label/Noun" msgid "Radio name" msgstr "" -#: front/src/components/library/radios/Builder.vue:18 -#: front/src/components/library/radios/Builder.vue:1 +#: src/components/library/radios/Builder.vue:18 +#: src/components/library/radios/Builder.vue:1 msgctxt "Content/Radio/Message" msgid "Radio updated" msgstr "" -#: front/src/components/Sidebar.vue:181 src/components/Sidebar.vue:216 -#: front/src/components/library/Radios.vue:212 -#: front/src/components/mixins/Translations.vue:119 src/views/Search.vue:235 -#: front/src/components/mixins/Translations.vue:120 +#: src/components/Sidebar.vue:181 +#: src/components/Sidebar.vue:216 +#: src/components/library/Radios.vue:36 +#: src/components/mixins/Translations.vue:119 +#: src/views/Search.vue:96 msgctxt "*/*/*" msgid "Radios" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:174 +#: src/components/auth/ApplicationForm.vue:53 msgctxt "Content/OAuth Scopes/Label/Verb" msgid "Read" msgstr "" -#: front/src/components/library/ImportStatusModal.vue:67 -#: front/src/components/library/ImportStatusModal.vue:38 +#: src/components/library/ImportStatusModal.vue:67 +#: src/components/library/ImportStatusModal.vue:38 msgctxt "Popup/Import/Table.Label/Value" msgid "Read our documentation for this error" msgstr "" -#: front/src/components/auth/Authorize.vue:42 +#: src/components/auth/Authorize.vue:42 msgctxt "Content/Auth/Label/Noun" msgid "Read-only" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:175 +#: src/components/auth/ApplicationForm.vue:54 msgctxt "Content/OAuth Scopes/Help Text" msgid "Read-only access to user data" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyCard.vue:56 -#: front/src/components/manage/moderation/InstancePolicyForm.vue:35 +#: src/components/manage/moderation/InstancePolicyCard.vue:56 +#: src/components/manage/moderation/InstancePolicyForm.vue:35 msgctxt "Content/Moderation/*/Noun" msgid "Reason" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:278 -#: front/src/views/admin/moderation/AccountsDetail.vue:273 -#: front/src/views/admin/moderation/DomainsDetail.vue:242 -#: front/src/views/admin/moderation/DomainsDetail.vue:237 +#: src/views/admin/moderation/AccountsDetail.vue:278 +#: src/views/admin/moderation/AccountsDetail.vue:273 +#: src/views/admin/moderation/DomainsDetail.vue:242 +#: src/views/admin/moderation/DomainsDetail.vue:237 msgctxt "Content/Moderation/Table.Label/Noun" msgid "Received library follows" msgstr "" -#: front/src/components/manage/moderation/DomainsTable.vue:70 -#: front/src/components/manage/moderation/DomainsTable.vue:12 -#: front/src/components/mixins/Translations.vue:89 -#: front/src/components/mixins/Translations.vue:90 +#: src/components/manage/moderation/DomainsTable.vue:70 +#: src/components/manage/moderation/DomainsTable.vue:12 +#: src/components/mixins/Translations.vue:89 msgctxt "Content/Moderation/*/Noun" msgid "Received messages" msgstr "" -#: front/src/components/library/EditForm.vue:30 src/components/library/EditForm.vue:1 +#: src/components/library/EditForm.vue:30 +#: src/components/library/EditForm.vue:1 msgctxt "Content/Library/Paragraph" msgid "Recent edits" msgstr "" -#: front/src/components/library/EditForm.vue:20 src/components/library/EditForm.vue:1 +#: src/components/library/EditForm.vue:20 +#: src/components/library/EditForm.vue:1 msgctxt "Content/Library/Paragraph" msgid "Recent edits awaiting review" msgstr "" -#: front/src/components/library/Home.vue:37 src/components/library/Home.vue:1 +#: src/components/library/Home.vue:37 +#: src/components/library/Home.vue:1 msgctxt "Content/Home/Title" msgid "Recently added" msgstr "" -#: front/src/components/Home.vue:207 src/components/Home.vue:1 +#: src/components/Home.vue:207 +#: src/components/Home.vue:1 msgctxt "Content/Home/Title" msgid "Recently added albums" msgstr "" -#: front/src/components/library/Home.vue:16 src/components/library/Home.vue:1 -#: front/src/views/auth/ProfileActivity.vue:15 +#: src/components/library/Home.vue:16 +#: src/components/library/Home.vue:1 +#: src/views/auth/ProfileActivity.vue:15 msgctxt "Content/Home/Title" msgid "Recently favorited" msgstr "" -#: front/src/components/library/Home.vue:7 src/components/library/Home.vue:1 -#: front/src/views/auth/ProfileActivity.vue:5 +#: src/components/library/Home.vue:7 +#: src/components/library/Home.vue:1 +#: src/views/auth/ProfileActivity.vue:5 msgctxt "Content/Home/Title" msgid "Recently listened" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:19 +#: src/components/auth/ApplicationForm.vue:19 msgctxt "Content/Applications/Input.Label/Noun" msgid "Redirect URI" msgstr "" -#: front/src/components/auth/Settings.vue:171 src/components/auth/Settings.vue:234 -#: front/src/components/common/EmptyState.vue:15 src/views/content/remote/Home.vue:32 -#: front/src/views/content/remote/Home.vue:7 +#: src/components/auth/Settings.vue:171 +#: src/components/auth/Settings.vue:234 +#: src/components/common/EmptyState.vue:15 +#: src/views/content/remote/Home.vue:32 +#: src/views/content/remote/Home.vue:7 msgctxt "Content/*/Button.Label/Short, Verb" msgid "Refresh" msgstr "" -#: front/src/components/federation/FetchButton.vue:39 -#: front/src/components/federation/FetchButton.vue:27 +#: src/components/federation/FetchButton.vue:39 +#: src/components/federation/FetchButton.vue:27 msgctxt "Popup/*/Message.Title" msgid "Refresh error" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:48 src/views/admin/ChannelDetail.vue:43 -#: front/src/views/admin/library/AlbumDetail.vue:53 -#: front/src/views/admin/library/AlbumDetail.vue:48 -#: front/src/views/admin/library/ArtistDetail.vue:52 -#: front/src/views/admin/library/ArtistDetail.vue:47 -#: front/src/views/admin/library/TrackDetail.vue:53 -#: front/src/views/admin/library/TrackDetail.vue:48 +#: src/views/admin/ChannelDetail.vue:48 +#: src/views/admin/ChannelDetail.vue:43 +#: src/views/admin/library/AlbumDetail.vue:53 +#: src/views/admin/library/AlbumDetail.vue:48 +#: src/views/admin/library/ArtistDetail.vue:52 +#: src/views/admin/library/ArtistDetail.vue:47 +#: src/views/admin/library/TrackDetail.vue:53 +#: src/views/admin/library/TrackDetail.vue:48 msgctxt "Content/Moderation/Button/Verb" msgid "Refresh from remote server" msgstr "" -#: front/src/views/admin/moderation/DomainsDetail.vue:183 -#: front/src/views/admin/moderation/DomainsDetail.vue:178 +#: src/views/admin/moderation/DomainsDetail.vue:183 +#: src/views/admin/moderation/DomainsDetail.vue:178 msgctxt "Content/Moderation/Button.Label/Verb" msgid "Refresh node info" msgstr "" -#: front/src/components/federation/FetchButton.vue:125 +#: src/components/federation/FetchButton.vue:125 msgctxt "Popup/*/Message.Title" msgid "Refresh pending" msgstr "" -#: front/src/components/federation/FetchButton.vue:27 -#: front/src/components/federation/FetchButton.vue:15 +#: src/components/federation/FetchButton.vue:27 +#: src/components/federation/FetchButton.vue:15 msgctxt "Popup/*/Message.Title" msgid "Refresh successful" msgstr "" -#: front/src/components/common/ActionTable.vue:299 +#: src/components/common/ActionTable.vue:65 msgctxt "Content/*/Button.Tooltip/Verb" msgid "Refresh table content" msgstr "" -#: front/src/components/federation/FetchButton.vue:15 -#: front/src/components/federation/FetchButton.vue:3 +#: src/components/federation/FetchButton.vue:15 +#: src/components/federation/FetchButton.vue:3 msgctxt "Popup/*/Message.Title" msgid "Refresh was skipped" msgstr "" -#: front/src/components/federation/FetchButton.vue:7 +#: src/components/federation/FetchButton.vue:7 msgctxt "Popup/*/Title" msgid "Refreshing object from remote server…" msgstr "" -#: front/src/components/manage/moderation/UserRequestCard.vue:158 +#: src/components/manage/moderation/UserRequestCard.vue:158 msgctxt "Content/*/Button.Label" msgid "Refuse" msgstr "" -#: front/src/components/manage/moderation/UserRequestCard.vue:58 -#: front/src/components/manage/moderation/UserRequestCard.vue:2 -#: front/src/views/admin/moderation/RequestsList.vue:36 +#: src/components/manage/moderation/UserRequestCard.vue:58 +#: src/components/manage/moderation/UserRequestCard.vue:2 +#: src/views/admin/moderation/RequestsList.vue:36 msgctxt "Content/*/*/Short" msgid "Refused" msgstr "" -#: front/src/components/auth/ApplicationEdit.vue:37 -#: front/src/components/auth/ApplicationEdit.vue:30 +#: src/components/auth/ApplicationEdit.vue:37 +#: src/components/auth/ApplicationEdit.vue:30 msgctxt "Content/Applications/Label" msgid "Regenerate token" msgstr "" -#: front/src/components/auth/Settings.vue:310 +#: src/components/auth/Settings.vue:310 msgctxt "Content/Settings/Button.Label" msgid "Register a new application" msgstr "" -#: front/src/components/auth/Settings.vue:380 +#: src/components/auth/Settings.vue:380 msgctxt "Content/Applications/Paragraph" msgid "Register one to integrate Funkwhale with third-party applications." msgstr "" -#: front/src/components/AboutPod.vue:173 +#: src/components/AboutPod.vue:173 msgctxt "*/*/*" msgid "Registrations" msgstr "" -#: front/src/components/auth/SignupForm.vue:28 +#: src/components/auth/SignupForm.vue:28 msgctxt "Content/Signup/Form/Paragraph" msgid "Registrations on this pod are open, but reviewed by moderators before approval." msgstr "" -#: front/src/components/manage/users/UsersTable.vue:110 -#: front/src/components/manage/users/UsersTable.vue:35 +#: src/components/manage/users/UsersTable.vue:110 +#: src/components/manage/users/UsersTable.vue:35 msgctxt "Content/Admin/Table, User role" msgid "Regular user" msgstr "" -#: front/src/components/notifications/NotificationRow.vue:121 +#: src/components/notifications/NotificationRow.vue:51 msgctxt "Content/*/Button.Label/Verb" msgid "Reject" msgstr "" -#: front/src/components/library/EditCard.vue:116 src/views/library/Edit.vue:70 +#: src/components/library/EditCard.vue:116 +#: src/views/library/Edit.vue:70 msgctxt "Content/Library/Button.Label" msgid "Reject" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyCard.vue:47 -#: front/src/components/manage/moderation/InstancePolicyForm.vue:217 +#: src/components/manage/moderation/InstancePolicyCard.vue:47 +#: src/components/manage/moderation/InstancePolicyForm.vue:47 msgctxt "Content/Moderation/*/Verb" msgid "Reject media" msgstr "" -#: front/src/components/library/EditCard.vue:35 -#: front/src/components/manage/library/EditsCardList.vue:30 -#: front/src/views/library/Edit.vue:60 +#: src/components/library/EditCard.vue:35 +#: src/components/manage/library/EditsCardList.vue:30 +#: src/views/library/Edit.vue:60 msgctxt "Content/Library/*/Short" msgid "Rejected" msgstr "" -#: front/src/components/library/TrackDetail.vue:204 +#: src/components/library/TrackDetail.vue:205 msgctxt "Content/*/Title/Noun" msgid "Related Libraries" msgstr "" -#: front/src/components/library/TrackDetail.vue:197 +#: src/components/library/TrackDetail.vue:198 msgctxt "Content/*/Title/Noun" msgid "Related Playlists" msgstr "" -#: front/src/components/manage/library/AlbumsTable.vue:62 -#: front/src/components/manage/library/AlbumsTable.vue:22 -#: front/src/components/mixins/Translations.vue:69 src/edits.js:71 -#: front/src/components/mixins/Translations.vue:70 +#: src/components/manage/library/AlbumsTable.vue:62 +#: src/components/manage/library/AlbumsTable.vue:22 +#: src/components/mixins/Translations.vue:69 msgctxt "Content/*/*/Noun" msgid "Release date" msgstr "" -#: front/src/components/library/TrackDetail.vue:100 +#: src/components/library/TrackDetail.vue:101 msgctxt "Content/*/*" msgid "Release Details" msgstr "" -#: front/src/components/library/FileUpload.vue:32 +#: src/components/library/FileUpload.vue:32 msgctxt "Content/Library/Paragraph" msgid "Remaining storage space" msgstr "" -#: front/src/components/channels/UploadModal.vue:26 -#: front/src/components/channels/UploadModal.vue:1 +#: src/components/channels/UploadModal.vue:26 +#: src/components/channels/UploadModal.vue:1 msgctxt "Content/Library/Paragraph" msgid "Remaining storage space:" msgstr "" -#: front/src/views/Notifications.vue:24 src/views/Notifications.vue:80 +#: src/views/Notifications.vue:24 +#: src/views/Notifications.vue:80 msgctxt "Content/Notifications/Label" msgid "Remind me in:" msgstr "" -#: front/src/views/content/remote/Home.vue:11 +#: src/views/content/remote/Home.vue:11 msgctxt "Content/Library/Title/Noun" msgid "Remote libraries" msgstr "" -#: front/src/views/content/remote/Home.vue:16 +#: src/views/content/remote/Home.vue:16 msgctxt "Content/Library/Paragraph" msgid "Remote libraries are owned by other users on the network. You can access them as long as they are public or you are granted access." msgstr "" -#: front/src/components/auth/Settings.vue:355 +#: src/components/auth/Settings.vue:355 msgctxt "*/*/*/Verb" msgid "Remove" msgstr "" -#: front/src/components/channels/UploadForm.vue:104 -#: front/src/components/channels/UploadForm.vue:69 -#: front/src/components/channels/UploadForm.vue:58 -#: front/src/components/common/AttachmentInput.vue:38 -#: front/src/components/library/radios/Filter.vue:58 +#: src/components/channels/UploadForm.vue:104 +#: src/components/channels/UploadForm.vue:69 +#: src/components/channels/UploadForm.vue:58 +#: src/components/common/AttachmentInput.vue:38 +#: src/components/library/radios/Filter.vue:58 msgctxt "Content/Radio/Button.Label/Verb" msgid "Remove" msgstr "" -#: front/src/components/auth/Settings.vue:367 +#: src/components/auth/Settings.vue:367 msgctxt "*/Settings/Button.Label/Verb" msgid "Remove application" msgstr "" -#: front/src/components/auth/Settings.vue:358 +#: src/components/auth/Settings.vue:358 msgctxt "Popup/Settings/Title" msgid "Remove application \"%{ application }\"?" msgstr "" -#: front/src/components/library/ArtistDetail.vue:16 +#: src/components/library/ArtistDetail.vue:16 msgctxt "Content/Moderation/Button.Label" msgid "Remove filter" msgstr "" -#: front/src/components/manage/moderation/DomainsTable.vue:257 -#: front/src/views/admin/moderation/DomainsDetail.vue:32 -#: front/src/views/admin/moderation/DomainsDetail.vue:27 +#: src/components/manage/moderation/DomainsTable.vue:69 +#: src/views/admin/moderation/DomainsDetail.vue:32 +#: src/views/admin/moderation/DomainsDetail.vue:27 msgctxt "Content/Moderation/Action/Verb" msgid "Remove from allow-list" msgstr "" -#: front/src/components/audio/podcast/Modal.vue:259 -#: front/src/components/audio/track/Modal.vue:259 -#: front/src/components/favorites/TrackFavoriteIcon.vue:42 -#: front/src/components/audio/podcast/Modal.vue:261 -#: front/src/components/audio/track/Modal.vue:261 +#: src/components/audio/podcast/Modal.vue:31 +#: src/components/audio/track/Modal.vue:31 +#: src/components/favorites/TrackFavoriteIcon.vue:10 msgctxt "Content/Track/Icon.Tooltip/Verb" msgid "Remove from favorites" msgstr "" -#: front/src/views/content/libraries/Quota.vue:56 +#: src/views/content/libraries/Quota.vue:56 msgctxt "Popup/Library/Paragraph" msgid "Removes uploaded but yet to be processed tracks completely, adding the corresponding data to your quota." msgstr "" -#: front/src/views/content/libraries/Quota.vue:95 +#: src/views/content/libraries/Quota.vue:95 msgctxt "Popup/Library/Paragraph" msgid "Removes uploaded tracks skipped during the import processes completely, adding the corresponding data to your quota." msgstr "" -#: front/src/views/content/libraries/Quota.vue:134 +#: src/views/content/libraries/Quota.vue:134 msgctxt "Popup/Library/Paragraph" msgid "Removes uploaded tracks that could not be processed by the server completely, adding the corresponding data to your quota." msgstr "" -#: front/src/components/mixins/Report.vue:6 src/components/mixins/Report.vue:7 +#: src/components/mixins/Report.vue:6 msgctxt "*/Moderation/*/Verb" msgid "Report @%{ username }…" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:5 +#: src/components/manage/moderation/ReportCard.vue:5 msgctxt "Content/Moderation/Card/Short" msgid "Report %{ id }" msgstr "" -#: front/src/components/moderation/ReportModal.vue:262 +#: src/components/moderation/ReportModal.vue:108 msgctxt "*/Moderation/Message" msgid "Report successfully submitted, thank you" msgstr "" -#: front/src/components/mixins/Report.vue:38 src/components/mixins/Report.vue:39 +#: src/components/mixins/Report.vue:38 msgctxt "*/Moderation/*/Verb" msgid "Report this album…" msgstr "" -#: front/src/components/mixins/Report.vue:65 src/components/mixins/Report.vue:66 +#: src/components/mixins/Report.vue:65 msgctxt "*/Moderation/*/Verb" msgid "Report this artist…" msgstr "" -#: front/src/components/mixins/Report.vue:54 src/components/mixins/Report.vue:55 +#: src/components/mixins/Report.vue:54 msgctxt "*/Moderation/*/Verb" msgid "Report this channel…" msgstr "" -#: front/src/components/mixins/Report.vue:89 src/components/mixins/Report.vue:90 +#: src/components/mixins/Report.vue:89 msgctxt "*/Moderation/*/Verb" msgid "Report this library…" msgstr "" -#: front/src/components/mixins/Report.vue:77 src/components/mixins/Report.vue:78 +#: src/components/mixins/Report.vue:77 msgctxt "*/Moderation/*/Verb" msgid "Report this playlist…" msgstr "" -#: front/src/components/mixins/Report.vue:24 src/components/mixins/Report.vue:25 +#: src/components/mixins/Report.vue:24 msgctxt "*/Moderation/*/Verb" msgid "Report this track…" msgstr "" -#: front/src/components/audio/PlayButton.vue:180 +#: src/components/audio/PlayButton.vue:54 msgctxt "*/Moderation/*/Button/Label,Verb" msgid "Report…" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:139 +#: src/components/manage/moderation/ReportCard.vue:139 msgctxt "Content/*/*/Short" msgid "Reported object" msgstr "" -#: front/src/components/mixins/Translations.vue:139 -#: front/src/views/admin/moderation/Base.vue:4 -#: front/src/views/admin/moderation/ReportsList.vue:4 -#: front/src/views/admin/moderation/ReportsList.vue:177 -#: front/src/components/mixins/Translations.vue:140 +#: src/components/mixins/Translations.vue:139 +#: src/views/admin/moderation/Base.vue:4 +#: src/views/admin/moderation/ReportsList.vue:4 +#: src/views/admin/moderation/ReportsList.vue:45 msgctxt "*/Moderation/*/Noun" msgid "Reports" msgstr "" -#: front/src/components/manage/moderation/UserRequestCard.vue:5 +#: src/components/manage/moderation/UserRequestCard.vue:5 msgctxt "Content/Moderation/Card/Short" msgid "Request %{ id }" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:52 -#: front/src/components/auth/SubsonicTokenForm.vue:66 -#: front/src/components/auth/SubsonicTokenForm.vue:6 -#: front/src/components/auth/SubsonicTokenForm.vue:20 +#: src/components/auth/SubsonicTokenForm.vue:52 +#: src/components/auth/SubsonicTokenForm.vue:66 +#: src/components/auth/SubsonicTokenForm.vue:6 +#: src/components/auth/SubsonicTokenForm.vue:20 msgctxt "*/Settings/Button.Label/Verb" msgid "Request a new password" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:56 -#: front/src/components/auth/SubsonicTokenForm.vue:10 +#: src/components/auth/SubsonicTokenForm.vue:56 +#: src/components/auth/SubsonicTokenForm.vue:10 msgctxt "Popup/Settings/Title" msgid "Request a new Subsonic API password?" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:72 -#: front/src/components/auth/SubsonicTokenForm.vue:26 +#: src/components/auth/SubsonicTokenForm.vue:72 +#: src/components/auth/SubsonicTokenForm.vue:26 msgctxt "Content/Settings/Button.Label/Verb" msgid "Request a password" msgstr "" -#: front/src/components/federation/FetchButton.vue:99 +#: src/components/federation/FetchButton.vue:99 msgctxt "Popup/*/Loading.Title" msgid "Requesting a fetch…" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:311 -#: front/src/views/admin/moderation/AccountsDetail.vue:306 +#: src/views/admin/moderation/AccountsDetail.vue:311 +#: src/views/admin/moderation/AccountsDetail.vue:306 msgctxt "Content/Moderation/Table.Label/Noun" msgid "Requests" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:53 +#: src/components/admin/SignupFormBuilder.vue:53 msgctxt "*/*/Form-builder,Help" msgid "Required" msgstr "" -#: front/src/components/library/EditForm.vue:112 +#: src/components/library/EditForm.vue:112 msgctxt "Content/Library/Button.Label" msgid "Reset to initial value" msgstr "" -#: front/src/components/auth/LoginForm.vue:41 src/components/auth/LoginForm.vue:17 -#: front/src/views/auth/PasswordReset.vue:5 src/views/auth/PasswordReset.vue:87 +#: src/components/auth/LoginForm.vue:41 +#: src/components/auth/LoginForm.vue:17 +#: src/views/auth/PasswordReset.vue:5 +#: src/views/auth/PasswordReset.vue:14 msgctxt "*/Login/*/Verb" msgid "Reset your password" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:97 -#: front/src/components/manage/moderation/UserRequestCard.vue:87 +#: src/components/manage/moderation/ReportCard.vue:97 +#: src/components/manage/moderation/UserRequestCard.vue:87 msgctxt "Content/*/*/Noun" msgid "Resolution date" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:261 +#: src/components/manage/moderation/ReportCard.vue:261 msgctxt "Content/*/Button.Label/Verb" msgid "Resolve" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:70 -#: front/src/views/admin/moderation/ReportsList.vue:26 +#: src/components/manage/moderation/ReportCard.vue:70 +#: src/views/admin/moderation/ReportsList.vue:26 msgctxt "Content/*/*/Short" msgid "Resolved" msgstr "" -#: front/src/views/content/libraries/FilesTable.vue:345 -#: front/src/views/content/libraries/FilesTable.vue:347 +#: src/views/content/libraries/FilesTable.vue:77 msgctxt "Content/Library/Dropdown/Verb" msgid "Restart import" msgstr "" -#: front/src/components/Queue.vue:401 +#: src/components/Queue.vue:60 msgctxt "*/*/*" msgid "Restart track" msgstr "" -#: front/src/components/library/EditForm.vue:34 src/components/library/EditForm.vue:5 +#: src/components/library/EditForm.vue:34 +#: src/components/library/EditForm.vue:5 msgctxt "Content/Library/Button.Label" msgid "Restrict to unreviewed edits" msgstr "" -#: front/src/views/library/DetailBase.vue:261 +#: src/views/library/DetailBase.vue:33 msgctxt "Content/Library/Card.Help text" msgid "Restricted" msgstr "" -#: front/src/components/library/FileUpload.vue:188 -#: front/src/components/library/FileUpload.vue:12 +#: src/components/library/FileUpload.vue:188 +#: src/components/library/FileUpload.vue:12 msgctxt "Content/Library/Paragraph" msgid "Results of your import:" msgstr "" -#: front/src/components/library/FileUpload.vue:183 -#: front/src/components/library/FileUpload.vue:7 +#: src/components/library/FileUpload.vue:183 +#: src/components/library/FileUpload.vue:7 msgctxt "Content/Library/Paragraph" msgid "Results of your previous import:" msgstr "" -#: front/src/components/favorites/List.vue:45 src/components/library/Albums.vue:49 -#: front/src/components/library/Artists.vue:49 src/components/library/Podcasts.vue:49 -#: front/src/components/library/Radios.vue:71 src/views/playlists/List.vue:51 +#: src/components/favorites/List.vue:45 +#: src/components/library/Albums.vue:49 +#: src/components/library/Artists.vue:49 +#: src/components/library/Podcasts.vue:49 +#: src/components/library/Radios.vue:71 +#: src/views/playlists/List.vue:51 msgctxt "Content/Search/Dropdown.Label/Noun" msgid "Results per page" msgstr "" -#: front/src/components/channels/UploadForm.vue:60 -#: front/src/components/channels/UploadForm.vue:25 -#: front/src/components/channels/UploadForm.vue:14 +#: src/components/channels/UploadForm.vue:60 +#: src/components/channels/UploadForm.vue:25 +#: src/components/channels/UploadForm.vue:14 msgctxt "*/*/*" msgid "Resume" msgstr "" -#: front/src/components/channels/UploadForm.vue:109 -#: front/src/components/channels/UploadForm.vue:74 -#: front/src/components/channels/UploadForm.vue:63 -#: front/src/components/channels/UploadForm.vue:3 +#: src/components/channels/UploadForm.vue:109 +#: src/components/channels/UploadForm.vue:74 +#: src/components/channels/UploadForm.vue:63 +#: src/components/channels/UploadForm.vue:3 msgctxt "*/*/*" msgid "Retry" msgstr "" -#: front/src/components/library/FileUpload.vue:383 +#: src/components/library/FileUpload.vue:75 msgctxt "*/*/*/Verb" msgid "Retry" msgstr "" -#: front/src/components/library/FileUpload.vue:113 +#: src/components/library/FileUpload.vue:113 msgctxt "Content/Library/Table" msgid "Retry failed uploads" msgstr "" -#: front/src/views/auth/EmailConfirm.vue:23 +#: src/views/auth/EmailConfirm.vue:23 msgctxt "Content/Signup/Link/Verb" msgid "Return to login" msgstr "" -#: front/src/components/library/ArtistDetail.vue:11 +#: src/components/library/ArtistDetail.vue:11 msgctxt "Content/Moderation/Link" msgid "Review my filters" msgstr "" -#: front/src/components/auth/Settings.vue:264 +#: src/components/auth/Settings.vue:264 msgctxt "*/*/*/Verb" msgid "Revoke" msgstr "" -#: front/src/components/auth/Settings.vue:276 +#: src/components/auth/Settings.vue:276 msgctxt "*/Settings/Button.Label/Verb" msgid "Revoke access" msgstr "" -#: front/src/components/auth/Settings.vue:267 +#: src/components/auth/Settings.vue:267 msgctxt "Popup/Settings/Title" msgid "Revoke access for application \"%{ application }\"?" msgstr "" -#: front/src/components/RemoteSearchForm.vue:4 +#: src/components/RemoteSearchForm.vue:4 msgctxt "Content/Search/Input.Label/Noun" msgid "RSS" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:168 src/views/admin/ChannelDetail.vue:163 +#: src/views/admin/ChannelDetail.vue:168 +#: src/views/admin/ChannelDetail.vue:163 msgctxt "'*/*/*" msgid "RSS Feed" msgstr "" -#: front/src/components/RemoteSearchForm.vue:127 +#: src/components/RemoteSearchForm.vue:29 msgctxt "*/*/*" msgid "RSS feed location" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyCard.vue:20 +#: src/components/manage/moderation/InstancePolicyCard.vue:20 msgctxt "Content/Moderation/Card.Title/Noun" msgid "Rule" msgstr "" -#: front/src/components/AboutPod.vue:23 src/components/AboutPod.vue:59 +#: src/components/AboutPod.vue:23 +#: src/components/AboutPod.vue:59 msgctxt "Content/About/Header" msgid "Rules" msgstr "" -#: front/src/components/admin/SettingsGroup.vue:65 src/components/auth/Plugin.vue:67 -#: front/src/components/library/radios/Builder.vue:44 +#: src/components/admin/SettingsGroup.vue:65 +#: src/components/auth/Plugin.vue:67 +#: src/components/library/radios/Builder.vue:44 msgctxt "Content/*/Button.Label/Verb" msgid "Save" msgstr "" -#: front/src/components/auth/Plugin.vue:72 +#: src/components/auth/Plugin.vue:72 msgctxt "Content/*/Button.Label/Verb" msgid "Scan" msgstr "" -#: front/src/views/content/remote/Card.vue:305 +#: src/views/content/remote/Card.vue:80 msgctxt "Content/Library/Message" msgid "Scan launched" msgstr "" -#: front/src/views/content/remote/Card.vue:87 +#: src/views/content/remote/Card.vue:87 msgctxt "Content/Library/Card.Button.Label/Verb" msgid "Scan now" msgstr "" -#: front/src/views/content/remote/Card.vue:41 src/views/content/remote/Card.vue:2 +#: src/views/content/remote/Card.vue:41 +#: src/views/content/remote/Card.vue:2 msgctxt "Content/Library/Card.List item" msgid "Scan pending" msgstr "" -#: front/src/views/content/remote/Card.vue:306 +#: src/views/content/remote/Card.vue:81 msgctxt "Content/Library/Message" msgid "Scan skipped (previous scan is too recent)" msgstr "" -#: front/src/views/content/remote/Card.vue:59 src/views/content/remote/Card.vue:2 +#: src/views/content/remote/Card.vue:59 +#: src/views/content/remote/Card.vue:2 msgctxt "Content/Library/Card.List item" msgid "Scanned" msgstr "" -#: front/src/views/content/remote/Card.vue:65 src/views/content/remote/Card.vue:2 +#: src/views/content/remote/Card.vue:65 +#: src/views/content/remote/Card.vue:2 msgctxt "Content/Library/Card.List item" msgid "Scanned with errors" msgstr "" -#: front/src/views/content/remote/Card.vue:47 src/views/content/remote/Card.vue:2 +#: src/views/content/remote/Card.vue:47 +#: src/views/content/remote/Card.vue:2 msgctxt "Content/Library/Card.List item" msgid "Scanning… (%{ progress }%)" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:28 -#: front/src/components/auth/Settings.vue:323 +#: src/components/auth/ApplicationForm.vue:28 +#: src/components/auth/Settings.vue:323 msgctxt "Content/*/*/Noun" msgid "Scopes" msgstr "" -#: front/src/components/RemoteSearchForm.vue:47 -#: front/src/components/common/InlineSearchBar.vue:4 -#: front/src/components/library/Albums.vue:12 src/components/library/Albums.vue:98 -#: front/src/components/library/Artists.vue:98 src/components/library/Podcasts.vue:98 -#: front/src/components/library/Radios.vue:39 src/components/library/Radios.vue:183 -#: front/src/components/manage/ChannelsTable.vue:5 -#: front/src/components/manage/library/AlbumsTable.vue:5 -#: front/src/components/manage/library/ArtistsTable.vue:5 -#: front/src/components/manage/library/EditsCardList.vue:6 -#: front/src/components/manage/library/LibrariesTable.vue:5 -#: front/src/components/manage/library/TagsTable.vue:5 -#: front/src/components/manage/library/TracksTable.vue:5 -#: front/src/components/manage/library/UploadsTable.vue:5 -#: front/src/components/manage/moderation/AccountsTable.vue:5 -#: front/src/components/manage/moderation/DomainsTable.vue:5 -#: front/src/components/manage/users/InvitationsTable.vue:5 -#: front/src/components/manage/users/UsersTable.vue:5 src/views/Search.vue:10 -#: front/src/views/Search.vue:190 src/views/admin/moderation/ReportsList.vue:12 -#: front/src/views/admin/moderation/RequestsList.vue:12 -#: front/src/views/content/libraries/FilesTable.vue:6 src/views/playlists/List.vue:19 -#: front/src/views/playlists/List.vue:132 +#: src/components/RemoteSearchForm.vue:47 +#: src/components/common/InlineSearchBar.vue:4 +#: src/components/library/Albums.vue:12 +#: src/components/library/Albums.vue:107 +#: src/components/library/Artists.vue:118 +#: src/components/library/Podcasts.vue:132 +#: src/components/library/Radios.vue:39 +#: src/components/library/Radios.vue:166 +#: src/components/manage/ChannelsTable.vue:5 +#: src/components/manage/library/AlbumsTable.vue:5 +#: src/components/manage/library/ArtistsTable.vue:5 +#: src/components/manage/library/EditsCardList.vue:6 +#: src/components/manage/library/LibrariesTable.vue:5 +#: src/components/manage/library/TagsTable.vue:5 +#: src/components/manage/library/TracksTable.vue:5 +#: src/components/manage/library/UploadsTable.vue:5 +#: src/components/manage/moderation/AccountsTable.vue:5 +#: src/components/manage/moderation/DomainsTable.vue:5 +#: src/components/manage/users/InvitationsTable.vue:5 +#: src/components/manage/users/UsersTable.vue:5 +#: src/views/Search.vue:10 +#: src/views/Search.vue:51 +#: src/views/admin/moderation/ReportsList.vue:12 +#: src/views/admin/moderation/RequestsList.vue:12 +#: src/views/content/libraries/FilesTable.vue:6 +#: src/views/playlists/List.vue:19 +#: src/views/playlists/List.vue:108 msgctxt "Content/Search/Input.Label/Noun" msgid "Search" msgstr "" -#: front/src/components/Sidebar.vue:151 +#: src/components/Sidebar.vue:151 msgctxt "Sidebar/Navigation/List item.Link/Verb" msgid "Search" msgstr "" -#: front/src/views/content/remote/ScanForm.vue:15 +#: src/views/content/remote/ScanForm.vue:15 msgctxt "Content/Library/Input.Label/Verb" msgid "Search a remote library" msgstr "" -#: front/src/views/Search.vue:192 +#: src/views/Search.vue:53 msgctxt "Head/Fetch/Title" msgid "Search a remote object" msgstr "" -#: front/src/components/manage/library/EditsCardList.vue:180 -#: front/src/views/admin/moderation/ReportsList.vue:176 +#: src/components/manage/library/EditsCardList.vue:44 +#: src/views/admin/moderation/ReportsList.vue:44 msgctxt "Content/Search/Input.Placeholder" msgid "Search by account, summary, domain…" msgstr "" -#: front/src/components/manage/library/LibrariesTable.vue:261 +#: src/components/manage/library/LibrariesTable.vue:40 msgctxt "Content/Search/Input.Placeholder" msgid "Search by domain, actor, name, description…" msgstr "" -#: front/src/components/manage/library/UploadsTable.vue:360 +#: src/components/manage/library/UploadsTable.vue:47 msgctxt "Content/Search/Input.Placeholder" msgid "Search by domain, actor, name, reference, source…" msgstr "" -#: front/src/components/manage/ChannelsTable.vue:245 +#: src/components/manage/ChannelsTable.vue:39 msgctxt "Content/Search/Input.Placeholder" msgid "Search by domain, name, account…" msgstr "" -#: front/src/components/manage/library/ArtistsTable.vue:229 +#: src/components/manage/library/ArtistsTable.vue:39 msgctxt "Content/Search/Input.Placeholder" msgid "Search by domain, name, MusicBrainz ID…" msgstr "" -#: front/src/components/manage/library/TracksTable.vue:242 +#: src/components/manage/library/TracksTable.vue:38 msgctxt "Content/Search/Input.Placeholder" msgid "Search by domain, title, artist, album, MusicBrainz ID…" msgstr "" -#: front/src/components/manage/library/AlbumsTable.vue:231 +#: src/components/manage/library/AlbumsTable.vue:40 msgctxt "Content/Search/Input.Placeholder" msgid "Search by domain, title, artist, MusicBrainz ID…" msgstr "" -#: front/src/components/manage/moderation/AccountsTable.vue:219 +#: src/components/manage/moderation/AccountsTable.vue:42 msgctxt "Content/Search/Input.Placeholder" msgid "Search by domain, username, bio…" msgstr "" -#: front/src/components/manage/library/TagsTable.vue:197 +#: src/components/manage/library/TagsTable.vue:45 msgctxt "Content/Search/Input.Placeholder" msgid "Search by name" msgstr "" -#: front/src/components/manage/moderation/DomainsTable.vue:227 +#: src/components/manage/moderation/DomainsTable.vue:39 msgctxt "Content/Search/Input.Placeholder" msgid "Search by name…" msgstr "" -#: front/src/views/content/libraries/FilesTable.vue:325 -#: front/src/views/content/libraries/FilesTable.vue:327 +#: src/views/content/libraries/FilesTable.vue:57 msgctxt "Content/Library/Input.Placeholder" msgid "Search by title, artist, album…" msgstr "" -#: front/src/components/manage/users/InvitationsTable.vue:198 +#: src/components/manage/users/InvitationsTable.vue:36 msgctxt "Content/Admin/Input.Placeholder/Verb" msgid "Search by username, e-mail address, code…" msgstr "" -#: front/src/components/manage/users/UsersTable.vue:241 +#: src/components/manage/users/UsersTable.vue:36 msgctxt "Content/Search/Input.Placeholder" msgid "Search by username, e-mail address, name…" msgstr "" -#: front/src/views/admin/moderation/RequestsList.vue:165 +#: src/views/admin/moderation/RequestsList.vue:39 msgctxt "Content/Search/Input.Placeholder" msgid "Search by username…" msgstr "" -#: front/src/components/audio/SearchBar.vue:36 +#: src/components/audio/SearchBar.vue:13 msgctxt "Sidebar/Search/Input.Placeholder" msgid "Search for artists, albums, tracks…" msgstr "" -#: front/src/components/audio/SearchBar.vue:37 +#: src/components/audio/SearchBar.vue:14 msgctxt "Sidebar/Search/Input.Label" msgid "Search for content" msgstr "" -#: front/src/components/audio/Search.vue:3 +#: src/components/audio/Search.vue:3 msgctxt "Content/Search/Title" msgid "Search for some music" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:37 -#: front/src/components/library/ArtistBase.vue:82 -#: front/src/components/library/ArtistBase.vue:77 -#: front/src/components/library/TrackBase.vue:65 -#: front/src/components/library/TrackBase.vue:60 +#: src/components/library/AlbumDropdown.vue:37 +#: src/components/library/ArtistBase.vue:82 +#: src/components/library/ArtistBase.vue:77 +#: src/components/library/TrackBase.vue:65 +#: src/components/library/TrackBase.vue:60 msgctxt "Content/*/Button.Label/Verb" msgid "Search on Discogs" msgstr "" -#: front/src/components/audio/SearchBar.vue:173 +#: src/components/audio/SearchBar.vue:150 msgctxt "Search/*/*" msgid "Search on the fediverse" msgstr "" -#: front/src/components/library/ArtistBase.vue:74 -#: front/src/components/library/ArtistBase.vue:69 -#: front/src/components/library/TrackBase.vue:61 -#: front/src/components/library/TrackBase.vue:56 +#: src/components/library/ArtistBase.vue:74 +#: src/components/library/ArtistBase.vue:69 +#: src/components/library/TrackBase.vue:61 +#: src/components/library/TrackBase.vue:56 msgctxt "Content/*/Button.Label/Verb" msgid "Search on Wikipedia" msgstr "" -#: front/src/components/library/TagsSelector.vue:6 +#: src/components/library/TagsSelector.vue:6 msgctxt "*/Dropdown/Placeholder/Verb" msgid "Search…" msgstr "" -#: front/src/components/common/InlineSearchBar.vue:45 -#: front/src/components/library/Artists.vue:206 -#: front/src/components/library/Podcasts.vue:242 +#: src/components/common/InlineSearchBar.vue:9 +#: src/components/library/Artists.vue:41 +#: src/components/library/Podcasts.vue:45 msgctxt "Content/Search/Input.Placeholder" msgid "Search…" msgstr "" -#: front/src/components/library/Library.vue:18 src/views/admin/library/Base.vue:85 -#: front/src/views/admin/moderation/Base.vue:77 src/views/admin/users/Base.vue:38 -#: front/src/views/content/Base.vue:36 +#: src/components/library/Library.vue:11 +#: src/views/admin/library/Base.vue:5 +#: src/views/admin/moderation/Base.vue:14 +#: src/views/admin/users/Base.vue:6 +#: src/views/content/Base.vue:5 msgctxt "Menu/*/Hidden text" msgid "Secondary menu" msgstr "" -#: front/src/views/admin/Settings.vue:12 +#: src/views/admin/Settings.vue:12 msgctxt "Content/Admin/Menu.Title" msgid "Sections" msgstr "" -#: front/src/views/admin/Settings.vue:71 +#: src/views/admin/Settings.vue:27 msgctxt "*/*/*/Noun" msgid "Security" msgstr "" -#: front/src/components/mixins/Translations.vue:135 -#: front/src/components/mixins/Translations.vue:136 +#: src/components/mixins/Translations.vue:135 msgctxt "*/Admin/*/Noun" msgid "Security" msgstr "" -#: front/src/components/ShortcutsModal.vue:110 +#: src/components/ShortcutsModal.vue:50 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek backwards 30s" msgstr "" -#: front/src/components/ShortcutsModal.vue:102 +#: src/components/ShortcutsModal.vue:42 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek backwards 5s" msgstr "" -#: front/src/components/ShortcutsModal.vue:114 +#: src/components/ShortcutsModal.vue:54 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek forwards 30s" msgstr "" -#: front/src/components/ShortcutsModal.vue:106 +#: src/components/ShortcutsModal.vue:46 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek forwards 5s" msgstr "" -#: front/src/components/common/ActionTable.vue:302 +#: src/components/common/ActionTable.vue:68 msgctxt "Content/*/Select/Verb" msgid "Select" msgstr "" -#: front/src/components/library/radios/Builder.vue:56 +#: src/components/library/radios/Builder.vue:56 msgctxt "Content/Radio/Dropdown.Placeholder/Verb" msgid "Select a filter" msgstr "" -#: front/src/components/common/ActionTable.vue:300 +#: src/components/common/ActionTable.vue:66 msgctxt "Content/*/Select/Verb" msgid "Select all items" msgstr "" -#: front/src/components/common/ActionTable.vue:64 -#: front/src/components/common/ActionTable.vue:2 +#: src/components/common/ActionTable.vue:64 +#: src/components/common/ActionTable.vue:2 msgctxt "Content/*/Link/Verb" msgid "Select one element" msgid_plural "Select all %{ total } elements" msgstr[0] "" msgstr[1] "" -#: front/src/components/common/ActionTable.vue:69 -#: front/src/components/common/ActionTable.vue:7 +#: src/components/common/ActionTable.vue:69 +#: src/components/common/ActionTable.vue:7 msgctxt "Content/*/Link/Verb" msgid "Select only current page" msgstr "" -#: front/src/components/AboutPod.vue:271 src/components/AboutPod.vue:7 +#: src/components/AboutPod.vue:271 +#: src/components/AboutPod.vue:7 msgctxt "Content/About/Email" msgid "Send us an email: {{ contactEmail }}" msgstr "" -#: front/src/components/library/TrackDetail.vue:123 +#: src/components/library/TrackDetail.vue:124 msgctxt "*/*/*" msgid "Serie" msgstr "" -#: front/src/components/channels/AlbumSelect.vue:3 src/views/Search.vue:251 +#: src/components/channels/AlbumSelect.vue:3 +#: src/views/Search.vue:112 msgctxt "*/*/*" msgid "Series" msgstr "" -#: front/src/views/channels/DetailOverview.vue:70 +#: src/views/channels/DetailOverview.vue:70 msgctxt "Content/Channel/Paragraph" msgid "Series" msgstr "" -#: front/src/components/Home.vue:48 src/components/Home.vue:18 +#: src/components/Home.vue:48 +#: src/components/Home.vue:18 msgctxt "Content/Home/Link" msgid "Server rules" msgstr "" -#: front/src/components/Sidebar.vue:46 src/components/common/UserMenu.vue:163 -#: front/src/components/common/UserModal.vue:192 -#: front/src/components/manage/users/UsersTable.vue:259 -#: front/src/views/admin/moderation/AccountsDetail.vue:618 +#: src/components/Sidebar.vue:46 +#: src/components/common/UserMenu.vue:16 +#: src/components/common/UserModal.vue:24 +#: src/components/manage/users/UsersTable.vue:54 +#: src/views/admin/moderation/AccountsDetail.vue:48 msgctxt "*/*/*/Noun" msgid "Settings" msgstr "" -#: front/src/components/auth/Settings.vue:12 +#: src/components/auth/Settings.vue:12 msgctxt "Content/Settings/Message" msgid "Settings updated" msgstr "" -#: front/src/components/admin/SettingsGroup.vue:19 +#: src/components/admin/SettingsGroup.vue:19 msgctxt "Content/Settings/Paragraph" msgid "Settings updated successfully." msgstr "" -#: front/src/components/manage/users/InvitationForm.vue:40 +#: src/components/manage/users/InvitationForm.vue:40 msgctxt "Content/Admin/Table.Label/Noun" msgid "Share link" msgstr "" -#: front/src/views/library/DetailBase.vue:83 +#: src/views/library/DetailBase.vue:83 msgctxt "Content/Library/Paragraph" msgid "Share this link with other users so they can request access to this library by copy-pasting it in their pod search bar." msgstr "" -#: front/src/views/content/Home.vue:18 +#: src/views/content/Home.vue:18 msgctxt "Content/Library/Paragraph" msgid "Share your work publicly and get subscribers on Funkwhale, the Fediverse or any podcasting application." msgstr "" -#: front/src/views/content/remote/Card.vue:97 src/views/library/DetailBase.vue:80 +#: src/views/content/remote/Card.vue:97 +#: src/views/library/DetailBase.vue:80 msgctxt "Content/Library/Title" msgid "Sharing link" msgstr "" -#: front/src/components/audio/EmbedWizard.vue:5 +#: src/components/audio/EmbedWizard.vue:5 msgctxt "Content/Embed/Message" msgid "Sharing will not work because this pod doesn't allow anonymous users to access content." msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:68 +#: src/components/admin/SignupFormBuilder.vue:68 msgctxt "*/*/Form-builder" msgid "Short text" msgstr "" -#: front/src/components/tags/List.vue:6 +#: src/components/tags/List.vue:6 msgctxt "Content/*/Button/Label/Verb" msgid "Show 1 more tag" msgid_plural "Show %{ count } more tags" msgstr[0] "" msgstr[1] "" -#: front/src/components/library/EditForm.vue:24 src/components/library/EditForm.vue:5 +#: src/components/library/EditForm.vue:24 +#: src/components/library/EditForm.vue:5 msgctxt "Content/Library/Button.Label" msgid "Show all edits" msgstr "" -#: front/src/components/ShortcutsModal.vue:76 +#: src/components/ShortcutsModal.vue:16 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Show available keyboard shortcuts" msgstr "" -#: front/src/views/content/libraries/FilesTable.vue:329 +#: src/views/content/libraries/FilesTable.vue:61 msgctxt "Content/Library/Button.Label/Verb" msgid "Show information about the upload status for this track" msgstr "" -#: front/src/components/common/ExpandableDiv.vue:7 -#: front/src/components/common/RenderedDescription.vue:10 -#: front/src/components/common/RenderedDescription.vue:8 -#: front/src/components/common/RenderedDescription.vue:6 +#: src/components/common/ExpandableDiv.vue:7 +#: src/components/common/RenderedDescription.vue:10 +#: src/components/common/RenderedDescription.vue:8 +#: src/components/common/RenderedDescription.vue:6 msgctxt "*/*/Button,Label" msgid "Show less" msgstr "" -#: front/src/components/audio/ChannelSeries.vue:16 -#: front/src/components/audio/ChannelSeries.vue:3 -#: front/src/components/audio/ChannelsWidget.vue:13 -#: front/src/components/audio/ChannelsWidget.vue:3 -#: front/src/components/audio/album/Widget.vue:21 -#: front/src/components/audio/album/Widget.vue:3 -#: front/src/components/audio/artist/Widget.vue:20 -#: front/src/components/audio/artist/Widget.vue:3 -#: front/src/components/audio/track/Widget.vue:63 -#: front/src/components/audio/track/Widget.vue:3 -#: front/src/components/common/ExpandableDiv.vue:8 -#: front/src/components/common/RenderedDescription.vue:7 -#: front/src/components/common/RenderedDescription.vue:5 -#: front/src/components/common/RenderedDescription.vue:3 -#: front/src/components/federation/LibraryWidget.vue:23 -#: front/src/components/federation/LibraryWidget.vue:3 -#: front/src/components/playlists/Widget.vue:28 src/components/playlists/Widget.vue:3 +#: src/components/audio/ChannelSeries.vue:16 +#: src/components/audio/ChannelSeries.vue:3 +#: src/components/audio/ChannelsWidget.vue:13 +#: src/components/audio/ChannelsWidget.vue:3 +#: src/components/audio/album/Widget.vue:21 +#: src/components/audio/album/Widget.vue:3 +#: src/components/audio/artist/Widget.vue:20 +#: src/components/audio/artist/Widget.vue:3 +#: src/components/audio/track/Widget.vue:63 +#: src/components/audio/track/Widget.vue:3 +#: src/components/common/ExpandableDiv.vue:8 +#: src/components/common/RenderedDescription.vue:7 +#: src/components/common/RenderedDescription.vue:5 +#: src/components/common/RenderedDescription.vue:3 +#: src/components/federation/LibraryWidget.vue:23 +#: src/components/federation/LibraryWidget.vue:3 +#: src/components/playlists/Widget.vue:28 +#: src/components/playlists/Widget.vue:3 msgctxt "*/*/Button,Label" msgid "Show more" msgstr "" -#: front/src/views/Notifications.vue:122 +#: src/views/Notifications.vue:122 msgctxt "Content/Notifications/Form.Label/Verb" msgid "Show read notifications" msgstr "" -#: front/src/components/audio/podcast/MobileRow.vue:183 -#: front/src/components/audio/track/MobileRow.vue:166 +#: src/components/audio/podcast/MobileRow.vue:43 +#: src/components/audio/track/MobileRow.vue:43 msgctxt "Content/Track/Icon.Tooltip/Verb" msgid "Show track actions" msgstr "" -#: front/src/components/forms/PasswordInput.vue:46 -#: front/src/components/forms/PasswordInput.vue:48 +#: src/components/forms/PasswordInput.vue:16 msgctxt "Content/Settings/Button.Tooltip/Verb" msgid "Show/hide password" msgstr "" -#: front/src/components/manage/users/InvitationsTable.vue:97 -#: front/src/components/manage/users/UsersTable.vue:119 +#: src/components/manage/users/InvitationsTable.vue:97 +#: src/components/manage/users/UsersTable.vue:119 msgctxt "Content/*/Paragraph" msgid "Showing one result" msgid_plural "Showing results %{ start } to %{ end } from %{ total }" msgstr[0] "" msgstr[1] "" -#: front/src/components/manage/ChannelsTable.vue:133 -#: front/src/components/manage/library/AlbumsTable.vue:117 -#: front/src/components/manage/library/ArtistsTable.vue:120 -#: front/src/components/manage/library/EditsCardList.vue:75 -#: front/src/components/manage/library/LibrariesTable.vue:141 -#: front/src/components/manage/library/TagsTable.vue:93 -#: front/src/components/manage/library/TracksTable.vue:120 -#: front/src/components/manage/library/UploadsTable.vue:202 -#: front/src/components/manage/moderation/AccountsTable.vue:109 -#: front/src/components/manage/moderation/DomainsTable.vue:119 -#: front/src/views/content/libraries/FilesTable.vue:182 +#: src/components/manage/ChannelsTable.vue:133 +#: src/components/manage/library/AlbumsTable.vue:117 +#: src/components/manage/library/ArtistsTable.vue:120 +#: src/components/manage/library/EditsCardList.vue:75 +#: src/components/manage/library/LibrariesTable.vue:141 +#: src/components/manage/library/TagsTable.vue:93 +#: src/components/manage/library/TracksTable.vue:120 +#: src/components/manage/library/UploadsTable.vue:202 +#: src/components/manage/moderation/AccountsTable.vue:109 +#: src/components/manage/moderation/DomainsTable.vue:119 +#: src/views/content/libraries/FilesTable.vue:182 msgctxt "Content/*/Paragraph" msgid "Showing results %{ start }-%{ end } on %{ total }" msgstr "" -#: front/src/components/ShortcutsModal.vue:146 +#: src/components/ShortcutsModal.vue:86 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Shuffle queue" msgstr "" -#: front/src/components/audio/Player.vue:422 +#: src/components/audio/Player.vue:82 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Shuffle your queue" msgstr "" -#: front/src/components/common/LoginModal.vue:75 -#: front/src/components/common/UserMenu.vue:175 -#: front/src/components/common/UserModal.vue:211 +#: src/components/common/LoginModal.vue:22 +#: src/components/common/UserMenu.vue:28 +#: src/components/common/UserModal.vue:43 msgctxt "*/*/Button.Label/Verb" msgid "Sign up" msgstr "" -#: front/src/components/About.vue:37 src/components/Home.vue:124 +#: src/components/About.vue:37 +#: src/components/About.vue:67 +#: src/components/Home.vue:124 msgctxt "*/Signup/Title" msgid "Sign up" msgstr "" -#: front/src/views/auth/Signup.vue:47 +#: src/views/auth/Signup.vue:24 msgctxt "*/Signup/Title" msgid "Sign Up" msgstr "" -#: front/src/components/About.vue:43 src/components/About.vue:2 +#: src/components/About.vue:43 +#: src/components/About.vue:2 msgctxt "Content/About/Paragraph" msgid "Sign up now to keep a track of your favorites, create playlists, discover new content and much more!" msgstr "" -#: front/src/components/Home.vue:130 src/components/Home.vue:2 +#: src/components/Home.vue:130 +#: src/components/Home.vue:2 msgctxt "Content/Home/Paragraph" msgid "Sign up now to keep track of your favorites, create playlists, discover new content and much more!" msgstr "" -#: front/src/components/manage/users/UsersTable.vue:55 -#: front/src/components/manage/users/UsersTable.vue:17 +#: src/components/manage/users/UsersTable.vue:55 +#: src/components/manage/users/UsersTable.vue:17 msgctxt "Content/Admin/Table.Label/Short, Noun (Value is a date)" msgid "Sign-up" msgstr "" -#: front/src/components/mixins/Translations.vue:84 -#: front/src/views/admin/moderation/AccountsDetail.vue:215 -#: front/src/views/admin/moderation/AccountsDetail.vue:210 -#: front/src/components/mixins/Translations.vue:85 +#: src/components/mixins/Translations.vue:84 +#: src/views/admin/moderation/AccountsDetail.vue:215 +#: src/views/admin/moderation/AccountsDetail.vue:210 msgctxt "Content/Admin/Table.Label/Noun" msgid "Sign-up date" msgstr "" -#: front/src/views/admin/Settings.vue:70 +#: src/views/admin/Settings.vue:26 msgctxt "*/*/*/Noun" msgid "Sign-ups" msgstr "" -#: front/src/components/library/FileUpload.vue:92 -#: front/src/components/library/TrackDetail.vue:35 -#: front/src/components/library/TrackDetail.vue:30 -#: front/src/components/manage/library/UploadsTable.vue:122 -#: front/src/components/manage/library/UploadsTable.vue:32 -#: front/src/components/mixins/Translations.vue:81 -#: front/src/views/admin/library/UploadDetail.vue:252 -#: front/src/views/admin/library/UploadDetail.vue:247 -#: front/src/views/content/libraries/FilesTable.vue:124 -#: front/src/views/content/libraries/FilesTable.vue:32 -#: front/src/components/mixins/Translations.vue:82 +#: src/components/library/FileUpload.vue:92 +#: src/components/library/TrackDetail.vue:36 +#: src/components/library/TrackDetail.vue:31 +#: src/components/manage/library/UploadsTable.vue:122 +#: src/components/manage/library/UploadsTable.vue:32 +#: src/components/mixins/Translations.vue:81 +#: src/views/admin/library/UploadDetail.vue:252 +#: src/views/admin/library/UploadDetail.vue:247 +#: src/views/content/libraries/FilesTable.vue:124 +#: src/views/content/libraries/FilesTable.vue:32 msgctxt "Content/*/*/Noun" msgid "Size" msgstr "" -#: front/src/components/manage/library/UploadsTable.vue:43 -#: front/src/components/mixins/Translations.vue:24 -#: front/src/views/content/libraries/FilesTable.vue:33 -#: front/src/components/mixins/Translations.vue:25 +#: src/components/manage/library/UploadsTable.vue:43 +#: src/components/mixins/Translations.vue:24 +#: src/views/content/libraries/FilesTable.vue:33 msgctxt "Content/Library/*" msgid "Skipped" msgstr "" -#: front/src/views/content/libraries/Quota.vue:74 +#: src/views/content/libraries/Quota.vue:74 msgctxt "Content/Library/Label" msgid "Skipped files" msgstr "" -#: front/src/views/admin/moderation/DomainsDetail.vue:135 -#: front/src/views/admin/moderation/DomainsDetail.vue:130 -#: front/src/views/admin/moderation/DomainsDetail.vue:3 +#: src/views/admin/moderation/DomainsDetail.vue:135 +#: src/views/admin/moderation/DomainsDetail.vue:130 +#: src/views/admin/moderation/DomainsDetail.vue:3 msgctxt "Content/Moderation/Table.Label" msgid "Software" msgstr "" -#: front/src/components/playlists/Editor.vue:29 +#: src/components/playlists/Editor.vue:29 msgctxt "Content/Playlist/Paragraph" msgid "Some tracks in your queue are already in this playlist:" msgstr "" -#: front/src/views/channels/DetailOverview.vue:18 -#: front/src/views/channels/DetailOverview.vue:2 +#: src/views/channels/DetailOverview.vue:18 +#: src/views/channels/DetailOverview.vue:2 msgctxt "Content/Channel/Header" msgid "Some uploads couldn't be published" msgstr "" -#: front/src/components/PageNotFound.vue:13 +#: src/components/PageNotFound.vue:13 msgctxt "Content/*/Paragraph" msgid "Sorry, the page you asked for does not exist:" msgstr "" -#: front/src/components/audio/SearchBar.vue:64 +#: src/components/audio/SearchBar.vue:41 msgctxt "Sidebar/Search/Error.Label" msgid "Sorry, there are no results for this search" msgstr "" -#: front/src/components/Footer.vue:87 -msgctxt "Footer/*/List item.Link" -msgid "Source code" -msgstr "" - -#: front/src/components/manage/users/UsersTable.vue:109 -#: front/src/components/manage/users/UsersTable.vue:34 +#: src/components/manage/users/UsersTable.vue:109 +#: src/components/manage/users/UsersTable.vue:34 msgctxt "Content/Profile/User role" msgid "Staff member" msgstr "" -#: front/src/components/AboutPod.vue:38 src/components/AboutPod.vue:214 -#: front/src/components/AboutPod.vue:2 +#: src/components/AboutPod.vue:38 +#: src/components/AboutPod.vue:214 +#: src/components/AboutPod.vue:2 msgctxt "Content/About/Header" msgid "Statistics" msgstr "" -#: front/src/components/Home.vue:60 src/components/Home.vue:2 -#: front/src/views/admin/Settings.vue:78 +#: src/components/Home.vue:60 +#: src/components/Home.vue:2 +#: src/views/admin/Settings.vue:34 msgctxt "Content/Home/Header" msgid "Statistics" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:602 +#: src/views/admin/moderation/AccountsDetail.vue:32 msgctxt "Content/Moderation/Help text" msgid "Statistics are computed from known activity and content on your instance, and do not reflect general activity for this account" msgstr "" -#: front/src/views/admin/moderation/DomainsDetail.vue:489 +#: src/views/admin/moderation/DomainsDetail.vue:30 msgctxt "Content/Moderation/Help text" msgid "Statistics are computed from known activity and content on your instance, and do not reflect general activity for this domain" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:439 -#: front/src/views/admin/library/AlbumDetail.vue:422 -#: front/src/views/admin/library/ArtistDetail.vue:434 -#: front/src/views/admin/library/LibraryDetail.vue:381 -#: front/src/views/admin/library/TagDetail.vue:227 -#: front/src/views/admin/library/TrackDetail.vue:475 -#: front/src/views/admin/library/UploadDetail.vue:405 +#: src/views/admin/ChannelDetail.vue:23 +#: src/views/admin/library/AlbumDetail.vue:22 +#: src/views/admin/library/ArtistDetail.vue:23 +#: src/views/admin/library/LibraryDetail.vue:21 +#: src/views/admin/library/TagDetail.vue:16 +#: src/views/admin/library/TrackDetail.vue:22 +#: src/views/admin/library/UploadDetail.vue:27 msgctxt "Content/Moderation/Help text" msgid "Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object" msgstr "" -#: front/src/components/library/FileUpload.vue:97 -#: front/src/components/manage/library/EditsCardList.vue:12 -#: front/src/components/manage/moderation/ReportCard.vue:63 -#: front/src/components/manage/moderation/UserRequestCard.vue:45 -#: front/src/components/manage/users/InvitationsTable.vue:17 -#: front/src/components/manage/users/InvitationsTable.vue:50 -#: front/src/components/manage/users/InvitationsTable.vue:7 -#: front/src/components/manage/users/UsersTable.vue:70 -#: front/src/components/manage/users/UsersTable.vue:32 -#: front/src/views/admin/moderation/DomainsDetail.vue:167 -#: front/src/views/admin/moderation/DomainsDetail.vue:162 -#: front/src/views/admin/moderation/DomainsDetail.vue:3 -#: front/src/views/admin/moderation/ReportsList.vue:18 -#: front/src/views/admin/moderation/RequestsList.vue:18 src/views/library/Edit.vue:38 +#: src/components/library/FileUpload.vue:97 +#: src/components/manage/library/EditsCardList.vue:12 +#: src/components/manage/moderation/ReportCard.vue:63 +#: src/components/manage/moderation/UserRequestCard.vue:45 +#: src/components/manage/users/InvitationsTable.vue:17 +#: src/components/manage/users/InvitationsTable.vue:50 +#: src/components/manage/users/InvitationsTable.vue:7 +#: src/components/manage/users/UsersTable.vue:70 +#: src/components/manage/users/UsersTable.vue:32 +#: src/views/admin/moderation/DomainsDetail.vue:167 +#: src/views/admin/moderation/DomainsDetail.vue:162 +#: src/views/admin/moderation/DomainsDetail.vue:3 +#: src/views/admin/moderation/ReportsList.vue:18 +#: src/views/admin/moderation/RequestsList.vue:18 +#: src/views/library/Edit.vue:38 msgctxt "*/*/*" msgid "Status" msgstr "" -#: front/src/views/playlists/Detail.vue:32 src/views/playlists/Detail.vue:1 +#: src/views/playlists/Detail.vue:32 +#: src/views/playlists/Detail.vue:1 msgctxt "Content/Playlist/Button.Label/Verb" msgid "Stop Editing" msgstr "" -#: front/src/components/Queue.vue:175 src/components/radios/Button.vue:4 -#: front/src/components/radios/Button.vue:1 +#: src/components/Queue.vue:175 +#: src/components/radios/Button.vue:4 +#: src/components/radios/Button.vue:1 msgctxt "*/Player/Button.Label/Short, Verb" msgid "Stop radio" msgstr "" -#: front/src/components/audio/ChannelForm.vue:110 -#: front/src/components/audio/ChannelForm.vue:96 -#: front/src/components/audio/ChannelForm.vue:76 +#: src/components/audio/ChannelForm.vue:110 +#: src/components/audio/ChannelForm.vue:96 +#: src/components/audio/ChannelForm.vue:76 msgctxt "*/*/*" msgid "Subcategory" msgstr "" -#: front/src/components/SetInstanceModal.vue:41 +#: src/components/SetInstanceModal.vue:41 msgctxt "*/*/Button.Label/Verb" msgid "Submit" msgstr "" -#: front/src/components/library/EditForm.vue:128 +#: src/components/library/EditForm.vue:128 msgctxt "Content/Library/Button.Label/Verb" msgid "Submit and apply edit" msgstr "" -#: front/src/components/library/EditForm.vue:11 +#: src/components/library/EditForm.vue:11 msgctxt "Content/Library/Button.Label" msgid "Submit another edit" msgstr "" -#: front/src/components/moderation/ReportModal.vue:87 +#: src/components/moderation/ReportModal.vue:87 msgctxt "Popup/*/Button.Label" msgid "Submit report" msgstr "" -#: front/src/views/content/remote/ScanForm.vue:61 +#: src/views/content/remote/ScanForm.vue:15 msgctxt "Content/Library/Input.Label" msgid "Submit search" msgstr "" -#: front/src/views/Search.vue:189 +#: src/views/Search.vue:50 msgctxt "Content/Search/Button.Label/Verb" msgid "Submit Search Query" msgstr "" -#: front/src/components/library/EditForm.vue:131 +#: src/components/library/EditForm.vue:131 msgctxt "Content/Library/Button.Label/Verb" msgid "Submit suggestion" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:19 -#: front/src/components/manage/moderation/UserRequestCard.vue:19 +#: src/components/manage/moderation/ReportCard.vue:19 +#: src/components/manage/moderation/UserRequestCard.vue:19 msgctxt "Content/Moderation/*" msgid "Submitted by" msgstr "" -#: front/src/components/library/Podcasts.vue:114 -#: front/src/views/channels/SubscriptionsList.vue:29 +#: src/components/library/Podcasts.vue:114 +#: src/views/channels/SubscriptionsList.vue:29 msgctxt "*/*/*/Verb" msgid "Subscribe" msgstr "" -#: front/src/components/channels/SubscribeButton.vue:54 +#: src/components/channels/SubscribeButton.vue:13 msgctxt "Content/Channel/Button/Verb" msgid "Subscribe" msgstr "" -#: front/src/components/channels/SubscribeButton.vue:6 -#: front/src/components/channels/SubscribeButton.vue:12 +#: src/components/channels/SubscribeButton.vue:6 +#: src/components/channels/SubscribeButton.vue:12 msgctxt "Content/Track/*/Verb" msgid "Subscribe" msgstr "" -#: front/src/views/channels/DetailBase.vue:49 src/views/channels/DetailBase.vue:44 -#: front/src/views/channels/DetailBase.vue:3 +#: src/views/channels/DetailBase.vue:49 +#: src/views/channels/DetailBase.vue:44 +#: src/views/channels/DetailBase.vue:3 msgctxt "Content/Channels/Header" msgid "Subscribe on Funkwhale" msgstr "" -#: front/src/views/channels/DetailBase.vue:72 src/views/channels/DetailBase.vue:67 -#: front/src/views/channels/DetailBase.vue:3 +#: src/views/channels/DetailBase.vue:72 +#: src/views/channels/DetailBase.vue:67 +#: src/views/channels/DetailBase.vue:3 msgctxt "Content/Channels/Header" msgid "Subscribe on the Fediverse" msgstr "" -#: front/src/components/RemoteSearchForm.vue:130 +#: src/components/RemoteSearchForm.vue:32 msgctxt "Head/Fetch/Title" msgid "Subscribe to a podcast hosted on the Fediverse" msgstr "" -#: front/src/components/RemoteSearchForm.vue:126 src/views/Search.vue:194 +#: src/components/RemoteSearchForm.vue:28 +#: src/views/Search.vue:55 msgctxt "Head/Fetch/Title" msgid "Subscribe to a podcast RSS feed" msgstr "" -#: front/src/components/library/Podcasts.vue:88 +#: src/components/library/Podcasts.vue:88 msgctxt "Content/Profile/Button" msgid "Subscribe to feed" msgstr "" -#: front/src/components/audio/SearchBar.vue:190 +#: src/components/audio/SearchBar.vue:167 msgctxt "Search/*/*" msgid "Subscribe to podcast via RSS" msgstr "" -#: front/src/views/channels/DetailBase.vue:40 src/views/channels/DetailBase.vue:35 +#: src/views/channels/DetailBase.vue:40 +#: src/views/channels/DetailBase.vue:35 msgctxt "Popup/Channel/Title/Verb" msgid "Subscribe to this channel" msgstr "" -#: front/src/views/channels/DetailBase.vue:58 src/views/channels/DetailBase.vue:53 -#: front/src/views/channels/DetailBase.vue:3 +#: src/views/channels/DetailBase.vue:58 +#: src/views/channels/DetailBase.vue:53 +#: src/views/channels/DetailBase.vue:3 msgctxt "Content/Channels/Header" msgid "Subscribe via RSS" msgstr "" -#: front/src/views/channels/SubscriptionsList.vue:102 +#: src/views/channels/SubscriptionsList.vue:30 msgctxt "Content/Subscriptions/Header" msgid "Subscribed Channels" msgstr "" -#: front/src/components/library/Podcasts.vue:99 -#: front/src/views/channels/SubscriptionsList.vue:14 +#: src/components/library/Podcasts.vue:99 +#: src/views/channels/SubscriptionsList.vue:14 msgctxt "*/*/*/Noun" msgid "Subscription" msgstr "" -#: front/src/views/admin/Settings.vue:77 +#: src/views/admin/Settings.vue:33 msgctxt "Content/Admin/Menu" msgid "Subsonic" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:3 +#: src/components/auth/SubsonicTokenForm.vue:3 msgctxt "Content/Settings/Title" msgid "Subsonic API password" msgstr "" -#: front/src/components/library/EditForm.vue:41 +#: src/components/library/EditForm.vue:41 msgctxt "Content/Library/Paragraph" msgid "Suggest a change using the form below." msgstr "" -#: front/src/components/library/AlbumEdit.vue:7 +#: src/components/library/AlbumEdit.vue:7 msgctxt "Content/*/Title" msgid "Suggest an edit on this album" msgstr "" -#: front/src/components/library/ArtistEdit.vue:7 +#: src/components/library/ArtistEdit.vue:7 msgctxt "Content/*/Title" msgid "Suggest an edit on this artist" msgstr "" -#: front/src/components/library/TrackEdit.vue:7 +#: src/components/library/TrackEdit.vue:7 msgctxt "Content/*/Title" msgid "Suggest an edit on this track" msgstr "" -#: front/src/components/SetInstanceModal.vue:52 +#: src/components/SetInstanceModal.vue:52 msgctxt "Popup/Instance/List.Label" msgid "Suggested choices" msgstr "" -#: front/src/components/library/EditForm.vue:119 +#: src/components/library/EditForm.vue:119 msgctxt "*/*/*" msgid "Summary (optional)" msgstr "" -#: front/src/components/Footer.vue:75 -msgctxt "Footer/*/Listitem.Link" -msgid "Support forum" -msgstr "" - -#: front/src/views/Notifications.vue:14 +#: src/views/Notifications.vue:14 msgctxt "Content/Notifications/Header" msgid "Support this Funkwhale pod" msgstr "" -#: front/src/components/channels/UploadForm.vue:121 -#: front/src/components/channels/UploadForm.vue:86 -#: front/src/components/channels/UploadForm.vue:75 -#: front/src/components/library/FileUpload.vue:78 +#: src/components/channels/UploadForm.vue:121 +#: src/components/channels/UploadForm.vue:86 +#: src/components/channels/UploadForm.vue:75 +#: src/components/library/FileUpload.vue:78 msgctxt "Content/Library/Paragraph" msgid "Supported extensions: %{ extensions }" msgstr "" -#: front/src/components/playlists/Editor.vue:11 src/components/playlists/Editor.vue:2 +#: src/components/playlists/Editor.vue:11 +#: src/components/playlists/Editor.vue:2 msgctxt "Content/Playlist/Paragraph" msgid "Syncing changes to server…" msgstr "" -#: front/src/components/audio/SearchBar.vue:45 +#: src/components/audio/SearchBar.vue:22 msgctxt "*/*/*/Noun" msgid "Tag" msgstr "" -#: front/src/views/admin/library/TagDetail.vue:70 -#: front/src/views/admin/library/TagDetail.vue:65 +#: src/views/admin/library/TagDetail.vue:70 +#: src/views/admin/library/TagDetail.vue:65 msgctxt "Content/Moderation/Title" msgid "Tag data" msgstr "" -#: front/src/components/audio/ChannelForm.vue:72 -#: front/src/components/audio/ChannelForm.vue:58 -#: front/src/components/audio/ChannelForm.vue:38 src/views/Search.vue:240 +#: src/components/audio/ChannelForm.vue:72 +#: src/components/audio/ChannelForm.vue:58 +#: src/components/audio/ChannelForm.vue:38 +#: src/views/Search.vue:101 msgctxt "*/*/*" msgid "Tags" msgstr "" -#: front/src/components/channels/UploadMetadataForm.vue:17 -#: front/src/components/library/Albums.vue:22 src/components/library/Artists.vue:22 -#: front/src/components/library/Podcasts.vue:22 src/views/admin/library/Base.vue:39 -#: front/src/views/admin/library/TagsList.vue:29 src/edits.js:51 src/edits.js:79 -#: front/src/edits.js:122 src/entities.js:120 +#: src/components/channels/UploadMetadataForm.vue:17 +#: src/components/library/Albums.vue:22 +#: src/components/library/Artists.vue:22 +#: src/components/library/Podcasts.vue:22 +#: src/views/admin/library/Base.vue:39 +#: src/views/admin/library/TagsList.vue:13 msgctxt "*/*/*/Noun" msgid "Tags" msgstr "" -#: front/src/components/mixins/Translations.vue:48 -#: front/src/components/mixins/Translations.vue:49 +#: src/components/mixins/Translations.vue:48 msgctxt "Content/Moderation/Dropdown" msgid "Takedown request" msgstr "" -#: front/src/components/AboutPod.vue:28 src/components/AboutPod.vue:71 +#: src/components/AboutPod.vue:28 +#: src/components/AboutPod.vue:71 msgctxt "Content/About/Header" msgid "Terms and privacy policy" msgstr "" -#: front/src/components/audio/EmbedWizard.vue:49 -#: front/src/components/common/CopyInput.vue:3 -#: front/src/components/forms/PasswordInput.vue:65 -#: front/src/components/forms/PasswordInput.vue:67 +#: src/components/audio/EmbedWizard.vue:49 +#: src/components/common/CopyInput.vue:3 +#: src/components/forms/PasswordInput.vue:35 msgctxt "Content/*/Paragraph" msgid "Text copied to clipboard!" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:48 +#: src/components/library/AlbumDropdown.vue:48 msgctxt "Content/Moderation/Paragraph" msgid "The album will be deleted, as well as any related files and data. This action is irreversible." msgstr "" -#: front/src/views/admin/library/AlbumDetail.vue:84 -#: front/src/views/admin/library/AlbumDetail.vue:79 +#: src/views/admin/library/AlbumDetail.vue:84 +#: src/views/admin/library/AlbumDetail.vue:79 msgctxt "Content/Moderation/Paragraph" msgid "The album will be removed, as well as associated uploads, tracks, favorites and listening history. This action is irreversible." msgstr "" -#: front/src/components/auth/Authorize.vue:57 +#: src/components/auth/Authorize.vue:57 msgctxt "Content/Auth/Paragraph" msgid "The application is also requesting the following unknown permissions:" msgstr "" -#: front/src/views/admin/library/ArtistDetail.vue:83 -#: front/src/views/admin/library/ArtistDetail.vue:78 +#: src/views/admin/library/ArtistDetail.vue:83 +#: src/views/admin/library/ArtistDetail.vue:78 msgctxt "Content/Moderation/Paragraph" msgid "The artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible." msgstr "" -#: front/src/views/channels/DetailBase.vue:125 src/views/channels/DetailBase.vue:120 -#: front/src/views/channels/DetailBase.vue:16 +#: src/views/channels/DetailBase.vue:127 +#: src/views/channels/DetailBase.vue:122 +#: src/views/channels/DetailBase.vue:18 msgctxt "Content/Moderation/Paragraph" msgid "The channel will be deleted, as well as any related files and data. This action is irreversible." msgstr "" -#: front/src/views/admin/ChannelDetail.vue:71 src/views/admin/ChannelDetail.vue:66 +#: src/views/admin/ChannelDetail.vue:71 +#: src/views/admin/ChannelDetail.vue:66 msgctxt "Content/Moderation/Paragraph" msgid "The channel will be removed, as well as associated uploads, tracks, and albums. This action is irreversible." msgstr "" -#: front/src/components/Footer.vue:91 -msgctxt "Footer/*/List item.Link" -msgid "The Funkwhale logo was kindly designed and provided by Francis Gading." -msgstr "" - -#: front/src/components/SetInstanceModal.vue:21 +#: src/components/SetInstanceModal.vue:21 msgctxt "Popup/Instance/Error message.List item" msgid "The given address is not a Funkwhale server" msgstr "" -#: front/src/views/content/libraries/Form.vue:58 +#: src/views/content/libraries/Form.vue:58 msgctxt "Popup/Library/Paragraph" msgid "The library and all its tracks will be deleted. This can not be undone." msgstr "" -#: front/src/views/admin/library/LibraryDetail.vue:57 -#: front/src/views/admin/library/LibraryDetail.vue:52 +#: src/views/admin/library/LibraryDetail.vue:57 +#: src/views/admin/library/LibraryDetail.vue:52 msgctxt "Content/Moderation/Paragraph" msgid "The library will be removed, as well as associated uploads, and follows. This action is irreversible." msgstr "" -#: front/src/components/library/ImportStatusModal.vue:199 +#: src/components/library/ImportStatusModal.vue:65 msgctxt "Popup/Import/Error.Label" msgid "The metadata included in the file is invalid or some mandatory fields are missing." msgstr "" -#: front/src/components/library/FileUpload.vue:65 +#: src/components/library/FileUpload.vue:65 msgctxt "Content/Library/List item" msgid "The music files you are uploading are in OGG, Flac, MP3 or AIFF format" msgstr "" -#: front/src/components/library/FileUpload.vue:59 +#: src/components/library/FileUpload.vue:59 msgctxt "Content/Library/List item" msgid "The music files you are uploading are tagged properly." msgstr "" -#: front/src/components/Queue.vue:36 src/components/Queue.vue:29 +#: src/components/Queue.vue:36 +#: src/components/Queue.vue:29 msgctxt "Sidebar/Player/Error message.Paragraph" msgid "The next track will play automatically in a few seconds…" msgstr "" -#: front/src/components/manage/moderation/NotesThread.vue:31 +#: src/components/manage/moderation/NotesThread.vue:31 msgctxt "Content/Moderation/Paragraph" msgid "The note will be removed. This action is irreversible." msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:144 +#: src/components/manage/moderation/ReportCard.vue:144 msgctxt "Content/Moderation/Message" msgid "The object associated with this report was deleted." msgstr "" -#: front/src/components/playlists/Form.vue:23 +#: src/components/playlists/Form.vue:23 msgctxt "Content/Playlist/Error message.Title" msgid "The playlist could not be created" msgstr "" -#: front/src/components/federation/FetchButton.vue:130 +#: src/components/federation/FetchButton.vue:130 msgctxt "Popup/*/Message.Content" msgid "The refresh request hasn't been processed in time by our server. It will be processed later." msgstr "" -#: front/src/components/federation/FetchButton.vue:67 -#: front/src/components/federation/FetchButton.vue:55 +#: src/components/federation/FetchButton.vue:67 +#: src/components/federation/FetchButton.vue:55 msgctxt "*/*/Error" msgid "The remote server answered with HTTP %{ status }" msgstr "" -#: front/src/components/federation/FetchButton.vue:20 -#: front/src/components/federation/FetchButton.vue:8 +#: src/components/federation/FetchButton.vue:20 +#: src/components/federation/FetchButton.vue:8 msgctxt "Popup/*/Message.Content" msgid "The remote server answered, but returned data was unsupported by Funkwhale." msgstr "" -#: front/src/components/federation/FetchButton.vue:73 -#: front/src/components/federation/FetchButton.vue:61 +#: src/components/federation/FetchButton.vue:73 +#: src/components/federation/FetchButton.vue:61 msgctxt "*/*/Error" msgid "The remote server didn't respond quickly enough" msgstr "" -#: front/src/components/federation/FetchButton.vue:79 -#: front/src/components/federation/FetchButton.vue:67 +#: src/components/federation/FetchButton.vue:79 +#: src/components/federation/FetchButton.vue:67 msgctxt "*/*/Error" msgid "The remote server returned invalid JSON or JSON-LD data" msgstr "" -#: front/src/components/manage/library/AlbumsTable.vue:247 +#: src/components/manage/library/AlbumsTable.vue:56 msgctxt "Popup/*/Paragraph" msgid "The selected albums will be removed, as well as associated tracks, uploads, favorites and listening history. This action is irreversible." msgstr "" -#: front/src/components/manage/library/ArtistsTable.vue:244 +#: src/components/manage/library/ArtistsTable.vue:54 msgctxt "Popup/*/Paragraph" msgid "The selected artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible." msgstr "" -#: front/src/components/manage/library/LibrariesTable.vue:276 +#: src/components/manage/library/LibrariesTable.vue:55 msgctxt "Popup/*/Paragraph" msgid "The selected library will be removed, as well as associated uploads and follows. This action is irreversible." msgstr "" -#: front/src/components/manage/library/TagsTable.vue:212 +#: src/components/manage/library/TagsTable.vue:60 msgctxt "Popup/*/Paragraph" msgid "The selected tag will be removed and unlinked with existing content, if any. This action is irreversible." msgstr "" -#: front/src/components/manage/library/TracksTable.vue:257 +#: src/components/manage/library/TracksTable.vue:53 msgctxt "Popup/*/Paragraph" msgid "The selected tracks will be removed, as well as associated uploads, favorites and listening history. This action is irreversible." msgstr "" -#: front/src/components/manage/library/UploadsTable.vue:375 +#: src/components/manage/library/UploadsTable.vue:62 msgctxt "Popup/*/Paragraph" msgid "The selected upload will be removed. This action is irreversible." msgstr "" -#: front/src/components/SetInstanceModal.vue:16 +#: src/components/SetInstanceModal.vue:16 msgctxt "Popup/Instance/Error message.List item" msgid "The server might be down" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:8 +#: src/components/auth/SubsonicTokenForm.vue:8 msgctxt "Content/Settings/Paragraph" msgid "The Subsonic API is not available on this Funkwhale instance." msgstr "" -#: front/src/components/library/EditCard.vue:131 +#: src/components/library/EditCard.vue:131 msgctxt "Popup/Library/Paragraph" msgid "The suggestion will be completely removed, this action is irreversible." msgstr "" -#: front/src/views/admin/library/TagDetail.vue:46 -#: front/src/views/admin/library/TagDetail.vue:41 +#: src/views/admin/library/TagDetail.vue:46 +#: src/views/admin/library/TagDetail.vue:41 msgctxt "Content/Moderation/Paragraph" msgid "The tag will be removed and unlinked from any existing entity. This action is irreversible." msgstr "" -#: front/src/components/playlists/PlaylistModal.vue:38 +#: src/components/playlists/PlaylistModal.vue:38 msgctxt "Popup/Playlist/Error message.Title" msgid "The track can't be added to a playlist" msgstr "" -#: front/src/components/Queue.vue:31 src/components/Queue.vue:24 +#: src/components/Queue.vue:31 +#: src/components/Queue.vue:24 msgctxt "Sidebar/Player/Error message.Title" msgid "The track cannot be loaded" msgstr "" -#: front/src/components/library/TrackBase.vue:85 -#: front/src/components/library/TrackBase.vue:80 +#: src/components/library/TrackBase.vue:85 +#: src/components/library/TrackBase.vue:80 msgctxt "Content/Moderation/Paragraph" msgid "The track will be deleted, as well as any related files and data. This action is irreversible." msgstr "" -#: front/src/views/admin/library/TrackDetail.vue:84 -#: front/src/views/admin/library/TrackDetail.vue:79 +#: src/views/admin/library/TrackDetail.vue:84 +#: src/views/admin/library/TrackDetail.vue:79 msgctxt "Content/Moderation/Paragraph" msgid "The track will be removed, as well as associated uploads, favorites and listening history. This action is irreversible." msgstr "" -#: front/src/views/admin/library/UploadDetail.vue:64 -#: front/src/views/admin/library/UploadDetail.vue:59 +#: src/views/admin/library/UploadDetail.vue:64 +#: src/views/admin/library/UploadDetail.vue:59 msgctxt "Content/Moderation/Paragraph" msgid "The upload will be removed. This action is irreversible." msgstr "" -#: front/src/components/Sidebar.vue:530 src/components/common/UserModal.vue:204 -#: front/src/components/common/UserModal.vue:206 src/components/Sidebar.vue:532 +#: src/components/Sidebar.vue:64 +#: src/components/common/UserModal.vue:36 msgctxt "Sidebar/Settings/Dropdown.Label/Short, Verb" msgid "Theme" msgstr "" -#: front/src/views/playlists/Detail.vue:106 +#: src/views/playlists/Detail.vue:106 msgctxt "Content/Home/Placeholder" msgid "There are no tracks in this playlist yet" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:88 +#: src/components/manage/moderation/InstancePolicyForm.vue:88 msgctxt "Popup/Moderation/Paragraph" msgid "This action is irreversible." msgstr "" -#: front/src/components/library/AlbumDetail.vue:31 -#: front/src/components/library/AlbumDetail.vue:7 +#: src/components/library/AlbumDetail.vue:31 +#: src/components/library/AlbumDetail.vue:7 msgctxt "Content/Album/Paragraph" msgid "This album is present in the following libraries:" msgstr "" -#: front/src/components/library/ArtistDetail.vue:60 +#: src/components/library/ArtistDetail.vue:60 msgctxt "Content/Artist/Paragraph" msgid "This artist is present in the following libraries:" msgstr "" -#: front/src/components/manage/moderation/DomainsTable.vue:228 +#: src/components/manage/moderation/DomainsTable.vue:40 msgctxt "Content/Moderation/Popup" msgid "This domain is present in your allow-list" msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:86 -#: front/src/views/admin/moderation/AccountsDetail.vue:81 -#: front/src/views/admin/moderation/DomainsDetail.vue:78 -#: front/src/views/admin/moderation/DomainsDetail.vue:73 +#: src/views/admin/moderation/AccountsDetail.vue:86 +#: src/views/admin/moderation/AccountsDetail.vue:81 +#: src/views/admin/moderation/DomainsDetail.vue:78 +#: src/views/admin/moderation/DomainsDetail.vue:73 msgctxt "Content/Moderation/Card.Title" msgid "This domain is subject to specific moderation rules" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyModal.vue:20 +#: src/components/manage/moderation/InstancePolicyModal.vue:20 msgctxt "Content/Moderation/Card.Title" msgid "This entity is subject to specific moderation rules" msgstr "" -#: front/src/views/content/Home.vue:5 +#: src/views/content/Home.vue:5 msgctxt "Content/Library/Paragraph" msgid "This instance offers up to %{quota} of storage space for every user." msgstr "" -#: front/src/components/auth/Settings.vue:503 +#: src/components/auth/Settings.vue:503 msgctxt "Popup/Settings/Paragraph" msgid "This is irreversible and will permanently remove your data from our servers. You will we immediatly logged out." msgstr "" -#: front/src/components/auth/Settings.vue:228 +#: src/components/auth/Settings.vue:228 msgctxt "Content/Settings/Paragraph" msgid "This is the list of applications that have access to your account data." msgstr "" -#: front/src/components/auth/Settings.vue:305 +#: src/components/auth/Settings.vue:305 msgctxt "Content/Settings/Paragraph" msgid "This is the list of applications that you have registered." msgstr "" -#: front/src/views/auth/ProfileBase.vue:42 src/views/auth/ProfileBase.vue:3 +#: src/views/auth/ProfileBase.vue:42 +#: src/views/auth/ProfileBase.vue:3 msgctxt "Content/Profile/Button.Paragraph" msgid "This is you!" msgstr "" -#: front/src/components/RemoteSearchForm.vue:54 +#: src/components/RemoteSearchForm.vue:54 msgctxt "Content/*/Error message.Title" msgid "This kind of object isn't supported yet" msgstr "" -#: front/src/views/content/libraries/Form.vue:143 +#: src/views/content/libraries/Form.vue:27 msgctxt "Content/Library/Input.Placeholder" msgid "This library contains my personal music, I hope you like it." msgstr "" -#: front/src/views/library/DetailAlbums.vue:5 src/views/library/DetailOverview.vue:9 -#: front/src/views/library/DetailTracks.vue:5 +#: src/views/library/DetailAlbums.vue:5 +#: src/views/library/DetailOverview.vue:9 +#: src/views/library/DetailTracks.vue:5 msgctxt "*/*/*" msgid "This library is empty, you should upload something in it!" msgstr "" -#: front/src/views/content/remote/Card.vue:252 src/views/library/DetailBase.vue:265 +#: src/views/content/remote/Card.vue:27 +#: src/views/library/DetailBase.vue:37 msgctxt "Content/Library/Card.Help text" msgid "This library is private and your approval from its owner is needed to access its content" msgstr "" -#: front/src/views/content/remote/Card.vue:253 src/views/library/DetailBase.vue:267 +#: src/views/content/remote/Card.vue:28 +#: src/views/library/DetailBase.vue:39 msgctxt "Content/Library/Card.Help text" msgid "This library is public and you can access its content freely" msgstr "" -#: front/src/views/library/DetailBase.vue:266 +#: src/views/library/DetailBase.vue:38 msgctxt "Content/Library/Card.Help text" msgid "This library is restricted to users on this pod only" msgstr "" -#: front/src/components/common/ActionTable.vue:39 +#: src/components/common/ActionTable.vue:39 msgctxt "Modal/*/Paragraph" msgid "This may affect a lot of elements or have irreversible consequences, please double check this is really what you want." msgstr "" -#: front/src/components/RemoteSearchForm.vue:227 +#: src/components/RemoteSearchForm.vue:129 msgctxt "Content/*/Error message.Title" msgid "This object cannot be retrieved" msgstr "" -#: front/src/components/library/AlbumEdit.vue:12 -#: front/src/components/library/ArtistEdit.vue:12 -#: front/src/components/library/TrackEdit.vue:12 +#: src/components/library/AlbumEdit.vue:12 +#: src/components/library/ArtistEdit.vue:12 +#: src/components/library/TrackEdit.vue:12 msgctxt "Content/*/Message" msgid "This object is managed by another server, you cannot edit it." msgstr "" -#: front/src/components/Home.vue:102 +#: src/components/Home.vue:102 msgctxt "Content/Home/Paragraph" msgid "This pod runs Funkwhale, a community-driven project that lets you listen and share music and audio within a decentralized, open network." msgstr "" -#: front/src/components/mixins/Translations.vue:37 -#: front/src/components/mixins/Translations.vue:38 +#: src/components/mixins/Translations.vue:37 msgctxt "Content/Library/Help text" msgid "This track could not be processed, please make sure it is tagged correctly" msgstr "" -#: front/src/components/mixins/Translations.vue:33 -#: front/src/components/mixins/Translations.vue:34 +#: src/components/mixins/Translations.vue:33 msgctxt "Content/Library/Help text" msgid "This track has been uploaded, but hasn't been processed by the server yet" msgstr "" -#: front/src/components/mixins/Translations.vue:29 -#: front/src/components/mixins/Translations.vue:30 +#: src/components/mixins/Translations.vue:29 msgctxt "Content/Library/Help text" msgid "This track has been uploaded, but hasn't been scheduled for processing yet" msgstr "" -#: front/src/components/mixins/Translations.vue:25 -#: front/src/components/mixins/Translations.vue:26 +#: src/components/mixins/Translations.vue:25 msgctxt "Content/Library/Help text" msgid "This track is already present in one of your libraries" msgstr "" -#: front/src/components/audio/PlayButton.vue:190 +#: src/components/audio/PlayButton.vue:64 msgctxt "*/Queue/Button/Title" msgid "This track is not available in any library you have access to" msgstr "" -#: front/src/components/library/TrackDetail.vue:209 +#: src/components/library/TrackDetail.vue:210 msgctxt "Content/Track/Paragraph" msgid "This track is present in the following libraries:" msgstr "" -#: front/src/views/auth/ProfileOverview.vue:33 +#: src/views/auth/ProfileOverview.vue:33 msgctxt "Content/Profile/Paragraph" msgid "This user shared the following libraries" msgstr "" -#: front/src/components/manage/moderation/UserRequestCard.vue:124 +#: src/components/manage/moderation/UserRequestCard.vue:124 msgctxt "Content/Moderation/Paragraph" msgid "This user wants to sign-up on your pod." msgstr "" -#: front/src/views/playlists/Detail.vue:58 +#: src/views/playlists/Detail.vue:58 msgctxt "Popup/Playlist/Paragraph" msgid "This will completely delete this playlist and cannot be undone." msgstr "" -#: front/src/views/radios/Detail.vue:30 src/views/radios/Detail.vue:11 +#: src/views/radios/Detail.vue:30 +#: src/views/radios/Detail.vue:11 msgctxt "Popup/Radio/Paragraph" msgid "This will completely delete this radio and cannot be undone." msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:86 -#: front/src/components/auth/SubsonicTokenForm.vue:40 +#: src/components/auth/SubsonicTokenForm.vue:86 +#: src/components/auth/SubsonicTokenForm.vue:40 msgctxt "Popup/Settings/Paragraph" msgid "This will completely disable access to the Subsonic API using from account." msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:497 +#: src/components/manage/moderation/ReportCard.vue:106 msgctxt "Content/Moderation/Popup,Paragraph" msgid "This will delete the object associated with this report and mark the report as resolved. The deletion is irreversible." msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:61 -#: front/src/components/auth/SubsonicTokenForm.vue:15 +#: src/components/auth/SubsonicTokenForm.vue:61 +#: src/components/auth/SubsonicTokenForm.vue:15 msgctxt "Popup/Settings/Paragraph" msgid "This will log you out from existing devices that use the current password." msgstr "" -#: front/src/components/auth/Settings.vue:362 +#: src/components/auth/Settings.vue:362 msgctxt "Popup/Settings/Paragraph" msgid "This will permanently remove the application and all the associated tokens." msgstr "" -#: front/src/components/auth/Settings.vue:271 +#: src/components/auth/Settings.vue:271 msgctxt "Popup/Settings/Paragraph" msgid "This will prevent this application from accessing the service on your behalf." msgstr "" -#: front/src/components/playlists/Editor.vue:65 +#: src/components/playlists/Editor.vue:65 msgctxt "Popup/Playlist/Paragraph" msgid "This will remove all tracks from this playlist and cannot be undone." msgstr "" -#: front/src/components/audio/podcast/Table.vue:121 -#: front/src/components/audio/track/Table.vue:209 -#: front/src/components/channels/AlbumForm.vue:16 -#: front/src/components/channels/UploadMetadataForm.vue:4 -#: front/src/components/manage/library/AlbumsTable.vue:42 -#: front/src/components/manage/library/AlbumsTable.vue:2 -#: front/src/components/manage/library/TracksTable.vue:42 -#: front/src/components/manage/library/TracksTable.vue:2 -#: front/src/views/admin/library/AlbumDetail.vue:117 -#: front/src/views/admin/library/AlbumDetail.vue:112 -#: front/src/views/admin/library/TrackDetail.vue:117 -#: front/src/views/admin/library/TrackDetail.vue:112 -#: front/src/views/content/libraries/FilesTable.vue:94 -#: front/src/views/content/libraries/FilesTable.vue:2 src/edits.js:63 src/edits.js:91 +#: src/components/audio/podcast/Table.vue:42 +#: src/components/audio/track/Table.vue:53 +#: src/components/channels/AlbumForm.vue:16 +#: src/components/channels/UploadMetadataForm.vue:4 +#: src/components/manage/library/AlbumsTable.vue:42 +#: src/components/manage/library/AlbumsTable.vue:2 +#: src/components/manage/library/TracksTable.vue:42 +#: src/components/manage/library/TracksTable.vue:2 +#: src/views/admin/library/AlbumDetail.vue:117 +#: src/views/admin/library/AlbumDetail.vue:112 +#: src/views/admin/library/TrackDetail.vue:117 +#: src/views/admin/library/TrackDetail.vue:112 +#: src/views/content/libraries/FilesTable.vue:94 +#: src/views/content/libraries/FilesTable.vue:2 msgctxt "*/*/*/Noun" msgid "Title" msgstr "" -#: front/src/components/SetInstanceModal.vue:32 +#: src/components/SetInstanceModal.vue:32 msgctxt "Popup/Instance/Paragraph" msgid "To continue, please select the Funkwhale instance you want to connect to. Enter the address directly, or select one of the suggested choices." msgstr "" -#: front/src/components/ShortcutsModal.vue:154 +#: src/components/ShortcutsModal.vue:94 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Toggle favorite" msgstr "" -#: front/src/components/ShortcutsModal.vue:134 +#: src/components/ShortcutsModal.vue:74 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Toggle mute" msgstr "" -#: front/src/components/ShortcutsModal.vue:142 +#: src/components/ShortcutsModal.vue:82 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Toggle queue looping" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:298 src/views/admin/ChannelDetail.vue:293 -#: front/src/views/admin/library/AlbumDetail.vue:279 -#: front/src/views/admin/library/AlbumDetail.vue:274 -#: front/src/views/admin/library/ArtistDetail.vue:278 -#: front/src/views/admin/library/ArtistDetail.vue:273 -#: front/src/views/admin/library/LibraryDetail.vue:241 -#: front/src/views/admin/library/LibraryDetail.vue:236 -#: front/src/views/admin/library/TrackDetail.vue:344 -#: front/src/views/admin/library/TrackDetail.vue:339 -#: front/src/views/admin/moderation/AccountsDetail.vue:374 -#: front/src/views/admin/moderation/AccountsDetail.vue:369 -#: front/src/views/admin/moderation/DomainsDetail.vue:295 -#: front/src/views/admin/moderation/DomainsDetail.vue:290 +#: src/views/admin/ChannelDetail.vue:298 +#: src/views/admin/ChannelDetail.vue:293 +#: src/views/admin/library/AlbumDetail.vue:279 +#: src/views/admin/library/AlbumDetail.vue:274 +#: src/views/admin/library/ArtistDetail.vue:278 +#: src/views/admin/library/ArtistDetail.vue:273 +#: src/views/admin/library/LibraryDetail.vue:241 +#: src/views/admin/library/LibraryDetail.vue:236 +#: src/views/admin/library/TrackDetail.vue:344 +#: src/views/admin/library/TrackDetail.vue:339 +#: src/views/admin/moderation/AccountsDetail.vue:374 +#: src/views/admin/moderation/AccountsDetail.vue:369 +#: src/views/admin/moderation/DomainsDetail.vue:295 +#: src/views/admin/moderation/DomainsDetail.vue:290 msgctxt "Content/Moderation/Table.Label" msgid "Total size" msgstr "" -#: front/src/views/content/libraries/Card.vue:88 +#: src/views/content/libraries/Card.vue:8 msgctxt "Content/Library/Card.Help text" msgid "Total size of the files in this library" msgstr "" -#: front/src/views/admin/moderation/DomainsDetail.vue:155 -#: front/src/views/admin/moderation/DomainsDetail.vue:150 -#: front/src/views/admin/moderation/DomainsDetail.vue:23 +#: src/views/admin/moderation/DomainsDetail.vue:155 +#: src/views/admin/moderation/DomainsDetail.vue:150 +#: src/views/admin/moderation/DomainsDetail.vue:23 msgctxt "Content/*/*" msgid "Total users" msgstr "" -#: front/src/components/AboutPod.vue:244 src/components/AboutPod.vue:32 +#: src/components/AboutPod.vue:244 +#: src/components/AboutPod.vue:32 msgctxt "Content/About/*" msgid "track" msgid_plural "tracks" msgstr[0] "" msgstr[1] "" -#: front/src/components/audio/SearchBar.vue:44 -#: front/src/components/library/TrackBase.vue:290 -#: front/src/components/library/TrackDetail.vue:315 -#: front/src/components/mixins/Report.vue:30 -#: front/src/views/admin/library/UploadDetail.vue:226 -#: front/src/views/admin/library/UploadDetail.vue:221 -#: front/src/components/mixins/Report.vue:31 +#: src/components/audio/SearchBar.vue:21 +#: src/components/library/TrackBase.vue:69 +#: src/components/library/TrackDetail.vue:25 +#: src/components/mixins/Report.vue:30 +#: src/views/admin/library/UploadDetail.vue:226 +#: src/views/admin/library/UploadDetail.vue:221 msgctxt "*/*/*/Noun" msgid "Track" msgstr "" -#: front/src/components/library/EditCard.vue:13 +#: src/components/library/EditCard.vue:13 msgctxt "Content/Library/Card/Short" msgid "Track #%{ id } - %{ name }" msgstr "" -#: front/src/components/Queue.vue:113 +#: src/components/Queue.vue:113 msgctxt "Sidebar/Queue/Text" msgid "Track %{ index } of %{ length }" msgstr "" -#: front/src/views/admin/library/TrackDetail.vue:108 -#: front/src/views/admin/library/TrackDetail.vue:103 +#: src/views/admin/library/TrackDetail.vue:108 +#: src/views/admin/library/TrackDetail.vue:103 msgctxt "Content/Moderation/Title" msgid "Track data" msgstr "" -#: front/src/components/audio/PlayButton.vue:29 -#: front/src/components/audio/podcast/Modal.vue:271 -#: front/src/components/audio/track/Modal.vue:271 +#: src/components/audio/PlayButton.vue:29 +#: src/components/audio/podcast/Modal.vue:43 +#: src/components/audio/track/Modal.vue:43 msgctxt "*/Queue/Dropdown/Button/Label/Short" msgid "Track details" msgstr "" -#: front/src/components/library/TrackDetail.vue:9 -#: front/src/components/library/TrackDetail.vue:4 +#: src/components/library/TrackDetail.vue:10 +#: src/components/library/TrackDetail.vue:5 msgctxt "Content/*/*" msgid "Track Details" msgstr "" -#: front/src/components/mixins/Translations.vue:75 -#: front/src/components/mixins/Translations.vue:76 +#: src/components/mixins/Translations.vue:75 msgctxt "Content/*/Dropdown/Noun" msgid "Track name" msgstr "" -#: front/src/components/channels/UploadMetadataForm.vue:9 +#: src/components/channels/UploadMetadataForm.vue:9 msgctxt "Content/Channel/*" msgid "Track Picture" msgstr "" -#: front/src/components/library/AlbumDetail.vue:6 -#: front/src/components/library/TagDetail.vue:50 -#: front/src/components/library/TagDetail.vue:1 -#: front/src/components/manage/ChannelsTable.vue:81 -#: front/src/components/manage/ChannelsTable.vue:22 -#: front/src/components/manage/library/AlbumsTable.vue:57 -#: front/src/components/manage/library/AlbumsTable.vue:17 -#: front/src/components/manage/library/ArtistsTable.vue:76 -#: front/src/components/manage/library/ArtistsTable.vue:17 -#: front/src/components/manage/library/TagsTable.vue:58 -#: front/src/components/manage/library/TagsTable.vue:17 -#: front/src/components/playlists/PlaylistModal.vue:76 src/views/Search.vue:227 -#: front/src/views/admin/ChannelDetail.vue:333 src/views/admin/ChannelDetail.vue:328 -#: front/src/views/admin/library/AlbumDetail.vue:315 -#: front/src/views/admin/library/AlbumDetail.vue:310 -#: front/src/views/admin/library/ArtistDetail.vue:326 -#: front/src/views/admin/library/ArtistDetail.vue:321 -#: front/src/views/admin/library/Base.vue:24 -#: front/src/views/admin/library/LibraryDetail.vue:276 -#: front/src/views/admin/library/LibraryDetail.vue:271 -#: front/src/views/admin/library/TagDetail.vue:164 -#: front/src/views/admin/library/TagDetail.vue:159 -#: front/src/views/admin/library/TracksList.vue:29 -#: front/src/views/admin/moderation/AccountsDetail.vue:440 -#: front/src/views/admin/moderation/AccountsDetail.vue:435 -#: front/src/views/admin/moderation/DomainsDetail.vue:366 -#: front/src/views/admin/moderation/DomainsDetail.vue:361 -#: front/src/views/channels/DetailBase.vue:246 src/views/channels/DetailBase.vue:241 -#: front/src/views/content/Base.vue:9 src/views/library/DetailBase.vue:106 -#: front/src/views/playlists/Detail.vue:97 src/views/playlists/Detail.vue:2 -#: front/src/views/radios/Detail.vue:45 +#: src/components/library/AlbumDetail.vue:6 +#: src/components/library/TagDetail.vue:50 +#: src/components/library/TagDetail.vue:1 +#: src/components/manage/ChannelsTable.vue:81 +#: src/components/manage/ChannelsTable.vue:22 +#: src/components/manage/library/AlbumsTable.vue:57 +#: src/components/manage/library/AlbumsTable.vue:17 +#: src/components/manage/library/ArtistsTable.vue:76 +#: src/components/manage/library/ArtistsTable.vue:17 +#: src/components/manage/library/TagsTable.vue:58 +#: src/components/manage/library/TagsTable.vue:17 +#: src/components/playlists/PlaylistModal.vue:76 +#: src/views/Search.vue:88 +#: src/views/admin/ChannelDetail.vue:333 +#: src/views/admin/ChannelDetail.vue:328 +#: src/views/admin/library/AlbumDetail.vue:315 +#: src/views/admin/library/AlbumDetail.vue:310 +#: src/views/admin/library/ArtistDetail.vue:326 +#: src/views/admin/library/ArtistDetail.vue:321 +#: src/views/admin/library/Base.vue:24 +#: src/views/admin/library/LibraryDetail.vue:276 +#: src/views/admin/library/LibraryDetail.vue:271 +#: src/views/admin/library/TagDetail.vue:164 +#: src/views/admin/library/TagDetail.vue:159 +#: src/views/admin/library/TracksList.vue:13 +#: src/views/admin/moderation/AccountsDetail.vue:440 +#: src/views/admin/moderation/AccountsDetail.vue:435 +#: src/views/admin/moderation/DomainsDetail.vue:366 +#: src/views/admin/moderation/DomainsDetail.vue:361 +#: src/views/channels/DetailBase.vue:248 +#: src/views/channels/DetailBase.vue:243 +#: src/views/content/Base.vue:9 +#: src/views/library/DetailBase.vue:106 +#: src/views/playlists/Detail.vue:97 +#: src/views/playlists/Detail.vue:2 +#: src/views/radios/Detail.vue:45 msgctxt "*/*/*" msgid "Tracks" msgstr "" -#: front/src/components/library/radios/Filter.vue:38 +#: src/components/library/radios/Filter.vue:38 msgctxt "Popup/Radio/Title/Noun" msgid "Tracks matching filter" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:164 -#: front/src/views/admin/library/UploadDetail.vue:293 -#: front/src/views/admin/library/UploadDetail.vue:288 -#: front/src/views/admin/moderation/AccountsDetail.vue:192 -#: front/src/views/admin/moderation/AccountsDetail.vue:187 +#: src/components/manage/moderation/ReportCard.vue:164 +#: src/views/admin/library/UploadDetail.vue:293 +#: src/views/admin/library/UploadDetail.vue:288 +#: src/views/admin/moderation/AccountsDetail.vue:192 +#: src/views/admin/moderation/AccountsDetail.vue:187 msgctxt "Content/Track/Table.Label/Noun" msgid "Type" msgstr "" -#: front/src/components/common/LoginModal.vue:73 +#: src/components/common/LoginModal.vue:20 msgctxt "Popup/Title/Noun" msgid "Unauthenticated" msgstr "" -#: front/src/components/manage/moderation/AccountsTable.vue:67 -#: front/src/components/manage/moderation/AccountsTable.vue:27 -#: front/src/components/manage/moderation/DomainsTable.vue:80 -#: front/src/components/manage/moderation/DomainsTable.vue:22 +#: src/components/manage/moderation/AccountsTable.vue:67 +#: src/components/manage/moderation/AccountsTable.vue:27 +#: src/components/manage/moderation/DomainsTable.vue:80 +#: src/components/manage/moderation/DomainsTable.vue:22 msgctxt "Content/Moderation/Table.Label/Short" msgid "Under moderation rule" msgstr "" -#: front/src/components/ShortcutsModal.vue:84 +#: src/components/ShortcutsModal.vue:24 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Unfocus searchbar" msgstr "" -#: front/src/views/content/remote/Card.vue:125 src/views/content/remote/Card.vue:141 -#: front/src/views/content/remote/Card.vue:21 src/views/content/remote/Card.vue:37 -#: front/src/views/content/remote/Card.vue:2 src/views/content/remote/Card.vue:18 +#: src/views/content/remote/Card.vue:125 +#: src/views/content/remote/Card.vue:141 +#: src/views/content/remote/Card.vue:21 +#: src/views/content/remote/Card.vue:37 +#: src/views/content/remote/Card.vue:2 +#: src/views/content/remote/Card.vue:18 msgctxt "*/Library/Button.Label/Verb" msgid "Unfollow" msgstr "" -#: front/src/components/audio/LibraryFollowButton.vue:3 +#: src/components/audio/LibraryFollowButton.vue:3 msgctxt "Content/Library/Card.Button.Label/Verb" msgid "Unfollow" msgstr "" -#: front/src/views/content/remote/Card.vue:129 src/views/content/remote/Card.vue:25 -#: front/src/views/content/remote/Card.vue:6 +#: src/views/content/remote/Card.vue:129 +#: src/views/content/remote/Card.vue:25 +#: src/views/content/remote/Card.vue:6 msgctxt "Popup/Library/Title" msgid "Unfollow this library?" msgstr "" -#: front/src/components/federation/FetchButton.vue:85 -#: front/src/components/federation/FetchButton.vue:88 -#: front/src/components/federation/FetchButton.vue:73 -#: front/src/components/federation/FetchButton.vue:76 -#: front/src/components/library/ImportStatusModal.vue:203 +#: src/components/federation/FetchButton.vue:85 +#: src/components/federation/FetchButton.vue:88 +#: src/components/federation/FetchButton.vue:73 +#: src/components/federation/FetchButton.vue:76 +#: src/components/library/ImportStatusModal.vue:69 msgctxt "*/*/Error" msgid "Unknown error" msgstr "" -#: front/src/components/audio/Player.vue:410 -#: front/src/components/audio/VolumeControl.vue:74 +#: src/components/audio/Player.vue:70 +#: src/components/audio/VolumeControl.vue:22 msgctxt "Sidebar/Player/Icon.Tooltip/Verb" msgid "Unmute" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:267 +#: src/components/manage/moderation/ReportCard.vue:267 msgctxt "Content/*/Button.Label" msgid "Unresolve" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:75 -#: front/src/views/admin/moderation/ReportsList.vue:31 +#: src/components/manage/moderation/ReportCard.vue:75 +#: src/views/admin/moderation/ReportsList.vue:31 msgctxt "Content/*/*/Short" msgid "Unresolved" msgstr "" -#: front/src/components/channels/SubscribeButton.vue:56 +#: src/components/channels/SubscribeButton.vue:15 msgctxt "Content/Channel/Button/Verb" msgid "Unsubscribe" msgstr "" -#: front/src/components/channels/SubscribeButton.vue:3 +#: src/components/channels/SubscribeButton.vue:3 msgctxt "Content/Track/Button.Message" msgid "Unsubscribe" msgstr "" -#: front/src/components/auth/Settings.vue:449 +#: src/components/auth/Settings.vue:449 msgctxt "*/*/*" msgid "Update" msgstr "" -#: front/src/components/channels/UploadModal.vue:44 +#: src/components/channels/UploadModal.vue:44 msgctxt "*/*/Button.Label/Verb" msgid "Update" msgstr "" -#: front/src/App.vue:213 +#: src/AppOld.vue:189 msgctxt "App/Message/Paragraph" msgid "Update" msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:71 +#: src/components/manage/moderation/InstancePolicyForm.vue:71 msgctxt "Content/Moderation/Card.Button.Label/Verb" msgid "Update" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:61 +#: src/components/auth/ApplicationForm.vue:61 msgctxt "Content/Applications/Button.Label/Verb" msgid "Update application" msgstr "" -#: front/src/views/channels/DetailBase.vue:224 src/views/channels/DetailBase.vue:219 +#: src/views/channels/DetailBase.vue:226 +#: src/views/channels/DetailBase.vue:221 msgctxt "*/Channels/Button.Label" msgid "Update channel" msgstr "" -#: front/src/components/common/RenderedDescription.vue:44 +#: src/components/common/RenderedDescription.vue:44 msgctxt "Content/Channels/Button.Label/Verb" msgid "Update description" msgstr "" -#: front/src/views/content/libraries/Form.vue:41 +#: src/views/content/libraries/Form.vue:41 msgctxt "Content/Library/Button.Label/Verb" msgid "Update library" msgstr "" -#: front/src/components/playlists/Form.vue:50 src/components/playlists/Form.vue:1 +#: src/components/playlists/Form.vue:50 +#: src/components/playlists/Form.vue:1 msgctxt "Content/Playlist/Button.Label/Verb" msgid "Update playlist" msgstr "" -#: front/src/components/auth/Settings.vue:42 +#: src/components/auth/Settings.vue:42 msgctxt "Content/Settings/Button.Label/Verb" msgid "Update settings" msgstr "" -#: front/src/views/auth/PasswordResetConfirm.vue:29 -#: front/src/views/auth/PasswordResetConfirm.vue:11 +#: src/views/auth/PasswordResetConfirm.vue:29 +#: src/views/auth/PasswordResetConfirm.vue:11 msgctxt "Content/Signup/Button.Label" msgid "Update your password" msgstr "" -#: front/src/components/audio/ChannelCard.vue:110 +#: src/components/audio/ChannelCard.vue:33 msgctxt "*/*/*" msgid "Updated on %{ date }" msgstr "" -#: front/src/views/channels/DetailBase.vue:169 src/views/channels/DetailBase.vue:164 +#: src/views/channels/DetailBase.vue:171 +#: src/views/channels/DetailBase.vue:166 msgctxt "Content/Channels/Button.Label/Verb" msgid "Upload" msgstr "" -#: front/src/views/content/libraries/Card.vue:38 src/views/library/DetailBase.vue:112 +#: src/views/content/libraries/Card.vue:38 +#: src/views/library/DetailBase.vue:112 msgctxt "Content/Library/Card.Button.Label/Verb" msgid "Upload" msgstr "" -#: front/src/views/admin/library/UploadDetail.vue:88 -#: front/src/views/admin/library/UploadDetail.vue:83 +#: src/views/admin/library/UploadDetail.vue:88 +#: src/views/admin/library/UploadDetail.vue:83 msgctxt "Content/Moderation/Title" msgid "Upload data" msgstr "" -#: front/src/views/content/libraries/FilesTable.vue:109 -#: front/src/views/content/libraries/FilesTable.vue:17 +#: src/views/content/libraries/FilesTable.vue:109 +#: src/views/content/libraries/FilesTable.vue:17 msgctxt "*/*/*/Noun" msgid "Upload date" msgstr "" -#: front/src/components/library/FileUpload.vue:364 -#: front/src/components/library/FileUpload.vue:365 +#: src/components/library/FileUpload.vue:56 msgctxt "Content/Library/Help text" msgid "Upload denied, ensure the file is not too big and that you have not reached your quota" msgstr "" -#: front/src/components/channels/UploadModal.vue:9 +#: src/components/channels/UploadModal.vue:9 msgctxt "Popup/Channels/Title" msgid "Upload details" msgstr "" -#: front/src/components/library/ImportStatusModal.vue:10 +#: src/components/library/ImportStatusModal.vue:10 msgctxt "Popup/Import/Message" msgid "Upload is still pending and will soon be processed by the server." msgstr "" -#: front/src/components/library/FileUpload.vue:42 +#: src/components/library/FileUpload.vue:42 msgctxt "Content/Library/Title/Verb" msgid "Upload music from your local storage" msgstr "" -#: front/src/components/common/AttachmentInput.vue:27 +#: src/components/common/AttachmentInput.vue:27 msgctxt "*/*/*" msgid "Upload New Picture…" msgstr "" -#: front/src/components/AboutPod.vue:192 -#: front/src/views/admin/moderation/AccountsDetail.vue:355 -#: front/src/views/admin/moderation/AccountsDetail.vue:350 +#: src/components/AboutPod.vue:192 +#: src/views/admin/moderation/AccountsDetail.vue:355 +#: src/views/admin/moderation/AccountsDetail.vue:350 msgctxt "*/*/*" msgid "Upload quota" msgstr "" -#: front/src/views/content/Home.vue:31 +#: src/views/content/Home.vue:31 msgctxt "Content/Library/Title/Verb" msgid "Upload third-party content in a library" msgstr "" -#: front/src/components/library/FileUpload.vue:373 +#: src/components/library/FileUpload.vue:65 msgctxt "Content/Library/Help text" msgid "Upload timeout, please try again" msgstr "" -#: front/src/components/library/ImportStatusModal.vue:20 +#: src/components/library/ImportStatusModal.vue:20 msgctxt "Popup/Import/Message" msgid "Upload was skipped because a similar one is already available in one of your libraries." msgstr "" -#: front/src/components/library/ImportStatusModal.vue:15 +#: src/components/library/ImportStatusModal.vue:15 msgctxt "Popup/Import/Message" msgid "Upload was successfully processed by the server." msgstr "" -#: front/src/views/content/Home.vue:36 +#: src/views/content/Home.vue:36 msgctxt "Content/Library/Paragraph" msgid "Upload your personal music library to Funkwhale to enjoy it from anywhere and share it with friends and family." msgstr "" -#: front/src/components/library/FileUpload.vue:133 +#: src/components/library/FileUpload.vue:133 msgctxt "Content/Library/Table" msgid "Uploaded" msgstr "" -#: front/src/components/library/TrackBase.vue:364 +#: src/components/library/TrackBase.vue:143 msgctxt "Content/Track/Paragraph" msgid "Uploaded by <a class=\"internal\" href=\"%{ uploaderUrl }\">%{ uploader }</a> on <time title=\"%{ date }\" datetime=\"%{ date }\">%{ prettyDate }</time>" msgstr "" -#: front/src/components/library/TrackBase.vue:372 +#: src/components/library/TrackBase.vue:151 msgctxt "Content/Track/Paragraph" msgid "Uploaded on <time title=\"%{ date }\" datetime=\"%{ date }\">%{ prettyDate }</time>" msgstr "" -#: front/src/components/channels/UploadForm.vue:91 -#: front/src/components/channels/UploadForm.vue:56 -#: front/src/components/channels/UploadForm.vue:45 -#: front/src/components/channels/UploadForm.vue:1 +#: src/components/channels/UploadForm.vue:91 +#: src/components/channels/UploadForm.vue:56 +#: src/components/channels/UploadForm.vue:45 +#: src/components/channels/UploadForm.vue:1 msgctxt "Channels/*/*" msgid "Uploading" msgstr "" -#: front/src/components/library/FileUpload.vue:4 +#: src/components/library/FileUpload.vue:4 msgctxt "Content/Library/Tab.Title/Short" msgid "Uploading" msgstr "" -#: front/src/components/common/AttachmentInput.vue:44 +#: src/components/common/AttachmentInput.vue:44 msgctxt "Content/*/*/Noun" msgid "Uploading file…" msgstr "" -#: front/src/components/library/FileUpload.vue:136 +#: src/components/library/FileUpload.vue:136 msgctxt "Content/Library/Table" msgid "Uploading…" msgstr "" -#: front/src/components/manage/library/LibrariesTable.vue:81 -#: front/src/components/manage/library/LibrariesTable.vue:22 -#: front/src/components/manage/moderation/AccountsTable.vue:52 -#: front/src/components/manage/moderation/AccountsTable.vue:12 -#: front/src/components/mixins/Translations.vue:90 -#: front/src/views/admin/ChannelDetail.vue:309 src/views/admin/ChannelDetail.vue:304 -#: front/src/views/admin/library/AlbumDetail.vue:303 -#: front/src/views/admin/library/AlbumDetail.vue:298 -#: front/src/views/admin/library/ArtistDetail.vue:302 -#: front/src/views/admin/library/ArtistDetail.vue:297 -#: front/src/views/admin/library/Base.vue:34 -#: front/src/views/admin/library/LibraryDetail.vue:288 -#: front/src/views/admin/library/LibraryDetail.vue:283 -#: front/src/views/admin/library/TrackDetail.vue:368 -#: front/src/views/admin/library/TrackDetail.vue:363 -#: front/src/views/admin/library/UploadsList.vue:29 -#: front/src/views/admin/moderation/AccountsDetail.vue:409 -#: front/src/views/admin/moderation/AccountsDetail.vue:404 -#: front/src/views/admin/moderation/DomainsDetail.vue:330 -#: front/src/views/admin/moderation/DomainsDetail.vue:325 -#: front/src/components/mixins/Translations.vue:91 +#: src/components/manage/library/LibrariesTable.vue:81 +#: src/components/manage/library/LibrariesTable.vue:22 +#: src/components/manage/moderation/AccountsTable.vue:52 +#: src/components/manage/moderation/AccountsTable.vue:12 +#: src/components/mixins/Translations.vue:90 +#: src/views/admin/ChannelDetail.vue:309 +#: src/views/admin/ChannelDetail.vue:304 +#: src/views/admin/library/AlbumDetail.vue:303 +#: src/views/admin/library/AlbumDetail.vue:298 +#: src/views/admin/library/ArtistDetail.vue:302 +#: src/views/admin/library/ArtistDetail.vue:297 +#: src/views/admin/library/Base.vue:34 +#: src/views/admin/library/LibraryDetail.vue:288 +#: src/views/admin/library/LibraryDetail.vue:283 +#: src/views/admin/library/TrackDetail.vue:368 +#: src/views/admin/library/TrackDetail.vue:363 +#: src/views/admin/library/UploadsList.vue:13 +#: src/views/admin/moderation/AccountsDetail.vue:409 +#: src/views/admin/moderation/AccountsDetail.vue:404 +#: src/views/admin/moderation/DomainsDetail.vue:330 +#: src/views/admin/moderation/DomainsDetail.vue:325 msgctxt "*/*/*" msgid "Uploads" msgstr "" -#: front/src/views/channels/DetailOverview.vue:37 -#: front/src/views/channels/DetailOverview.vue:3 +#: src/views/channels/DetailOverview.vue:37 +#: src/views/channels/DetailOverview.vue:3 msgctxt "Content/Channel/Header" msgid "Uploads are being processed" msgstr "" -#: front/src/views/channels/DetailOverview.vue:6 -#: front/src/views/channels/DetailOverview.vue:3 +#: src/views/channels/DetailOverview.vue:6 +#: src/views/channels/DetailOverview.vue:3 msgctxt "Content/Channel/Header" msgid "Uploads published successfully" msgstr "" -#: front/src/views/admin/ChannelDetail.vue:158 src/views/admin/ChannelDetail.vue:153 +#: src/views/admin/ChannelDetail.vue:158 +#: src/views/admin/ChannelDetail.vue:153 msgctxt "'Content/*/*/Noun" msgid "URL" msgstr "" -#: front/src/components/library/TrackDetail.vue:180 +#: src/components/library/TrackDetail.vue:181 msgctxt "Content/*/*/Noun" msgid "URL" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:22 +#: src/components/auth/ApplicationForm.vue:22 msgctxt "Content/Applications/Help Text" msgid "Use \"urn:ietf:wg:oauth:2.0:oob\" as a redirect URI if your application is not served on the web." msgstr "" -#: front/src/components/Footer.vue:36 -msgctxt "Footer/*/List item.Link" -msgid "Use another instance" -msgstr "" - -#: front/src/components/common/UserModal.vue:213 -#: front/src/components/common/UserModal.vue:215 +#: src/components/common/UserModal.vue:45 msgctxt "Sidebar/*/List item.Link" msgid "Use another instance" msgstr "" -#: front/src/components/Home.vue:181 +#: src/components/Home.vue:181 msgctxt "Content/Home/Link" msgid "Use Funkwhale on other devices with our apps" msgstr "" -#: front/src/components/About.vue:164 +#: src/components/About.vue:169 msgctxt "Content/About/Paragraph" msgid "Use Funkwhale on other devices with our apps." msgstr "" -#: front/src/components/auth/Settings.vue:397 +#: src/components/auth/Settings.vue:397 msgctxt "Content/Settings/Paragraph" msgid "Use plugins to extend Funkwhale and get additional features." msgstr "" -#: front/src/components/moderation/ReportModal.vue:50 +#: src/components/moderation/ReportModal.vue:50 msgctxt "*/*/Field,Help" msgid "Use this field to provide additional context to the moderator that will handle your report." msgstr "" -#: front/src/views/auth/PasswordReset.vue:23 +#: src/views/auth/PasswordReset.vue:23 msgctxt "Content/Signup/Paragraph" msgid "Use this form to request a password reset. We will send an e-mail to the given address with instructions to reset your password." msgstr "" -#: front/src/components/moderation/ReportModal.vue:26 +#: src/components/moderation/ReportModal.vue:26 msgctxt "*/Moderation/Popup,Paragraph" msgid "Use this form to submit a report to our moderation team." msgstr "" -#: front/src/components/RemoteSearchForm.vue:40 +#: src/components/RemoteSearchForm.vue:40 msgctxt "Content/Fetch/Paragraph" msgid "Use this form to subscribe to a channel hosted somewhere else on the Fediverse." msgstr "" -#: front/src/components/RemoteSearchForm.vue:35 +#: src/components/RemoteSearchForm.vue:35 msgctxt "Content/Fetch/Paragraph" msgid "Use this form to subscribe to an RSS feed from its URL." msgstr "" -#: front/src/components/manage/moderation/InstancePolicyForm.vue:205 +#: src/components/manage/moderation/InstancePolicyForm.vue:35 msgctxt "Content/Moderation/Help text" msgid "Use this setting to temporarily enable/disable the policy without completely removing it." msgstr "" -#: front/src/components/manage/users/InvitationsTable.vue:77 -#: front/src/components/manage/users/InvitationsTable.vue:7 +#: src/components/manage/users/InvitationsTable.vue:77 +#: src/components/manage/users/InvitationsTable.vue:7 msgctxt "Content/Admin/Table" msgid "Used" msgstr "" -#: front/src/components/audio/ChannelForm.vue:134 -#: front/src/components/audio/ChannelForm.vue:120 -#: front/src/components/audio/ChannelForm.vue:100 +#: src/components/audio/ChannelForm.vue:134 +#: src/components/audio/ChannelForm.vue:120 +#: src/components/audio/ChannelForm.vue:100 msgctxt "*/*/*" msgid "Used for the itunes:email and itunes:name field required by certain platforms such as Spotify or iTunes." msgstr "" -#: front/src/components/audio/ChannelForm.vue:54 -#: front/src/components/audio/ChannelForm.vue:40 -#: front/src/components/audio/ChannelForm.vue:20 -#: front/src/components/audio/ChannelForm.vue:3 +#: src/components/audio/ChannelForm.vue:54 +#: src/components/audio/ChannelForm.vue:40 +#: src/components/audio/ChannelForm.vue:20 +#: src/components/audio/ChannelForm.vue:3 msgctxt "Content/Channels/Paragraph" msgid "Used in URLs and to follow this channel in the Fediverse. It cannot be changed later." msgstr "" -#: front/src/components/Home.vue:154 +#: src/components/Home.vue:154 msgctxt "Content/Home/Header" msgid "Useful links" msgstr "" -#: front/src/views/library/Edit.vue:28 +#: src/views/library/Edit.vue:28 msgctxt "Content/Library/Table.Label" msgid "User" msgstr "" -#: front/src/components/Home.vue:191 +#: src/components/Home.vue:191 msgctxt "Content/Home/Link" msgid "User guides" msgstr "" -#: front/src/views/admin/Settings.vue:79 +#: src/views/admin/Settings.vue:35 msgctxt "Content/Admin/Menu" msgid "User Interface" msgstr "" -#: front/src/components/library/AlbumDetail.vue:26 -#: front/src/components/library/AlbumDetail.vue:2 -#: front/src/components/library/ArtistDetail.vue:55 +#: src/components/library/AlbumDetail.vue:26 +#: src/components/library/AlbumDetail.vue:2 +#: src/components/library/ArtistDetail.vue:55 msgctxt "Content/*/Title/Noun" msgid "User libraries" msgstr "" -#: front/src/views/auth/ProfileOverview.vue:20 +#: src/views/auth/ProfileOverview.vue:20 msgctxt "Content/Profile/Header" msgid "User Libraries" msgstr "" -#: front/src/components/library/Radios.vue:26 +#: src/components/library/Radios.vue:26 msgctxt "Content/Radio/Title" msgid "User radios" msgstr "" -#: front/src/views/admin/moderation/Base.vue:12 -#: front/src/views/admin/moderation/RequestsList.vue:4 -#: front/src/views/admin/moderation/RequestsList.vue:166 +#: src/views/admin/moderation/Base.vue:12 +#: src/views/admin/moderation/RequestsList.vue:4 +#: src/views/admin/moderation/RequestsList.vue:40 msgctxt "*/Moderation/*/Noun" msgid "User Requests" msgstr "" -#: front/src/components/auth/SignupForm.vue:49 -#: front/src/components/manage/users/UsersTable.vue:40 -#: front/src/components/manage/users/UsersTable.vue:2 -#: front/src/components/mixins/Translations.vue:86 -#: front/src/views/admin/moderation/AccountsDetail.vue:113 -#: front/src/views/admin/moderation/AccountsDetail.vue:108 -#: front/src/components/mixins/Translations.vue:87 +#: src/components/auth/SignupForm.vue:49 +#: src/components/manage/users/UsersTable.vue:40 +#: src/components/manage/users/UsersTable.vue:2 +#: src/components/mixins/Translations.vue:86 +#: src/views/admin/moderation/AccountsDetail.vue:113 +#: src/views/admin/moderation/AccountsDetail.vue:108 msgctxt "Content/*/*" msgid "Username" msgstr "" -#: front/src/components/auth/LoginForm.vue:27 src/components/auth/LoginForm.vue:3 +#: src/components/auth/LoginForm.vue:27 +#: src/components/auth/LoginForm.vue:3 msgctxt "Content/Login/Input.Label/Noun" msgid "Username or e-mail address" msgstr "" -#: front/src/components/Sidebar.vue:41 -#: front/src/components/manage/moderation/DomainsTable.vue:65 -#: front/src/components/manage/moderation/DomainsTable.vue:7 -#: front/src/components/mixins/Translations.vue:88 src/views/admin/users/Base.vue:4 -#: front/src/views/admin/users/UsersList.vue:23 -#: front/src/components/mixins/Translations.vue:89 +#: src/components/Sidebar.vue:41 +#: src/components/manage/moderation/DomainsTable.vue:65 +#: src/components/manage/moderation/DomainsTable.vue:7 +#: src/components/mixins/Translations.vue:88 +#: src/views/admin/users/Base.vue:4 +#: src/views/admin/users/UsersList.vue:10 msgctxt "*/*/*/Noun" msgid "Users" msgstr "" -#: front/src/components/About.vue:48 src/components/About.vue:7 +#: src/components/About.vue:48 +#: src/components/About.vue:7 msgctxt "Content/About/Paragraph" msgid "Users on this pod also get %{ quota } of free storage to upload their own content!" msgstr "" -#: front/src/components/Home.vue:135 src/components/Home.vue:7 +#: src/components/Home.vue:135 +#: src/components/Home.vue:7 msgctxt "Content/Home/Paragraph" msgid "Users on this pod also get %{ quota } of free storage to upload their own content!" msgstr "" -#: front/src/components/Footer.vue:51 -msgctxt "Footer/*/Title" -msgid "Using Funkwhale" -msgstr "" - -#: front/src/components/Footer.vue:33 -msgctxt "Footer/*/List item" -msgid "Version %{version}" -msgstr "" - -#: front/src/components/audio/podcast/Modal.vue:278 -#: front/src/components/audio/track/Modal.vue:278 +#: src/components/audio/podcast/Modal.vue:50 +#: src/components/audio/track/Modal.vue:50 msgctxt "*/Queue/Dropdown/Button/Label/Short" msgid "View album" msgstr "" -#: front/src/components/audio/podcast/Modal.vue:285 -#: front/src/components/audio/track/Modal.vue:285 +#: src/components/audio/podcast/Modal.vue:57 +#: src/components/audio/track/Modal.vue:57 msgctxt "*/Queue/Dropdown/Button/Label/Short" msgid "View artist" msgstr "" -#: front/src/components/audio/podcast/Modal.vue:283 -#: front/src/components/audio/track/Modal.vue:283 +#: src/components/audio/podcast/Modal.vue:55 +#: src/components/audio/track/Modal.vue:55 msgctxt "*/Queue/Dropdown/Button/Label/Short" msgid "View channel" msgstr "" -#: front/src/views/channels/DetailOverview.vue:29 -#: front/src/views/channels/DetailOverview.vue:13 +#: src/views/channels/DetailOverview.vue:29 +#: src/views/channels/DetailOverview.vue:13 msgctxt "Content/Channel/Button" msgid "View errored uploads" msgstr "" -#: front/src/views/content/libraries/Quota.vue:41 -#: front/src/views/content/libraries/Quota.vue:81 -#: front/src/views/content/libraries/Quota.vue:120 +#: src/views/content/libraries/Quota.vue:41 +#: src/views/content/libraries/Quota.vue:81 +#: src/views/content/libraries/Quota.vue:120 msgctxt "Content/Library/Link/Verb" msgid "View files" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:63 -#: front/src/components/library/ArtistBase.vue:104 -#: front/src/components/library/ArtistBase.vue:99 -#: front/src/components/library/TrackBase.vue:109 -#: front/src/components/library/TrackBase.vue:104 -#: front/src/views/admin/ChannelDetail.vue:44 src/views/admin/ChannelDetail.vue:39 -#: front/src/views/admin/library/AlbumDetail.vue:45 -#: front/src/views/admin/library/AlbumDetail.vue:40 -#: front/src/views/admin/library/ArtistDetail.vue:44 -#: front/src/views/admin/library/ArtistDetail.vue:39 -#: front/src/views/admin/library/LibraryDetail.vue:29 -#: front/src/views/admin/library/LibraryDetail.vue:36 -#: front/src/views/admin/library/LibraryDetail.vue:24 -#: front/src/views/admin/library/LibraryDetail.vue:31 -#: front/src/views/admin/library/TagDetail.vue:29 -#: front/src/views/admin/library/TagDetail.vue:24 -#: front/src/views/admin/library/TrackDetail.vue:45 -#: front/src/views/admin/library/TrackDetail.vue:40 -#: front/src/views/admin/library/UploadDetail.vue:30 -#: front/src/views/admin/library/UploadDetail.vue:37 -#: front/src/views/admin/library/UploadDetail.vue:25 -#: front/src/views/admin/library/UploadDetail.vue:32 -#: front/src/views/admin/moderation/AccountsDetail.vue:33 -#: front/src/views/admin/moderation/AccountsDetail.vue:37 -#: front/src/views/admin/moderation/AccountsDetail.vue:28 -#: front/src/views/admin/moderation/AccountsDetail.vue:32 -#: front/src/views/admin/moderation/DomainsDetail.vue:26 -#: front/src/views/admin/moderation/DomainsDetail.vue:21 +#: src/components/library/AlbumDropdown.vue:63 +#: src/components/library/ArtistBase.vue:104 +#: src/components/library/ArtistBase.vue:99 +#: src/components/library/TrackBase.vue:109 +#: src/components/library/TrackBase.vue:104 +#: src/views/admin/ChannelDetail.vue:44 +#: src/views/admin/ChannelDetail.vue:39 +#: src/views/admin/library/AlbumDetail.vue:45 +#: src/views/admin/library/AlbumDetail.vue:40 +#: src/views/admin/library/ArtistDetail.vue:44 +#: src/views/admin/library/ArtistDetail.vue:39 +#: src/views/admin/library/LibraryDetail.vue:29 +#: src/views/admin/library/LibraryDetail.vue:36 +#: src/views/admin/library/LibraryDetail.vue:24 +#: src/views/admin/library/LibraryDetail.vue:31 +#: src/views/admin/library/TagDetail.vue:29 +#: src/views/admin/library/TagDetail.vue:24 +#: src/views/admin/library/TrackDetail.vue:45 +#: src/views/admin/library/TrackDetail.vue:40 +#: src/views/admin/library/UploadDetail.vue:30 +#: src/views/admin/library/UploadDetail.vue:37 +#: src/views/admin/library/UploadDetail.vue:25 +#: src/views/admin/library/UploadDetail.vue:32 +#: src/views/admin/moderation/AccountsDetail.vue:33 +#: src/views/admin/moderation/AccountsDetail.vue:37 +#: src/views/admin/moderation/AccountsDetail.vue:28 +#: src/views/admin/moderation/AccountsDetail.vue:32 +#: src/views/admin/moderation/DomainsDetail.vue:26 +#: src/views/admin/moderation/DomainsDetail.vue:21 msgctxt "Content/Moderation/Link/Verb" msgid "View in Django's admin" msgstr "" -#: front/src/components/Home.vue:212 +#: src/components/Home.vue:212 msgctxt "Content/Home/Link" msgid "View more…" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:24 -#: front/src/components/library/ArtistBase.vue:63 -#: front/src/components/library/ArtistBase.vue:58 -#: front/src/components/library/TrackBase.vue:51 -#: front/src/components/library/TrackBase.vue:46 src/views/auth/ProfileBase.vue:13 -#: front/src/views/channels/DetailBase.vue:102 src/views/channels/DetailBase.vue:97 -#: front/src/views/library/DetailBase.vue:11 +#: src/components/library/AlbumDropdown.vue:24 +#: src/components/library/ArtistBase.vue:63 +#: src/components/library/ArtistBase.vue:58 +#: src/components/library/TrackBase.vue:51 +#: src/components/library/TrackBase.vue:46 +#: src/views/auth/ProfileBase.vue:13 +#: src/views/channels/DetailBase.vue:102 +#: src/views/channels/DetailBase.vue:97 +#: src/views/library/DetailBase.vue:11 msgctxt "Content/*/Button.Label/Verb" msgid "View on %{ domain }" msgstr "" -#: front/src/components/library/AlbumDropdown.vue:33 -#: front/src/components/library/ArtistBase.vue:78 -#: front/src/components/library/ArtistBase.vue:73 -#: front/src/components/library/TrackDetail.vue:194 +#: src/components/library/AlbumDropdown.vue:33 +#: src/components/library/ArtistBase.vue:78 +#: src/components/library/ArtistBase.vue:73 +#: src/components/library/TrackDetail.vue:195 msgctxt "Content/*/*/Clickable, Verb" msgid "View on MusicBrainz" msgstr "" -#: front/src/components/manage/moderation/ReportCard.vue:150 +#: src/components/manage/moderation/ReportCard.vue:150 msgctxt "Content/Moderation/Link" msgid "View public page" msgstr "" -#: front/src/components/audio/podcast/Modal.vue:276 -#: front/src/components/audio/track/Modal.vue:276 +#: src/components/audio/podcast/Modal.vue:48 +#: src/components/audio/track/Modal.vue:48 msgctxt "*/Queue/Dropdown/Button/Label/Short" msgid "View series" msgstr "" -#: front/src/views/channels/DetailOverview.vue:24 -#: front/src/views/channels/DetailOverview.vue:8 +#: src/views/channels/DetailOverview.vue:24 +#: src/views/channels/DetailOverview.vue:8 msgctxt "Content/Channel/Button" msgid "View skipped uploads" msgstr "" -#: front/src/components/manage/library/LibrariesTable.vue:11 -#: front/src/components/manage/library/LibrariesTable.vue:76 -#: front/src/components/manage/library/LibrariesTable.vue:17 -#: front/src/components/manage/library/UploadsTable.vue:11 -#: front/src/components/manage/library/UploadsTable.vue:112 -#: front/src/components/manage/library/UploadsTable.vue:22 -#: front/src/views/admin/library/LibraryDetail.vue:101 -#: front/src/views/admin/library/LibraryDetail.vue:96 -#: front/src/views/admin/library/UploadDetail.vue:108 -#: front/src/views/admin/library/UploadDetail.vue:103 -#: front/src/views/content/libraries/Form.vue:28 +#: src/components/manage/library/LibrariesTable.vue:11 +#: src/components/manage/library/LibrariesTable.vue:76 +#: src/components/manage/library/LibrariesTable.vue:17 +#: src/components/manage/library/UploadsTable.vue:11 +#: src/components/manage/library/UploadsTable.vue:112 +#: src/components/manage/library/UploadsTable.vue:22 +#: src/views/admin/library/LibraryDetail.vue:101 +#: src/views/admin/library/LibraryDetail.vue:96 +#: src/views/admin/library/UploadDetail.vue:108 +#: src/views/admin/library/UploadDetail.vue:103 +#: src/views/content/libraries/Form.vue:28 msgctxt "*/*/*" msgid "Visibility" msgstr "" -#: front/src/components/Home.vue:110 +#: src/components/Home.vue:110 msgctxt "Content/Home/Link" msgid "Visit funkwhale.audio" msgstr "" -#: front/src/components/library/AlbumDetail.vue:15 -#: front/src/components/library/AlbumDetail.vue:4 +#: src/components/library/AlbumDetail.vue:15 +#: src/components/library/AlbumDetail.vue:4 msgctxt "Content/Album/" msgid "Volume %{ number }" msgstr "" -#: front/src/components/federation/FetchButton.vue:106 +#: src/components/federation/FetchButton.vue:106 msgctxt "Popup/*/Loading.Title" msgid "Waiting for result…" msgstr "" -#: front/src/components/auth/Settings.vue:430 +#: src/components/auth/Settings.vue:430 msgctxt "Content/Settings/Error message.Title" msgid "We cannot change your e-mail address" msgstr "" -#: front/src/components/auth/Settings.vue:478 +#: src/components/auth/Settings.vue:478 msgctxt "Content/Settings/Error message.Title" msgid "We cannot delete your account" msgstr "" -#: front/src/components/auth/LoginForm.vue:4 +#: src/components/auth/LoginForm.vue:4 msgctxt "Content/Login/Error message.Title" msgid "We cannot log you in" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:4 +#: src/components/auth/ApplicationForm.vue:4 msgctxt "Content/*/Error message.Title" msgid "We cannot save your changes" msgstr "" -#: front/src/views/Notifications.vue:65 +#: src/views/Notifications.vue:65 msgctxt "Content/Notifications/Paragraph" msgid "We noticed you've been here for a while. If Funkwhale is useful to you, we could use your help to make it even better!" msgstr "" -#: front/src/components/library/FileUpload.vue:62 +#: src/components/library/FileUpload.vue:62 msgctxt "Content/Library/Link" msgid "We recommend using Picard for that purpose." msgstr "" -#: front/src/components/moderation/ReportModal.vue:39 +#: src/components/moderation/ReportModal.vue:39 msgctxt "*/*/Field,Help" msgid "We'll use this e-mail address if we need to contact you regarding this report." msgstr "" -#: front/src/components/Home.vue:5 +#: src/components/Home.vue:5 msgctxt "Content/Home/Header" msgid "Welcome to %{ podName }!" msgstr "" -#: front/src/components/audio/ChannelForm.vue:17 -#: front/src/components/audio/ChannelForm.vue:3 +#: src/components/audio/ChannelForm.vue:17 +#: src/components/audio/ChannelForm.vue:3 msgctxt "Content/Channel/Paragraph" msgid "What will this channel be used for?" msgstr "" -#: front/src/components/audio/EmbedWizard.vue:29 -#: front/src/components/audio/EmbedWizard.vue:3 +#: src/components/audio/EmbedWizard.vue:29 +#: src/components/audio/EmbedWizard.vue:3 msgctxt "Popup/Embed/Input.Label" msgid "Widget height" msgstr "" -#: front/src/components/audio/EmbedWizard.vue:18 +#: src/components/audio/EmbedWizard.vue:18 msgctxt "Popup/Embed/Input.Label" msgid "Widget width" msgstr "" -#: front/src/components/common/ContentForm.vue:5 +#: src/components/common/ContentForm.vue:5 msgctxt "*/Form/Menu.item" msgid "Write" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:180 +#: src/components/auth/ApplicationForm.vue:59 msgctxt "Content/OAuth Scopes/Label/Verb" msgid "Write" msgstr "" -#: front/src/components/common/ContentForm.vue:100 +#: src/components/common/ContentForm.vue:25 msgctxt "*/Form/Placeholder" msgid "Write a few words here…" msgstr "" -#: front/src/components/auth/Authorize.vue:39 +#: src/components/auth/Authorize.vue:39 msgctxt "Content/Auth/Label/Noun" msgid "Write-only" msgstr "" -#: front/src/components/auth/ApplicationForm.vue:181 +#: src/components/auth/ApplicationForm.vue:60 msgctxt "Content/OAuth Scopes/Help Text" msgid "Write-only access to user data" msgstr "" -#: front/src/components/library/TrackDetail.vue:135 +#: src/components/library/TrackDetail.vue:136 msgctxt "*/*/*" msgid "Year" msgstr "" -#: front/src/components/admin/SignupFormBuilder.vue:82 -#: front/src/components/manage/moderation/AccountsTable.vue:100 -#: front/src/components/manage/moderation/AccountsTable.vue:28 -#: front/src/components/manage/moderation/DomainsTable.vue:17 -#: front/src/components/manage/moderation/DomainsTable.vue:102 -#: front/src/views/admin/moderation/DomainsDetail.vue:110 -#: front/src/views/admin/moderation/DomainsDetail.vue:105 +#: src/components/admin/SignupFormBuilder.vue:82 +#: src/components/manage/moderation/AccountsTable.vue:100 +#: src/components/manage/moderation/AccountsTable.vue:28 +#: src/components/manage/moderation/DomainsTable.vue:17 +#: src/components/manage/moderation/DomainsTable.vue:102 +#: src/views/admin/moderation/DomainsDetail.vue:110 +#: src/views/admin/moderation/DomainsDetail.vue:105 msgctxt "*/*/*" msgid "Yes" msgstr "" -#: front/src/components/auth/Logout.vue:13 +#: src/components/auth/Logout.vue:13 msgctxt "Content/Login/Button.Label" msgid "Yes, log me out!" msgstr "" -#: front/src/views/content/libraries/Form.vue:30 +#: src/views/content/libraries/Form.vue:30 msgctxt "Content/Library/Paragraph" msgid "You are able to share your library with other people, regardless of its visibility." msgstr "" -#: front/src/components/library/FileUpload.vue:48 +#: src/components/library/FileUpload.vue:48 msgctxt "Content/Library/Paragraph" msgid "You are about to upload music to your library. Before proceeding, please ensure that:" msgstr "" -#: front/src/components/SetInstanceModal.vue:28 +#: src/components/SetInstanceModal.vue:28 msgctxt "Popup/Login/Paragraph" msgid "You are currently connected to <a href=\"%{ url }\" target=\"_blank\">%{ hostname } <i class=\"external icon\"/></a>. If you continue, you will be disconnected from your current instance and all your local data will be deleted." msgstr "" -#: front/src/components/library/ArtistDetail.vue:6 +#: src/components/library/ArtistDetail.vue:6 msgctxt "Content/Artist/Paragraph" msgid "You are currently hiding content related to this artist." msgstr "" -#: front/src/components/auth/Logout.vue:9 +#: src/components/auth/Logout.vue:9 msgctxt "Content/Login/Paragraph" msgid "You are currently logged in as %{ username }" msgstr "" -#: front/src/components/library/FileUpload.vue:54 +#: src/components/library/FileUpload.vue:54 msgctxt "Content/Library/List item" msgid "You are not uploading copyrighted content in a public library, otherwise you may be infringing the law" msgstr "" -#: front/src/components/SetInstanceModal.vue:189 +#: src/components/SetInstanceModal.vue:82 msgctxt "*/Instance/Message" msgid "You are now using the Funkwhale instance at %{ url }" msgstr "" -#: front/src/components/auth/Logout.vue:20 +#: src/components/auth/Logout.vue:20 msgctxt "Content/Login/Title" msgid "You aren't currently logged in" msgstr "" -#: front/src/components/moderation/FilterModal.vue:50 -#: front/src/components/moderation/FilterModal.vue:29 +#: src/components/moderation/FilterModal.vue:50 +#: src/components/moderation/FilterModal.vue:29 msgctxt "Popup/Moderation/Paragraph" msgid "You can manage and update your filters any time from your account settings." msgstr "" -#: front/src/views/auth/EmailConfirm.vue:38 +#: src/views/auth/EmailConfirm.vue:38 msgctxt "Content/Signup/Paragraph" msgid "You can now use the service without limitations." msgstr "" -#: front/src/components/auth/Settings.vue:466 +#: src/components/auth/Settings.vue:466 msgctxt "Content/Settings/Paragraph'" msgid "You can permanently and irreversibly delete your account and all the associated data using the form below. You will be asked for confirmation." msgstr "" -#: front/src/components/library/radios/Builder.vue:10 +#: src/components/library/radios/Builder.vue:10 msgctxt "Content/Radio/Paragraph" msgid "You can use this interface to build your own custom radio, which will play tracks according to your criteria." msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:15 +#: src/components/auth/SubsonicTokenForm.vue:15 msgctxt "Content/Settings/Paragraph" msgid "You can use those to enjoy your playlist and music in offline mode, on your smartphone or tablet, for instance." msgstr "" -#: front/src/components/common/LoginModal.vue:76 +#: src/components/common/LoginModal.vue:23 msgctxt "Popup/*/Paragraph" msgid "You don't have access!" msgstr "" -#: front/src/components/auth/Settings.vue:286 +#: src/components/auth/Settings.vue:286 msgctxt "Content/Applications/Paragraph" msgid "You don't have any application connected with your account." msgstr "" -#: front/src/views/admin/moderation/AccountsDetail.vue:67 -#: front/src/views/admin/moderation/AccountsDetail.vue:62 -#: front/src/views/admin/moderation/AccountsDetail.vue:4 +#: src/views/admin/moderation/AccountsDetail.vue:67 +#: src/views/admin/moderation/AccountsDetail.vue:62 +#: src/views/admin/moderation/AccountsDetail.vue:4 msgctxt "Content/Moderation/Card.Title" msgid "You don't have any rule in place for this account." msgstr "" -#: front/src/views/admin/moderation/DomainsDetail.vue:61 -#: front/src/views/admin/moderation/DomainsDetail.vue:56 -#: front/src/views/admin/moderation/DomainsDetail.vue:4 +#: src/views/admin/moderation/DomainsDetail.vue:61 +#: src/views/admin/moderation/DomainsDetail.vue:56 +#: src/views/admin/moderation/DomainsDetail.vue:4 msgctxt "Content/Moderation/Card.Title" msgid "You don't have any rule in place for this domain." msgstr "" -#: front/src/components/channels/UploadForm.vue:40 -#: front/src/components/channels/UploadForm.vue:5 +#: src/components/channels/UploadForm.vue:40 +#: src/components/channels/UploadForm.vue:5 msgctxt "Content/Library/Paragraph" msgid "You don't have any space left to upload your files. Please contact the moderators." msgstr "" -#: front/src/components/auth/Settings.vue:377 +#: src/components/auth/Settings.vue:377 msgctxt "Content/Applications/Paragraph" msgid "You don't have registered any application yet." msgstr "" -#: front/src/components/library/EditForm.vue:61 +#: src/components/library/EditForm.vue:61 msgctxt "Content/Library/Paragraph" msgid "You don't have the permission to edit this object, but you can suggest changes. Once submitted, suggestions will be reviewed before approval." msgstr "" -#: front/src/components/Queue.vue:165 +#: src/components/Queue.vue:165 msgctxt "Sidebar/Player/Title" msgid "You have a radio playing" msgstr "" -#: front/src/components/channels/UploadForm.vue:50 -#: front/src/components/channels/UploadForm.vue:15 -#: front/src/components/channels/UploadForm.vue:4 +#: src/components/channels/UploadForm.vue:50 +#: src/components/channels/UploadForm.vue:15 +#: src/components/channels/UploadForm.vue:4 msgctxt "Popup/Channels/Paragraph" msgid "You have some draft uploads pending publication." msgstr "" -#: front/src/components/Queue.vue:42 src/components/Queue.vue:35 +#: src/components/Queue.vue:42 +#: src/components/Queue.vue:35 msgctxt "Sidebar/Player/Error message.Paragraph" msgid "You may have a connectivity issue." msgstr "" -#: front/src/views/library/DetailAlbums.vue:8 src/views/library/DetailOverview.vue:12 -#: front/src/views/library/DetailTracks.vue:8 +#: src/views/library/DetailAlbums.vue:8 +#: src/views/library/DetailOverview.vue:12 +#: src/views/library/DetailTracks.vue:8 msgctxt "*/*/*" msgid "You may need to follow this library to see its content." msgstr "" -#: front/src/components/audio/ChannelEntries.vue:12 -#: front/src/components/audio/ChannelEntries.vue:3 +#: src/components/audio/ChannelEntries.vue:12 +#: src/components/audio/ChannelEntries.vue:3 msgctxt "Content/Channels/*" msgid "You may need to subscribe to this channel to see its content." msgstr "" -#: front/src/components/audio/ChannelSeries.vue:24 -#: front/src/components/audio/ChannelSeries.vue:3 +#: src/components/audio/ChannelSeries.vue:24 +#: src/components/audio/ChannelSeries.vue:3 msgctxt "Content/Channels/*" msgid "You may need to subscribe to this channel to see its contents." msgstr "" -#: front/src/components/channels/SubscribeButton.vue:64 +#: src/components/channels/SubscribeButton.vue:23 msgctxt "Popup/Message/Paragraph" msgid "You need to be logged in to subscribe to this channel" msgstr "" -#: front/src/components/notifications/NotificationRow.vue:87 +#: src/components/notifications/NotificationRow.vue:17 msgctxt "Content/Notifications/Paragraph" msgid "You rejected %{ username }'s request to follow \"%{ library }\"" msgstr "" -#: front/src/components/auth/Settings.vue:131 +#: src/components/auth/Settings.vue:131 msgctxt "Popup/Settings/List item" msgid "You will be logged out from this session and have to log in with the new one" msgstr "" -#: front/src/components/auth/LoginForm.vue:49 src/components/auth/LoginForm.vue:2 +#: src/components/auth/LoginForm.vue:49 +#: src/components/auth/LoginForm.vue:2 msgctxt "Contant/Auth/Paragraph" msgid "You will be redirected to %{ domain } to authenticate." msgstr "" -#: front/src/components/auth/Authorize.vue:71 +#: src/components/auth/Authorize.vue:71 msgctxt "Content/Auth/Paragraph" msgid "You will be redirected to <strong>%{ url }</strong>" msgstr "" -#: front/src/components/auth/Authorize.vue:68 +#: src/components/auth/Authorize.vue:68 msgctxt "Content/Auth/Paragraph" msgid "You will be shown a code to copy-paste in the application." msgstr "" -#: front/src/components/auth/Settings.vue:87 +#: src/components/auth/Settings.vue:87 msgctxt "Content/Settings/Paragraph" msgid "You will have to update your password on your clients that use this password." msgstr "" -#: front/src/components/moderation/FilterModal.vue:23 -#: front/src/components/moderation/FilterModal.vue:2 +#: src/components/moderation/FilterModal.vue:23 +#: src/components/moderation/FilterModal.vue:2 msgctxt "Popup/Moderation/Paragraph" msgid "You will not see tracks, albums and user activity linked to this artist any more:" msgstr "" -#: front/src/components/About.vue:68 +#: src/components/About.vue:72 msgctxt "Content/About/Message" msgid "You're already signed in!" msgstr "" -#: front/src/components/auth/SignupForm.vue:38 +#: src/components/auth/SignupForm.vue:38 msgctxt "Content/Signup/Form/Paragraph" msgid "Your account cannot be created." msgstr "" -#: front/src/components/auth/SignupForm.vue:4 +#: src/components/auth/SignupForm.vue:4 msgctxt "Content/Signup/Form/Paragraph" msgid "Your account request was successfully submitted. You will be notified by e-mail when our moderation team has reviewed your request." msgstr "" -#: front/src/components/auth/SignupForm.vue:9 +#: src/components/auth/SignupForm.vue:9 msgctxt "Content/Signup/Form/Paragraph" msgid "Your account was successfully created. Please verify your e-mail address before trying to login." msgstr "" -#: front/src/components/auth/Settings.vue:471 +#: src/components/auth/Settings.vue:471 msgctxt "Content/Settings/Paragraph'" msgid "Your account will be deleted from our servers within a few minutes. We will also notify other servers who may have a copy of some of your data so they can proceed to deletion. Please note that some of these servers may be offline or unwilling to comply though." msgstr "" -#: front/src/components/auth/Settings.vue:299 +#: src/components/auth/Settings.vue:299 msgctxt "Content/Settings/Title/Noun" msgid "Your applications" msgstr "" -#: front/src/components/common/AttachmentInput.vue:4 +#: src/components/common/AttachmentInput.vue:4 msgctxt "Content/*/Error message.Title" msgid "Your attachment cannot be saved" msgstr "" -#: front/src/components/auth/Settings.vue:58 +#: src/components/auth/Settings.vue:58 msgctxt "Content/Settings/Error message.Title" msgid "Your avatar cannot be saved" msgstr "" -#: front/src/components/auth/Settings.vue:423 +#: src/components/auth/Settings.vue:423 msgctxt "Content/Settings/Paragraph'" msgid "Your current e-mail address is %{ email }." msgstr "" -#: front/src/components/auth/Settings.vue:934 +#: src/components/auth/Settings.vue:236 msgctxt "*/Auth/Message" msgid "Your deletion request was submitted, your account and content will be deleted shortly" msgstr "" -#: front/src/components/auth/Settings.vue:962 +#: src/components/auth/Settings.vue:264 msgctxt "*/Auth/Message" msgid "Your e-mail address has been changed, please check your inbox for our confirmation message." msgstr "" -#: front/src/components/library/EditForm.vue:4 +#: src/components/library/EditForm.vue:4 msgctxt "Content/Library/Paragraph" msgid "Your edit was successfully submitted." msgstr "" -#: front/src/components/favorites/List.vue:173 +#: src/components/favorites/List.vue:38 msgctxt "Head/Favorites/Title" msgid "Your Favorites" msgstr "" -#: front/src/views/Notifications.vue:6 +#: src/views/Notifications.vue:6 msgctxt "Content/Notifications/Title" msgid "Your messages" msgstr "" -#: front/src/views/Notifications.vue:116 +#: src/views/Notifications.vue:116 msgctxt "Content/Notifications/Title" msgid "Your notifications" msgstr "" -#: front/src/components/auth/Settings.vue:94 +#: src/components/auth/Settings.vue:94 msgctxt "Content/Settings/Error message.Title" msgid "Your password cannot be changed" msgstr "" -#: front/src/views/auth/PasswordResetConfirm.vue:49 +#: src/views/auth/PasswordResetConfirm.vue:49 msgctxt "Content/Signup/Card.Paragraph" msgid "Your password has been updated successfully." msgstr "" -#: front/src/components/auth/Settings.vue:19 +#: src/components/auth/Settings.vue:19 msgctxt "Content/Settings/Error message.Title" msgid "Your settings can't be updated" msgstr "" -#: front/src/components/auth/SubsonicTokenForm.vue:170 +#: src/components/auth/SubsonicTokenForm.vue:24 msgctxt "Content/Password/Input.label" msgid "Your subsonic API password" msgstr "" -#: front/src/components/auth/Settings.vue:136 +#: src/components/auth/Settings.vue:136 msgctxt "Popup/Settings/List item" msgid "Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password" msgstr "" -#: front/src/views/channels/DetailOverview.vue:42 -#: front/src/views/channels/DetailOverview.vue:8 +#: src/views/channels/DetailOverview.vue:42 +#: src/views/channels/DetailOverview.vue:8 msgctxt "Content/Channel/Paragraph" msgid "Your uploads are being processed by Funkwhale and will be live very soon." msgstr "" - -#: front/src/main.js:120 -msgctxt "*/Error/Paragraph" -msgid "You sent too many requests and have been rate limited, please try again in %{ delay }" -msgstr "" - -#: front/src/main.js:123 -msgctxt "*/Error/Paragraph" -msgid "You sent too many requests and have been rate limited, please try again later" -msgstr "" - -#: front/src/edits.js:26 -msgctxt "Content/*/*/Noun" -msgid "Cover" -msgstr "" - -#: front/src/entities.js:126 -msgctxt "*/*/*/Noun" -msgid "MusicBrainz ID" -msgstr "" diff --git a/front/package.json b/front/package.json index d427ca60aad4f4e72c7b03022e14e894b4517724..deffdc0641acf9163aa2949e9d2224ee46d294c1 100644 --- a/front/package.json +++ b/front/package.json @@ -9,132 +9,91 @@ "build": "vite build --mode development", "build:deployment": "vite build --base /front/", "serve": "vite preview", - "test:unit": "jest", - "lint": "eslint --ext .js,.vue src", + "test": "vitest run", + "test:unit": "vitest run", + "lint": "eslint --ext .ts,.js,.vue,.html src public/embed.html", + "lint:tsc": "vue-tsc --noEmit", "fix-fomantic-css": "scripts/fix-fomantic-css.sh", "i18n-compile": "scripts/i18n-compile.sh", "i18n-extract": "scripts/i18n-extract.sh", "postinstall": "yarn run fix-fomantic-css" }, "dependencies": { - "@vue/composition-api": "1.4.9", - "@vueuse/core": "8.2.5", - "axios": "0.26.1", - "axios-auth-refresh": "3.2.2", - "diff": "5.0.0", - "focus-trap": "6.7.3", + "@sentry/tracing": "7.12.1", + "@sentry/vue": "7.12.1", + "@vue/runtime-core": "3.2.40", + "@vueuse/core": "9.1.1", + "@vueuse/integrations": "9.1.1", + "@vueuse/router": "9.1.1", + "axios": "0.27.2", + "axios-auth-refresh": "3.3.3", + "diff": "5.1.0", + "dompurify": "2.4.0", + "focus-trap": "7.0.0", "fomantic-ui-css": "2.8.8", "howler": "2.2.3", "js-logger": "1.6.1", - "lodash": "4.17.21", - "moment": "2.29.3", - "qs": "6.10.5", - "register-service-worker": "1.7.2", - "sanitize-html": "2.7.0", - "sass": "1.49.11", - "showdown": "2.0.3", + "lodash-es": "4.17.21", + "moment": "2.29.4", + "qs": "6.11.0", + "sass": "1.54.9", + "showdown": "2.1.0", "text-clipper": "2.2.0", - "vue": "2.6.14", + "transliteration": "2.3.5", + "universal-cookie": "4.0.4", + "vue": "3.2.40", "vue-gettext": "2.1.12", - "vue-lazyload": "1.3.4", - "vue-plyr": "7.0.0", - "vue-router": "3.5.4", - "vue-upload-component": "2.8.22", - "vuedraggable": "2.24.3", - "vuex": "3.6.2", + "vue-router": "4.1.5", + "vue-upload-component": "3.1.2", + "vue-virtual-scroller": "2.0.0-alpha.1", + "vue3-gettext": "2.3.4", + "vue3-lazyload": "0.3.6", + "vuedraggable": "4.1.0", + "vuex": "4.0.2", "vuex-persistedstate": "4.1.0", "vuex-router-sync": "5.0.0" }, "devDependencies": { - "@babel/core": "7.17.12", - "@babel/plugin-transform-runtime": "7.17.12", - "@babel/preset-env": "7.16.11", - "@vue/test-utils": "1.3.0", - "autoprefixer": "10.4.7", - "babel-core": "7.0.0-bridge.0", - "babel-jest": "27.5.1", - "chai": "4.3.6", + "@types/diff": "5.0.2", + "@types/dompurify": "2.3.4", + "@types/howler": "2.2.7", + "@types/jquery": "3.5.14", + "@types/lodash-es": "4.17.6", + "@types/moxios": "0.4.15", + "@types/qs": "6.9.7", + "@types/semantic-ui": "2.2.7", + "@types/showdown": "2.0.0", + "@types/vue-virtual-scroller": "npm:@earltp/vue-virtual-scroller", + "@typescript-eslint/eslint-plugin": "5.36.2", + "@vitejs/plugin-vue": "3.0.3", + "@vitest/coverage-c8": "0.23.2", + "@vue/compiler-sfc": "3.2.39", + "@vue/eslint-config-standard": "8.0.1", + "@vue/eslint-config-typescript": "11.0.1", + "@vue/test-utils": "2.0.2", + "@vue/tsconfig": "0.1.3", "easygettext": "2.17.0", - "eslint": "8.11.0", - "eslint-config-standard": "16.0.3", - "eslint-plugin-html": "6.2.0", - "eslint-plugin-import": "2.25.4", + "eslint": "8.23.1", + "eslint-config-standard": "17.0.0", + "eslint-plugin-html": "7.1.0", + "eslint-plugin-import": "2.26.0", + "eslint-plugin-n": "15.2.5", "eslint-plugin-node": "11.1.0", - "eslint-plugin-promise": "6.0.0", - "eslint-plugin-vue": "7.20.0", - "glob-all": "3.3.0", - "jest-cli": "27.5.1", + "eslint-plugin-promise": "6.0.1", + "eslint-plugin-vue": "9.4.0", + "jsdom": "20.0.0", "moxios": "0.4.0", - "sinon": "13.0.2", - "vite": "2.8.6", - "vite-plugin-vue2": "1.9.3", - "vue-jest": "3.0.7", - "vue-template-compiler": "2.6.14" - }, - "resolutions": { - "vue-plyr/plyr": "3.6.12" - }, - "eslintConfig": { - "root": true, - "env": { - "browser": true, - "node": true - }, - "plugins": [ - "html" - ], - "rules": { - "no-console": 0, - "no-unused-vars": [ - 2, - { - "vars": "all", - "args": "none" - } - ] - }, - "extends": [ - "plugin:vue/essential", - "eslint:recommended" - ], - "parserOptions": { - "parser": "babel-eslint" - } - }, - "postcss": { - "plugins": { - "autoprefixer": {} - } - }, - "browserslist": [ - "IE >= 11", - "Firefox >= 52", - "ChromeAndroid >= 70", - "Chrome >= 49", - "Safari >= 9", - "Edge >= 16", - "Opera >= 57", - "OperaMini >= 57", - "Samsung >= 7", - "FirefoxAndroid >= 63", - "UCAndroid >= 11", - "iOS >= 9", - "Android >= 4", - "not dead" - ], - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "vue" - ], - "transform": { - ".*\\.(vue)$": "vue-jest", - "^.+\\.js$": "babel-jest" - }, - "moduleNameMapper": { - "^@/(.*)$": "<rootDir>/src/$1" - }, - "testEnvironment": "jsdom" + "sinon": "14.0.0", + "typescript": "4.8.3", + "utility-types": "3.10.0", + "vite": "3.0.9", + "vite-plugin-pwa": "0.12.4", + "vite-plugin-vue-inspector": "1.1.1", + "vitest": "0.22.1", + "vue-tsc": "0.40.5", + "workbox-core": "6.5.4", + "workbox-precaching": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" } } diff --git a/front/src/assets/embed/default-cover.jpeg b/front/public/embed-default-cover.jpeg similarity index 100% rename from front/src/assets/embed/default-cover.jpeg rename to front/public/embed-default-cover.jpeg diff --git a/front/public/embed.css b/front/public/embed.css new file mode 100644 index 0000000000000000000000000000000000000000..e03586a266f849f7cf024561595c350da9af4e41 --- /dev/null +++ b/front/public/embed.css @@ -0,0 +1,233 @@ +:root { + --fw-darker: #1b1c1d; + --fw-dark: #2f3030; + --fw-light: #666666; + --fw-primary: #f2711c; + --fw-text: #fff; +} + +audio, +[v-cloak] { + display: none; +} + +body { + margin: 0; + + font-family: sans-serif; + + background-color: var(--fw-darker); + color: var(--fw-text); +} + +main { + display: grid; + grid-template-rows: 166px 1fr; + height: 100vh; +} + +/* + Error +*/ + +.error { + padding-left: 8px; + line-height: 50px; + font-weight: bold; + text-align: center; +} + +.error:first-letter { + text-transform: uppercase; +} + +.error .logo-link { + position: absolute; + top: 0; + right: 0; +} + +/* + Player +*/ + +.player { + display: grid; + grid-template-areas: 'cover content content' 'cover controls logo'; + grid-template-columns: 166px 1fr 50px; + align-items: flex-end; + padding: 8px; +} + +img { + display: block; + object-fit: contain; + object-position: center; + aspect-ratio: 1; +} + +h1, h2 { + margin: 0; +} + +.cover-image { + grid-area: cover; + background-color: var(--fw-dark); + width: 150px; +} + +.player-content { + grid-area: content; +} + +.player-controls { + grid-area: controls; + height: 36px; + + display: grid; + grid-template-columns: auto auto auto 1fr auto 100px; + gap: 8px; +} + +button { + color: inherit; + background-color: transparent; + border: none; + font-size: 2em; + padding: 0; + display: flex; + align-items: center; + cursor: pointer; +} + +button:hover { + color: var(--fw-primary); +} + +button.play { + font-size: 2.5em; +} + +button > span { + display: inline-flex; +} + +button svg.icon { + height: 1em; + width: 1em; +} + +.logo-link { + display: block; + width: 42px; + height: 42px; + background-color: var(--fw-primary); + padding: 4px; +} + +.player .logo-wrapper { + margin: 8px -8px -8px 8px; +} + +/* + Track list +*/ + +.track-list { + background-color: var(--fw-dark); + overflow-y: scroll; + padding: 16px 8px; +} + +table { + border-collapse: collapse; + width: 100%; +} + +tr { + font-weight: bold; + cursor: pointer; + background: var(--entry-bg, transparent); +} + +tr.current { + --entry-bg: var(--fw-darker); +} + +tr:hover { + --entry-bg: var(--fw-light); +} + +td { + padding: 8px; + min-width: 40px; +} + +td:first-child { + padding-left: 16px; + width: 0; +} + +td:last-child { + text-align: right; + width: 0; +} + +/* + Sliders +*/ + +input[type=range] { + background: transparent; + appearance: none; + height: 100%; + margin: 0; + + --range-color: var(--fw-light); + --range-size: 0.6em; + + --min: 0; + --max: 100; + --value: 50; + --range: calc(var(--max) - var(--min)); + --ratio: calc((var(--value) - var(--min)) / var(--range)); + --sx: calc(0.5 * var(--range-size) + var(--ratio) * (100% - var(--range-size))); +} + +input[type=range]::-webkit-slider-thumb { + appearance: none; + width: var(--range-size); + height: var(--range-size); + border-radius: calc(var(--range-size) / 2); + background: var(--range-color); + border: none; + box-shadow: none; +} + +input[type=range]::-moz-range-thumb { + appearance: none; + width: var(--range-size); + height: var(--range-size); + border-radius: calc(var(--range-size) / 2); + background: var(--range-color); + border: none; + box-shadow: none; +} + +input[type=range]::-moz-range-track { + appearance: none; + height: var(--range-size); + border: none; + border-radius: calc(var(--range-size) / 2); + box-shadow: none; + background: linear-gradient(var(--range-color),var(--range-color)) 0/var(--sx) 100% no-repeat, var(--fw-dark); +} + +input[type=range]::-webkit-slider-runnable-track { + appearance: none; + height: var(--range-size); + border: none; + border-radius: calc(var(--range-size) / 2); + box-shadow: none; + background: linear-gradient(var(--range-color),var(--range-color)) 0/var(--sx) 100% no-repeat, var(--fw-dark); +} diff --git a/front/public/embed.html b/front/public/embed.html new file mode 100644 index 0000000000000000000000000000000000000000..96551b87a4018f6fd06da0df57f6cad362cb5ad9 --- /dev/null +++ b/front/public/embed.html @@ -0,0 +1,500 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width,initial-scale=1.0"> + <meta name="generator" content="Funkwhale"> + + <link rel="icon" href="favicon.png"> + + <title>Funkwhale Widget</title> + + <link rel="stylesheet" href="embed.css"> + + <script type="module"> + import { createApp, reactive, nextTick } from 'https://unpkg.com/petite-vue@0.4.1?module' + + const SUPPORTED_TYPES = ['track', 'album', 'artist', 'playlist', 'channel'] + + // Params + const params = new URL(location.href).searchParams + const baseUrl = params.get('instance') ?? params.get('b') ?? '' + const type = params.get('type') + const id = params.get('id') + + // Error + let error = reactive({ value: false }) + if (!SUPPORTED_TYPES.includes(type)) { + error.value = `The embed widget doesn't support this media type: ${type}.` + } + + if (id === null || isNaN(+id)) { + error.value = `The embed widget couldn't read the provided media ID: ${id}.` + } + + // Cover + const DEFAULT_COVER = 'embed-default-cover.jpeg' + const cover = reactive({ value: DEFAULT_COVER }) + + const fetchArtistCover = async (id) => { + const response = await fetch(`${baseUrl}/api/v1/artists/${id}/`) + const data = await response.json() + cover.value = data.cover?.urls.medium_square_crop ?? DEFAULT_COVER + } + + if (type === 'artist') { + fetchArtistCover(id).catch(() => undefined) + } + + // Tracks + const tracks = reactive([]) + + const getTracksUrl = () => type === 'track' + ? `${baseUrl}/api/v1/tracks/${id}` + : type === 'playlist' + ? `${baseUrl}/api/v1/playlists/${id}/tracks/` + : `${baseUrl}/api/v1/tracks/` + + const getAudioSources = (uploads) => { + const sources = uploads + // NOTE: Filter out repeating and unplayable media types + .filter(({ mimetype }, index, array) => array.findIndex((upload) => upload.mimetype === mimetype) === index) + .filter(({ mimetype }) => ['probably', 'maybe'].includes(audio.element?.canPlayType(mimetype))) + // NOTE: For backwards compatibilty, prepend the baseUrl if listen_url starts with a slash + .map(source => ({ + ...source, + listen_url: source.listen_url[0] === '/' + ? `${baseUrl}${source.listen_url}` + : source.listen_url + })) + + // NOTE: Add a transcoded MP3 src at the end for browsers + // that do not support other codecs to be able to play it :) + if (sources.length > 0 && !sources.some(({ type }) => type === 'audio/mpeg')) { + sources.push({ mimetype: 'audio/mpeg', listen_url: `${sources[0].listen_url}?to=mp3` }) + } + + return sources + } + + const fetchTracks = async (url = getTracksUrl()) => { + const filters = new URLSearchParams({ + include_channels: true, + playable: true, + [type]: id + }) + + switch (type) { + case 'album': + filters.set('ordering', 'disc_number,position') + break + + case 'artist': + filters.set('ordering', '-album__release_date,disc_number,position') + break + + case 'channel': + filters.set('ordering', '-creation_date') + break + + case 'playlist': break + case 'track': break + + // NOTE: The type is undefined, let's return before we make any request + default: return + } + + const response = await fetch(`${url}?${filters}`) + const data = await response.json() + + if (response.status > 299) { + switch (response.status) { + case 400: + case 404: + error.value = `This ${type} wasn't found on the server.` + break + + case 403: + error.value = `You need to log in to access this ${type}.` + break + + case 500: + error.value = `An unknown error occurred while loading this ${type} from the server.` + break + + default: + error.value = `An unknown error occurred while loading this ${type}.` + } + + // NOTE: If we already have some tracks, let's fail silently + if (tracks.length > 0) { + console.error(error.value) + error.value = false + } + + return + } + + if (type === 'track') { + data.results = [data] + } + + if (type === 'playlist') { + data.results = data.results.map(({ track }) => track) + } + + tracks.push( + ...data.results.map((track) => ({ + id: track.id, + title: track.title, + artist: track.artist, + album: track.album, + cover: (track.cover ?? track.album.cover)?.urls.medium_square_crop, + sources: getAudioSources(track.uploads) + })).filter(({ sources }) => sources.length > 0) + ) + + if (data.next) { + return fetchTracks(data.next) + } + } + + // NOTE: Fetch tracks only if there is no error + if (error.value === false) { + fetchTracks().catch(err => { + console.error(err) + error.value = `An unknown error occurred while loading this ${type}.` + }) + } + + // Duration + const ZERO_DATE = +new Date('2022-01-01T00:00:00.000') + const intl = new Intl.DateTimeFormat('en', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }) + + const formatDuration = (duration) => { + if (duration === 0) return + + const time = intl.format(new Date(ZERO_DATE + duration * 1e3)) + return time.replace(/^00:/, '') + } + + // Logo component + const Logo = () => ({ $template: '#logo-template' }) + + // Icon component + const Icon = ({ icon }) => ({ $template: '#icon-template', icon }) + + // Media Session + const initializeMediaSession = () => { + if ('mediaSession' in navigator) { + navigator.mediaSession.setActionHandler('play', () => { + player.playing = true + audio.element.play() + }) + + navigator.mediaSession.setActionHandler('pause', () => { + player.playing = false + audio.element.pause() + }) + + navigator.mediaSession.setActionHandler('seekbackward', () => player.seekTime({ + target: { value: (audio.element.currentTime - 5) / audio.element.duration * 100 } + })) + + navigator.mediaSession.setActionHandler('seekforward', () => player.seekTime({ + target: { value: (audio.element.currentTime + 5) / audio.element.duration * 100 } + })) + + navigator.mediaSession.setActionHandler('previoustrack', () => player.prev()) + navigator.mediaSession.setActionHandler('nexttrack', () => player.next()) + } + } + + const updateMediaSessionMetadata = () => { + const { current } = player + + if (tracks[current] && 'mediaSession' in navigator) { + const metadata = new MediaMetadata({ + title: tracks[current].title, + album: tracks[current]?.album.title ?? '', + artist: tracks[current]?.artist.name ?? '', + artwork: [ + { src: tracks[current]?.cover ?? cover.value, sizes: '96x96', type: 'image/png' }, + { src: tracks[current]?.cover ?? cover.value, sizes: '128x128', type: 'image/png' }, + { src: tracks[current]?.cover ?? cover.value, sizes: '192x192', type: 'image/png' }, + { src: tracks[current]?.cover ?? cover.value, sizes: '256x256', type: 'image/png' }, + { src: tracks[current]?.cover ?? cover.value, sizes: '384x384', type: 'image/png' }, + { src: tracks[current]?.cover ?? cover.value, sizes: '512x512', type: 'image/png' } + ] + }) + + requestAnimationFrame(() => { + navigator.mediaSession.metadata = metadata + }) + } + } + + // Player + const player = reactive({ + playing: false, + current: 0, + seek: 0, + play (unsafeIndex) { + const index = Math.min(tracks.length - 1, Math.max(unsafeIndex, 0)) + if (this.current === index) return + + const wasPlaying = this.playing + if (wasPlaying) audio.element.pause() + + this.current = index + audio.element.currentTime = 0 + audio.element.load() + + if (wasPlaying) audio.element.play() + + updateMediaSessionMetadata() + }, + + next () { + this.play(this.current + 1) + }, + + prev () { + this.play(this.current - 1) + }, + + seekTime (event) { + if (!audio.element) return + + const seek = audio.element.duration * event.target.value / 100 + audio.element.currentTime = isNaN(seek) ? 0 : Math.min(seek, audio.element.duration - 1) + }, + + togglePlay () { + this.playing = !this.playing + + if (this.playing) audio.element.play() + else audio.element.pause() + + updateMediaSessionMetadata() + } + }) + + // Volume + const DEFAULT_VOLUME = 75 + const volume = reactive({ + level: DEFAULT_VOLUME, + lastLevel: DEFAULT_VOLUME, + + mute () { + if (this.lastLevel === 0) { + this.lastLevel = DEFAULT_VOLUME + } + + const lastLevel = this.level + this.level = lastLevel === 0 + ? this.lastLevel + : 0 + + this.lastLevel = lastLevel + } + }) + + // Audio + const audio = reactive({ + element: undefined, + current: -1, + volume: -1 + }) + + const watchAudio = (element, volume) => { + if (audio.element !== element) { + audio.element = element + + element.addEventListener('timeupdate', (event) => { + const seek = element.currentTime / element.duration * 100 + player.seek = isNaN(seek) ? 0 : seek + }) + + element.addEventListener('ended', () => { + // NOTE: Pause playback if it's a last track + if (player.current === tracks.length - 1) { + player.playing = false + } + + player.next() + }) + } + + if (audio.volume !== volume) { + audio.element.volume = volume / 100 + audio.volume = volume + } + } + + // Application + const app = createApp({ + // Components + Logo, + Icon, + + // Errors + error, + + // Playback + initializeMediaSession, + watchAudio, + player, + volume, + + // Track info + formatDuration, + tracks, + cover + }) + + app.directive('range', (ctx) => { + ctx.effect(() => { + ctx.el.style.setProperty('--value', ctx.get()) + }) + }) + + app.mount() + </script> +</head> + +<template id="logo-template"> + <a + title="Funkwhale" + href="https://funkwhale.audio" + target="_blank" + rel="noopener noreferrer" + class="logo-link" + tabindex="-1" + > + <img src="logo-white.svg" /> + </a> +</template> + +<template id="icon-template"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="icon" fill="currentColor" viewBox="0 0 16 16"> + <path v-if="icon === 'pause'" d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z" /> + <path v-else-if="icon === 'play'" d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z" /> + <path v-else-if="icon === 'prev'" d="M4 4a.5.5 0 0 1 1 0v3.248l6.267-3.636c.54-.313 1.232.066 1.232.696v7.384c0 .63-.692 1.01-1.232.697L5 8.753V12a.5.5 0 0 1-1 0V4z" /> + <path v-else-if="icon === 'next'" d="M12.5 4a.5.5 0 0 0-1 0v3.248L5.233 3.612C4.693 3.3 4 3.678 4 4.308v7.384c0 .63.692 1.01 1.233.697L11.5 8.753V12a.5.5 0 0 0 1 0V4z" /> + <path v-else-if="icon === 'mute'" d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06zm7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0z" /> + <g v-else-if="icon === 'volume'"> + <path d="M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z" /> + <path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z" /> + <path d="M8.707 11.182A4.486 4.486 0 0 0 10.025 8a4.486 4.486 0 0 0-1.318-3.182L8 5.525A3.489 3.489 0 0 1 9.025 8 3.49 3.49 0 0 1 8 10.475l.707.707zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06z" /> + </g> + </svg> +</template> + +<body> + <noscript> + <strong>You need to enable Javascript to use the embed widget.</strong> + </noscript> + + <main v-scope v-cloak> + <div v-if="error.value !== false" class="error"> + {{ error.value }} + <div v-scope="Logo()"></div> + </div> + + <template v-else> + <div class="player"> + <img :src="tracks[player.current]?.cover ?? cover.value" class="cover-image" /> + + <div class="player-content"> + <h1>{{ tracks[player.current]?.title }}</h1> + <h2>{{ tracks[player.current]?.artist.name }}</h2> + </div> + + <div class="player-controls"> + <button @click="player.prev"> + <span v-scope="Icon({ icon: 'prev' })"></span> + </button> + <button @click="player.togglePlay" class="play"> + <span v-if="!player.playing" v-scope="Icon({ icon: 'play' })"></span> + <span v-else v-scope="Icon({ icon: 'pause' })"></span> + </button> + <button @click="player.next"> + <span v-scope="Icon({ icon: 'next' })"></span> + </button> + + <input + v-model.number="player.seek" + v-range="player.seek" + @input="player.seekTime" + type="range" + step="0.1" + /> + + <button @click="volume.mute"> + <span v-if="volume.level === 0" v-scope="Icon({ icon: 'mute' })"></span> + <span v-else v-scope="Icon({ icon: 'volume' })"></span> + </button> + + <input + v-model.number="volume.level" + v-range="volume.level" + type="range" + step="0.1" + /> + </div> + + <span v-scope="Logo()" class="logo-wrapper"></span> + </div> + + <div class="track-list"> + <table> + <tr + v-for="(track, index) in tracks" + :id="'queue-item-' + index" + :key="track.id" + role="button" + :class="{ 'current': player.current === index }" + @click="player.play(index)" + @keyup.enter="player.play(index)" + tabindex="0" + > + <td> + {{ index + 1 }} + </td> + <td :title="track.title"> + {{ track.title }} + </td> + <td :title="track.artist.name"> + {{ track.artist.name }} + </td> + <td :title="track.album?.title"> + {{ track.album?.title }} + </td> + <td> + {{ formatDuration(track.sources?.[0].duration ?? 0) }} + </td> + </tr> + </table> + </div> + + <audio v-effect="watchAudio($el, volume.level)" @vue:mounted="initializeMediaSession"> + <source + v-for="source in tracks[player.current]?.sources ?? []" + :key="source.mimetype + source.listen_url" + :type="source.mimetype" + :src="source.listen_url" + > + </audio> + </template> + </main> +</body> + +</html> diff --git a/front/public/logo-white.svg b/front/public/logo-white.svg new file mode 100644 index 0000000000000000000000000000000000000000..e00515822ff5b1ddf943e6151c2348285b0d2dd2 --- /dev/null +++ b/front/public/logo-white.svg @@ -0,0 +1,39 @@ + <svg + id="layer_1" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + x="0px" + y="0px" + viewBox="0 0 141.7 141.7" + enable-background="new 0 0 141.7 141.7" + xml:space="preserve" + > + <g> + <g> + <path + fill="#fff" + d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11 + c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z" + /> + <path + fill="#fff" + d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1 + c0,17.1-13.9,31-31,31c-17.1,0-31-13.9-31-31c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C29.6,87.6,48.1,106.1,70.9,106.1z" + /> + <path + fill="#fff" + d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1 + c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,33.8,27.5,61.3,61.3,61.3c33.8,0,61.3-27.5,61.3-61.3 + C132.2,64.3,131.7,63.8,131.1,63.8z" + /> + </g> + <path + fill="#fff" + d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2 + c0,0,2,5.2,6.4,5.2c4.7,0,6.4-5.2,6.4-5.2l0.3-5.2c0,0-0.1-4.7,2.4-8.5c1.6-2.5,3.2-4.3,5.8-5.7c4-2.3,8.4-2.7,12.5-4.8 + c4.1-2.1,8.1-4.8,10.8-8.6c2.7-3.8,4-8.8,2.5-13.2c-7.8-0.4-16.8,0.5-23.7,4.2c-9.6,5.1-15.4,3.3-17.1,10.9h-0.1 + c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z" + /> + </g> + </svg> diff --git a/front/public/service-worker.js b/front/public/service-worker.js deleted file mode 100644 index 11f73ea72bd6568f30d38984083f211a2cc39f8f..0000000000000000000000000000000000000000 --- a/front/public/service-worker.js +++ /dev/null @@ -1,93 +0,0 @@ -/* eslint no-undef: "off" */ - -// This is the code piece that GenerateSW mode can't provide for us. -// This code listens for the user's confirmation to update the app. -workbox.loadModule('workbox-routing') -workbox.loadModule('workbox-strategies') -workbox.loadModule('workbox-expiration') - -self.addEventListener('message', (e) => { - if (!e.data) { - return - } - console.log('[sw] received message', e.data) - switch (e.data.command) { - case 'skipWaiting': - self.skipWaiting() - break - case 'serverChosen': - self.registerServerRoutes(e.data.serverUrl) - break - default: - // NOOP - break - } -}) -workbox.core.clientsClaim() - -const router = new workbox.routing.Router() -router.addCacheListener() -router.addFetchListener() - -let registeredServerRoutes = [] -self.registerServerRoutes = (serverUrl) => { - console.log('[sw] Setting up API caching for', serverUrl) - registeredServerRoutes.forEach((r) => { - console.log('[sw] Unregistering previous API route...', r) - router.unregisterRoute(r) - }) - if (!serverUrl) { - return - } - const regexReadyServerUrl = serverUrl.replace('.', '\\.') - registeredServerRoutes = [] - const networkFirstPaths = [ - 'api/v1/', - 'media/' - ] - const networkFirstExcludedPaths = [ - 'api/v1/listen' - ] - const strategy = new workbox.strategies.NetworkFirst({ - cacheName: 'api-cache:' + serverUrl, - plugins: [ - new workbox.expiration.Plugin({ - maxAgeSeconds: 24 * 60 * 60 * 7 - }) - ] - }) - const networkFirstRoutes = networkFirstPaths.map((path) => { - const regex = new RegExp(regexReadyServerUrl + path) - return new workbox.routing.RegExpRoute(regex, () => {}) - }) - const matcher = ({ url, event }) => { - for (let index = 0; index < networkFirstExcludedPaths.length; index++) { - const blacklistedPath = networkFirstExcludedPaths[index] - if (url.pathname.startsWith('/' + blacklistedPath)) { - // the path is blacklisted, we don't cache it at all - console.log('[sw] Path is blacklisted, not caching', url.pathname) - return false - } - } - // we call other regex matchers - for (let index = 0; index < networkFirstRoutes.length; index++) { - const route = networkFirstRoutes[index] - const result = route.match({ url, event }) - if (result) { - return result - } - } - return false - } - - const route = new workbox.routing.Route(matcher, strategy) - console.log('[sw] registering new API route...', route) - router.registerRoute(route) - registeredServerRoutes.push(route) -} - -// The precaching code provided by Workbox. -self.__precacheManifest = [].concat(self.__precacheManifest || []) - -// workbox.precaching.suppressWarnings(); // Only used with Vue CLI 3 and Workbox v3. -workbox.precaching.precacheAndRoute(self.__precacheManifest, {}) diff --git a/front/public/settings.json b/front/public/settings.json index b295bd92caec0127f9d8b9a0e8ce7f3032a359d2..6264b12cf851e73c57fa9670d19d5ed62997a35a 100644 --- a/front/public/settings.json +++ b/front/public/settings.json @@ -1,6 +1,6 @@ { "additionalStylesheets": [ - "/custom.css" + "/front/custom.css" ], "defaultServerUrl": null } diff --git a/front/scripts/fix-fomantic-css.sh b/front/scripts/fix-fomantic-css.sh index bbb7b85088fdf66a97965c17b32d9d19ed07abfd..30a63d9181c27de91f441afefc070249d188291a 100755 --- a/front/scripts/fix-fomantic-css.sh +++ b/front/scripts/fix-fomantic-css.sh @@ -8,3 +8,5 @@ echo 'Removing google font…' sed -i '/@import url(/d' node_modules/fomantic-ui-css/components/site.css echo "Replacing hardcoded values by CSS vars…" scripts/fix-fomantic-css.py node_modules/fomantic-ui-css node_modules/fomantic-ui-css/tweaked +echo 'Fixing jQuery import…' +sed -i '1s/^/import jQuery from "jquery"\n/' `find node_modules/fomantic-ui-css/ -name '*.js'` \ No newline at end of file diff --git a/front/scripts/i18n-compile.sh b/front/scripts/i18n-compile.sh index 0b90bfd4279b1d4517d4ce56c4aa99a928f1f425..c144c34c6adcc1e547ff4ba6025982b3fdea6d94 100755 --- a/front/scripts/i18n-compile.sh +++ b/front/scripts/i18n-compile.sh @@ -3,7 +3,7 @@ cd "$(dirname $0)/.." # change into base directory source scripts/utils.sh -locales=$(tail -n +2 src/locales.js | sed -e 's/export default //' | jq '.locales[].code' | grep -v 'en_US' | xargs echo) +locales=$(jq -r '.[].code' src/locales.json | grep -v 'en_US') mkdir -p src/translations for locale in $locales; do diff --git a/front/scripts/i18n-extract.sh b/front/scripts/i18n-extract.sh index 63b79b164f87203651ca3f04206653406bd06599..209731d638b893f3ccb6f4b576334af1b9df25e2 100755 --- a/front/scripts/i18n-extract.sh +++ b/front/scripts/i18n-extract.sh @@ -3,7 +3,7 @@ cd "$(dirname $0)/.." # change into base directory source scripts/utils.sh -locales=$(tail -n +2 src/locales.js | sed -e 's/export default //' | jq '.locales[].code' | xargs echo) +locales=$(jq -r '.[].code' src/locales.json) locales_dir="locales" sources=$(find src -name '*.vue' -o -name '*.html' 2> /dev/null) js_sources=$(find src -name '*.vue' -o -name '*.js') diff --git a/front/scripts/i18n-populate-contextualized-strings.sh b/front/scripts/i18n-populate-contextualized-strings.sh deleted file mode 100755 index 37f0b1871d659f27a463e0cb4ad5fcc176a04434..0000000000000000000000000000000000000000 --- a/front/scripts/i18n-populate-contextualized-strings.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env -S bash -eux - -# Typical use: -# cp -r locales old_locales -# ./scripts/i18n-extract.sh -# ./scripts/i18n-populate-contextualized-strings.sh old_locales locales -# Then review/commit the changes - -cd "$(dirname $0)/.." # change into base directory - -old_locales_dir=$1 -new_locales_dir=$2 - -locales=$(tail -n +2 src/locales.js | sed -e 's/export default //' | jq '.locales[].code' | xargs echo) - -# Generate .po files for each available language. -echo $locales -for lang in $locales; do - echo "Fixing contexts for $lang…" - old_po_file=$old_locales_dir/$lang/LC_MESSAGES/app.po - new_po_file=$new_locales_dir/$lang/LC_MESSAGES/app.po - python3 ./scripts/contextualize.py $old_po_file $new_po_file --no-dry-run -done; diff --git a/front/src/App.vue b/front/src/App.vue index f35939351783e1a50e56cedda0bcbbf36de1b94e..4ee986ac2946755d6a852a4f04470b6746e25a75 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,9 +1,84 @@ +<script setup lang="ts"> +import type { Track } from '~/types' + +import { useIntervalFn, useToggle, useWindowSize } from '@vueuse/core' +import { computed, nextTick, onMounted, ref, watchEffect } from 'vue' +import { useStore } from '~/store' + +import ChannelUploadModal from '~/components/channels/UploadModal.vue' +import PlaylistModal from '~/components/playlists/PlaylistModal.vue' +import FilterModal from '~/components/moderation/FilterModal.vue' +import ReportModal from '~/components/moderation/ReportModal.vue' +import SetInstanceModal from '~/components/SetInstanceModal.vue' +import ServiceMessages from '~/components/ServiceMessages.vue' +import ShortcutsModal from '~/components/ShortcutsModal.vue' +import AudioPlayer from '~/components/audio/Player.vue' +import Sidebar from '~/components/Sidebar.vue' +import Queue from '~/components/Queue.vue' + +import onKeyboardShortcut from '~/composables/onKeyboardShortcut' +import useQueue from '~/composables/audio/useQueue' + +const store = useStore() + +// Tracks +const { currentTrack } = useQueue() +const getTrackInformationText = (track: Track | undefined) => { + if (!track) { + return null + } + + const artist = track.artist ?? track.album?.artist + return `♫ ${track.title} – ${artist?.name} ♫` +} + +// Update title +const initialTitle = document.title +watchEffect(() => { + const parts = [ + getTrackInformationText(currentTrack.value), + store.state.ui.pageTitle, + initialTitle || 'Funkwhale' + ] + + document.title = parts.filter(i => i).join(' – ') +}) + +// Styles +const customStylesheets = computed(() => { + return store.state.instance.frontSettings.additionalStylesheets ?? [] +}) + +// Fake content +onMounted(async () => { + await nextTick() + document.getElementById('fake-content')?.classList.add('loaded') +}) + +// Time ago +useIntervalFn(() => { + // used to redraw ago dates every minute + store.commit('ui/computeLastDate') +}, 1000 * 60) + +// Shortcuts +const [showShortcutsModal, toggleShortcutsModal] = useToggle(false) +onKeyboardShortcut('h', () => toggleShortcutsModal()) + +const { width } = useWindowSize() +const showSetInstanceModal = ref(false) + +// Fetch user data on startup +// NOTE: We're not checking if we're authenticated in the store, +// because we want to learn if we are authenticated at all +store.dispatch('auth/fetchUser') +</script> + <template> <div - id="app" - :key="String($store.state.instance.instanceUrl)" - :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', - {'has-bottom-player': $store.state.queue.tracks.length > 0}]" + :key="String(store.state.instance.instanceUrl)" + :class="[store.state.ui.queueFocused ? 'queue-focused' : '', + {'has-bottom-player': store.state.queue.tracks.length > 0}]" > <!-- here, we display custom stylesheets, if any --> <link @@ -13,448 +88,41 @@ property="stylesheet" :href="url" > + <sidebar :width="width" @show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal" - @show:shortcuts-modal="showShortcutsModal = !showShortcutsModal" - /> - <set-instance-modal - :show="showSetInstanceModal" - @update:show="showSetInstanceModal = $event" + @show:shortcuts-modal="toggleShortcutsModal" /> + <set-instance-modal v-model:show="showSetInstanceModal" /> <service-messages /> <transition name="queue"> - <queue - v-if="$store.state.ui.queueFocused" - @touch-progress="$refs.player.setCurrentTime($event)" - /> + <queue v-show="store.state.ui.queueFocused" /> </transition> - <router-view - role="main" - :class="{hidden: $store.state.ui.queueFocused}" - /> - <player ref="player" /> - <playlist-modal v-if="$store.state.auth.authenticated" /> - <channel-upload-modal v-if="$store.state.auth.authenticated" /> - <filter-modal v-if="$store.state.auth.authenticated" /> + + <router-view v-slot="{ Component }"> + <template v-if="Component"> + <keep-alive :max="1"> + <Suspense> + <component :is="Component" /> + <template #fallback> + <!-- TODO (wvffle): Add loader --> + Loading... + </template> + </Suspense> + </keep-alive> + </template> + </router-view> + + <audio-player /> + <playlist-modal v-if="store.state.auth.authenticated" /> + <channel-upload-modal v-if="store.state.auth.authenticated" /> + <filter-modal v-if="store.state.auth.authenticated" /> <report-modal /> - <shortcuts-modal - :show="showShortcutsModal" - @update:show="showShortcutsModal = $event" - /> - <GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal" /> + <shortcuts-modal v-model:show="showShortcutsModal" /> </div> </template> -<script> -import axios from 'axios' -import _ from 'lodash' -import { mapState, mapGetters } from 'vuex' -import { useWebSocket, whenever } from '@vueuse/core' -import GlobalEvents from '@/components/utils/global-events.vue' -import locales from './locales' -import { getClientOnlyRadio } from '@/radios' - -import Player from '@/components/audio/Player.vue' -import Queue from '@/components/Queue.vue' -import PlaylistModal from '@/components/playlists/PlaylistModal.vue' -import ChannelUploadModal from '@/components/channels/UploadModal.vue' -import Sidebar from '@/components/Sidebar.vue' -import ServiceMessages from '@/components/ServiceMessages.vue' -import SetInstanceModal from '@/components/SetInstanceModal.vue' -import ShortcutsModal from '@/components/ShortcutsModal.vue' -import FilterModal from '@/components/moderation/FilterModal.vue' -import ReportModal from '@/components/moderation/ReportModal.vue' -import { watch, watchEffect } from '@vue/composition-api' - -export default { - name: 'App', - components: { - Player, - Queue, - PlaylistModal, - ChannelUploadModal, - Sidebar, - ServiceMessages, - SetInstanceModal, - ShortcutsModal, - FilterModal, - ReportModal, - GlobalEvents - }, - setup (props, { root }) { - const store = root.$store - - const url = store.getters['instance/absoluteUrl']('api/v1/activity') - .replace(/^http/, 'ws') - - const { data, status, open, close } = useWebSocket(url, { - autoReconnect: true, - immediate: false - }) - - watch(() => store.state.auth.authenticated, (authenticated) => { - if (authenticated) return open() - close() - }) - - whenever(data, () => { - store.dispatch('ui/websocketEvent', JSON.parse(data.value)) - }) - - watchEffect(() => { - console.log('Websocket status:', status.value) - }) - }, - data () { - return { - instanceUrl: null, - showShortcutsModal: false, - showSetInstanceModal: false, - initialTitle: document.title, - width: window.innerWidth - } - }, - computed: { - ...mapState({ - messages: state => state.ui.messages, - nodeinfo: state => state.instance.nodeinfo, - playing: state => state.player.playing, - bufferProgress: state => state.player.bufferProgress, - isLoadingAudio: state => state.player.isLoadingAudio, - serviceWorker: state => state.ui.serviceWorker - }), - ...mapGetters({ - hasNext: 'queue/hasNext', - currentTrack: 'queue/currentTrack', - progress: 'player/progress' - }), - labels () { - const play = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Play track') - const pause = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Pause track') - const next = this.$pgettext('Sidebar/Player/Icon.Tooltip', 'Next track') - const expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Expand queue') - return { - play, - pause, - next, - expandQueue - } - }, - suggestedInstances () { - const instances = this.$store.state.instance.knownInstances.slice(0) - if (this.$store.state.instance.frontSettings.defaultServerUrl) { - let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl - if (!serverUrl.endsWith('/')) { - serverUrl = serverUrl + '/' - } - instances.push(serverUrl) - } - instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/') - return _.uniq(instances.filter((e) => { return e })) - }, - version () { - if (!this.nodeinfo) { - return null - } - return _.get(this.nodeinfo, 'software.version') - }, - customStylesheets () { - if (this.$store.state.instance.frontSettings) { - return this.$store.state.instance.frontSettings.additionalStylesheets || [] - } - return null - }, - matchDarkColorScheme () { - if (window.matchMedia) { - return window.matchMedia('(prefers-color-scheme: dark)') - } - return null - } - }, - watch: { - '$store.state.instance.instanceUrl' (v) { - this.$store.dispatch('instance/fetchSettings') - this.fetchNodeInfo() - }, - '$store.state.ui.theme': { - immediate: true, - handler (newValue) { - const matchesDark = this.matchDarkColorScheme - if (matchesDark) { - if (newValue === 'system') { - newValue = matchesDark.matches ? 'dark' : 'light' - matchesDark.addEventListener('change', this.handleThemeChange) - } else { - matchesDark.removeEventListener('change', this.handleThemeChange) - } - } else { - if (newValue === 'system') { - newValue = 'light' - } - } - this.setTheme(newValue) - } - }, - '$store.state.ui.currentLanguage': { - immediate: true, - handler (newValue) { - const self = this - const htmlLocale = newValue.toLowerCase().replace('_', '-') - document.documentElement.setAttribute('lang', htmlLocale) - if (newValue === 'en_US') { - self.$language.current = 'noop' - self.$language.current = newValue - return self.$store.commit('ui/momentLocale', 'en') - } - } - }, - currentTrack: { - immediate: true, - handler (newValue) { - this.updateDocumentTitle() - } - }, - '$store.state.ui.pageTitle': { - immediate: true, - handler (newValue) { - this.updateDocumentTitle() - } - }, - 'serviceWorker.updateAvailable': { - handler (v) { - if (!v) { - return - } - const self = this - this.$store.commit('ui/addMessage', { - content: this.$pgettext('App/Message/Paragraph', 'A new version of the app is available.'), - date: new Date(), - key: 'refreshApp', - displayTime: 0, - classActions: 'bottom attached opaque', - actions: [ - { - text: this.$pgettext('App/Message/Paragraph', 'Update'), - class: 'primary', - click: function () { - self.updateApp() - } - }, - { - text: this.$pgettext('App/Message/Paragraph', 'Later'), - class: 'basic' - } - ] - }) - }, - immediate: true - } - }, - async created () { - if (navigator.serviceWorker) { - navigator.serviceWorker.addEventListener( - 'controllerchange', () => { - if (this.serviceWorker.refreshing) return - this.$store.commit('ui/serviceWorker', { - refreshing: true - }) - window.location.reload() - } - ) - } - window.addEventListener('resize', this.handleResize) - this.handleResize() - const self = this - if (!this.$store.state.ui.selectedLanguage) { - this.autodetectLanguage() - } - setInterval(() => { - // used to redraw ago dates every minute - self.$store.commit('ui/computeLastDate') - }, 1000 * 60) - const urlParams = new URLSearchParams(window.location.search) - const serverUrl = urlParams.get('_server') - if (serverUrl) { - this.$store.commit('instance/instanceUrl', serverUrl) - } - const url = urlParams.get('_url') - if (url) { - await this.$router.replace(url) - } else if (!this.$store.state.instance.instanceUrl) { - // we have several way to guess the API server url. By order of precedence: - // 1. use the url provided in settings.json, if any - // 2. use the url specified when building via VUE_APP_INSTANCE_URL - // 3. use the current url - const defaultInstanceUrl = - this.$store.state.instance.frontSettings.defaultServerUrl || - import.meta.env.VUE_APP_INSTANCE_URL || this.$store.getters['instance/defaultUrl']() - this.$store.commit('instance/instanceUrl', defaultInstanceUrl) - } else { - // needed to trigger initialization of axios / service worker - this.$store.commit('instance/instanceUrl', this.$store.state.instance.instanceUrl) - } - await this.fetchNodeInfo() - this.$store.dispatch('instance/fetchSettings') - this.$store.commit('ui/addWebsocketEventHandler', { - eventName: 'inbox.item_added', - id: 'sidebarCount', - handler: this.incrementNotificationCountInSidebar - }) - this.$store.commit('ui/addWebsocketEventHandler', { - eventName: 'mutation.created', - id: 'sidebarReviewEditCount', - handler: this.incrementReviewEditCountInSidebar - }) - this.$store.commit('ui/addWebsocketEventHandler', { - eventName: 'mutation.updated', - id: 'sidebarReviewEditCount', - handler: this.incrementReviewEditCountInSidebar - }) - this.$store.commit('ui/addWebsocketEventHandler', { - eventName: 'report.created', - id: 'sidebarPendingReviewReportCount', - handler: this.incrementPendingReviewReportsCountInSidebar - }) - this.$store.commit('ui/addWebsocketEventHandler', { - eventName: 'user_request.created', - id: 'sidebarPendingReviewRequestCount', - handler: this.incrementPendingReviewRequestsCountInSidebar - }) - this.$store.commit('ui/addWebsocketEventHandler', { - eventName: 'Listen', - id: 'handleListen', - handler: this.handleListen - }) - }, - mounted () { - const self = this - // slight hack to allow use to have internal links in <translate> tags - // while preserving router behaviour - document.documentElement.addEventListener('click', function (event) { - if (!event.target.matches('a.internal')) return - self.$router.push(event.target.getAttribute('href')) - event.preventDefault() - }, false) - this.$nextTick(() => { - document.getElementById('fake-content').classList.add('loaded') - }) - }, - destroyed () { - this.$store.commit('ui/removeWebsocketEventHandler', { - eventName: 'inbox.item_added', - id: 'sidebarCount' - }) - this.$store.commit('ui/removeWebsocketEventHandler', { - eventName: 'mutation.created', - id: 'sidebarReviewEditCount' - }) - this.$store.commit('ui/removeWebsocketEventHandler', { - eventName: 'mutation.updated', - id: 'sidebarReviewEditCount' - }) - this.$store.commit('ui/removeWebsocketEventHandler', { - eventName: 'mutation.updated', - id: 'sidebarPendingReviewReportCount' - }) - this.$store.commit('ui/removeWebsocketEventHandler', { - eventName: 'user_request.created', - id: 'sidebarPendingReviewRequestCount' - }) - this.$store.commit('ui/removeWebsocketEventHandler', { - eventName: 'Listen', - id: 'handleListen' - }) - }, - methods: { - incrementNotificationCountInSidebar (event) { - this.$store.commit('ui/incrementNotifications', { type: 'inbox', count: 1 }) - }, - incrementReviewEditCountInSidebar (event) { - this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewEdits', value: event.pending_review_count }) - }, - incrementPendingReviewReportsCountInSidebar (event) { - this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewReports', value: event.unresolved_count }) - }, - incrementPendingReviewRequestsCountInSidebar (event) { - this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewRequests', value: event.pending_count }) - }, - handleListen (event) { - if (this.$store.state.radios.current && this.$store.state.radios.running) { - const current = this.$store.state.radios.current - if (current.clientOnly && current.type === 'account') { - getClientOnlyRadio(current).handleListen(current, event, this.$store) - } - } - }, - async fetchNodeInfo () { - const response = await axios.get('instance/nodeinfo/2.0/') - this.$store.commit('instance/nodeinfo', response.data) - }, - autodetectLanguage () { - const userLanguage = navigator.language || navigator.userLanguage - const available = locales.locales.map(e => { return e.code }) - let candidate - const matching = available.filter((a) => { - return userLanguage.replace('-', '_') === a - }) - const almostMatching = available.filter((a) => { - return userLanguage.replace('-', '_').split('_')[0] === a.split('_')[0] - }) - if (matching.length > 0) { - candidate = matching[0] - } else if (almostMatching.length > 0) { - candidate = almostMatching[0] - } else { - return - } - this.$store.commit('ui/currentLanguage', candidate) - }, - getTrackInformationText (track) { - const trackTitle = track.title - const albumArtist = (track.album) ? track.album.artist.name : null - const artistName = ( - (track.artist) ? track.artist.name : albumArtist) - const text = `♫ ${trackTitle} – ${artistName} ♫` - return text - }, - updateDocumentTitle () { - const parts = [] - const currentTrackPart = ( - (this.currentTrack) - ? this.getTrackInformationText(this.currentTrack) - : null) - if (currentTrackPart) { - parts.push(currentTrackPart) - } - if (this.$store.state.ui.pageTitle) { - parts.push(this.$store.state.ui.pageTitle) - } - parts.push(this.initialTitle || 'Funkwhale') - document.title = parts.join(' – ') - }, - - updateApp () { - this.$store.commit('ui/serviceWorker', { updateAvailable: false }) - if (!this.serviceWorker.registration || !this.serviceWorker.registration.waiting) { return } - this.serviceWorker.registration.waiting.postMessage({ command: 'skipWaiting' }) - }, - handleResize () { - this.width = window.innerWidth - }, - handleThemeChange (event) { - this.setTheme(event.matches ? 'dark' : 'light') - }, - setTheme (theme) { - const oldTheme = (theme === 'light') ? 'dark' : 'light' - document.body.classList.remove(`theme-${oldTheme}`) - document.body.classList.add(`theme-${theme}`) - } - } -} -</script> - <style lang="scss"> @import "style/_main"; - </style> diff --git a/front/src/audio/backend.js b/front/src/audio/backend.js deleted file mode 100644 index 20032ac13dd942e3a299e1d7f11b34be2e7af41f..0000000000000000000000000000000000000000 --- a/front/src/audio/backend.js +++ /dev/null @@ -1,23 +0,0 @@ -const Album = { - clean (album) { - // we manually rebind the album and artist to each child track - album.tracks = album.tracks.map((track) => { - track.album = album - return track - }) - return album - } -} -const Artist = { - clean (artist) { - // clean data as given by the API - artist.albums = artist.albums.map((album) => { - return Album.clean(album) - }) - return artist - } -} -export default { - Artist: Artist, - Album: Album -} diff --git a/front/src/audio/formats.js b/front/src/audio/formats.js deleted file mode 100644 index d8a5a412546433ce73d5641c0aee075a480aba75..0000000000000000000000000000000000000000 --- a/front/src/audio/formats.js +++ /dev/null @@ -1,11 +0,0 @@ -export default { - formats: [ - // 'audio/ogg', - 'audio/mpeg' - ], - formatsMap: { - 'audio/ogg': 'ogg', - 'audio/mpeg': 'mp3', - 'audio/x-flac': 'flac' - } -} diff --git a/front/src/audio/volume.js b/front/src/audio/volume.js deleted file mode 100644 index 05c74e670a123cc003f81ce7affcdb851488d51d..0000000000000000000000000000000000000000 --- a/front/src/audio/volume.js +++ /dev/null @@ -1,23 +0,0 @@ -const DYNAMIC_RANGE = 40 // dB - -export function toLinearVolumeScale (v) { - if (v <= 0.0) { - return 0.0 - } - - // (1.0; 0.0) -> (0; -DYNAMIC_RANGE) dB - const dB = (v - 1) * DYNAMIC_RANGE - - return Math.pow(10, dB / 20) -} - -export function toLogarithmicVolumeScale (v) { - if (v <= 0.0) { - return 0.0 - } - - const dB = 20 * Math.log10(v) - - // (0; -DYNAMIC_RANGE) [dB] -> (1.0; 0.0) - return 1 - (dB / -DYNAMIC_RANGE) -} diff --git a/front/src/components/About.vue b/front/src/components/About.vue index 5ef16e57bf3473ce6ae2336b3d39dd6daab6fe94..72355ef58f350b2320a0fe8d42b5bf558359c9ca 100644 --- a/front/src/components/About.vue +++ b/front/src/components/About.vue @@ -1,3 +1,50 @@ +<script setup lang="ts"> +import { useStore } from '~/store' +import { useGettext } from 'vue3-gettext' +import { get } from 'lodash-es' +import { humanSize } from '~/utils/filters' +import { computed } from 'vue' + +import SignupForm from '~/components/auth/SignupForm.vue' +import LogoText from '~/components/LogoText.vue' + +const store = useStore() +const nodeinfo = computed(() => store.state.instance.nodeinfo) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/About/Title', 'About') +})) + +const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') ?? 'Funkwhale') +const banner = computed(() => get(nodeinfo.value, 'metadata.banner')) +const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription')) + +const stats = computed(() => { + const users = get(nodeinfo.value, 'usage.users.activeMonth', null) + const hours = get(nodeinfo.value, 'metadata.library.music.hours', 0) + + if (users === null) { + return null + } + + return { users, hours } +}) + +const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations')) +const defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota') * 1000 * 1000)) + +const headerStyle = computed(() => { + if (!banner.value) { + return '' + } + + return { + backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})` + } +}) +</script> + <template> <main v-title="labels.title" @@ -173,7 +220,11 @@ </div> </div> </div> - <div class="ui three stackable cards"> + <!-- TODO (wvffle): Remove style when migrate away from fomantic --> + <div + class="ui three stackable cards" + style="z-index: 1; position: relative;" + > <router-link to="/" class="ui card" @@ -247,103 +298,3 @@ </div> </main> </template> - -<script> -import { mapState } from 'vuex' -import _ from 'lodash' -import showdown from 'showdown' -import { humanSize } from '@/filters' - -import SignupForm from '@/components/auth/SignupForm.vue' -import LogoText from '@/components/LogoText.vue' - -export default { - components: { - SignupForm, - LogoText - }, - data () { - return { - markdown: new showdown.Converter(), - showAllowedDomains: false - } - }, - computed: { - - ...mapState({ - nodeinfo: state => state.instance.nodeinfo - }), - labels () { - return { - title: this.$pgettext('Head/About/Title', 'About') - } - }, - podName () { - return _.get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale' - }, - banner () { - return _.get(this.nodeinfo, 'metadata.banner') - }, - shortDescription () { - return _.get(this.nodeinfo, 'metadata.shortDescription') - }, - longDescription () { - return _.get(this.nodeinfo, 'metadata.longDescription') - }, - rules () { - return _.get(this.nodeinfo, 'metadata.rules') - }, - terms () { - return _.get(this.nodeinfo, 'metadata.terms') - }, - stats () { - const data = { - users: _.get(this.nodeinfo, 'usage.users.activeMonth', null), - hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null), - artists: _.get(this.nodeinfo, 'metadata.library.artists.total', null), - albums: _.get(this.nodeinfo, 'metadata.library.albums.total', null), - tracks: _.get(this.nodeinfo, 'metadata.library.tracks.total', null), - listenings: _.get(this.nodeinfo, 'metadata.usage.listenings.total', null) - } - if (data.users === null || data.artists === null) { - return - } - return data - }, - contactEmail () { - return _.get(this.nodeinfo, 'metadata.contactEmail') - }, - anonymousCanListen () { - return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen') - }, - allowListEnabled () { - return _.get(this.nodeinfo, 'metadata.allowList.enabled') - }, - allowListDomains () { - return _.get(this.nodeinfo, 'metadata.allowList.domains') - }, - version () { - return _.get(this.nodeinfo, 'software.version') - }, - openRegistrations () { - return _.get(this.nodeinfo, 'openRegistrations') - }, - defaultUploadQuota () { - return humanSize(_.get(this.nodeinfo, 'metadata.defaultUploadQuota') * 1000 * 1000) - }, - federationEnabled () { - return _.get(this.nodeinfo, 'metadata.library.federationEnabled') - }, - headerStyle () { - if (!this.banner) { - return '' - } - return ( - 'background-image: url(' + - this.$store.getters['instance/absoluteUrl'](this.banner) + - ')' - ) - } - } -} -</script> diff --git a/front/src/components/AboutPod.vue b/front/src/components/AboutPod.vue index 62a8ee8f2c918c8bd11dd9452fc4c9b4efb61657..ae26a67b489512c2346ddb42ae65d7708baea49b 100644 --- a/front/src/components/AboutPod.vue +++ b/front/src/components/AboutPod.vue @@ -1,6 +1,71 @@ -<!-- eslint-disable vue/no-v-html -We render some markdown to html here, the content is set by the admin so we should be save ---> +<script setup lang="ts"> +import { humanSize } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' +import { get } from 'lodash-es' +import { computed } from 'vue' + +import axios from 'axios' + +import useMarkdown from '~/composables/useMarkdown' + +const store = useStore() +const nodeinfo = computed(() => store.state.instance.nodeinfo) + +const fetchData = async () => { + const response = await axios.get('instance/nodeinfo/2.0/') + store.commit('instance/nodeinfo', response.data) +} +fetchData() + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/About/Title', 'About') +})) + +const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') || 'Funkwhale') +const banner = computed(() => get(nodeinfo.value, 'metadata.banner')) +const longDescription = useMarkdown(() => get(nodeinfo.value, 'metadata.longDescription')) +const rules = useMarkdown(() => get(nodeinfo.value, 'metadata.rules')) +const terms = useMarkdown(() => get(nodeinfo.value, 'metadata.terms')) +const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail')) +const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen')) +const allowListEnabled = computed(() => get(nodeinfo.value, 'metadata.allowList.enabled')) +const version = computed(() => get(nodeinfo.value, 'software.version')) +const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations')) +const defaultUploadQuota = computed(() => get(nodeinfo.value, 'metadata.defaultUploadQuota')) +const federationEnabled = computed(() => get(nodeinfo.value, 'metadata.library.federationEnabled')) + +const onDesktop = computed(() => window.innerWidth > 800) + +const stats = computed(() => { + const data = { + users: get(nodeinfo.value, 'usage.users.activeMonth', null), + hours: get(nodeinfo.value, 'metadata.library.music.hours', null), + artists: get(nodeinfo.value, 'metadata.library.artists.total', null), + albums: get(nodeinfo.value, 'metadata.library.albums.total', null), + tracks: get(nodeinfo.value, 'metadata.library.tracks.total', null), + listenings: get(nodeinfo.value, 'metadata.usage.listenings.total', null) + } + + if (data.users === null || data.artists === null) { + return + } + + return data +}) + +const headerStyle = computed(() => { + if (!banner.value) { + return '' + } + + return { + backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})` + } +}) +</script> + <template> <main v-title="labels.title" @@ -80,9 +145,9 @@ We render some markdown to html here, the content is set by the admin so we shou About this pod </translate> </h2> - <div + <sanitized-html v-if="longDescription" - v-html="markdown.makeHtml(longDescription)" + :html="longDescription" /> <p v-else> <translate translate-context="Content/About/Paragraph"> @@ -98,9 +163,9 @@ We render some markdown to html here, the content is set by the admin so we shou Rules </translate> </h3> - <div + <sanitized-html v-if="rules" - v-html="markdown.makeHtml(rules)" + :html="rules" /> <p v-else> <translate translate-context="Content/About/Paragraph"> @@ -116,9 +181,9 @@ We render some markdown to html here, the content is set by the admin so we shou Terms and privacy policy </translate> </h3> - <div + <sanitized-html v-if="terms" - v-html="markdown.makeHtml(terms)" + :html="terms" /> <p v-else> <translate translate-context="Content/About/Paragraph"> @@ -278,7 +343,7 @@ We render some markdown to html here, the content is set by the admin so we shou class="right aligned" > <span class="features-status ui text"> - {{ defaultUploadQuota * 1000 * 1000 | humanSize }} + {{ humanSize(defaultUploadQuota * 1000 * 1000) }} </span> </td> <td @@ -431,99 +496,3 @@ We render some markdown to html here, the content is set by the admin so we shou </div> </main> </template> - -<script> -import { mapState } from 'vuex' -import _ from 'lodash' -import showdown from 'showdown' - -export default { - data () { - return { - markdown: new showdown.Converter(), - showAllowedDomains: false - } - }, - computed: { - - ...mapState({ - nodeinfo: state => state.instance.nodeinfo - }), - labels () { - return { - title: this.$pgettext('Head/About/Title', 'About') - } - }, - podName () { - return _.get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale' - }, - banner () { - return _.get(this.nodeinfo, 'metadata.banner') - }, - shortDescription () { - return _.get(this.nodeinfo, 'metadata.shortDescription') - }, - longDescription () { - return _.get(this.nodeinfo, 'metadata.longDescription') - }, - rules () { - return _.get(this.nodeinfo, 'metadata.rules') - }, - terms () { - return _.get(this.nodeinfo, 'metadata.terms') - }, - stats () { - const data = { - users: _.get(this.nodeinfo, 'usage.users.activeMonth', null), - hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null), - artists: _.get(this.nodeinfo, 'metadata.library.artists.total', null), - albums: _.get(this.nodeinfo, 'metadata.library.albums.total', null), - tracks: _.get(this.nodeinfo, 'metadata.library.tracks.total', null), - listenings: _.get(this.nodeinfo, 'metadata.usage.listenings.total', null) - } - if (data.users === null || data.artists === null) { - return - } - return data - }, - contactEmail () { - return _.get(this.nodeinfo, 'metadata.contactEmail') - }, - anonymousCanListen () { - return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen') - }, - allowListEnabled () { - return _.get(this.nodeinfo, 'metadata.allowList.enabled') - }, - allowListDomains () { - return _.get(this.nodeinfo, 'metadata.allowList.domains') - }, - version () { - return _.get(this.nodeinfo, 'software.version') - }, - openRegistrations () { - return _.get(this.nodeinfo, 'openRegistrations') - }, - defaultUploadQuota () { - return _.get(this.nodeinfo, 'metadata.defaultUploadQuota') - }, - federationEnabled () { - return _.get(this.nodeinfo, 'metadata.library.federationEnabled') - }, - headerStyle () { - if (!this.banner) { - return '' - } - return ( - 'background-image: url(' + - this.$store.getters['instance/absoluteUrl'](this.banner) + - ')' - ) - }, - onDesktop () { - if (window.innerWidth > 800) return true - return false - } - } -} -</script> diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue index 2ae84e4849ce4ff266386da623bd7a17835b4323..a44619bbddf358d2c96016b97039ed6a281651bf 100644 --- a/front/src/components/Home.vue +++ b/front/src/components/Home.vue @@ -1,3 +1,64 @@ +<script setup lang="ts"> +import { get } from 'lodash-es' +import AlbumWidget from '~/components/audio/album/Widget.vue' +import ChannelsWidget from '~/components/audio/ChannelsWidget.vue' +import LoginForm from '~/components/auth/LoginForm.vue' +import SignupForm from '~/components/auth/SignupForm.vue' +import useMarkdown from '~/composables/useMarkdown' +import { humanSize } from '~/utils/filters' +import { useStore } from '~/store' +import { computed } from 'vue' +import { whenever } from '@vueuse/core' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/Home/Title', 'Home') +})) + +const store = useStore() +const nodeinfo = computed(() => store.state.instance.nodeinfo) + +const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') || 'Funkwhale') +const banner = computed(() => get(nodeinfo.value, 'metadata.banner')) +const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription')) +const longDescription = useMarkdown(() => get(nodeinfo.value, 'metadata.longDescription')) +const rules = computed(() => get(nodeinfo.value, 'metadata.rules')) +const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail')) +const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen')) +const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations')) +const defaultUploadQuota = computed(() => get(nodeinfo.value, 'metadata.defaultUploadQuota')) + +const stats = computed(() => { + const users = get(nodeinfo.value, 'usage.users.activeMonth', null) + const hours = get(nodeinfo.value, 'metadata.library.music.hours', 0) + + if (users === null) { + return null + } + + return { users, hours } +}) + +const headerStyle = computed(() => { + if (!banner.value) { + return '' + } + + return { + backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})` + } +}) + +// TODO (wvffle): Check if needed +const router = useRouter() +whenever(() => store.state.auth.authenticated, () => { + console.log('Authenticated, redirecting to /library…') + router.push('/library') +}) +</script> + <template> <main v-title="labels.title" @@ -39,24 +100,24 @@ > <div class="ui stackable grid"> <div class="eight wide column"> - <p v-if="!renderedDescription"> + <p v-if="!longDescription"> <translate translate-context="Content/Home/Paragraph"> No description available. </translate> </p> - <template v-if="renderedDescription || rules"> - <div - v-if="renderedDescription" + <template v-if="longDescription || rules"> + <sanitized-html + v-if="longDescription" id="renderedDescription" - v-html="renderedDescription" + :html="longDescription" /> <div - v-if="renderedDescription" + v-if="longDescription" class="ui hidden divider" /> <div class="ui relaxed list"> <div - v-if="renderedDescription" + v-if="longDescription" class="item" > <i class="arrow right icon" /> @@ -297,7 +358,7 @@ :filters="{playable: true, ordering: '-creation_date'}" :limit="10" > - <template slot="title"> + <template #title> <translate translate-context="Content/Home/Title"> Recently added albums </translate> @@ -323,106 +384,3 @@ </section> </main> </template> - -<script> -import _ from 'lodash' -import { mapState } from 'vuex' -import showdown from 'showdown' -import AlbumWidget from '@/components/audio/album/Widget.vue' -import ChannelsWidget from '@/components/audio/ChannelsWidget.vue' -import LoginForm from '@/components/auth/LoginForm.vue' -import SignupForm from '@/components/auth/SignupForm.vue' -import { humanSize } from '@/filters' - -export default { - components: { - AlbumWidget, - ChannelsWidget, - LoginForm, - SignupForm - }, - data () { - return { - markdown: new showdown.Converter(), - excerptLength: 2, // html nodes, - humanSize - } - }, - computed: { - ...mapState({ - nodeinfo: state => state.instance.nodeinfo - }), - labels () { - return { - title: this.$pgettext('Head/Home/Title', 'Home') - } - }, - podName () { - return _.get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale' - }, - banner () { - return _.get(this.nodeinfo, 'metadata.banner') - }, - shortDescription () { - return _.get(this.nodeinfo, 'metadata.shortDescription') - }, - longDescription () { - return _.get(this.nodeinfo, 'metadata.longDescription') - }, - rules () { - return _.get(this.nodeinfo, 'metadata.rules') - }, - renderedDescription () { - if (!this.longDescription) { - return - } - const doc = this.markdown.makeHtml(this.longDescription) - return doc - }, - stats () { - const data = { - users: _.get(this.nodeinfo, 'usage.users.activeMonth', null), - hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null) - } - if (data.users === null || data.artists === null) { - return - } - return data - }, - contactEmail () { - return _.get(this.nodeinfo, 'metadata.contactEmail') - }, - defaultUploadQuota () { - return _.get(this.nodeinfo, 'metadata.defaultUploadQuota') - }, - anonymousCanListen () { - return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen') - }, - openRegistrations () { - return _.get(this.nodeinfo, 'openRegistrations') - }, - headerStyle () { - if (!this.banner) { - return '' - } - return ( - 'background-image: url(' + - this.$store.getters['instance/absoluteUrl'](this.banner) + - ')' - ) - } - }, - watch: { - '$store.state.auth.authenticated': { - handler (v) { - if (v) { - console.log('Authenticated, redirecting to /library…') - this.$router.push('/library') - } - }, - immediate: true - } - } - -} -</script> diff --git a/front/src/components/Logo.vue b/front/src/components/Logo.vue index 073edb958fca45357030b53a885bd34b6d25b679..986aa3dd81d64c0b393b7f9268f943c6e17f56fd 100644 --- a/front/src/components/Logo.vue +++ b/front/src/components/Logo.vue @@ -1,3 +1,13 @@ +<script setup lang="ts"> +interface Props { + fill?: string +} + +withDefaults(defineProps<Props>(), { + fill: '#222222' +}) +</script> + <template> <svg id="layer_1" @@ -39,12 +49,3 @@ </g> </svg> </template> - -<script> - -export default { - props: { - fill: { type: String, default: '#222222' } - } -} -</script> diff --git a/front/src/components/LogoText.vue b/front/src/components/LogoText.vue index 6e9cc3fc136b8c58833bd44bb304378b5cee2f54..480e4b56fa9be92cbbe10613f2720b33b898f561 100644 --- a/front/src/components/LogoText.vue +++ b/front/src/components/LogoText.vue @@ -1,3 +1,17 @@ +<script setup lang="ts"> +interface Props { + primary?: string + secondary?: string + text?: string +} + +withDefaults(defineProps<Props>(), { + primary: '#009fe3', + secondary: 'var(--text-color)', + text: 'var(--text-color)' +}) +</script> + <template> <svg viewBox="0 0 271.66678 53.49133" @@ -36,13 +50,3 @@ </g> </svg> </template> - -<script> -export default { - props: { - primary: { type: String, default: '#009fe3' }, - secondary: { type: String, default: 'var(--text-color)' }, - text: { type: String, default: 'var(--text-color)' } - } -} -</script> diff --git a/front/src/components/PageNotFound.vue b/front/src/components/PageNotFound.vue index 57d7c335029bcf823b254abbbc67937953677c9b..60c7121e3f87cbabb73ca3129e3c15366ef26f92 100644 --- a/front/src/components/PageNotFound.vue +++ b/front/src/components/PageNotFound.vue @@ -1,3 +1,15 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +const path = window.location.href + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/*/Title', 'Page Not Found') +})) +</script> + <template> <main class="main pusher" @@ -33,20 +45,3 @@ </section> </main> </template> - -<script> -export default { - data: function () { - return { - path: window.location.href - } - }, - computed: { - labels () { - return { - title: this.$pgettext('Head/*/Title', 'Page Not Found') - } - } - } -} -</script> diff --git a/front/src/components/Pagination.vue b/front/src/components/Pagination.vue deleted file mode 100644 index b466625f6a58c712be2b45834056c1b91d5a4c2a..0000000000000000000000000000000000000000 --- a/front/src/components/Pagination.vue +++ /dev/null @@ -1,112 +0,0 @@ -<template> - <div - v-if="maxPage > 1" - class="ui pagination menu component-pagination" - role="navigation" - :aria-label="labels.pagination" - > - <a - href - :disabled="current - 1 < 1" - role="button" - :aria-label="labels.previousPage" - :class="[{'disabled': current - 1 < 1}, 'item']" - @click.prevent.stop="selectPage(current - 1)" - ><i class="angle left icon" /></a> - <template v-if="!compact"> - <a - v-for="page in pages" - :key="page" - href - :class="[{'active': page === current}, {'disabled': page === 'skip'}, 'item']" - @click.prevent.stop="selectPage(page)" - > - <span v-if="page !== 'skip'">{{ page }}</span> - <span v-else>…</span> - </a> - </template> - <a - href - :disabled="current + 1 > maxPage" - role="button" - :aria-label="labels.nextPage" - :class="[{'disabled': current + 1 > maxPage}, 'item']" - @click.prevent.stop="selectPage(current + 1)" - ><i class="angle right icon" /></a> - </div> -</template> - -<script> -import _ from 'lodash' - -export default { - props: { - current: { type: Number, default: 1 }, - paginateBy: { type: Number, default: 25 }, - total: { type: Number, required: true }, - compact: { type: Boolean, default: false } - }, - computed: { - labels () { - return { - pagination: this.$pgettext('Content/*/Hidden text/Noun', 'Pagination'), - previousPage: this.$pgettext('Content/*/Link', 'Previous Page'), - nextPage: this.$pgettext('Content/*/Link', 'Next Page') - } - }, - pages: function () { - const range = 2 - const current = this.current - const beginning = _.range(1, Math.min(this.maxPage, 1 + range)) - const middle = _.range( - Math.max(1, current - range + 1), - Math.min(this.maxPage, current + range) - ) - const end = _.range(this.maxPage, Math.max(1, this.maxPage - range)) - let allowed = beginning.concat(middle, end) - allowed = _.uniq(allowed) - allowed = _.sortBy(allowed, [ - e => { - return e - } - ]) - const final = [] - allowed.forEach(p => { - const last = final.slice(-1)[0] - let consecutive = true - if (last === 'skip') { - consecutive = false - } else { - if (!last) { - consecutive = true - } else { - consecutive = last + 1 === p - } - } - if (consecutive) { - final.push(p) - } else { - if (p !== 'skip') { - final.push('skip') - final.push(p) - } - } - }) - return final - }, - maxPage: function () { - return Math.ceil(this.total / this.paginateBy) - } - }, - methods: { - selectPage: function (page) { - if (page > this.maxPage || page < 1) { - return - } - if (this.current !== page) { - this.$emit('page-changed', page) - } - } - } -} -</script> diff --git a/front/src/components/Queue.vue b/front/src/components/Queue.vue index 4d54e22d8e475847fbd352e419a2b79d96bd66bb..3d2001710273ac2773b3167d3c2d1956309fc8bb 100644 --- a/front/src/components/Queue.vue +++ b/front/src/components/Queue.vue @@ -1,521 +1,431 @@ +<script setup lang="ts"> +import type { Track, QueueItemSource } from '~/types' + +import { useStore } from '~/store' +import { nextTick, ref, computed, watchEffect, onMounted } from 'vue' +import { useRouter } from 'vue-router' +import time from '~/utils/time' +import { useFocusTrap } from '@vueuse/integrations/useFocusTrap' +import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' +import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue' +import { whenever, watchDebounced, useCurrentElement, useScrollLock } from '@vueuse/core' +import { useGettext } from 'vue3-gettext' +import useQueue from '~/composables/audio/useQueue' +import usePlayer from '~/composables/audio/usePlayer' + +import VirtualList from '~/components/vui/list/VirtualList.vue' +import QueueItem from '~/components/QueueItem.vue' + +const queueModal = ref() +const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true }) + +const { $pgettext } = useGettext() +const scrollLock = useScrollLock(document.body) +const store = useStore() + +const { + playing, + loading: isLoadingAudio, + errored, + duration, + durationFormatted, + currentTimeFormatted, + progress, + bufferProgress, + currentTime, + pause, + resume +} = usePlayer() + +const { + currentTrack, + hasNext, + isEmpty: emptyQueue, + tracks, + reorder, + endsIn: timeLeft, + currentIndex, + removeTrack, + clear, + next, + previous +} = useQueue() + +const labels = computed(() => ({ + queue: $pgettext('*/*/*', 'Queue'), + duration: $pgettext('*/*/*', 'Duration'), + addArtistContentFilter: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…'), + restart: $pgettext('*/*/*', 'Restart track'), + previous: $pgettext('*/*/*', 'Previous track'), + next: $pgettext('*/*/*', 'Next track'), + pause: $pgettext('*/*/*', 'Pause'), + play: $pgettext('*/*/*', 'Play') +})) + +watchEffect(async () => { + scrollLock.value = !!store.state.ui.queueFocused + if (store.state.ui.queueFocused) { + await nextTick() + activate() + } else { + deactivate() + } +}) + +const list = ref() +const el = useCurrentElement() +const scrollToCurrent = (behavior: ScrollBehavior = 'smooth') => { + const item = el.value?.querySelector('.queue-item.active') + item?.scrollIntoView({ + behavior, + block: 'center' + }) +} + +watchDebounced(currentTrack, () => scrollToCurrent(), { debounce: 100 }) + +const scrollLoop = () => { + const visible = [...(list.value?.scroller.$_views.values() ?? [])].map(item => item.nr.index) + if (!visible.includes(currentIndex.value)) { + list.value?.scrollToIndex(currentIndex.value) + requestAnimationFrame(scrollLoop) + } +} + +onMounted(scrollLoop) + +whenever( + () => tracks.value.length === 0, + () => store.commit('ui/queueFocused', null), + { immediate: true } +) + +const router = useRouter() +router.beforeEach(() => store.commit('ui/queueFocused', null)) + +const progressBar = ref() +const touchProgress = (event: MouseEvent) => { + const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value + currentTime.value = time +} + +const play = (index: unknown) => { + store.dispatch('queue/currentIndex', index as number) + resume() +} + +const getCover = (track: Track) => { + return store.getters['instance/absoluteUrl']( + track.cover?.urls.medium_square_crop + ?? track.album?.cover?.urls.medium_square_crop + ?? new URL('../assets/audio/default-cover.png', import.meta.url).href + ) +} + +const queueItems = computed(() => tracks.value.map((track, index) => ({ + id: `${index}-${track.id}`, + track, + coverUrl: getCover(track), + labels: { + remove: $pgettext('*/*/*', 'Remove'), + selectTrack: $pgettext('*/*/*', 'Select track'), + favorite: $pgettext('*/*/*', 'Favorite track') + }, + duration: time.durationFormatted(track.uploads[0]?.duration ?? 0) ?? '' +}) as QueueItemSource)) + +const reorderTracks = async (from: number, to: number) => { + reorder(from, to) + + await nextTick() + if (to === currentIndex.value) { + scrollToCurrent() + } +} +</script> + <template> <section class="main with-background component-queue" :aria-label="labels.queue" > - <div :class="['ui vertical stripe queue segment', playerFocused ? 'player-focused' : '']"> - <div class="ui fluid container"> - <div - id="queue-grid" - class="ui stackable grid" - > - <div class="ui six wide column current-track"> - <div - id="player" - class="ui basic segment" - > - <template v-if="currentTrack"> - <img - v-if="currentTrack.cover && currentTrack.cover.urls.large_square_crop" - ref="cover" - alt="" - :src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.large_square_crop)" - > - <img - v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls.large_square_crop" - ref="cover" - alt="" - :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.large_square_crop)" - > - <img - v-else - class="ui image" - alt="" - src="../assets/audio/default-cover.png" - > - <h1 class="ui header"> - <div class="content ellipsis"> - <router-link - class="small header discrete link track" - :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}" - > - {{ currentTrack.title }} - </router-link> - <div class="sub header ellipsis"> - <router-link - class="discrete link artist" - :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}" - > - {{ currentTrack.artist.name }} - </router-link> - <template v-if="currentTrack.album"> - / - <router-link - class="discrete link album" - :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}" - > - {{ currentTrack.album.title }} - </router-link> - </template> - </div> - </div> - </h1> - <div - v-if="currentTrack && errored" - class="ui small warning message" + <div + id="queue-grid" + :class="store.state.ui.queueFocused && `show-${store.state.ui.queueFocused}`" + > + <div + id="player" + class="ui basic segment" + > + <template v-if="currentTrack"> + <div class="cover-container"> + <div class="cover"> + <img + v-if="currentTrack.cover && currentTrack.cover.urls.large_square_crop" + ref="cover" + alt="" + :src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.large_square_crop)" + > + <img + v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls.large_square_crop" + ref="cover" + alt="" + :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.large_square_crop)" + > + <img + v-else + class="ui image" + alt="" + src="../assets/audio/default-cover.png" + > + </div> + </div> + <h1 class="ui header"> + <div class="content ellipsis"> + <router-link + class="small header discrete link track" + :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}" + > + {{ currentTrack.title }} + </router-link> + <div class="sub header ellipsis"> + <router-link + class="discrete link artist" + :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}" > - <h3 class="header"> - <translate translate-context="Sidebar/Player/Error message.Title"> - The track cannot be loaded - </translate> - </h3> - <p v-if="hasNext && playing && $store.state.player.errorCount < $store.state.player.maxConsecutiveErrors"> - <translate translate-context="Sidebar/Player/Error message.Paragraph"> - The next track will play automatically in a few seconds… - </translate> - <i class="loading spinner icon" /> - </p> - <p> - <translate translate-context="Sidebar/Player/Error message.Paragraph"> - You may have a connectivity issue. - </translate> - </p> - </div> - <div class="additional-controls tablet-and-below"> - <track-favorite-icon - v-if="$store.state.auth.authenticated" - :track="currentTrack" - /> - <track-playlist-icon - v-if="$store.state.auth.authenticated" - :track="currentTrack" - /> - <button - v-if="$store.state.auth.authenticated" - :class="['ui', 'really', 'basic', 'circular', 'icon', 'button']" - :aria-label="labels.addArtistContentFilter" - :title="labels.addArtistContentFilter" - @click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})" - > - <i :class="['eye slash outline', 'basic', 'icon']" /> - </button> - </div> - <div class="progress-wrapper"> - <div - v-if="currentTrack && !errored" - class="progress-area" - > - <div - ref="progress" - :class="['ui', 'small', 'vibrant', {'indicating': isLoadingAudio}, 'progress']" - @click="touchProgress" - > - <div - class="buffer bar" - :data-percent="bufferProgress" - :style="{ 'width': bufferProgress + '%' }" - /> - <div - class="position bar" - :data-percent="progress" - :style="{ 'width': progress + '%' }" - /> - </div> - </div> - <div - v-else - class="progress-area" - > - <div - ref="progress" - :class="['ui', 'small', 'vibrant', 'progress']" - > - <div class="buffer bar" /> - <div class="position bar" /> - </div> - </div> - <div class="progress"> - <template v-if="!isLoadingAudio"> - <a - href="" - :aria-label="labels.restart" - class="left floated timer discrete start" - @click.prevent="setCurrentTime(0)" - >{{ currentTimeFormatted }}</a> - <span class="right floated timer total">{{ durationFormatted }}</span> - </template> - <template v-else> - <span class="left floated timer">00:00</span> - <span class="right floated timer">00:00</span> - </template> - </div> - </div> - <div class="player-controls tablet-and-below"> - <span - role="button" - :title="labels.previousTrack" - :aria-label="labels.previousTrack" - class="control" - :disabled="emptyQueue" - @click.prevent.stop="$store.dispatch('queue/previous')" - > - <i :class="['ui', 'backward step', {'disabled': emptyQueue}, 'icon']" /> - </span> - - <span - v-if="!playing" - role="button" - :title="labels.play" - :aria-label="labels.play" - class="control" - @click.prevent.stop="resumePlayback" + {{ currentTrack.artist.name }} + </router-link> + <template v-if="currentTrack.album"> + / + <router-link + class="discrete link album" + :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}" > - <i :class="['ui', 'play', {'disabled': !currentTrack}, 'icon']" /> - </span> - <span - v-else - role="button" - :title="labels.pause" - :aria-label="labels.pause" - class="control" - @click.prevent.stop="pausePlayback" - > - <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'icon']" /> - </span> - <span - role="button" - :title="labels.next" - :aria-label="labels.next" - class="control" - :disabled="!hasNext" - @click.prevent.stop="$store.dispatch('queue/next')" - > - <i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" /> - </span> - </div> - </template> + {{ currentTrack.album.title }} + </router-link> + </template> + </div> </div> + </h1> + <div + v-if="currentTrack && errored" + class="ui small warning message" + > + <h3 class="header"> + <translate translate-context="Sidebar/Player/Error message.Title"> + The track cannot be loaded + </translate> + </h3> + <p v-if="hasNext && playing && $store.state.player.errorCount < $store.state.player.maxConsecutiveErrors"> + <translate translate-context="Sidebar/Player/Error message.Paragraph"> + The next track will play automatically in a few seconds… + </translate> + <i class="loading spinner icon" /> + </p> + <p> + <translate translate-context="Sidebar/Player/Error message.Paragraph"> + You may have a connectivity issue. + </translate> + </p> </div> - <div class="ui ten wide column queue-column"> - <div class="ui basic clearing fixed-header segment"> - <h2 class="ui header"> - <div class="content"> - <button - class="ui right floated basic button" - @click="$store.commit('ui/queueFocused', null)" - > - <translate translate-context="*/Queue/*/Verb"> - Close - </translate> - </button> - <button - class="ui right floated basic button danger" - @click="$store.dispatch('queue/clean')" - > - <translate translate-context="*/Queue/*/Verb"> - Clear - </translate> - </button> - {{ labels.queue }} - <div class="sub header"> - <div> - <translate - translate-context="Sidebar/Queue/Text" - :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}" - > - Track %{ index } of %{ length } - </translate><template v-if="!$store.state.radios.running"> - - - <span :title="labels.duration"> - {{ timeLeft }} - </span> - </template> - </div> - </div> - </div> - </h2> + <div class="additional-controls desktop-and-below"> + <track-favorite-icon + v-if="$store.state.auth.authenticated" + :track="currentTrack" + /> + <track-playlist-icon + v-if="$store.state.auth.authenticated" + :track="currentTrack" + /> + <button + v-if="$store.state.auth.authenticated" + :class="['ui', 'really', 'basic', 'circular', 'icon', 'button']" + :aria-label="labels.addArtistContentFilter" + :title="labels.addArtistContentFilter" + @click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})" + > + <i :class="['eye slash outline', 'basic', 'icon']" /> + </button> + </div> + <div class="progress-wrapper"> + <div + v-if="currentTrack && !errored" + class="progress-area" + > + <div + ref="progressBar" + :class="['ui', 'small', 'vibrant', {'indicating': isLoadingAudio}, 'progress']" + @click="touchProgress" + > + <div + class="buffer bar" + :style="{ 'transform': `translateX(${bufferProgress - 100}%)` }" + /> + <div + class="position bar" + :style="{ 'transform': `translateX(calc(${progress}% - 100%)` }" + /> + </div> </div> - <table class="ui compact very basic fixed single line selectable unstackable table"> - <draggable - v-model="tracks" - tag="tbody" - handle=".handle" - @update="reorder" + <div + v-else + class="progress-area" + > + <div + ref="progress" + :class="['ui', 'small', 'vibrant', 'progress']" > - <tr - v-for="(track, index) in tracks" - :key="index" - :class="['queue-item', {'active': index === queue.currentIndex}]" - > - <td class="handle"> - <i class="grip lines icon" /> - </td> - <td - class="image-cell" - @click="$store.dispatch('queue/currentIndex', index)" - > - <img - v-if="track.cover && track.cover.urls.original" - class="ui mini image" - alt="" - :src="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" - > - <img - v-else-if="track.album && track.album.cover && track.album.cover.urls.original" - class="ui mini image" - alt="" - :src="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)" - > - <img - v-else - class="ui mini image" - alt="" - src="../assets/audio/default-cover.png" - > - </td> - <td - colspan="3" - @click="$store.dispatch('queue/currentIndex', index)" - > - <button - class="title reset ellipsis" - :title="track.title" - :aria-label="labels.selectTrack" - > - <strong>{{ track.title }}</strong><br> - <span> - {{ track.artist.name }} - </span> - </button> - </td> - <td class="duration-cell"> - <template v-if="track.uploads.length > 0"> - {{ time.durationFormatted(track.uploads[0].duration) }} - </template> - </td> - <td class="controls"> - <template v-if="$store.getters['favorites/isFavorite'](track.id)"> - <i class="pink heart icon" /> - </template> - <button - :aria-label="labels.removeFromQueue" - :title="labels.removeFromQueue" - :class="['ui', 'really', 'tiny', 'basic', 'circular', 'icon', 'button']" - @click.stop="cleanTrack(index)" - > - <i class="x icon" /> - </button> - </td> - </tr> - </draggable> - </table> + <div class="buffer bar" /> + <div class="position bar" /> + </div> + </div> + <div class="progress"> + <template v-if="!isLoadingAudio"> + <a + href="" + :aria-label="labels.restart" + class="left floated timer discrete start" + @click.prevent="currentTime = 0" + >{{ currentTimeFormatted }}</a> + <span class="right floated timer total">{{ durationFormatted }}</span> + </template> + <template v-else> + <span class="left floated timer">00:00</span> + <span class="right floated timer">00:00</span> + </template> + </div> + </div> + <div class="player-controls desktop-and-below"> + <span + role="button" + :title="labels.previous" + :aria-label="labels.previous" + class="control" + :disabled="emptyQueue || null" + @click.prevent.stop="previous" + > + <i :class="['ui', 'backward step', {'disabled': emptyQueue}, 'icon']" /> + </span> - <div - v-if="$store.state.radios.running" - class="ui info message" + <span + v-if="!playing" + role="button" + :title="labels.play" + :aria-label="labels.play" + class="control" + @click.prevent.stop="resume" > - <div class="content"> - <h3 class="header"> - <i class="feed icon" /> <translate translate-context="Sidebar/Player/Title"> - You have a radio playing - </translate> - </h3> - <p> - <translate translate-context="Sidebar/Player/Paragraph"> - New tracks will be appended here automatically. - </translate> - </p> - <button - class="ui basic primary button" - @click="$store.dispatch('radios/stop')" - > - <translate translate-context="*/Player/Button.Label/Short, Verb"> - Stop radio + <i :class="['ui', 'play', {'disabled': !currentTrack}, 'icon']" /> + </span> + <span + v-else + role="button" + :title="labels.pause" + :aria-label="labels.pause" + class="control" + @click.prevent.stop="pause" + > + <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'icon']" /> + </span> + <span + role="button" + :title="labels.next" + :aria-label="labels.next" + class="control" + :disabled="hasNext || null" + @click.prevent.stop="next" + > + <i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" /> + </span> + </div> + </template> + </div> + <div id="queue"> + <div class="ui basic clearing segment"> + <h2 class="ui header"> + <div class="content"> + <button + class="ui right floated basic button" + @click="$store.commit('ui/queueFocused', null)" + > + <translate translate-context="*/Queue/*/Verb"> + Close + </translate> + </button> + <button + class="ui right floated basic button danger" + @click="clear" + > + <translate translate-context="*/Queue/*/Verb"> + Clear + </translate> + </button> + {{ labels.queue }} + <div class="sub header"> + <div> + <translate + translate-context="Sidebar/Queue/Text" + :translate-params="{index: currentIndex + 1, length: tracks.length}" + > + Track %{ index } of %{ length } </translate> - </button> + <template v-if="!$store.state.radios.running"> + - + <span :title="labels.duration"> + {{ timeLeft }} + </span> + </template> + </div> </div> </div> + </h2> + </div> + <virtual-list + ref="list" + :list="queueItems" + :component="QueueItem" + :size="50" + @reorder="reorderTracks" + @visible="scrollToCurrent('auto')" + @hidden="scrollLoop" + > + <template #default="{ index, item, classList }"> + <queue-item + :data-index="index" + :index="index" + :source="item" + :class="[...classList, currentIndex === index && 'active']" + @play="play" + @remove="removeTrack" + /> + </template> + </virtual-list> + <div + v-if="$store.state.radios.running" + class="ui info message" + > + <div class="content"> + <h3 class="header"> + <i class="feed icon" /> <translate translate-context="Sidebar/Player/Title"> + You have a radio playing + </translate> + </h3> + <p> + <translate translate-context="Sidebar/Player/Paragraph"> + New tracks will be appended here automatically. + </translate> + </p> + <button + class="ui basic primary button" + @click="$store.dispatch('radios/stop')" + > + <translate translate-context="*/Player/Button.Label/Short, Verb"> + Stop radio + </translate> + </button> </div> </div> </div> </div> </section> </template> -<script> -import { mapState, mapGetters, mapActions } from 'vuex' -import $ from 'jquery' -import moment from 'moment' -import lodash from 'lodash' -import time from '@/utils/time.js' -import { createFocusTrap } from 'focus-trap' -import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue' -import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon.vue' -import draggable from 'vuedraggable' - -export default { - components: { - TrackFavoriteIcon, - TrackPlaylistIcon, - draggable - }, - data () { - return { - showVolume: false, - isShuffling: false, - tracksChangeBuffer: null, - focusTrap: null, - time - } - }, - computed: { - ...mapState({ - currentIndex: state => state.queue.currentIndex, - playing: state => state.player.playing, - isLoadingAudio: state => state.player.isLoadingAudio, - volume: state => state.player.volume, - looping: state => state.player.looping, - duration: state => state.player.duration, - bufferProgress: state => state.player.bufferProgress, - errored: state => state.player.errored, - currentTime: state => state.player.currentTime, - queue: state => state.queue - }), - ...mapGetters({ - currentTrack: 'queue/currentTrack', - hasNext: 'queue/hasNext', - emptyQueue: 'queue/isEmpty', - durationFormatted: 'player/durationFormatted', - currentTimeFormatted: 'player/currentTimeFormatted', - progress: 'player/progress' - }), - tracks: { - get () { - return this.$store.state.queue.tracks - }, - set (value) { - this.tracksChangeBuffer = value - } - }, - labels () { - return { - queue: this.$pgettext('*/*/*', 'Queue'), - duration: this.$pgettext('*/*/*', 'Duration'), - addArtistContentFilter: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…'), - restart: this.$pgettext('*/*/*', 'Restart track') - } - }, - timeLeft () { - const seconds = lodash.sum( - this.queue.tracks.slice(this.queue.currentIndex).map((t) => { - return (t.uploads || []).map((u) => { - return u.duration || 0 - })[0] || 0 - }) - ) - return moment(this.$store.state.ui.lastDate).add(seconds, 'seconds').fromNow(true) - }, - sliderVolume: { - get () { - return this.volume - }, - set (v) { - this.$store.commit('player/volume', v) - } - }, - playerFocused () { - return this.$store.state.ui.queueFocused === 'player' - } - }, - watch: { - '$store.state.ui.queueFocused': { - handler (v) { - if (v === 'queue') { - this.$nextTick(() => { - this.scrollToCurrent() - }) - } - }, - immediate: true - }, - '$store.state.queue.currentIndex': { - handler () { - this.$nextTick(() => { - this.scrollToCurrent() - }) - } - }, - '$store.state.queue.tracks': { - handler (v) { - if (!v || v.length === 0) { - this.$store.commit('ui/queueFocused', null) - } - }, - immediate: true - }, - '$route.fullPath' () { - this.$store.commit('ui/queueFocused', null) - } - }, - mounted () { - this.focusTrap = createFocusTrap(this.$el, { allowOutsideClick: () => { return true } }) - this.focusTrap.activate() - this.$nextTick(() => { - setTimeout(() => { - this.scrollToCurrent() - // delay is to let transition work - }, 400) - }) - }, - methods: { - ...mapActions({ - cleanTrack: 'queue/cleanTrack', - mute: 'player/mute', - unmute: 'player/unmute', - clean: 'queue/clean', - toggleMute: 'player/toggleMute', - resumePlayback: 'player/resumePlayback', - pausePlayback: 'player/pausePlayback' - }), - reorder: function (event) { - this.$store.commit('queue/reorder', { - tracks: this.tracksChangeBuffer, - oldIndex: event.oldIndex, - newIndex: event.newIndex - }) - }, - scrollToCurrent () { - const current = $(this.$el).find('.queue-item.active')[0] - if (!current) { - return - } - const elementRect = current.getBoundingClientRect() - const absoluteElementTop = elementRect.top + window.pageYOffset - const middle = absoluteElementTop - (window.innerHeight / 2) - window.scrollTo({ top: middle, behaviour: 'smooth' }) - }, - touchProgress (e) { - const target = this.$refs.progress - const time = (e.layerX / target.offsetWidth) * this.duration - this.$emit('touch-progress', time) - }, - shuffle () { - const disabled = this.queue.tracks.length === 0 - if (this.isShuffling || disabled) { - return - } - const self = this - const msg = this.$pgettext('Content/Queue/Message', 'Queue shuffled!') - this.isShuffling = true - setTimeout(() => { - self.$store.dispatch('queue/shuffle', () => { - self.isShuffling = false - self.$store.commit('ui/addMessage', { - content: msg, - date: new Date() - }) - }) - }, 100) - } - } -} -</script> diff --git a/front/src/components/QueueItem.vue b/front/src/components/QueueItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..1a8121ec1c74f473975e53a3c9137d1675c3d9c8 --- /dev/null +++ b/front/src/components/QueueItem.vue @@ -0,0 +1,75 @@ +<script setup lang="ts"> +import type { QueueItemSource } from '~/types' + +interface Events { + (e: 'play', index: number): void + (e: 'remove', index: number): void +} + +interface Props { + source: QueueItemSource + index: number +} + +defineEmits<Events>() +defineProps<Props>() +</script> + +<template> + <div + class="queue-item" + tabindex="0" + > + <div class="handle"> + <i class="grip lines icon" /> + </div> + <div + class="image-cell" + @click="$emit('play', index)" + > + <img + class="ui mini image" + alt="" + :src="source.coverUrl" + > + </div> + <div @click="$emit('play', index)"> + <button + class="title reset ellipsis" + :title="source.track.title" + :aria-label="source.labels.selectTrack" + > + <strong>{{ source.track.title }}</strong><br> + <span> + {{ source.track.artist?.name }} + </span> + </button> + </div> + <div class="duration-cell"> + <template v-if="source.track.uploads.length > 0"> + {{ source.duration }} + </template> + </div> + <div class="controls"> + <button + :aria-label="source.labels.favorite" + :title="source.labels.favorite" + class="ui really basic circular icon button" + @click.stop="$store.dispatch('favorites/toggle', source.track.id)" + > + <i + :class="$store.getters['favorites/isFavorite'](source.track.id) ? 'pink' : ''" + class="heart icon" + /> + </button> + <button + :aria-label="source.labels.remove" + :title="source.labels.remove" + class="ui really tiny basic circular icon button" + @click.stop="$emit('remove', index)" + > + <i class="x icon" /> + </button> + </div> + </div> +</template> diff --git a/front/src/components/RemoteSearchForm.vue b/front/src/components/RemoteSearchForm.vue index 22482771aba2cebffd94db6e5135d238306908be..302ccb96aafbbf28c7884c3e226a83d96562970e 100644 --- a/front/src/components/RemoteSearchForm.vue +++ b/front/src/components/RemoteSearchForm.vue @@ -1,11 +1,176 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { ref, computed, watch, watchEffect } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { useStore } from '~/store' + +import axios from 'axios' + +import updateQueryString from '~/composables/updateQueryString' + +type Type = 'rss' | 'artists' | 'both' + +interface Events { + (e: 'subscribed', rss: object): void +} + +interface Props { + initialId?: string + initialType?: Type + redirect?: boolean + showSubmit?: boolean + standalone?: boolean +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + initialId: '', + initialType: 'artists', + redirect: true, + showSubmit: true, + standalone: true +}) + +const type = ref(props.initialType) +const id = ref(props.initialId) +const errors = ref([] as string[]) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: type.value === 'rss' + ? $pgettext('Head/Fetch/Title', 'Subscribe to a podcast RSS feed') + : $pgettext('Head/Fetch/Title', 'Subscribe to a podcast hosted on the Fediverse'), + fieldLabel: type.value === 'rss' + ? $pgettext('*/*/*', 'RSS feed location') + : $pgettext('*/*/*', 'Fediverse object'), + fieldPlaceholder: type.value === 'rss' + ? $pgettext('Head/Fetch/Field.Placeholder', 'https://website.example.com/rss.xml') + : $pgettext('Head/Fetch/Field.Placeholder', '@username@example.com') +})) + +const obj = ref() +const objInfo = computed(() => obj.value?.status === 'finished' ? obj.value.object : null) +const redirectRoute = computed(() => { + if (!objInfo.value) { + return null + } + + switch (objInfo.value.type) { + case 'account': { + const [username, domain] = objInfo.value.full_username.split('@') + return { name: 'profile.full', params: { username, domain } } + } + + case 'library': + return { name: 'library.detail', params: { id: objInfo.value.uuid } } + + case 'artist': + return { name: 'library.artists.detail', params: { id: objInfo.value.id } } + + case 'album': + return { name: 'library.albums.detail', params: { id: objInfo.value.id } } + + case 'track': + return { name: 'library.tracks.detail', params: { id: objInfo.value.id } } + + case 'upload': + return { name: 'library.uploads.detail', params: { id: objInfo.value.uuid } } + + case 'channel': + return { name: 'channels.detail', params: { id: objInfo.value.uuid } } + } + + return null +}) + +const router = useRouter() +watch(redirectRoute, () => { + if (props.redirect && redirectRoute.value) { + return router.push(redirectRoute.value) + } +}) + +const submit = () => { + if (type.value === 'rss') { + return rssSubscribe() + } + + return createFetch() +} + +const isLoading = ref(false) +const createFetch = async () => { + console.log(id.value, props.standalone) + if (!id.value) return + if (props.standalone) { + history.replaceState(history.state, '', updateQueryString(location.href, 'id', id.value)) + } + + obj.value = undefined + errors.value = [] + isLoading.value = true + + try { + const response = await axios.post('federation/fetches/', { object: id.value }) + obj.value = response.data + + if (response.data.status === 'errored' || response.data.status === 'skipped') { + errors.value.push($pgettext('Content/*/Error message.Title', 'This object cannot be retrieved')) + } + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const store = useStore() + +const rssSubscribe = async () => { + if (!id.value) return + if (props.standalone) { + history.replaceState(history.state, '', updateQueryString(location.href, 'id', id.value)) + } + + obj.value = undefined + errors.value = [] + isLoading.value = true + + try { + const response = await axios.post('channels/rss-subscribe/', { url: id.value }) + store.commit('channels/subscriptions', { uuid: response.data.channel.uuid, value: true }) + emit('subscribed', response.data) + + if (props.redirect) { + return router.push({ name: 'channels.detail', params: { id: response.data.channel.uuid } }) + } + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +watchEffect(() => { + id.value = props.initialId + // createFetch() + + if (id.value) { + submit() + } +}) +</script> + <template> <div - v-if="type === 'both' || type === undefined" + v-if="type === 'both'" class="two ui buttons" > <button class="ui left floated labeled icon button" - @click.prevent="changeType('rss')" + @click.prevent="type = 'rss'" > <i class="feed icon" /> <translate translate-context="Content/Search/Input.Label/Noun"> @@ -15,7 +180,7 @@ <div class="or" /> <button class="ui right floated right labeled icon button" - @click.prevent="changeType('artists')" + @click.prevent="type = 'artists'" > <i class="globe icon" /> <translate translate-context="Content/Search/Input.Label/Noun"> @@ -83,7 +248,7 @@ </button> </form> <div - v-if="!isLoading && fetch && fetch.status === 'finished' && !redirectRoute" + v-if="!isLoading && obj?.status === 'finished' && !redirectRoute" role="alert" class="ui warning message" > @@ -95,170 +260,3 @@ </div> </div> </template> -<script> -import axios from 'axios' - -export default { - props: { - initialId: { type: String, required: false, default: '' }, - initialType: { type: String, required: false, default: '' }, - redirect: { type: Boolean, default: true }, - showSubmit: { type: Boolean, default: true }, - standalone: { type: Boolean, default: true } - }, - - data () { - return { - type: this.initialType, - id: this.initialId, - fetch: null, - obj: null, - isLoading: false, - errors: [] - } - }, - computed: { - labels () { - let title = '' - let fieldLabel = '' - let fieldPlaceholder = '' - if (this.type === 'rss') { - title = this.$pgettext('Head/Fetch/Title', 'Subscribe to a podcast RSS feed') - fieldLabel = this.$pgettext('*/*/*', 'RSS feed location') - fieldPlaceholder = this.$pgettext('Head/Fetch/Field.Placeholder', 'https://website.example.com/rss.xml') - } else if (this.type === 'artists') { - title = this.$pgettext('Head/Fetch/Title', 'Subscribe to a podcast hosted on the Fediverse') - fieldLabel = this.$pgettext('*/*/*', 'Fediverse object') - fieldPlaceholder = this.$pgettext('Head/Fetch/Field.Placeholder', '@username@example.com') - } - return { - title, - fieldLabel, - fieldPlaceholder - } - }, - objInfo () { - if (this.fetch && this.fetch.status === 'finished') { - return this.fetch.object - } - return null - }, - redirectRoute () { - if (!this.objInfo) { - return - } - switch (this.objInfo.type) { - case 'account': { - const [username, domain] = this.objInfo.full_username.split('@') - return { name: 'profile.full', params: { username, domain } } - } - case 'library': - return { name: 'library.detail', params: { id: this.objInfo.uuid } } - case 'artist': - return { name: 'library.artists.detail', params: { id: this.objInfo.id } } - case 'album': - return { name: 'library.albums.detail', params: { id: this.objInfo.id } } - case 'track': - return { name: 'library.tracks.detail', params: { id: this.objInfo.id } } - case 'upload': - return { name: 'library.uploads.detail', params: { id: this.objInfo.uuid } } - case 'channel': - return { name: 'channels.detail', params: { id: this.objInfo.uuid } } - - default: - break - } - return null - } - }, - - watch: { - initialId (v) { - this.id = v - this.createFetch() - }, - redirectRoute (v) { - if (v && this.redirect) { - this.$router.push(v) - } - } - }, - created () { - if (this.id) { - if (this.type === 'rss') { - this.rssSubscribe() - } else if (this.type === 'artists') { - this.createFetch() - } - } - }, - - methods: { - changeType (newType) { - this.type = newType - }, - submit () { - if (this.type === 'rss') { - return this.rssSubscribe() - } else { - return this.createFetch() - } - }, - createFetch () { - if (!this.id) { - return - } - if (this.standalone) { - this.$router.replace({ name: 'search', query: { id: this.id } }) - } - this.fetch = null - const self = this - self.errors = [] - self.isLoading = true - const payload = { - object: this.id - } - - axios.post('federation/fetches/', payload).then((response) => { - self.isLoading = false - self.fetch = response.data - if (self.fetch.status === 'errored' || self.fetch.status === 'skipped') { - self.errors.push( - self.$pgettext('Content/*/Error message.Title', 'This object cannot be retrieved') - ) - } - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - rssSubscribe () { - if (!this.id) { - return - } - if (this.standalone) { - this.$router.replace({ name: 'search', query: { id: this.id, type: 'rss' } }) - } - this.fetch = null - const self = this - self.errors = [] - self.isLoading = true - const payload = { - url: this.id - } - - axios.post('channels/rss-subscribe/', payload).then((response) => { - self.isLoading = false - self.$store.commit('channels/subscriptions', { uuid: response.data.channel.uuid, value: true }) - self.$emit('subscribed', response.data) - if (self.redirect) { - self.$router.push({ name: 'channels.detail', params: { id: response.data.channel.uuid } }) - } - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/SanitizedHtml.vue b/front/src/components/SanitizedHtml.vue new file mode 100644 index 0000000000000000000000000000000000000000..3c9dac151651d74eb7f995089106a09551615ac1 --- /dev/null +++ b/front/src/components/SanitizedHtml.vue @@ -0,0 +1,27 @@ +<script setup lang="ts"> +import DOMPurify from 'dompurify' +import { computed, h } from 'vue' + +interface Props { + tag?: string + html: string +} + +const props = withDefaults(defineProps<Props>(), { + tag: 'div' +}) + +DOMPurify.addHook('afterSanitizeAttributes', (node) => { + // set all elements owning target to target=_blank + if ('target' in node) { + node.setAttribute('target', '_blank') + } +}) + +const html = computed(() => DOMPurify.sanitize(props.html)) +const root = () => h(props.tag, { innerHTML: html.value }) +</script> + +<template> + <root /> +</template> diff --git a/front/src/components/ServiceMessages.vue b/front/src/components/ServiceMessages.vue index a7f990888493e999e874b1726aaf81886f40389c..4f16067bae2b2c4f5b99c802ea3cdac83b587bd9 100644 --- a/front/src/components/ServiceMessages.vue +++ b/front/src/components/ServiceMessages.vue @@ -8,8 +8,3 @@ <slot /> </div> </template> - -<script> - -export default {} -</script> diff --git a/front/src/components/SetInstanceModal.vue b/front/src/components/SetInstanceModal.vue index db7c81cdf1db48f62798b2ca78fba240ade44c59..ec50c76fd526b871f50c4a5527a3736148a6879a 100644 --- a/front/src/components/SetInstanceModal.vue +++ b/front/src/components/SetInstanceModal.vue @@ -1,7 +1,74 @@ +<script setup lang="ts"> +import { ref, computed, watch, nextTick } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useVModel } from '@vueuse/core' +import { useStore } from '~/store' +import { uniq } from 'lodash-es' + +import axios from 'axios' + +import SemanticModal from '~/components/semantic/Modal.vue' + +interface Events { + (e: 'update:show', show: boolean): void +} + +interface Props { + show: boolean +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const show = useVModel(props, 'show', emit) + +const instanceUrl = ref('') + +const store = useStore() +const suggestedInstances = computed(() => { + const serverUrl = store.state.instance.frontSettings.defaultServerUrl + + return uniq([ + store.state.instance.instanceUrl, + ...store.state.instance.knownInstances, + serverUrl.endsWith('/') ? serverUrl : serverUrl + '/', + store.getters['instance/defaultInstance'] + ]).slice(1) +}) + +watch(() => store.state.instance.instanceUrl, () => store.dispatch('instance/fetchSettings')) + +const { $pgettext } = useGettext() +const isError = ref(false) +const isLoading = ref(false) +const checkAndSwitch = async (url: string) => { + isError.value = false + isLoading.value = true + + try { + const instanceUrl = new URL(url.startsWith('https://') || url.startsWith('http://') ? url : `https://${url}`).origin + await axios.get(instanceUrl + '/api/v1/instance/nodeinfo/2.0/') + + show.value = false + store.commit('ui/addMessage', { + content: $pgettext('*/Instance/Message', 'You are now using the Funkwhale instance at %{ url }', { url: instanceUrl }), + date: new Date() + }) + + await nextTick() + store.dispatch('instance/setUrl', instanceUrl) + } catch (error) { + isError.value = true + } + + isLoading.value = false +} +</script> + <template> - <modal - :show="show" - @update:show="$emit('update:show', $event); isError = false" + <semantic-modal + v-model:show="show" + @update:show="isError = false" > <h3 class="header"> <translate translate-context="Popup/Instance/Title"> @@ -38,7 +105,7 @@ > <p v-if="$store.state.instance.instanceUrl" - v-translate="{url: $store.state.instance.instanceUrl, hostname: instanceHostname }" + v-translate="{url: $store.state.instance.instanceUrl, hostname: $store.getters['instance/domain'] }" class="description" translate-context="Popup/Login/Paragraph" > @@ -101,108 +168,5 @@ </translate> </button> </div> - </modal> + </semantic-modal> </template> - -<script> -import Modal from '@/components/semantic/Modal.vue' -import axios from 'axios' -import _ from 'lodash' - -export default { - components: { - Modal - }, - props: { show: { type: Boolean, required: true } }, - data () { - return { - instanceUrl: null, - nodeinfo: null, - isError: false, - isLoading: false, - path: 'api/v1/instance/nodeinfo/2.0/' - } - }, - computed: { - suggestedInstances () { - const instances = this.$store.state.instance.knownInstances.slice(0) - if (this.$store.state.instance.frontSettings.defaultServerUrl) { - let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl - if (!serverUrl.endsWith('/')) { - serverUrl = serverUrl + '/' - } - instances.push(serverUrl) - } - const self = this - instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/') - return _.uniq(instances.filter((e) => { return e !== self.$store.state.instance.instanceUrl })) - }, - instanceHostname () { - const url = this.$store.state.instance.instanceUrl - const parser = document.createElement('a') - parser.href = url - return parser.hostname - } - }, - watch: { - '$store.state.instance.instanceUrl' () { - this.$store.dispatch('instance/fetchSettings') - this.fetchNodeInfo() - } - }, - methods: { - fetchNodeInfo () { - const self = this - axios.get('instance/nodeinfo/2.0/').then(response => { - self.nodeinfo = response.data - }) - }, - fetchUrl (url) { - let urlFetch = url - if (!urlFetch.endsWith('/')) { - urlFetch = `${urlFetch}/${this.path}` - } else { - urlFetch = `${urlFetch}${this.path}` - } - if (!urlFetch.startsWith('https://') && !urlFetch.startsWith('http://')) { - urlFetch = `https://${urlFetch}` - } - return urlFetch - }, - requestDistantNodeInfo (url) { - const self = this - axios.get(this.fetchUrl(url)).then(function (response) { - self.isLoading = false - if (!url.startsWith('https://') && !url.startsWith('http://')) { - url = `https://${url}` - } - self.switchInstance(url) - }).catch(function () { - self.isLoading = false - self.isError = true - }) - }, - switchInstance (url) { - // Here we disconnect from the current instance and reconnect to the new one. No check is performed… - this.$emit('update:show', false) - this.isError = false - const msg = this.$pgettext('*/Instance/Message', 'You are now using the Funkwhale instance at %{ url }') - this.$store.commit('ui/addMessage', { - content: this.$gettextInterpolate(msg, { url: url }), - date: new Date() - }) - const self = this - this.$nextTick(() => { - self.$store.commit('instance/instanceUrl', null) - self.$store.dispatch('instance/setUrl', url) - }) - }, - checkAndSwitch (url) { - // First we have to check if the address is a valid FW server. If yes, we switch: - this.isError = false // Clear error message if any… - this.isLoading = true - this.requestDistantNodeInfo(url) - } - } -} -</script> diff --git a/front/src/components/ShortcutsModal.vue b/front/src/components/ShortcutsModal.vue index c75976c6882067641241e25073df35b68008bf27..9c406d284098b322c36513f9c7c3b9da9780ea49 100644 --- a/front/src/components/ShortcutsModal.vue +++ b/front/src/components/ShortcutsModal.vue @@ -1,8 +1,114 @@ +<script setup lang="ts"> +import SemanticModal from '~/components/semantic/Modal.vue' +import { useVModel } from '@vueuse/core' +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +interface Events { + (e: 'update:show', show: boolean): void +} + +interface Props { + show: boolean +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const showRef = useVModel(props, 'show', emit) + +const { $pgettext } = useGettext() +const general = computed(() => [ + { + title: $pgettext('Popup/Keyboard shortcuts/Title', 'General shortcuts'), + shortcuts: [ + { + key: 'h', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Show available keyboard shortcuts') + }, + { + key: 'shift + f', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Focus searchbar') + }, + { + key: 'esc', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Unfocus searchbar') + } + ] + } +]) + +const player = computed(() => [ + { + title: $pgettext('Popup/Keyboard shortcuts/Title', 'Audio player shortcuts'), + shortcuts: [ + { + key: 'p', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Pause/play the current track') + }, + { + key: 'left', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Seek backwards 5s') + }, + { + key: 'right', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Seek forwards 5s') + }, + { + key: 'shift + left', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Seek backwards 30s') + }, + { + key: 'shift + right', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Seek forwards 30s') + }, + { + key: 'ctrl + shift + left', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Play previous track') + }, + { + key: 'ctrl + shift + right', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Play next track') + }, + { + key: 'shift + up', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Increase volume') + }, + { + key: 'shift + down', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Decrease volume') + }, + { + key: 'm', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle mute') + }, + { + key: 'e', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Expand queue/player view') + }, + { + key: 'l', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle queue looping') + }, + { + key: 's', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Shuffle queue') + }, + { + key: 'q', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Clear queue') + }, + { + key: 'f', + summary: $pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle favorite') + } + ] + } +]) +</script> + <template> - <modal - :show="show" - @update:show="$emit('update:show', $event)" - > + <semantic-modal v-model:show="showRef"> <header class="header"> <translate translate-context="*/*/*/Noun"> Keyboard shortcuts @@ -55,110 +161,5 @@ </translate> </button> </footer> - </modal> + </semantic-modal> </template> - -<script> - -import Modal from '@/components/semantic/Modal.vue' - -export default { - components: { - Modal - }, - props: { show: { type: Boolean, required: true } }, - computed: { - general () { - return [ - { - title: this.$pgettext('Popup/Keyboard shortcuts/Title', 'General shortcuts'), - shortcuts: [ - { - key: 'h', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Show available keyboard shortcuts') - }, - { - key: 'shift + f', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Focus searchbar') - }, - { - key: 'esc', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Unfocus searchbar') - } - ] - } - ] - }, - - player () { - return [ - { - title: this.$pgettext('Popup/Keyboard shortcuts/Title', 'Audio player shortcuts'), - shortcuts: [ - { - key: 'p', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Pause/play the current track') - }, - { - key: 'left', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Seek backwards 5s') - }, - { - key: 'right', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Seek forwards 5s') - }, - { - key: 'shift + left', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Seek backwards 30s') - }, - { - key: 'shift + right', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Seek forwards 30s') - }, - { - key: 'ctrl + shift + left', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Play previous track') - }, - { - key: 'ctrl + shift + right', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Play next track') - }, - { - key: 'shift + up', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Increase volume') - }, - { - key: 'shift + down', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Decrease volume') - }, - { - key: 'm', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle mute') - }, - { - key: 'e', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Expand queue/player view') - }, - { - key: 'l', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle queue looping') - }, - { - key: 's', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Shuffle queue') - }, - { - key: 'q', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Clear queue') - }, - { - key: 'f', - summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle favorite') - } - ] - } - ] - } - } -} -</script> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 1f6b63f5e1a27f697932cbae4c00bfdab316c2c4..f8ce4441bae351a34af268bff24bc1d96123c1e2 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -1,3 +1,129 @@ +<script setup lang="ts"> +import type { RouteRecordName } from 'vue-router' + +import UserModal from '~/components/common/UserModal.vue' +import Logo from '~/components/Logo.vue' +import SearchBar from '~/components/audio/SearchBar.vue' +import UserMenu from '~/components/common/UserMenu.vue' +import SemanticModal from '~/components/semantic/Modal.vue' + +import useThemeList from '~/composables/useThemeList' +import useTheme from '~/composables/useTheme' +import { useRoute } from 'vue-router' +import { computed, ref, watch, watchEffect, onMounted } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' +import { setupDropdown } from '~/utils/fomantic' +import { useCurrentElement } from '@vueuse/core' + +interface Events { + (e: 'show:set-instance-modal'): void +} + +interface Props { + width: number +} + +const emit = defineEmits<Events>() +defineProps<Props>() + +const store = useStore() +const theme = useTheme() +const themes = useThemeList() +const { $pgettext } = useGettext() + +const route = useRoute() +const isCollapsed = ref(true) +watch(() => route.path, () => (isCollapsed.value = true)) + +const additionalNotifications = computed(() => store.getters['ui/additionalNotifications']) +const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index' : 'index') + +const labels = computed(() => ({ + mainMenu: $pgettext('Sidebar/*/Hidden text', 'Main menu'), + selectTrack: $pgettext('Sidebar/Player/Hidden text', 'Play this track'), + pendingFollows: $pgettext('Sidebar/Notifications/Hidden text', 'Pending follow requests'), + pendingReviewEdits: $pgettext('Sidebar/Moderation/Hidden text', 'Pending review edits'), + pendingReviewReports: $pgettext('Sidebar/Moderation/Hidden text', 'Pending review reports'), + language: $pgettext('Sidebar/Settings/Dropdown.Label/Short, Verb', 'Language'), + theme: $pgettext('Sidebar/Settings/Dropdown.Label/Short, Verb', 'Theme'), + addContent: $pgettext('*/Library/*/Verb', 'Add content'), + administration: $pgettext('Sidebar/Admin/Title/Noun', 'Administration') +})) + +type SidebarMenuTabs = 'explore' | 'myLibrary' +const expanded = ref<SidebarMenuTabs>('explore') + +const ROUTE_MAPPINGS: Record<SidebarMenuTabs, RouteRecordName[]> = { + explore: [ + 'search', + 'library.index', + 'library.podcasts.browse', + 'library.albums.browse', + 'library.albums.detail', + 'library.artists.browse', + 'library.artists.detail', + 'library.tracks.detail', + 'library.playlists.browse', + 'library.playlists.detail', + 'library.radios.browse', + 'library.radios.detail' + ], + myLibrary: [ + 'library.me', + 'library.albums.me', + 'library.artists.me', + 'library.playlists.me', + 'library.radios.me', + 'favorites' + ] +} + +watchEffect(() => { + if (ROUTE_MAPPINGS.explore.includes(route.name as RouteRecordName)) { + expanded.value = 'explore' + return + } + + if (ROUTE_MAPPINGS.myLibrary.includes(route.name as RouteRecordName)) { + expanded.value = 'myLibrary' + return + } + + expanded.value = store.state.auth.authenticated ? 'myLibrary' : 'explore' +}) + +const moderationNotifications = computed(() => + store.state.ui.notifications.pendingReviewEdits + + store.state.ui.notifications.pendingReviewReports + + store.state.ui.notifications.pendingReviewRequests +) + +const isProduction = import.meta.env.PROD +const showUserModal = ref(false) +const showLanguageModal = ref(false) +const showThemeModal = ref(false) + +const gettext = useGettext() +const languageSelection = ref(gettext.current) +watch(languageSelection, (v) => { + store.dispatch('ui/currentLanguage', v) +}) + +const el = useCurrentElement() +watchEffect(() => { + if (store.state.auth.authenticated) { + setupDropdown('.admin-dropdown', el.value) + } + + setupDropdown('.user-dropdown', el.value) +}) + +onMounted(() => { + document.getElementById('fake-sidebar')?.classList.add('loaded') +}) +</script> + <template> <aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar', 'component-sidebar']"> <header class="ui basic segment header-wrapper"> @@ -54,7 +180,7 @@ :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}" > <div - v-if="$store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests> 0" + v-if="$store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests > 0" :title="labels.pendingReviewReports" :class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']" > @@ -98,10 +224,10 @@ <div class="item"> <div class="ui user-dropdown dropdown"> <img - v-if="$store.state.auth.authenticated && $store.state.auth.profile.avatar && $store.state.auth.profile.avatar.urls.medium_square_crop" + v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar && $store.state.auth.profile?.avatar.urls.medium_square_crop" class="ui avatar image" alt="" - :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.urls.medium_square_crop)" + :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)" > <actor-avatar v-else-if="$store.state.auth.authenticated" @@ -118,8 +244,8 @@ {{ $store.state.ui.notifications.inbox + additionalNotifications }} </div> <user-menu + v-bind="$attrs" :width="width" - v-on="$listeners" /> </div> </div> @@ -131,10 +257,10 @@ @click.prevent.exact="showUserModal = !showUserModal" > <img - v-if="$store.state.auth.authenticated && $store.state.auth.profile.avatar && $store.state.auth.profile.avatar.urls.medium_square_crop" + v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar?.urls.medium_square_crop" class="ui avatar image" alt="" - :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.urls.medium_square_crop)" + :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)" > <actor-avatar v-else-if="$store.state.auth.authenticated" @@ -153,16 +279,14 @@ </a> </template> <user-modal - :show="showUserModal" - @showThemeModalEvent="showThemeModal=true" - @showLanguageModalEvent="showLanguageModal=true" - @update:show="showUserModal = $event" + v-model:show="showUserModal" + @show-theme-modal-event="showThemeModal=true" + @show-language-modal-event="showLanguageModal=true" /> - <modal + <semantic-modal ref="languageModal" + v-model:show="showLanguageModal" :fullscreen="false" - :show="showLanguageModal" - @update:show="showLanguageModal = $event" > <i role="button" @@ -180,21 +304,20 @@ :key="key" > <input - :id="key" + :id="`${key}`" v-model="languageSelection" type="radio" name="language" :value="key" > - <label :for="key">{{ language }}</label> + <label :for="`${key}`">{{ language }}</label> </fieldset> </div> - </modal> - <modal + </semantic-modal> + <semantic-modal ref="themeModal" + v-model:show="showThemeModal" :fullscreen="false" - :show="showThemeModal" - @update:show="showThemeModal = $event" > <i role="button" @@ -208,20 +331,20 @@ </div> <div class="content"> <fieldset - v-for="theme in themes" - :key="theme.key" + v-for="t in themes" + :key="t.key" > <input - :id="theme.key" - v-model="themeSelection" + :id="t.key" + v-model="theme" type="radio" name="theme" - :value="theme.key" + :value="t.key" > - <label :for="theme.key">{{ theme.name }}</label> + <label :for="t.key">{{ t.name }}</label> </fieldset> </div> - </modal> + </semantic-modal> <div class="item collapse-button-wrapper"> <button :class="['ui', 'basic', 'big', {'vibrant': !isCollapsed}, 'inverted icon', 'collapse', 'button']" @@ -272,27 +395,27 @@ </h1> <div class="ui small hidden divider" /> <section - :class="['ui', 'bottom', 'attached', {active: selectedTab === 'library'}, 'tab']" :aria-label="labels.mainMenu" + class="ui bottom attached active tab" > <nav class="ui vertical large fluid inverted menu" role="navigation" :aria-label="labels.mainMenu" > - <div :class="[{collapsed: !exploreExpanded}, 'collapsible item']"> + <div :class="[{ collapsed: expanded !== 'explore' }, 'collapsible item']"> <h2 class="header" role="button" tabindex="0" - @click="exploreExpanded = true" - @focus="exploreExpanded = true" + @click="expanded = 'explore'" + @focus="expanded = 'explore'" > <translate translate-context="*/*/*/Verb"> Explore </translate> <i - v-if="!exploreExpanded" + v-if="expanded !== 'explore'" class="angle right icon" /> </h2> @@ -307,8 +430,8 @@ </router-link> <router-link class="item" - :exact="true" :to="{name: 'library.index'}" + active-class="_active" > <i class="music icon" /><translate translate-context="Sidebar/Navigation/List item.Link/Verb"> Browse @@ -358,27 +481,26 @@ </div> <div v-if="$store.state.auth.authenticated" - :class="[{collapsed: !myLibraryExpanded}, 'collapsible item']" + :class="[{ collapsed: expanded !== 'myLibrary' }, 'collapsible item']" > <h3 class="header" role="button" tabindex="0" - @click="myLibraryExpanded = true" - @focus="myLibraryExpanded = true" + @click="expanded = 'myLibrary'" + @focus="expanded = 'myLibrary'" > <translate translate-context="*/*/*/Noun"> My Library </translate> <i - v-if="!myLibraryExpanded" + v-if="expanded !== 'myLibrary'" class="angle right icon" /> </h3> <div class="menu"> <router-link class="item" - :exact="true" :to="{name: 'library.me'}" > <i class="music icon" /><translate translate-context="Sidebar/Navigation/List item.Link/Verb"> @@ -446,6 +568,7 @@ <router-link class="item" to="/about" + active-class="router-link-exact-active active" > <i class="info icon" /><translate translate-context="Sidebar/*/List item.Link"> About this pod @@ -454,14 +577,14 @@ </div> </div> <div - v-if="!production" + v-if="!isProduction" class="item" > <a role="button" href="" class="link item" - @click.prevent="$emit('show:set-instance-modal')" + @click.prevent="emit('show:set-instance-modal')" >Switch instance</a> </div> </nav> @@ -470,239 +593,6 @@ </aside> </template> -<script> -import { mapState, mapActions, mapGetters } from 'vuex' -import UserModal from '@/components/common/UserModal.vue' -import Logo from '@/components/Logo.vue' -import SearchBar from '@/components/audio/SearchBar.vue' -import ThemesMixin from '@/components/mixins/Themes.vue' -import UserMenu from '@/components/common/UserMenu.vue' -import Modal from '@/components/semantic/Modal.vue' - -import $ from 'jquery' - -export default { - name: 'Sidebar', - components: { - SearchBar, - Logo, - UserMenu, - UserModal, - Modal - }, - mixins: [ThemesMixin], - props: { - width: { type: Number, required: true } - }, - data () { - return { - selectedTab: 'library', - isCollapsed: true, - fetchInterval: null, - exploreExpanded: false, - myLibraryExpanded: false, - showUserModal: false, - showLanguageModal: false, - showThemeModal: false, - languageSelection: this.$language.current, - themeSelection: this.$store.state.ui.theme - } - }, - destroy () { - if (this.fetchInterval) { - clearInterval(this.fetchInterval) - } - }, - computed: { - ...mapState({ - queue: state => state.queue, - url: state => state.route.path - }), - ...mapGetters({ - additionalNotifications: 'ui/additionalNotifications' - }), - labels () { - const mainMenu = this.$pgettext('Sidebar/*/Hidden text', 'Main menu') - const selectTrack = this.$pgettext('Sidebar/Player/Hidden text', 'Play this track') - const pendingFollows = this.$pgettext('Sidebar/Notifications/Hidden text', 'Pending follow requests') - const pendingReviewEdits = this.$pgettext('Sidebar/Moderation/Hidden text', 'Pending review edits') - const language = this.$pgettext( - 'Sidebar/Settings/Dropdown.Label/Short, Verb', - 'Language') - const theme = this.$pgettext( - 'Sidebar/Settings/Dropdown.Label/Short, Verb', - 'Theme') - return { - pendingFollows, - mainMenu, - selectTrack, - pendingReviewEdits, - language, - theme, - addContent: this.$pgettext('*/Library/*/Verb', 'Add content'), - administration: this.$pgettext('Sidebar/Admin/Title/Noun', 'Administration') - } - }, - logoUrl () { - if (this.$store.state.auth.authenticated) { - return 'library.index' - } else { - return 'index' - } - }, - focusedMenu () { - const mapping = { - search: 'exploreExpanded', - 'library.index': 'exploreExpanded', - 'library.podcasts.browse': 'exploreExpanded', - 'library.albums.browse': 'exploreExpanded', - 'library.albums.detail': 'exploreExpanded', - 'library.artists.browse': 'exploreExpanded', - 'library.artists.detail': 'exploreExpanded', - 'library.tracks.detail': 'exploreExpanded', - 'library.playlists.browse': 'exploreExpanded', - 'library.playlists.detail': 'exploreExpanded', - 'library.radios.browse': 'exploreExpanded', - 'library.radios.detail': 'exploreExpanded', - 'library.me': 'myLibraryExpanded', - 'library.albums.me': 'myLibraryExpanded', - 'library.artists.me': 'myLibraryExpanded', - 'library.playlists.me': 'myLibraryExpanded', - 'library.radios.me': 'myLibraryExpanded', - favorites: 'myLibraryExpanded' - } - const m = mapping[this.$route.name] - if (m) { - return m - } - - if (this.$store.state.auth.authenticated) { - return 'myLibraryExpanded' - } else { - return 'exploreExpanded' - } - }, - moderationNotifications () { - return ( - this.$store.state.ui.notifications.pendingReviewEdits + - this.$store.state.ui.notifications.pendingReviewReports + - this.$store.state.ui.notifications.pendingReviewRequests - ) - }, - production () { - return import.meta.env.PROD - } - }, - watch: { - url: function () { - this.isCollapsed = true - }, - '$store.state.moderation.lastUpdate': function () { - this.applyContentFilters() - }, - '$store.state.auth.authenticated': { - immediate: true, - handler (v) { - if (v) { - this.$nextTick(() => { - this.setupDropdown('.user-dropdown') - this.setupDropdown('.admin-dropdown') - }) - } else { - this.$nextTick(() => { - this.setupDropdown('.user-dropdown') - }) - } - } - }, - '$store.state.auth.availablePermissions': { - immediate: true, - handler (v) { - this.$nextTick(() => { - this.setupDropdown('.admin-dropdown') - }) - }, - deep: true - }, - focusedMenu: { - immediate: true, - handler (n) { - if (n) { - this[n] = true - } - } - }, - myLibraryExpanded (v) { - if (v) { - this.exploreExpanded = false - } - }, - exploreExpanded (v) { - if (v) { - this.myLibraryExpanded = false - } - }, - languageSelection: function (v) { - this.$store.dispatch('ui/currentLanguage', v) - this.$refs.languageModal.closeModal() - }, - themeSelection: function (v) { - this.$store.dispatch('ui/theme', v) - this.$refs.themeModal.closeModal() - } - }, - mounted () { - this.$nextTick(() => { - document.getElementById('fake-sidebar').classList.add('loaded') - }) - }, - methods: { - ...mapActions({ - cleanTrack: 'queue/cleanTrack' - }), - applyContentFilters () { - const artistIds = this.$store.getters['moderation/artistFilters']().map((f) => { - return f.target.id - }) - - if (artistIds.length === 0) { - return - } - const self = this - const tracks = this.tracks.slice().reverse() - tracks.forEach(async (t, i) => { - // we loop from the end because removing index from the start can lead to removing the wrong tracks - const realIndex = tracks.length - i - 1 - const matchArtist = artistIds.indexOf(t.artist.id) > -1 - if (matchArtist) { - return await self.cleanTrack(realIndex) - } - if (t.album && artistIds.indexOf(t.album.artist.id) > -1) { - return await self.cleanTrack(realIndex) - } - }) - }, - setupDropdown (selector) { - const self = this - $(self.$el).find(selector).dropdown({ - selectOnKeydown: false, - action: function (text, value, $el) { - // used ton ensure focusing the dropdown and clicking via keyboard - // works as expected - const link = $($el).closest('a') - const url = link.attr('href') - if (url.startsWith('http')) { - window.open(url, '_blank').focus() - } else { - self.$router.push(url) - } - $(self.$el).find(selector).dropdown('hide') - } - }) - } - } -} -</script> <style> [type="radio"] { position: absolute; diff --git a/front/src/components/admin/SettingsGroup.vue b/front/src/components/admin/SettingsGroup.vue index 991fe371bfe5b3773150813b89005a677e32a8d1..d0879552dc8102722a48c0f51393599cadcf2e72 100644 --- a/front/src/components/admin/SettingsGroup.vue +++ b/front/src/components/admin/SettingsGroup.vue @@ -1,3 +1,99 @@ +<script setup lang="ts"> +import type { BackendError, SettingsGroup, SettingsDataEntry, FunctionRef, Form } from '~/types' +import axios from 'axios' +import SignupFormBuilder from '~/components/admin/SignupFormBuilder.vue' +import useFormData from '~/composables/useFormData' +import { ref, computed, reactive } from 'vue' +import { useStore } from '~/store' +import useLogger from '~/composables/useLogger' + +interface Props { + group: SettingsGroup + settingsData: SettingsDataEntry[] +} + +const props = defineProps<Props>() + +const values = reactive({} as Record<string, unknown | Form | string>) +const result = ref<boolean | null>(null) +const errors = ref([] as string[]) + +const fileRefs = reactive({} as Record<string, HTMLInputElement>) +const setFileRef = (identifier: string) => (el: FunctionRef) => { + console.log(el) + fileRefs[identifier] = el as HTMLInputElement +} + +const logger = useLogger() +const store = useStore() + +const settings = computed(() => { + const byIdentifier = props.settingsData.reduce((acc, entry) => { + acc[entry.identifier] = entry + return acc + }, {} as Record<string, SettingsDataEntry>) + + return props.group.settings.map(entry => { + return { ...byIdentifier[entry.name], fieldType: entry.fieldType, fieldParams: entry.fieldParams || {} } + }) +}) + +const fileSettings = computed(() => settings.value.filter(setting => setting.field.widget.class === 'ImageWidget')) + +for (const setting of settings.value) { + values[setting.identifier] = setting.value +} + +const isLoading = ref(false) +const save = async () => { + errors.value = [] + result.value = null + + let postData: unknown = values + let contentType = 'application/json' + + if (fileSettings.value.length > 0) { + const fileSettingsIDs = fileSettings.value.map((setting) => setting.identifier) + const data = settings.value.reduce((data, setting) => { + if (fileSettingsIDs.includes(setting.identifier)) { + const input = fileRefs[setting.identifier] + const { files } = input + + logger.debug('ref', input, files) + + if (files && files.length > 0) { + data[setting.identifier] = files[0] + } + } else { + data[setting.identifier] = values[setting.identifier] as string + } + + return data + }, {} as Record<string, string | File>) + + contentType = 'multipart/form-data' + postData = useFormData(data) + } + + try { + const response = await axios.post('instance/admin/settings/bulk/', postData, { + headers: { 'Content-Type': contentType } + }) + + result.value = true + for (const setting of response.data) { + values[setting.identifier] = setting.value + } + + await store.dispatch('instance/fetchSettings') + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> <form :id="group.id" @@ -35,9 +131,6 @@ Settings updated successfully. </translate> </div> - <p v-if="group.help"> - {{ group.help }} - </p> <div v-for="(setting, key) in settings" :key="key" @@ -51,15 +144,16 @@ </template> <content-form v-if="setting.fieldType === 'markdown'" - v-model="values[setting.identifier]" v-bind="setting.fieldParams" + v-model="values[setting.identifier]" /> + <!-- eslint-disable vue/valid-v-model --> <signup-form-builder v-else-if="setting.fieldType === 'formBuilder'" - :value="values[setting.identifier]" - :signup-approval-enabled="values.moderation__signup_approval_enabled" - @input="set(setting.identifier, $event)" + v-model="values[setting.identifier] as Form" + :signup-approval-enabled="!!values.moderation__signup_approval_enabled" /> + <!-- eslint-enable vue/valid-v-model --> <input v-else-if="setting.field.widget.class === 'PasswordInput'" :id="setting.identifier" @@ -84,24 +178,28 @@ type="number" class="ui input" > + <!-- eslint-disable vue/valid-v-model --> <textarea v-else-if="setting.field.widget.class === 'Textarea'" :id="setting.identifier" - v-model="values[setting.identifier]" + v-model="values[setting.identifier] as string" :name="setting.identifier" type="text" class="ui input" /> + <!-- eslint-enable vue/valid-v-model --> <div v-else-if="setting.field.widget.class === 'CheckboxInput'" class="ui toggle checkbox" > + <!-- eslint-disable vue/valid-v-model --> <input :id="setting.identifier" - v-model="values[setting.identifier]" + v-model="values[setting.identifier] as boolean" :name="setting.identifier" type="checkbox" > + <!-- eslint-enable vue/valid-v-model --> <label :for="setting.identifier">{{ setting.verbose_name }}</label> <p v-if="setting.help_text"> {{ setting.help_text }} @@ -115,8 +213,8 @@ class="ui search selection dropdown" > <option - v-for="(v, index) in setting.additional_data.choices" - :key="index" + v-for="v in setting.additional_data?.choices" + :key="v[0]" :value="v[0]" > {{ v[1] }} @@ -125,7 +223,7 @@ <div v-else-if="setting.field.widget.class === 'ImageWidget'"> <input :id="setting.identifier" - :ref="setting.identifier" + :ref="setFileRef(setting.identifier)" type="file" > <div v-if="values[setting.identifier]"> @@ -154,96 +252,3 @@ </button> </form> </template> - -<script> -import axios from 'axios' -import lodash from 'lodash' -import SignupFormBuilder from '@/components/admin/SignupFormBuilder.vue' - -export default { - components: { - SignupFormBuilder - }, - props: { - group: { type: Object, required: true }, - settingsData: { type: Array, required: true } - }, - data () { - return { - values: {}, - result: null, - errors: [], - isLoading: false - } - }, - computed: { - settings () { - const byIdentifier = {} - this.settingsData.forEach(e => { - byIdentifier[e.identifier] = e - }) - return this.group.settings.map(e => { - return { ...byIdentifier[e.name], fieldType: e.fieldType, fieldParams: e.fieldParams || {} } - }) - }, - fileSettings () { - return this.settings.filter((s) => { - return s.field.widget.class === 'ImageWidget' - }) - } - }, - created () { - const self = this - this.settings.forEach(e => { - self.values[e.identifier] = e.value - }) - }, - methods: { - save () { - const self = this - this.isLoading = true - self.errors = [] - self.result = null - let postData = self.values - let contentType = 'application/json' - const fileSettingsIDs = this.fileSettings.map((s) => { return s.identifier }) - if (fileSettingsIDs.length > 0) { - contentType = 'multipart/form-data' - postData = new FormData() - this.settings.forEach((s) => { - if (fileSettingsIDs.indexOf(s.identifier) > -1) { - const input = self.$refs[s.identifier][0] - const files = input.files - console.log('ref', input, files) - if (files && files.length > 0) { - postData.append(s.identifier, files[0]) - } - } else { - postData.append(s.identifier, self.values[s.identifier]) - } - }) - } - axios.post('instance/admin/settings/bulk/', postData, { - headers: { - 'Content-Type': contentType - } - }).then((response) => { - self.result = true - response.data.forEach((s) => { - self.values[s.identifier] = s.value - }) - self.isLoading = false - self.$store.dispatch('instance/fetchSettings') - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - set (key, value) { - // otherwise reactivity doesn't trigger :/ - this.values = lodash.cloneDeep(this.values) - this.$set(this.values, key, value) - } - } -} -</script> diff --git a/front/src/components/admin/SignupFormBuilder.vue b/front/src/components/admin/SignupFormBuilder.vue index 2ca887b99d9f533204260ad77905669411d83e98..f64c537d872ae3813b89063370f28afc595b9c8d 100644 --- a/front/src/components/admin/SignupFormBuilder.vue +++ b/front/src/components/admin/SignupFormBuilder.vue @@ -1,3 +1,67 @@ +<script setup lang="ts"> +import type { Form } from '~/types' + +import SignupForm from '~/components/auth/SignupForm.vue' +import { useVModel } from '@vueuse/core' +import { computed, ref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { arrayMove } from '~/utils' + +interface Events { + (e: 'update:modelValue', value: Form): void +} + +interface Props { + modelValue: Form + signupApprovalEnabled?: boolean +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + signupApprovalEnabled: false +}) + +const value = useVModel(props, 'modelValue', emit, { deep: true }) + +const maxFields = ref(10) +const isPreviewing = ref(false) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + delete: $pgettext('*/*/*', 'Delete'), + up: $pgettext('*/*/*', 'Move up'), + down: $pgettext('*/*/*', 'Move down') +})) + +if (!value.value?.fields) { + value.value = { + help_text: { + text: '', + content_type: 'text/markdown' + }, + fields: [] + } +} + +const addField = () => { + value.value.fields.push({ + label: $pgettext('*/*/Form-builder', 'Additional field') + ' ' + (value.value.fields.length + 1), + required: true, + input_type: 'short_text' + }) +} + +const remove = (idx: number) => { + value.value.fields.splice(idx, 1) +} + +const move = (idx: number, increment: number) => { + if (idx + increment >= value.value.fields.length) return + if (idx === 0 && increment < 0) return + arrayMove(value.value.fields, idx, idx + increment) +} +</script> + <template> <div> <div class="ui top attached tabular menu"> @@ -23,7 +87,7 @@ class="ui bottom attached segment" > <signup-form - :customization="local" + :customization="value" :signup-approval-enabled="signupApprovalEnabled" :fetch-description-html="true" /> @@ -43,10 +107,10 @@ </translate> </p> <content-form + v-if="value.help_text" + v-model="value.help_text.text" field-id="help-text" :permissive="true" - :value="(local.help_text || {}).text" - @input="update('help_text.text', $event)" /> </div> <div class="field"> @@ -58,7 +122,7 @@ Additional form fields to be displayed in the form. Only shown if manual sign-up validation is enabled. </translate> </p> - <table v-if="local.fields.length > 0"> + <table v-if="value.fields?.length > 0"> <thead> <tr> <th> @@ -81,7 +145,7 @@ </thead> <tbody> <tr - v-for="(field, idx) in local.fields" + v-for="(field, idx) in value.fields" :key="idx" > <td> @@ -121,17 +185,17 @@ </td> <td> <i - :disabled="idx === 0" + :disabled="idx === 0 || null" role="button" :title="labels.up" - :class="['up', 'arrow', {disabled: idx === 0}, 'icon']" + :class="['up', 'arrow', { disabled: idx === 0 }, 'icon']" @click="move(idx, -1)" /> <i - :disabled="idx >= local.fields.length - 1" + :disabled="idx >= value.fields.length - 1 || null" role="button" :title="labels.down" - :class="['down', 'arrow', {disabled: idx >= local.fields.length - 1}, 'icon']" + :class="['down', 'arrow', { disabled: idx >= value.fields.length - 1 }, 'icon']" @click="move(idx, 1)" /> <i @@ -146,7 +210,7 @@ </table> <div class="ui hidden divider" /> <button - v-if="local.fields.length < maxFields" + v-if="value.fields?.length < maxFields" class="ui basic button" @click.stop.prevent="addField" > @@ -159,90 +223,3 @@ <div class="ui hidden divider" /> </div> </template> - -<script> -import lodash from 'lodash' - -import SignupForm from '@/components/auth/SignupForm.vue' - -function arrayMove (arr, oldIndex, newIndex) { - if (newIndex >= arr.length) { - let k = newIndex - arr.length + 1 - while (k--) { - arr.push(undefined) - } - } - arr.splice(newIndex, 0, arr.splice(oldIndex, 1)[0]) - return arr -}; - -// v-model with objects is complex, cf -// https://simonkollross.de/posts/vuejs-using-v-model-with-objects-for-custom-components -export default { - components: { - SignupForm - }, - props: { - value: { type: Object, required: true }, - signupApprovalEnabled: { type: Boolean } - }, - data () { - return { - maxFields: 10, - isPreviewing: false - } - }, - computed: { - labels () { - return { - delete: this.$pgettext('*/*/*', 'Delete'), - up: this.$pgettext('*/*/*', 'Move up'), - down: this.$pgettext('*/*/*', 'Move down') - } - }, - local () { - return (this.value && this.value.fields) ? this.value : { help_text: { text: null, content_type: 'text/markdown' }, fields: [] } - } - }, - created () { - this.$emit('input', this.local) - }, - methods: { - addField () { - const newValue = lodash.tap(lodash.cloneDeep(this.local), v => v.fields.push({ - label: this.$pgettext('*/*/Form-builder', 'Additional field') + ' ' + (this.local.fields.length + 1), - required: true, - input_type: 'short_text' - })) - this.$emit('input', newValue) - }, - remove (idx) { - this.$emit('input', lodash.tap(lodash.cloneDeep(this.local), v => v.fields.splice(idx, 1))) - }, - move (idx, incr) { - if (idx === 0 && incr < 0) { - return - } - if (idx + incr >= this.local.fields.length) { - return - } - const newFields = arrayMove(lodash.cloneDeep(this.local).fields, idx, idx + incr) - this.update('fields', newFields) - }, - update (key, value) { - if (key === 'help_text.text') { - key = 'help_text' - if (!value || value.length === 0) { - value = null - } else { - value = { - text: value, - content_type: 'text/markdown' - } - } - } - this.$emit('input', lodash.tap(lodash.cloneDeep(this.local), v => lodash.set(v, key, value))) - } - } -} -</script> diff --git a/front/src/components/audio/ArtistLabel.vue b/front/src/components/audio/ArtistLabel.vue index bbcc502934f4c2ba6033441ed3164a30a56bb5ca..58e6f7b23fecc6fb52628ae492728143db109462 100644 --- a/front/src/components/audio/ArtistLabel.vue +++ b/front/src/components/audio/ArtistLabel.vue @@ -1,3 +1,20 @@ +<script setup lang="ts"> +import type { Artist } from '~/types' + +import { computed } from 'vue' + +interface Props { + artist: Artist +} + +const props = defineProps<Props>() + +const route = computed(() => props.artist.channel + ? { name: 'channels.detail', params: { id: props.artist.channel.uuid } } + : { name: 'library.artists.detail', params: { id: props.artist.id } } +) +</script> + <template> <router-link class="artist-label ui image label" @@ -16,20 +33,3 @@ {{ artist.name }} </router-link> </template> - -<script> - -export default { - props: { - artist: { type: Object, required: true } - }, - computed: { - route () { - if (this.artist.channel) { - return { name: 'channels.detail', params: { id: this.artist.channel.uuid } } - } - return { name: 'library.artists.detail', params: { id: this.artist.id } } - } - } -} -</script> diff --git a/front/src/components/audio/ChannelCard.vue b/front/src/components/audio/ChannelCard.vue index 382928b1931e132aa5b6f39004d18c37add3f5a7..4f0f3b5c23252ec84bbd476a435bc61d0519186d 100644 --- a/front/src/components/audio/ChannelCard.vue +++ b/front/src/components/audio/ChannelCard.vue @@ -1,8 +1,50 @@ +<script setup lang="ts"> +import type { Channel } from '~/types' + +import { momentFormat } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' +import { computed } from 'vue' + +import moment from 'moment' + +import PlayButton from '~/components/audio/PlayButton.vue' +import TagsList from '~/components/tags/List.vue' + +interface Props { + object: Channel +} + +const props = defineProps<Props>() +const store = useStore() + +const imageUrl = computed(() => props.object.artist?.cover + ? store.getters['instance/absoluteUrl'](props.object.artist.cover.urls.medium_square_crop) + : null +) + +const urlId = computed(() => props.object.actor?.is_local + ? props.object.actor.preferred_username + : props.object.actor + ? props.object.actor.full_username + : props.object.uuid +) + +const { $pgettext } = useGettext() +const updatedTitle = computed(() => { + const date = momentFormat(new Date(props.object.artist?.modification_date ?? '1970-01-01')) + return $pgettext('*/*/*', 'Updated on %{ date }', { date }) +}) + +// TODO (wvffle): Use time ago +const updatedAgo = computed(() => moment(props.object.artist?.modification_date).fromNow()) +</script> + <template> <div class="card app-card"> <div v-lazy:background-image="imageUrl" - :class="['ui', 'head-image', {'circular': object.artist.content_category != 'podcast'}, {'padded': object.artist.content_category === 'podcast'}, 'image', {'default-cover': !object.artist.cover}]" + :class="['ui', 'head-image', {'circular': object.artist?.content_category != 'podcast'}, {'padded': object.artist?.content_category === 'podcast'}, 'image', {'default-cover': !object.artist?.cover}]" @click="$router.push({name: 'channels.detail', params: {id: urlId}})" > <play-button @@ -18,13 +60,12 @@ class="discrete link" :to="{name: 'channels.detail', params: {id: urlId}}" > - {{ object.artist.name }} + {{ object.artist?.name }} </router-link> </strong> <div class="description"> <translate - v-if="object.artist.content_category === 'podcast'" - key="1" + v-if="object.artist?.content_category === 'podcast'" class="meta ellipsis" translate-context="Content/Channel/Paragraph" translate-plural="%{ count } episodes" @@ -35,10 +76,9 @@ </translate> <translate v-else - key="2" translate-context="*/*/*" - :translate-params="{count: object.artist.tracks_count}" - :translate-n="object.artist.tracks_count" + :translate-params="{count: object.artist?.tracks_count}" + :translate-n="object.artist?.tracks_count" translate-plural="%{ count } tracks" > %{ count } track @@ -48,15 +88,16 @@ :truncate-size="20" :limit="2" :show-more="false" - :tags="object.artist.tags" + :tags="object.artist?.tags ?? []" /> </div> </div> <div class="extra content"> <time - v-translate + v-translate="{ updatedAgo }" + :translate-params="{ updatedAgo }" class="meta ellipsis" - :datetime="object.artist.modification_date" + :datetime="object.artist?.modification_date" :title="updatedTitle" > %{ updatedAgo } @@ -73,46 +114,3 @@ </div> </div> </template> - -<script> -import PlayButton from '@/components/audio/PlayButton.vue' -import TagsList from '@/components/tags/List.vue' - -import { momentFormat } from '@/filters' -import moment from 'moment' - -export default { - components: { - PlayButton, - TagsList - }, - props: { - object: { type: Object, required: true } - }, - computed: { - imageUrl () { - if (this.object.artist.cover) { - return this.$store.getters['instance/absoluteUrl'](this.object.artist.cover.urls.medium_square_crop) - } - return null - }, - urlId () { - if (this.object.actor && this.object.actor.is_local) { - return this.object.actor.preferred_username - } else if (this.object.actor) { - return this.object.actor.full_username - } else { - return this.object.uuid - } - }, - updatedTitle () { - const d = momentFormat(this.object.artist.modification_date) - const message = this.$pgettext('*/*/*', 'Updated on %{ date }') - return this.$gettextInterpolate(message, { date: d }) - }, - updatedAgo () { - return moment(this.object.artist.modification_date).fromNow() - } - } -} -</script> diff --git a/front/src/components/audio/ChannelEntries.vue b/front/src/components/audio/ChannelEntries.vue index f0ef672afb5e18c2c2ff4907360ae58ba290f215..ae2967f3cba7f1982d29fc8cf90f7244855498cb 100644 --- a/front/src/components/audio/ChannelEntries.vue +++ b/front/src/components/audio/ChannelEntries.vue @@ -1,3 +1,63 @@ +<script setup lang="ts"> +import type { Cover, Track, BackendResponse, BackendError } from '~/types' + +import { clone } from 'lodash-es' +import { ref, watch } from 'vue' + +import axios from 'axios' +import PodcastTable from '~/components/audio/podcast/Table.vue' +import TrackTable from '~/components/audio/track/Table.vue' + +interface Events { + (e: 'fetched', data: BackendResponse<Track[]>): void +} + +interface Props { + filters: object + limit?: number + defaultCover: Cover | null + isPodcast: boolean +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + limit: 10, + defaultCover: null +}) + +const channels = ref([] as Track[]) +const nextPage = ref() +const page = ref(1) +const count = ref(0) +const errors = ref([] as string[]) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + const params = { + ...clone(props.filters), + page_size: props.limit, + page: page.value, + include_channels: true + } + + try { + const response = await axios.get('tracks/', { params }) + nextPage.value = response.data.next + channels.value = response.data.results + count.value = response.data.count + emit('fetched', response.data) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +watch(page, fetchData, { immediate: true }) +</script> + <template> <div> <slot /> @@ -10,39 +70,37 @@ </div> <podcast-table v-if="isPodcast" + v-model:page="page" :default-cover="defaultCover" :is-podcast="isPodcast" :show-art="true" :show-position="false" - :tracks="objects" + :tracks="channels" :show-artist="false" :show-album="false" :paginate-results="true" :total="count" - :page="page" :paginate-by="limit" - @page-changed="updatePage" /> <track-table v-else + v-model:page="page" :default-cover="defaultCover" :is-podcast="isPodcast" :show-art="true" :show-position="false" - :tracks="objects" + :tracks="channels" :show-artist="false" :show-album="false" :paginate-results="true" :total="count" - :page="page" :paginate-by="limit" :filters="filters" - @page-changed="updatePage" /> - <template v-if="!isLoading && objects.length === 0"> + <template v-if="!isLoading && channels.length === 0"> <empty-state :refresh="true" - @refresh="fetchData('tracks/')" + @refresh="fetchData()" > <p> <translate translate-context="Content/Channels/*"> @@ -53,68 +111,3 @@ </template> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import PodcastTable from '@/components/audio/podcast/Table.vue' -import TrackTable from '@/components/audio/track/Table.vue' - -export default { - components: { - PodcastTable, - TrackTable - }, - props: { - filters: { type: Object, required: true }, - limit: { type: Number, default: 10 }, - defaultCover: { type: Object, default: () => ({}) }, - isPodcast: { type: Boolean, required: true } - }, - data () { - return { - objects: [], - count: 0, - isLoading: false, - errors: [], - nextPage: null, - page: 1 - } - }, - watch: { - page () { - this.fetchData('tracks/') - } - }, - created () { - this.fetchData('tracks/') - }, - methods: { - async fetchData (url) { - if (!url) { - return - } - this.isLoading = true - const self = this - const params = _.clone(this.filters) - params.page_size = this.limit - params.page = this.page - params.include_channels = true - try { - const channelsPromise = await axios.get(url, { params: params }) - self.nextPage = channelsPromise.data.next - self.objects = channelsPromise.data.results - self.count = channelsPromise.data.count - self.$emit('fetched', channelsPromise.data) - self.isLoading = false - } catch (e) { - self.isLoading = false - self.errors = e.backendErrors - } - }, - updatePage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/audio/ChannelEntryCard.vue b/front/src/components/audio/ChannelEntryCard.vue index b190f7519087bd88369ce6b6a7ba64dccc5c0e99..3b9addebbd98f541d7365561e451c1df3b43e319 100644 --- a/front/src/components/audio/ChannelEntryCard.vue +++ b/front/src/components/audio/ChannelEntryCard.vue @@ -1,5 +1,28 @@ +<script setup lang="ts"> +import type { Cover, Track } from '~/types' + +import PlayButton from '~/components/audio/PlayButton.vue' +import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' +import useQueue from '~/composables/audio/useQueue' +import usePlayer from '~/composables/audio/usePlayer' +import { computed } from 'vue' + +interface Props { + entry: Track + defaultCover: Cover +} + +const props = defineProps<Props>() + +const { currentTrack } = useQueue() +const { playing } = usePlayer() + +const cover = computed(() => props.entry.cover ?? null) +const duration = computed(() => props.entry.uploads.find(upload => upload.duration)?.duration ?? null) +</script> + <template> - <div :class="[{active: currentTrack && isPlaying && entry.id === currentTrack.id}, 'channel-entry-card']"> + <div :class="[{active: currentTrack && playing && entry.id === currentTrack.id}, 'channel-entry-card']"> <div class="controls"> <play-button class="basic circular icon" @@ -18,7 +41,7 @@ @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" > <img - v-else-if="entry.artist.content_category === 'podcast' && defaultCover != undefined" + v-else-if="entry.artist?.content_category === 'podcast' && defaultCover != undefined" v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)" class="channel-image image" @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" @@ -75,45 +98,3 @@ </div> </div> </template> - -<script> -import PlayButton from '@/components/audio/PlayButton.vue' -import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue' -import { mapGetters } from 'vuex' - -export default { - components: { - PlayButton, - TrackFavoriteIcon - }, - props: { - entry: { type: Object, required: true }, - defaultCover: { type: Object, required: true } - }, - computed: { - - ...mapGetters({ - currentTrack: 'queue/currentTrack' - }), - - isPlaying () { - return this.$store.state.player.playing - }, - cover () { - if (this.entry.cover) { - return this.entry.cover - } - return null - }, - duration () { - const uploads = this.entry.uploads.filter((e) => { - return e.duration - }) - if (uploads.length > 0) { - return uploads[0].duration - } - return null - } - } -} -</script> diff --git a/front/src/components/audio/ChannelForm.vue b/front/src/components/audio/ChannelForm.vue index 8426a86463a6b4d1b33881f4c0e5e033a4c23e22..e46ce1037fc8b171d81265600a318706d93d665c 100644 --- a/front/src/components/audio/ChannelForm.vue +++ b/front/src/components/audio/ChannelForm.vue @@ -1,3 +1,155 @@ +<script setup lang="ts"> +import type { ContentCategory, Channel, BackendError } from '~/types' + +import { slugify } from 'transliteration' +import { reactive, computed, ref, watchEffect, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' +import AttachmentInput from '~/components/common/AttachmentInput.vue' +import TagsSelector from '~/components/library/TagsSelector.vue' + +interface Events { + (e: 'category', contentCategory: ContentCategory): void + (e: 'submittable', value: boolean): void + (e: 'loading', value: boolean): void + (e: 'errored', errors: string[]): void + (e: 'created', channel: Channel): void + (e: 'updated', channel: Channel): void +} + +interface Props { + object?: Channel | null + step: number +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + object: null, + step: 1 +}) + +const { $pgettext } = useGettext() + +const newValues = reactive({ + name: props.object?.artist?.name ?? '', + username: props.object?.actor.preferred_username ?? '', + tags: props.object?.artist?.tags ?? [] as string[], + description: props.object?.artist?.description?.text ?? '', + cover: props.object?.artist?.cover?.uuid ?? null, + content_category: props.object?.artist?.content_category ?? 'podcast', + metadata: { ...(props.object?.metadata ?? {}) } +}) + +const creating = computed(() => props.object === null) +const categoryChoices = computed(() => [ + { + value: 'podcast', + label: $pgettext('*/*/*', 'Podcasts'), + helpText: $pgettext('Content/Channels/Help', 'Host your episodes and keep your community updated.') + }, + { + value: 'music', + label: $pgettext('*/*/*', 'Artist discography'), + helpText: $pgettext('Content/Channels/Help', 'Publish music you make as a nice discography of albums and singles.') + } +]) + +interface ITunesCategory { + value: string + label: string + children: [] +} + +interface MetadataChoices { + itunes_category?: ITunesCategory[] | null + language: { + value: string + label: string + }[] +} + +const metadataChoices = ref({ itunes_category: null } as MetadataChoices) +const itunesSubcategories = computed(() => { + for (const element of metadataChoices.value.itunes_category ?? []) { + if (element.value === newValues.metadata.itunes_category) { + return element.children ?? [] + } + } + + return [] +}) + +const labels = computed(() => ({ + namePlaceholder: $pgettext('Content/Channel/Form.Field.Placeholder', 'Awesome channel name'), + usernamePlaceholder: $pgettext('Content/Channel/Form.Field.Placeholder', 'awesomechannelname') +})) + +const submittable = computed(() => !!( + newValues.content_category === 'podcast' + ? newValues.name && newValues.username && newValues.metadata.itunes_category && newValues.metadata.language + : newValues.name && newValues.username +)) + +watch(() => newValues.name, (name) => { + if (creating.value) { + newValues.username = slugify(name) + } +}) + +watch(() => newValues.metadata.itunes_category, () => { + newValues.metadata.itunes_subcategory = null +}) + +const isLoading = ref(false) +const errors = ref([] as string[]) + +watchEffect(() => emit('category', newValues.content_category)) +watchEffect(() => emit('loading', isLoading.value)) +watchEffect(() => emit('submittable', submittable.value)) + +// TODO (wvffle): Add loader / Use Suspense +const fetchMetadataChoices = async () => { + try { + const response = await axios.get('channels/metadata-choices') + metadataChoices.value = response.data + } catch (error) { + errors.value = (error as BackendError).backendErrors + } +} + +fetchMetadataChoices() + +const submit = async () => { + isLoading.value = true + + const payload = { + ...newValues, + description: newValues.description + ? { + content_type: 'text/markdown', + text: newValues.description + } + : null + } + + try { + const request = () => creating.value + ? axios.post('channels/', payload) + : axios.patch(`channels/${props.object?.uuid}`, payload) + + const response = await request() + if (creating.value) emit('created', response.data) + else emit('updated', response.data) + } catch (error) { + errors.value = (error as BackendError).backendErrors + emit('errored', errors.value) + } + + isLoading.value = false +} +</script> + <template> <form class="ui form" @@ -95,14 +247,10 @@ <div class="six wide column"> <attachment-input v-model="newValues.cover" - :required="false" :image-class="newValues.content_category === 'podcast' ? '' : 'circular'" @delete="newValues.cover = null" > - <translate - slot="label" - translate-context="Content/Channel/*" - > + <translate translate-context="Content/Channel/*"> Channel Picture </translate> </attachment-input> @@ -245,171 +393,3 @@ </div> </form> </template> - -<script> -import axios from 'axios' - -import AttachmentInput from '@/components/common/AttachmentInput.vue' -import TagsSelector from '@/components/library/TagsSelector.vue' - -function slugify (text) { - return text.toString().toLowerCase() - .replace(/\s+/g, '') // Remove spaces - .replace(/[^\w]+/g, '') // Remove all non-word chars -} - -export default { - components: { - AttachmentInput, - TagsSelector - }, - props: { - object: { type: Object, required: false, default: null }, - step: { type: Number, required: false, default: 1 } - }, - data () { - const oldValues = {} - if (this.object) { - oldValues.metadata = { ...(this.object.metadata || {}) } - oldValues.name = this.object.artist.name - oldValues.description = this.object.artist.description - oldValues.cover = this.object.artist.cover - oldValues.tags = this.object.artist.tags - oldValues.content_category = this.object.artist.content_category - oldValues.username = this.object.actor.preferred_username - } - return { - isLoading: false, - errors: [], - metadataChoices: null, - newValues: { - name: oldValues.name || '', - username: oldValues.username || '', - tags: oldValues.tags || [], - description: (oldValues.description || {}).text || '', - cover: (oldValues.cover || {}).uuid || null, - content_category: oldValues.content_category || 'podcast', - metadata: oldValues.metadata || {} - } - } - }, - computed: { - creating () { - return this.object === null - }, - categoryChoices () { - return [ - { - value: 'podcast', - label: this.$pgettext('*/*/*', 'Podcasts'), - helpText: this.$pgettext('Content/Channels/Help', 'Host your episodes and keep your community updated.') - }, - { - value: 'music', - label: this.$pgettext('*/*/*', 'Artist discography'), - helpText: this.$pgettext('Content/Channels/Help', 'Publish music you make as a nice discography of albums and singles.') - } - ] - }, - itunesSubcategories () { - for (let index = 0; index < this.metadataChoices.itunes_category.length; index++) { - const element = this.metadataChoices.itunes_category[index] - if (element.value === this.newValues.metadata.itunes_category) { - return element.children || [] - } - } - return [] - }, - labels () { - return { - namePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', 'Awesome channel name'), - usernamePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', 'awesomechannelname') - } - }, - submittable () { - let v = this.newValues.name && this.newValues.username - if (this.newValues.content_category === 'podcast') { - v = v && this.newValues.metadata.itunes_category && this.newValues.metadata.language - } - return !!v - } - }, - watch: { - 'newValues.name' (v) { - if (this.creating) { - this.newValues.username = slugify(v) - } - }, - 'newValues.metadata.itunes_category' (v) { - this.newValues.metadata.itunes_subcategory = null - }, - 'newValues.content_category': { - handler (v) { - this.$emit('category', v) - }, - immediate: true - }, - isLoading: { - handler (v) { - this.$emit('loading', v) - }, - immediate: true - }, - submittable: { - handler (v) { - this.$emit('submittable', v) - }, - immediate: true - } - }, - - created () { - this.fetchMetadataChoices() - }, - methods: { - fetchMetadataChoices () { - const self = this - axios.get('channels/metadata-choices').then((response) => { - self.metadataChoices = response.data - }, error => { - self.errors = error.backendErrors - }) - }, - submit () { - this.isLoading = true - const self = this - const handler = this.creating ? axios.post : axios.patch - const url = this.creating ? 'channels/' : `channels/${this.object.uuid}` - const payload = { - name: this.newValues.name, - username: this.newValues.username, - tags: this.newValues.tags, - content_category: this.newValues.content_category, - cover: this.newValues.cover, - metadata: this.newValues.metadata - } - if (this.newValues.description) { - payload.description = { - content_type: 'text/markdown', - text: this.newValues.description - } - } else { - payload.description = null - } - - handler(url, payload).then((response) => { - self.isLoading = false - if (self.creating) { - self.$emit('created', response.data) - } else { - self.$emit('updated', response.data) - } - }, error => { - self.isLoading = false - self.errors = error.backendErrors - self.$emit('errored', self.errors) - }) - } - } -} -</script> diff --git a/front/src/components/audio/ChannelSerieCard.vue b/front/src/components/audio/ChannelSerieCard.vue index 269cc410797951107866f89681f17785ede72b39..7f95cce5eaf892d831bae7095a53fedb17e37ddf 100644 --- a/front/src/components/audio/ChannelSerieCard.vue +++ b/front/src/components/audio/ChannelSerieCard.vue @@ -1,3 +1,18 @@ +<script setup lang="ts"> +import type { Album } from '~/types' + +import PlayButton from '~/components/audio/PlayButton.vue' +import { computed } from 'vue' + +interface Props { + serie: Album +} + +const props = defineProps<Props>() + +const cover = computed(() => props.serie?.cover ?? null) +</script> + <template> <div class="channel-serie-card"> <div class="two-images"> @@ -60,28 +75,3 @@ </div> </div> </template> - -<script> -import PlayButton from '@/components/audio/PlayButton.vue' - -export default { - components: { - PlayButton - }, - props: { serie: { type: Object, required: true } }, - computed: { - cover () { - if (this.serie.cover) { - return this.serie.cover - } - return null - }, - duration () { - const uploads = this.serie.uploads.filter((e) => { - return e.duration - }) - return uploads[0].duration - } - } -} -</script> diff --git a/front/src/components/audio/ChannelSeries.vue b/front/src/components/audio/ChannelSeries.vue index 376b3085d47aff453c2bfd434acb3169b92afac3..fe30459a901e15a3cdf4e4e1b6a0abbbf995cdb1 100644 --- a/front/src/components/audio/ChannelSeries.vue +++ b/front/src/components/audio/ChannelSeries.vue @@ -1,3 +1,55 @@ +<script setup lang="ts"> +import type { BackendError, Album } from '~/types' + +import { clone } from 'lodash-es' +import { ref, reactive } from 'vue' + +import axios from 'axios' +import ChannelSerieCard from '~/components/audio/ChannelSerieCard.vue' +import AlbumCard from '~/components/audio/album/Card.vue' + +interface Props { + filters: object + isPodcast?: boolean + limit?: number +} + +const props = withDefaults(defineProps<Props>(), { + isPodcast: true, + limit: 5 +}) + +const isLoading = ref(false) +const errors = ref([] as string[]) + +const albums = reactive([] as Album[]) +const nextPage = ref() +const count = ref(0) + +const fetchData = async (url = 'albums/') => { + isLoading.value = true + + try { + const params = { + ...clone(props.filters), + page_size: props.limit, + include_channels: true + } + + const response = await axios.get(url, { params }) + nextPage.value = response.data.next + count.value = response.data.count + albums.push(...response.data.results) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +fetchData() +</script> + <template> <div> <slot /> @@ -10,7 +62,7 @@ </div> <template v-if="isPodcast"> <channel-serie-card - v-for="serie in objects" + v-for="serie in albums" :key="serie.id" :serie="serie" /> @@ -20,7 +72,7 @@ class="ui app-cards cards" > <album-card - v-for="album in objects" + v-for="album in albums" :key="album.id" :album="album" /> @@ -37,10 +89,10 @@ </translate> </button> </template> - <template v-if="!isLoading && objects.length === 0"> + <template v-if="!isLoading && albums.length === 0"> <empty-state :refresh="true" - @refresh="fetchData('albums/')" + @refresh="fetchData()" > <p> <translate translate-context="Content/Channels/*"> @@ -51,55 +103,3 @@ </template> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import ChannelSerieCard from '@/components/audio/ChannelSerieCard.vue' -import AlbumCard from '@/components/audio/album/Card.vue' - -export default { - components: { - ChannelSerieCard, - AlbumCard - }, - props: { - filters: { type: Object, required: true }, - isPodcast: { type: Boolean, default: true }, - limit: { type: Number, default: 5 } - }, - data () { - return { - objects: [], - count: 0, - isLoading: false, - errors: null, - nextPage: null - } - }, - created () { - this.fetchData('albums/') - }, - methods: { - fetchData (url) { - if (!url) { - return - } - this.isLoading = true - const self = this - const params = _.clone(this.filters) - params.page_size = this.limit - params.include_channels = true - axios.get(url, { params: params }).then((response) => { - self.nextPage = response.data.next - self.isLoading = false - self.objects = self.objects.concat(response.data.results) - self.count = response.data.count - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/audio/ChannelsWidget.vue b/front/src/components/audio/ChannelsWidget.vue index 68621b1f008a37b54e57204fcf5a40e5abc5e7a3..1feffac9dffde61c14ad9a0cdaeb135212ddec8b 100644 --- a/front/src/components/audio/ChannelsWidget.vue +++ b/front/src/components/audio/ChannelsWidget.vue @@ -1,3 +1,58 @@ +<script setup lang="ts"> +import type { BackendError, BackendResponse, Channel } from '~/types' + +import { ref, reactive } from 'vue' +import { clone } from 'lodash-es' + +import axios from 'axios' + +import ChannelCard from '~/components/audio/ChannelCard.vue' + +interface Events { + (e: 'fetched', channels: BackendResponse<Channel>): void +} + +interface Props { + filters: object + limit?: number +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + limit: 5 +}) + +const channels = reactive([] as Channel[]) +const errors = ref([] as string[]) +const nextPage = ref() +const count = ref(0) + +const isLoading = ref(false) +const fetchData = async (url = 'channels/') => { + isLoading.value = true + + const params = { + ...clone(props.filters), + page_size: props.limit, + include_channels: true + } + + try { + const response = await axios.get(url, { params }) + nextPage.value = response.data.next + count.value = response.data.count + channels.push(...response.data.results) + emit('fetched', response.data) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +fetchData() +</script> + <template> <div> <slot /> @@ -10,7 +65,7 @@ <div class="ui loader" /> </div> <channel-card - v-for="object in objects" + v-for="object in channels" :key="object.uuid" :object="object" /> @@ -27,7 +82,7 @@ </translate> </button> </template> - <template v-if="!isLoading && objects.length === 0"> + <template v-if="!isLoading && channels.length === 0"> <empty-state :refresh="true" @refresh="fetchData('channels/')" @@ -35,53 +90,3 @@ </template> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import ChannelCard from '@/components/audio/ChannelCard.vue' - -export default { - components: { - ChannelCard - }, - props: { - filters: { type: Object, required: true }, - limit: { type: Number, default: 5 } - }, - data () { - return { - objects: [], - count: 0, - isLoading: false, - errors: null, - nextPage: null - } - }, - created () { - this.fetchData('channels/') - }, - methods: { - fetchData (url) { - if (!url) { - return - } - this.isLoading = true - const self = this - const params = _.clone(this.filters) - params.page_size = this.limit - params.include_channels = true - axios.get(url, { params: params }).then((response) => { - self.nextPage = response.data.next - self.isLoading = false - self.objects = self.objects.concat(response.data.results) - self.count = response.data.count - self.$emit('fetched', response.data) - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/audio/EmbedWizard.vue b/front/src/components/audio/EmbedWizard.vue index adcc3390fd9c3391996fdf26e59fe3b2be5213a0..ccaa78fc7943c1dd58da7ba895c854cf63b6bd21 100644 --- a/front/src/components/audio/EmbedWizard.vue +++ b/front/src/components/audio/EmbedWizard.vue @@ -1,3 +1,48 @@ +<script setup lang="ts"> +import { get } from 'lodash-es' +import { ref, computed } from 'vue' +import { useStore } from '~/store' +import { useClipboard } from '@vueuse/core' + +interface Props { + type: string + id: number +} + +const props = defineProps<Props>() +const width = ref(null) +const height = ref(150) +const minHeight = ref(100) + +if (props.type === 'album' || props.type === 'artist' || props.type === 'playlist') { + height.value = 330 + minHeight.value = 250 +} + +const store = useStore() +const nodeinfo = computed(() => store.state.instance.nodeinfo) +const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen', false)) +const iframeSrc = computed(() => { + const base = import.meta.env.BASE_URL.startsWith('/') + ? `${window.location.origin}${import.meta.env.BASE_URL}` + : import.meta.env.BASE_URL + + const instanceUrl = store.state.instance.instanceUrl as string + + const bParam = !window.location.href.startsWith(instanceUrl) + ? `&b=${instanceUrl}` + : '' + + return `${base}embed.html?&type=${props.type}&id=${props.id}${bParam}` +}) + +const frameWidth = computed(() => width.value ?? '100%') +const embedCode = computed(() => `<iframe width="${frameWidth.value}" height="${height.value}" scrolling="no" frameborder="no" src="${iframeSrc.value.replace(/&/g, '&')}"></iframe>`) + +const textarea = ref() +const { copy, copied } = useClipboard({ source: textarea }) +</script> + <template> <div> <div @@ -52,7 +97,7 @@ <div class="field"> <button class="ui right accent labeled icon floated button" - @click="copy" + @click="copy()" > <i class="copy icon" /><translate translate-context="*/*/Button.Label/Short, Verb"> Copy @@ -102,73 +147,3 @@ </div> </div> </template> - -<script> - -import { mapState } from 'vuex' -import _ from 'lodash' - -export default { - props: { - type: { type: String, required: true }, - id: { type: Number, required: true } - }, - data () { - const d = { - width: null, - height: 150, - minHeight: 100, - copied: false - } - if (this.type === 'album' || this.type === 'artist' || this.type === 'playlist') { - d.height = 330 - d.minHeight = 250 - } - return d - }, - computed: { - ...mapState({ - nodeinfo: state => state.instance.nodeinfo - }), - anonymousCanListen () { - return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen', false) - }, - iframeSrc () { - let base = import.meta.env.BASE_URL - if (base.startsWith('/')) { - // include hostname/protocol too so that the iframe link is absolute - base = `${window.location.protocol}//${window.location.host}${base}` - } - const instanceUrl = this.$store.state.instance.instanceUrl - let b = '' - if (!window.location.href.startsWith(instanceUrl)) { - // the frontend is running on a separate domain, so we need to provide - // the b= parameter in the iframe - b = `&b=${instanceUrl}` - } - return `${base}embed.html?&type=${this.type}&id=${this.id}${b}` - }, - frameWidth () { - if (this.width) { - return this.width - } - return '100%' - }, - embedCode () { - const src = this.iframeSrc.replace(/&/g, '&') - return `<iframe width="${this.frameWidth}" height="${this.height}" scrolling="no" frameborder="no" src="${src}"></iframe>` - } - }, - methods: { - copy () { - this.$refs.textarea.select() - document.execCommand('Copy') - const self = this - self.copied = true - this.timeout = setTimeout(() => { - self.copied = false - }, 5000) - } - } -} -</script> diff --git a/front/src/components/audio/LibraryFollowButton.vue b/front/src/components/audio/LibraryFollowButton.vue index 8190cca6ebdf6351b2f89c9d6e9032b3a9979b6e..0f81f815f121480ac2ca266521221df182f944fb 100644 --- a/front/src/components/audio/LibraryFollowButton.vue +++ b/front/src/components/audio/LibraryFollowButton.vue @@ -1,3 +1,37 @@ +<script setup lang="ts"> +import type { Library } from '~/types' + +import { computed } from 'vue' +import { useStore } from '~/store' + +interface Events { + (e: 'unfollowed'): void + (e: 'followed'): void +} + +interface Props { + library: Library +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const store = useStore() +const follow = computed(() => store.getters['libraries/follow'](props.library.uuid)) +const isPending = computed(() => follow.value && follow.value.approved === null) +const isApproved = computed(() => follow.value && (follow.value?.approved === true || (isPending.value && props.library.privacy_level === 'everyone'))) + +const toggle = () => { + if (isPending.value || isApproved.value) { + emit('unfollowed') + } else { + emit('followed') + } + + return store.dispatch('libraries/toggle', props.library.uuid) +} +</script> + <template> <button :class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']" @@ -24,33 +58,3 @@ </translate> </button> </template> - -<script> -export default { - props: { - library: { type: Object, required: true } - }, - computed: { - isPending () { - return this.follow && this.follow.approved === null - }, - isApproved () { - return this.follow && (this.follow.approved === true || (this.follow.approved === null && this.library.privacy_level === 'everyone')) - }, - follow () { - return this.$store.getters['libraries/follow'](this.library.uuid) - } - }, - methods: { - toggle () { - if (this.isApproved || this.isPending) { - this.$emit('unfollowed') - } else { - this.$emit('followed') - } - this.$store.dispatch('libraries/toggle', this.library.uuid) - } - } - -} -</script> diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index f7165d8402a739898fe76bb9c54eb3688e5a38e0..c38731e619555ea986d6148a138c8a5f5b619d7c 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -1,13 +1,133 @@ +<script setup lang="ts"> +import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' +import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' + +import { ref, computed, onMounted } from 'vue' +import { useGettext } from 'vue3-gettext' +import usePlayOptions from '~/composables/audio/usePlayOptions' +import useReport from '~/composables/moderation/useReport' +import { useCurrentElement } from '@vueuse/core' +import { setupDropdown } from '~/utils/fomantic' + +interface Props extends PlayOptionsProps { + dropdownIconClasses?: string[] + playIconClass?: string + buttonClasses?: string[] + discrete?: boolean + dropdownOnly?: boolean + iconOnly?: boolean + playing?: boolean + paused?: boolean + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + isPlayable?: boolean + tracks?: Track[] + track?: Track | null + artist?: Artist | null + album?: Album | null + playlist?: Playlist | null + library?: Library | null + channel?: Channel | null + account?: Actor | null +} + +const props = withDefaults(defineProps<Props>(), { + tracks: () => [], + track: null, + artist: null, + playlist: null, + album: null, + library: null, + channel: null, + account: null, + dropdownIconClasses: () => ['dropdown'], + playIconClass: () => 'play icon', + buttonClasses: () => ['button'], + discrete: () => false, + dropdownOnly: () => false, + iconOnly: () => false, + isPlayable: () => false, + playing: () => false, + paused: () => false +}) + +const { + playable, + filterableArtist, + filterArtist, + enqueue, + enqueueNext, + replacePlay, + isLoading +} = usePlayOptions(props) + +const { report, getReportableObjects } = useReport() + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + playNow: $pgettext('*/Queue/Dropdown/Button/Title', 'Play now'), + addToQueue: $pgettext('*/Queue/Dropdown/Button/Title', 'Add to current queue'), + playNext: $pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), + startRadio: $pgettext('*/Queue/Dropdown/Button/Title', 'Play similar songs'), + report: $pgettext('*/Moderation/*/Button/Label,Verb', 'Report…'), + addToPlaylist: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…'), + hideArtist: $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Hide content from this artist'), + replacePlay: props.track + ? $pgettext('*/Queue/Dropdown/Button/Title', 'Play track') + : props.album + ? $pgettext('*/Queue/Dropdown/Button/Title', 'Play album') + : props.artist + ? $pgettext('*/Queue/Dropdown/Button/Title', 'Play artist') + : props.playlist + ? $pgettext('*/Queue/Dropdown/Button/Title', 'Play playlist') + : $pgettext('*/Queue/Dropdown/Button/Title', 'Play tracks') +})) + +const title = computed(() => { + if (playable.value) { + return $pgettext('*/*/Button.Label/Noun', 'More…') + } + + if (props.track) { + return $pgettext('*/Queue/Button/Title', 'This track is not available in any library you have access to') + } + + return '' +}) + +const el = useCurrentElement() +const dropdown = ref() +onMounted(() => { + dropdown.value = setupDropdown('.ui.dropdown', el.value) +}) + +const openMenu = () => { + // little magic to ensure the menu is always visible in the viewport + // By default, try to diplay it on the right if there is enough room + const menu = dropdown.value.find('.menu') + const viewportOffset = menu.get(0)?.getBoundingClientRect() ?? { right: 0, left: 0 } + const viewportWidth = document.documentElement.clientWidth + const rightOverflow = viewportOffset.right - viewportWidth + const leftOverflow = -viewportOffset.left + + if (rightOverflow > 0) { + menu.css({ cssText: `left: ${-rightOverflow - 5}px !important;` }) + } else if (leftOverflow > 0) { + menu.css({ cssText: `right: -${leftOverflow + 5}px !important;` }) + } +} +</script> + <template> <span :title="title" - :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']" + :class="['ui', {'tiny': discrete, 'icon': !discrete, 'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']" > <button v-if="!dropdownOnly" :disabled="!playable" :aria-label="labels.replacePlay" - :class="buttonClasses.concat(['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}])" + :class="[...buttonClasses, 'ui', {loading: isLoading, 'mini': discrete, disabled: !playable}]" @click.stop.prevent="replacePlay" > <i @@ -23,43 +143,34 @@ <button v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]" - @click.stop.prevent="clicked = true" + @click.stop.prevent="openMenu" > <i :class="dropdownIconClasses.concat(['icon'])" :title="title" /> - <div - v-if="clicked" - class="menu" - > + <div class="menu"> <button - ref="add" class="item basic" - data-ref="add" :disabled="!playable" :title="labels.addToQueue" - @click.stop.prevent="add" + @click.stop.prevent="enqueue" > <i class="plus icon" /><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Add to queue</translate> </button> <button - ref="addNext" class="item basic" - data-ref="addNext" :disabled="!playable" :title="labels.playNext" - @click.stop.prevent="addNext()" + @click.stop.prevent="enqueueNext()" > <i class="step forward icon" />{{ labels.playNext }} </button> <button - ref="playNow" class="item basic" - data-ref="playNow" :disabled="!playable" :title="labels.playNow" - @click.stop.prevent="addNext(true)" + @click.stop.prevent="enqueueNext(true)" > <i class="play icon" />{{ labels.playNow }} </button> @@ -68,7 +179,7 @@ class="item basic" :disabled="!playable" :title="labels.startRadio" - @click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})" + @click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track?.id})" > <i class="feed icon" /><translate translate-context="*/Queue/Button.Label/Short, Verb">Play radio</translate> </button> @@ -82,13 +193,13 @@ <translate translate-context="Sidebar/Player/Icon.Tooltip/Verb">Add to playlist…</translate> </button> <button - v-if="track && !onTrackPage" + v-if="track && $route.name !== 'library.tracks.detail'" class="item basic" - @click.stop.prevent="$router.push(`/library/tracks/${track.id}/`)" + @click.stop.prevent="$router.push(`/library/tracks/${track?.id}/`)" > <i class="info icon" /> <translate - v-if="track.artist.content_category === 'podcast'" + v-if="track.artist?.content_category === 'podcast'" translate-context="*/Queue/Dropdown/Button/Label/Short" >Episode details</translate> <translate @@ -99,22 +210,19 @@ <div class="divider" /> <button v-if="filterableArtist" - ref="filterArtist" - data-ref="filterArtist" class="item basic" :disabled="!filterableArtist" :title="labels.hideArtist" @click.stop.prevent="filterArtist" > - <i class="eye slash outline icon" /><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Hide content from this artist</translate> + <i class="eye slash outline icon" /> + {{ labels.hideArtist }} </button> <button - v-for="obj in getReportableObjs({track, album, artist, playlist, account, channel})" + v-for="obj in getReportableObjects({track, album, artist, playlist, account, channel})" :key="obj.target.type + obj.target.id" - :ref="`report${obj.target.type}${obj.target.id}`" class="item basic" - :data-ref="`report${obj.target.type}${obj.target.id}`" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <i class="share icon" /> {{ obj.label }} </button> @@ -122,117 +230,3 @@ </button> </span> </template> - -<script> -import jQuery from 'jquery' - -import ReportMixin from '@/components/mixins/Report.vue' -import PlayOptionsMixin from '@/components/mixins/PlayOptions.vue' - -export default { - mixins: [ReportMixin, PlayOptionsMixin], - props: { - // we can either have a single or multiple tracks to play when clicked - tracks: { type: Array, required: false, default: () => { return null } }, - track: { type: Object, required: false, default: () => { return null } }, - account: { type: Object, required: false, default: () => { return null } }, - dropdownIconClasses: { type: Array, required: false, default: () => { return ['dropdown'] } }, - playIconClass: { type: String, required: false, default: 'play icon' }, - buttonClasses: { type: Array, required: false, default: () => { return ['button'] } }, - playlist: { type: Object, required: false, default: () => { return null } }, - discrete: { type: Boolean, default: false }, - dropdownOnly: { type: Boolean, default: false }, - iconOnly: { type: Boolean, default: false }, - artist: { type: Object, required: false, default: () => { return null } }, - album: { type: Object, required: false, default: () => { return null } }, - library: { type: Object, required: false, default: () => { return null } }, - channel: { type: Object, required: false, default: () => { return null } }, - isPlayable: { type: Boolean, required: false, default: null }, - playing: { type: Boolean, required: false, default: false }, - paused: { type: Boolean, required: false, default: false } - }, - data () { - return { - isLoading: false, - clicked: false - } - }, - computed: { - labels () { - let replacePlay - if (this.track) { - replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play track') - } else if (this.album) { - replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play album') - } else if (this.artist) { - replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play artist') - } else if (this.playlist) { - replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play playlist') - } else { - replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play tracks') - } - - return { - playNow: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play now'), - addToQueue: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Add to current queue'), - playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), - startRadio: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play similar songs'), - report: this.$pgettext('*/Moderation/*/Button/Label,Verb', 'Report…'), - addToPlaylist: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…'), - replacePlay - } - }, - title () { - if (this.playable) { - return this.$pgettext('*/*/Button.Label/Noun', 'More…') - } else { - if (this.track) { - return this.$pgettext('*/Queue/Button/Title', 'This track is not available in any library you have access to') - } - } - return null - }, - onTrackPage () { - return this.$router.currentRoute.name === 'library.tracks.detail' - } - }, - watch: { - clicked () { - const self = this - this.$nextTick(() => { - jQuery(this.$el).find('.ui.dropdown').dropdown({ - selectOnKeydown: false, - action: function (text, value, $el) { - // used to ensure focusing the dropdown and clicking via keyboard - // works as expected - const button = self.$refs[$el.data('ref')] - if (Array.isArray(button)) { - button[0].click() - } else { - button.click() - } - jQuery(self.$el).find('.ui.dropdown').dropdown('hide') - } - }) - jQuery(this.$el).find('.ui.dropdown').dropdown('show', function () { - // little magic to ensure the menu is always visible in the viewport - // By default, try to diplay it on the right if there is enough room - const menu = jQuery(self.$el).find('.ui.dropdown').find('.menu') - const viewportOffset = menu.get(0).getBoundingClientRect() - const viewportWidth = document.documentElement.clientWidth - const rightOverflow = viewportOffset.right - viewportWidth - const leftOverflow = -viewportOffset.left - let offset = 0 - if (rightOverflow > 0) { - offset = -rightOverflow - 5 - menu.css({ cssText: `left: ${offset}px !important;` }) - } else if (leftOverflow > 0) { - offset = leftOverflow + 5 - menu.css({ cssText: `right: -${offset}px !important;` }) - } - }) - }) - } - } -} -</script> diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index b56c688103e7f5940236193234289346e409ad70..f060839aa2aa8658e6457f0270dbfa1ef4598dc1 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -1,3 +1,104 @@ +<script setup lang="ts"> +import { useStore } from '~/store' +import VolumeControl from './VolumeControl.vue' +import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' +import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue' +import onKeyboardShortcut from '~/composables/onKeyboardShortcut' +import { computed, ref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useMouse, useWindowSize } from '@vueuse/core' +import useQueue from '~/composables/audio/useQueue' +import usePlayer from '~/composables/audio/usePlayer' + +const store = useStore() +const { $pgettext } = useGettext() + +const toggleMobilePlayer = () => { + store.commit('ui/queueFocused', ['queue', 'player'].includes(store.state.ui.queueFocused as string) ? null : 'player') +} + +const { + isShuffling, + shuffle, + previous, + isEmpty: queueIsEmpty, + hasNext, + hasPrevious, + currentTrack, + currentIndex, + tracks, + next +} = useQueue() + +const { + playing, + loading: isLoadingAudio, + looping, + currentTime, + progress, + durationFormatted, + currentTimeFormatted, + bufferProgress, + duration, + toggleMute, + seek, + togglePlayback, + resume, + pause +} = usePlayer() + +// Key binds +onKeyboardShortcut('e', toggleMobilePlayer) +onKeyboardShortcut('p', togglePlayback) +onKeyboardShortcut('s', shuffle) +onKeyboardShortcut('q', () => store.dispatch('queue/clean')) +onKeyboardShortcut('m', () => toggleMute) +onKeyboardShortcut('l', () => store.commit('player/toggleLooping')) +onKeyboardShortcut('f', () => store.dispatch('favorites/toggle', currentTrack.value?.id)) +onKeyboardShortcut('escape', () => store.commit('ui/queueFocused', null)) + +onKeyboardShortcut(['shift', 'up'], () => store.commit('player/incrementVolume', 0.1), true) +onKeyboardShortcut(['shift', 'down'], () => store.commit('player/incrementVolume', -0.1), true) + +onKeyboardShortcut('right', () => seek(5), true) +onKeyboardShortcut(['shift', 'right'], () => seek(30), true) +onKeyboardShortcut('left', () => seek(-5), true) +onKeyboardShortcut(['shift', 'left'], () => seek(-30), true) + +onKeyboardShortcut(['ctrl', 'shift', 'left'], previous, true) +onKeyboardShortcut(['ctrl', 'shift', 'right'], next, true) + +const labels = computed(() => ({ + audioPlayer: $pgettext('Sidebar/Player/Hidden text', 'Media player'), + previous: $pgettext('Sidebar/Player/Icon.Tooltip', 'Previous track'), + play: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Play'), + pause: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Pause'), + next: $pgettext('Sidebar/Player/Icon.Tooltip', 'Next track'), + unmute: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Unmute'), + mute: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Mute'), + expandQueue: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Expand queue'), + loopingDisabled: $pgettext('Sidebar/Player/Icon.Tooltip', 'Looping disabled. Click to switch to single-track looping.'), + loopingSingle: $pgettext('Sidebar/Player/Icon.Tooltip', 'Looping on a single track. Click to switch to whole queue looping.'), + loopingWhole: $pgettext('Sidebar/Player/Icon.Tooltip', 'Looping on whole queue. Click to disable looping.'), + shuffle: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Shuffle your queue'), + clear: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Clear your queue'), + addArtistContentFilter: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…') +})) + +const switchTab = () => { + store.commit('ui/queueFocused', store.state.ui.queueFocused === 'player' ? 'queue' : 'player') +} + +const progressBar = ref() +const touchProgress = (event: MouseEvent) => { + const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value + currentTime.value = time +} + +const { x } = useMouse() +const { width: screenWidth } = useWindowSize() +</script> + <template> <section v-if="currentTrack" @@ -18,17 +119,21 @@ @click.prevent.stop="toggleMobilePlayer" > <div + ref="progressBar" :class="['ui', 'top attached', 'small', 'inverted', {'indicating': isLoadingAudio}, 'progress']" + @click.prevent.stop="touchProgress" > <div class="buffer bar" - :data-percent="bufferProgress" - :style="{ 'width': bufferProgress + '%' }" + :style="{ 'transform': `translateX(${bufferProgress - 100}%)` }" /> <div class="position bar" - :data-percent="progress" - :style="{ 'width': progress + '%' }" + :style="{ 'transform': `translateX(calc(${progress}% - 100%)` }" + /> + <div + class="seek bar" + :style="{ 'transform': `translateX(${x / screenWidth * 100 - 100}%)` }" /> </div> <div class="controls-row"> @@ -89,7 +194,7 @@ </div> </div> </div> - <div class="controls track-controls queue-not-focused tablet-and-below"> + <div class="controls track-controls queue-not-focused desktop-and-below"> <div class="ui tiny image"> <img v-if="currentTrack.cover && currentTrack.cover.urls.original" @@ -145,8 +250,8 @@ <button :title="labels.previous" :aria-label="labels.previous" - class="circular button control tablet-and-up" :disabled="!hasPrevious" + class="circular button control tablet-and-up" @click.prevent.stop="$store.dispatch('queue/previous')" > <i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" /> @@ -156,7 +261,7 @@ :title="labels.play" :aria-label="labels.play" class="circular button control" - @click.prevent.stop="resumePlayback" + @click.prevent.stop="resume" > <i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" /> </button> @@ -165,15 +270,15 @@ :title="labels.pause" :aria-label="labels.pause" class="circular button control" - @click.prevent.stop="pausePlayback" + @click.prevent.stop="pause" > <i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" /> </button> <button :title="labels.next" :aria-label="labels.next" - class="circular button control" :disabled="!hasNext" + class="circular button control" @click.prevent.stop="$store.dispatch('queue/next')" > <i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" /> @@ -185,9 +290,12 @@ <template v-if="!isLoadingAudio"> <span class="start" - @click.stop.prevent="setCurrentTime(0)" - >{{ currentTimeFormatted }}</span> - | <span class="total">{{ durationFormatted }}</span> + @click.stop.prevent="currentTime = 0" + > + {{ currentTimeFormatted }} + </span> + | + <span class="total">{{ durationFormatted }}</span> </template> </div> </div> @@ -206,10 +314,10 @@ </button> <button v-if="looping === 1" - class="looping circular control button" :title="labels.loopingSingle" :aria-label="labels.loopingSingle" :disabled="!currentTrack" + class="looping circular control button" @click.prevent.stop="$store.commit('player/looping', 2)" > <i @@ -234,7 +342,7 @@ </button> <button class="circular control button" - :disabled="queue.tracks.length === 0" + :disabled="queueIsEmpty || null" :title="labels.shuffle" :aria-label="labels.shuffle" @click.prevent.stop="shuffle()" @@ -245,7 +353,7 @@ /> <i v-else - :class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']" + :class="['ui', 'random', {'disabled': queueIsEmpty}, 'icon']" /> </button> </div> @@ -259,19 +367,19 @@ <i class="stream icon" /> <translate translate-context="Sidebar/Queue/Text" - :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}" + :translate-params="{index: currentIndex + 1, length: tracks.length}" > %{ index } of %{ length } </translate> </button> <button - class="position circular control button tablet-and-below" + class="position circular control button desktop-and-below" @click.stop="switchTab" > <i class="stream icon" /> <translate translate-context="Sidebar/Queue/Text" - :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}" + :translate-params="{index: currentIndex + 1, length: tracks.length}" > %{ index } of %{ length } </translate> @@ -293,21 +401,21 @@ </button> <button v-if="$store.state.ui.queueFocused === 'player'" - class="circular control button close-control tablet-and-below" + class="circular control button close-control desktop-and-below" @click.stop="switchTab" > <i class="large up angle icon" /> </button> <button v-if="$store.state.ui.queueFocused === 'queue'" - class="circular control button tablet-and-below" + class="circular control button desktop-and-below" @click.stop="switchTab" > <i class="large down angle icon" /> </button> </div> <button - class="circular control button close-control tablet-and-below" + class="circular control button close-control desktop-and-below" @click.stop="$store.commit('ui/queueFocused', null)" > <i class="x icon" /> @@ -316,591 +424,5 @@ </div> </div> </div> - <GlobalEvents - @keydown.p.prevent.exact="togglePlayback" - @keydown.esc.prevent.exact="$store.commit('ui/queueFocused', null)" - @keydown.ctrl.shift.left.prevent.exact="previous" - @keydown.ctrl.shift.right.prevent.exact="next" - @keydown.shift.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)" - @keydown.shift.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)" - @keydown.right.prevent.exact="seek (5)" - @keydown.left.prevent.exact="seek (-5)" - @keydown.shift.right.prevent.exact="seek (30)" - @keydown.shift.left.prevent.exact="seek (-30)" - @keydown.m.prevent.exact="toggleMute" - @keydown.l.exact="$store.commit('player/toggleLooping')" - @keydown.s.exact="shuffle" - @keydown.f.exact="$store.dispatch('favorites/toggle', currentTrack.id)" - @keydown.q.exact="clean" - @keydown.e.exact="toggleMobilePlayer" - /> </section> </template> - -<script> -import { mapState, mapGetters, mapActions } from 'vuex' -import GlobalEvents from '@/components/utils/global-events.vue' -import { toLinearVolumeScale } from '@/audio/volume.js' -import { Howl, Howler } from 'howler' -import _ from 'lodash' -import url from '@/utils/url' -import axios from 'axios' -import VolumeControl from './VolumeControl.vue' -import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue' -import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon.vue' - -export default { - components: { - VolumeControl, - TrackFavoriteIcon, - TrackPlaylistIcon, - GlobalEvents - }, - data () { - return { - isShuffling: false, - sliderVolume: this.volume, - showVolume: false, - currentSound: null, - dummyAudio: null, - isUpdatingTime: false, - sourceErrors: 0, - progressInterval: null, - maxPreloaded: 3, - preloadDelay: 15, - listenDelay: 15, - listeningIsSubmitted: false, - soundsCache: [], - soundId: null, - playTimeout: null, - nextTrackPreloaded: false - } - }, - computed: { - ...mapState({ - currentIndex: state => state.queue.currentIndex, - playing: state => state.player.playing, - isLoadingAudio: state => state.player.isLoadingAudio, - volume: state => state.player.volume, - looping: state => state.player.looping, - duration: state => state.player.duration, - bufferProgress: state => state.player.bufferProgress, - errored: state => state.player.errored, - currentTime: state => state.player.currentTime, - queue: state => state.queue - }), - ...mapGetters({ - currentTrack: 'queue/currentTrack', - hasNext: 'queue/hasNext', - hasPrevious: 'queue/hasPrevious', - emptyQueue: 'queue/isEmpty', - durationFormatted: 'player/durationFormatted', - currentTimeFormatted: 'player/currentTimeFormatted', - progress: 'player/progress' - }), - updateProgressThrottled () { - return _.throttle(this.updateProgress, 50) - }, - labels () { - const audioPlayer = this.$pgettext('Sidebar/Player/Hidden text', 'Media player') - const previous = this.$pgettext('Sidebar/Player/Icon.Tooltip', 'Previous track') - const play = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Play') - const pause = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Pause') - const next = this.$pgettext('Sidebar/Player/Icon.Tooltip', 'Next track') - const unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Unmute') - const mute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Mute') - const expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Expand queue') - const loopingDisabled = this.$pgettext('Sidebar/Player/Icon.Tooltip', - 'Looping disabled. Click to switch to single-track looping.' - ) - const loopingSingle = this.$pgettext('Sidebar/Player/Icon.Tooltip', - 'Looping on a single track. Click to switch to whole queue looping.' - ) - const loopingWhole = this.$pgettext('Sidebar/Player/Icon.Tooltip', - 'Looping on whole queue. Click to disable looping.' - ) - const shuffle = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Shuffle your queue') - const clear = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Clear your queue') - const addArtistContentFilter = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…') - return { - audioPlayer, - previous, - play, - pause, - next, - unmute, - mute, - loopingDisabled, - loopingSingle, - loopingWhole, - shuffle, - clear, - expandQueue, - addArtistContentFilter - } - } - }, - watch: { - currentTrack: { - async handler (newValue, oldValue) { - if (newValue === oldValue) { - return - } - this.nextTrackPreloaded = false - clearTimeout(this.playTimeout) - if (this.currentSound) { - this.currentSound.pause() - } - this.$store.commit('player/isLoadingAudio', true) - this.playTimeout = setTimeout(async () => { - await this.loadSound(newValue, oldValue) - }, 100) - this.updateMetadata() - }, - immediate: false - }, - volume: { - immediate: true, - handler (newValue) { - this.sliderVolume = newValue - Howler.volume(toLinearVolumeScale(newValue)) - } - }, - sliderVolume (newValue) { - this.$store.commit('player/volume', newValue) - }, - playing: async function (newValue) { - if (this.currentSound) { - if (newValue === true) { - this.soundId = this.currentSound.play(this.soundId) - } else { - this.currentSound.pause(this.soundId) - } - } else { - await this.loadSound(this.currentTrack, null) - } - - this.observeProgress(newValue) - }, - currentTime (newValue) { - if (!this.isUpdatingTime) { - this.setCurrentTime(newValue) - } - this.isUpdatingTime = false - }, - emptyQueue (newValue) { - if (newValue) { - Howler.unload() - } - } - }, - mounted () { - this.$store.dispatch('player/updateProgress', 0) - this.$store.commit('player/playing', false) - this.$store.commit('player/isLoadingAudio', false) - Howler.unload() // clear existing cache, if any - this.nextTrackPreloaded = false - // this is needed to unlock audio playing under some browsers, - // cf https://github.com/goldfire/howler.js#mobilechrome-playback - // but we never actually load those audio files - this.dummyAudio = new Howl({ - preload: false, - autoplay: false, - src: ['noop.webm', 'noop.mp3'] - }) - if (this.currentTrack) { - this.getSound(this.currentTrack) - this.updateMetadata() - } - // Add controls for notification drawer - if ('mediaSession' in navigator) { - navigator.mediaSession.setActionHandler('play', this.resumePlayback) - navigator.mediaSession.setActionHandler('pause', this.pausePlayback) - navigator.mediaSession.setActionHandler('seekforward', this.seekForward) - navigator.mediaSession.setActionHandler('seekbackward', this.seekBackward) - navigator.mediaSession.setActionHandler('nexttrack', this.next) - navigator.mediaSession.setActionHandler('previoustrack', this.previous) - } - }, - beforeDestroy () { - this.dummyAudio.unload() - this.observeProgress(false) - }, - destroyed () { - }, - methods: { - ...mapActions({ - resumePlayback: 'player/resumePlayback', - pausePlayback: 'player/pausePlayback', - togglePlayback: 'player/togglePlayback', - mute: 'player/mute', - unmute: 'player/unmute', - clean: 'queue/clean', - toggleMute: 'player/toggleMute' - }), - async getTrackData (trackData) { - // use previously fetched trackData - if (trackData.uploads.length) return trackData - - // we don't have any information for this track, we need to fetch it - return axios.get(`tracks/${trackData.id}/`) - .then( - response => response.data, - () => null - ) - }, - shuffle () { - const disabled = this.queue.tracks.length === 0 - if (this.isShuffling || disabled) { - return - } - const self = this - const msg = this.$pgettext('Content/Queue/Message', 'Queue shuffled!') - this.isShuffling = true - setTimeout(() => { - self.$store.dispatch('queue/shuffle', () => { - self.isShuffling = false - self.$store.commit('ui/addMessage', { - content: msg, - date: new Date() - }) - }) - }, 100) - }, - next () { - const self = this - this.$store.dispatch('queue/next').then(() => { - self.$emit('next') - }) - }, - previous () { - const self = this - this.$store.dispatch('queue/previous').then(() => { - self.$emit('previous') - }) - }, - handleError ({ sound, error }) { - this.$store.commit('player/isLoadingAudio', false) - this.$store.dispatch('player/trackErrored') - }, - getSound (trackData) { - const cached = this.getSoundFromCache(trackData) - if (cached) { - return cached.sound - } - const srcs = this.getSrcs(trackData) - const self = this - const sound = new Howl({ - src: srcs.map((s) => { return s.url }), - format: srcs.map((s) => { return s.type }), - autoplay: false, - loop: false, - html5: true, - preload: true, - onend: function () { - self.ended() - }, - onunlock: function () { - if (self.$store.state.player.playing && self.sound) { - self.soundId = self.sound.play(self.soundId) - } - }, - onload: function () { - const sound = this - const node = this._sounds[0]._node - node.addEventListener('progress', () => { - if (sound !== self.currentSound) { - return - } - self.updateBuffer(node) - }) - }, - onplay: function () { - if (this !== self.currentSound) { - this.stop() - return - } - const t = self.currentSound.seek() - const d = self.currentSound.duration() - if (t <= (d / 2)) { - self.listeningIsSubmitted = false - } - self.$store.commit('player/isLoadingAudio', false) - self.$store.commit('player/resetErrorCount') - self.$store.commit('player/errored', false) - self.$store.commit('player/duration', this.duration()) - }, - onloaderror: function (sound, error) { - self.removeFromCache(this) - if (this !== self.currentSound) { - return - } - console.log('Error while playing:', sound, error) - self.handleError({ sound, error }) - } - }) - this.addSoundToCache(sound, trackData) - return sound - }, - getSrcs: function (trackData) { - const a = document.createElement('audio') - const allowed = ['probably', 'maybe'] - const sources = trackData.uploads.filter(u => { - const canPlay = a.canPlayType(u.mimetype) - return allowed.indexOf(canPlay) > -1 - }).map(u => { - return { - type: u.extension, - url: this.$store.getters['instance/absoluteUrl'](u.listen_url) - } - }) - a.remove() - // We always add a transcoded MP3 src at the end - // because transcoding is expensive, but we want browsers that do - // not support other codecs to be able to play it :) - sources.push({ - type: 'mp3', - url: url.updateQueryString( - this.$store.getters['instance/absoluteUrl'](trackData.listen_url), - 'to', - 'mp3' - ) - }) - if (this.$store.state.auth.authenticated) { - // we need to send the token directly in url - // so authentication can be checked by the backend - // because for audio files we cannot use the regular Authentication - // header - let param = 'jwt' - let value = this.$store.state.auth.token - if (this.$store.state.auth.scopedTokens && this.$store.state.auth.scopedTokens.listen) { - // used scoped tokens instead of JWT to reduce the attack surface if the token - // is leaked - param = 'token' - value = this.$store.state.auth.scopedTokens.listen - } - sources.forEach(e => { - e.url = url.updateQueryString(e.url, param, value) - }) - } - return sources - }, - - updateBuffer (node) { - // from https://github.com/goldfire/howler.js/issues/752#issuecomment-372083163 - let range = 0 - const bf = node.buffered - const time = node.currentTime - try { - while (!(bf.start(range) <= time && time <= bf.end(range))) { - range += 1 - } - } catch (IndexSizeError) { - return - } - let loadPercentage - const start = bf.start(range) - const end = bf.end(range) - if (range === 0) { - // easy case, no user-seek - const loadStartPercentage = start / node.duration - const loadEndPercentage = end / node.duration - loadPercentage = loadEndPercentage - loadStartPercentage - } else { - const loaded = end - start - const remainingToLoad = node.duration - start - // user seeked a specific position in the audio, our progress must be - // computed based on the remaining portion of the track - loadPercentage = loaded / remainingToLoad - } - if (loadPercentage * 100 === this.bufferProgress) { - return - } - this.$store.commit('player/bufferProgress', loadPercentage * 100) - }, - updateProgress: function () { - this.isUpdatingTime = true - if (this.currentSound && this.currentSound.state() === 'loaded') { - const t = this.currentSound.seek() - const d = this.currentSound.duration() - this.$store.dispatch('player/updateProgress', t) - this.updateBuffer(this.currentSound._sounds[0]._node) - const toPreload = this.$store.state.queue.tracks[this.currentIndex + 1] - if (!this.nextTrackPreloaded && toPreload && !this.getSoundFromCache(toPreload) && (t > this.preloadDelay || d - t < 30)) { - this.getSound(toPreload) - this.nextTrackPreloaded = true - } - if (t > (d / 2)) { - if (!this.listeningIsSubmitted) { - this.$store.dispatch('player/trackListened', this.currentTrack) - this.listeningIsSubmitted = true - } - } - } - }, - seek (step) { - if (step > 0) { - // seek right - if (this.currentTime + step < this.duration) { - this.$store.dispatch('player/updateProgress', (this.currentTime + step)) - } else { - this.next() // parenthesis where missing here - } - } else { - // seek left - const position = Math.max(this.currentTime + step, 0) - this.$store.dispatch('player/updateProgress', position) - } - }, - seekForward () { - this.seek(5) - }, - seekBackward () { - this.seek(-5) - }, - observeProgress: function (enable) { - const self = this - if (enable) { - if (self.progressInterval) { - clearInterval(self.progressInterval) - } - self.progressInterval = setInterval(() => { - self.updateProgress() - }, 1000) - } else { - clearInterval(self.progressInterval) - } - }, - setCurrentTime (t) { - if (t < 0 || t > this.duration) { - return - } - if (!this.currentSound || !this.currentSound._sounds[0]) { - return - } - if (t === this.currentSound.seek()) { - return - } - if (t === 0) { - this.updateProgressThrottled.cancel() - } - this.currentSound.seek(t) - // If player is paused update progress immediately to ensure updated UI - if (!this.$store.state.player.playing) { - this.updateProgress() - } - }, - ended: function () { - const onlyTrack = this.$store.state.queue.tracks.length === 1 - if (this.looping === 1 || (onlyTrack && this.looping === 2)) { - this.currentSound.seek(0) - this.$store.dispatch('player/updateProgress', 0) - this.soundId = this.currentSound.play(this.soundId) - } else { - this.$store.dispatch('player/trackEnded', this.currentTrack) - } - }, - getSoundFromCache (trackData) { - return this.soundsCache.filter((d) => { - if (d.track.id !== trackData.id) { - return false - } - - return true - })[0] - }, - addSoundToCache (sound, trackData) { - const data = { - date: new Date(), - track: trackData, - sound: sound - } - this.soundsCache.push(data) - this.checkCache() - }, - checkCache () { - const self = this - const toKeep = [] - _.reverse(this.soundsCache).forEach((e) => { - if (toKeep.length < self.maxPreloaded) { - toKeep.push(e) - } else { - e.sound.unload() - } - }) - this.soundsCache = _.reverse(toKeep) - }, - removeFromCache (sound) { - const toKeep = [] - this.soundsCache.forEach((e) => { - if (e.sound === sound) { - e.sound.unload() - } else { - toKeep.push(e) - } - }) - this.soundsCache = toKeep - }, - async loadSound (newValue, oldValue) { - let trackData = newValue - const oldSound = this.currentSound - if (oldSound && trackData !== oldValue) { - oldSound.stop(this.soundId) - this.soundId = null - } - if (!trackData) { - return - } - if (!this.isShuffling && trackData !== oldValue) { - trackData = await this.getTrackData(trackData) - if (trackData == null) { - this.handleError({}) - } - this.currentSound = this.getSound(trackData) - this.$store.commit('player/isLoadingAudio', true) - this.soundId = this.currentSound.play() - this.$store.commit('player/errored', false) - this.$store.commit('player/playing', true) - this.$store.dispatch('player/updateProgress', 0) - this.observeProgress(true) - } - }, - toggleMobilePlayer () { - if (['queue', 'player'].indexOf(this.$store.state.ui.queueFocused) > -1) { - this.$store.commit('ui/queueFocused', null) - } else { - this.$store.commit('ui/queueFocused', 'player') - } - }, - switchTab () { - if (this.$store.state.ui.queueFocused === 'player') { - this.$store.commit('ui/queueFocused', 'queue') - } else { - this.$store.commit('ui/queueFocused', 'player') - } - }, - updateMetadata () { - // If the session is playing as a PWA, populate the notification - // with details from the track - if (this.currentTrack && 'mediaSession' in navigator) { - const metadata = { - title: this.currentTrack.title, - artist: this.currentTrack.artist.name - } - if (this.currentTrack.album && this.currentTrack.album.cover) { - metadata.album = this.currentTrack.album.title - metadata.artwork = [ - { src: this.currentTrack.album.cover.urls.original, sizes: '96x96', type: 'image/png' }, - { src: this.currentTrack.album.cover.urls.original, sizes: '128x128', type: 'image/png' }, - { src: this.currentTrack.album.cover.urls.original, sizes: '192x192', type: 'image/png' }, - { src: this.currentTrack.album.cover.urls.original, sizes: '256x256', type: 'image/png' }, - { src: this.currentTrack.album.cover.urls.original, sizes: '384x384', type: 'image/png' }, - { src: this.currentTrack.album.cover.urls.original, sizes: '512x512', type: 'image/png' } - ] - } - navigator.mediaSession.metadata = new window.MediaMetadata(metadata) - } - } - } -} -</script> diff --git a/front/src/components/audio/Search.vue b/front/src/components/audio/Search.vue index c7ef99feeb1fc80455bc8efe49c2723b73c2b7e6..822963f22720bf083f97f716c9ebbf3b2015c554 100644 --- a/front/src/components/audio/Search.vue +++ b/front/src/components/audio/Search.vue @@ -1,3 +1,75 @@ +<script setup lang="ts"> +import type { Artist, Album } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { ref, computed, reactive, watch, onMounted } from 'vue' +import { refDebounced } from '@vueuse/core' + +import axios from 'axios' +import AlbumCard from '~/components/audio/album/Card.vue' +import ArtistCard from '~/components/audio/artist/Card.vue' + +import useErrorHandler from '~/composables/useErrorHandler' +import useLogger from '~/composables/useLogger' + +interface Props { + autofocus?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + autofocus: false +}) + +const logger = useLogger() +const { $pgettext } = useGettext() + +const query = ref('') +const queryDebounced = refDebounced(query, 500) + +const results = reactive({ + artists: [] as Artist[], + albums: [] as Album[] +}) + +const isLoading = ref(false) +const search = async () => { + if (queryDebounced.value.length < 1) { + return + } + + isLoading.value = true + logger.debug(`Searching track matching "${queryDebounced.value}"`) + + const params = { + query: queryDebounced.value + } + + try { + const response = await axios.get('search/', { params }) + results.artists = response.data.artists + results.albums = response.data.albums + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +watch(queryDebounced, search, { immediate: true }) + +const searchInput = ref() +onMounted(() => { + if (props.autofocus) { + searchInput.value.focus() + } +}) + +const labels = computed(() => ({ + searchPlaceholder: $pgettext('*/Search/Input.Placeholder', 'Artist, album, track…') +})) + +</script> + <template> <div> <h2> @@ -9,7 +81,7 @@ <div class="ui icon big input"> <i class="search icon" /> <input - ref="search" + ref="searchInput" v-model.trim="query" class="prompt" :placeholder="labels.searchPlaceholder" @@ -67,74 +139,3 @@ </template> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import logger from '@/logging' -import AlbumCard from '@/components/audio/album/Card.vue' -import ArtistCard from '@/components/audio/artist/Card.vue' - -export default { - components: { - AlbumCard, - ArtistCard - }, - props: { - autofocus: { type: Boolean, default: false } - }, - data () { - return { - query: '', - results: { - albums: [], - artists: [] - }, - isLoading: false - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('*/Search/Input.Placeholder', 'Artist, album, track…') - } - } - }, - watch: { - query () { - this.search() - } - }, - mounted () { - if (this.autofocus) { - this.$refs.search.focus() - } - this.search() - }, - methods: { - search: _.debounce(function () { - if (this.query.length < 1) { - return - } - const self = this - self.isLoading = true - logger.default.debug('Searching track matching "' + this.query + '"') - const params = { - query: this.query - } - axios.get('search', { - params: params - }).then((response) => { - self.results = self.castResults(response.data) - self.isLoading = false - }) - }, 500), - castResults (results) { - return { - albums: results.albums, - artists: results.artists - } - } - } -} -</script> diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index 71f89d78bd87aa4177c67dbd087dac9e5bc61ff5..97275c1351e1ffbbda2622fcfc3ff6f406b03e87 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -1,265 +1,261 @@ -<template> - <div class="ui fluid category search"> - <slot /><div class="ui icon input"> - <input - ref="search" - :aria-label="labels.searchContent" - type="search" - class="prompt" - name="search" - :placeholder="labels.placeholder" - @keydown.esc="$event.target.blur()" - > - <i class="search icon" /> - </div> - <div class="results" /> - <slot name="after" /> - <GlobalEvents - @keydown.shift.f.prevent.exact="focusSearch" - /> - </div> -</template> +<script setup lang="ts"> +import type { Artist, Track, Album, Tag } from '~/types' +import type { RouteRecordName, RouteLocationNamedRaw } from 'vue-router' -<script> import jQuery from 'jquery' -import router from '@/router' -import lodash from 'lodash' -import GlobalEvents from '@/components/utils/global-events.vue' +import { trim } from 'lodash-es' +import { useFocus, useCurrentElement } from '@vueuse/core' +import { ref, computed, onMounted } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { useStore } from '~/store' + +import onKeyboardShortcut from '~/composables/onKeyboardShortcut' + +interface Events { + (e: 'search'): void +} -export default { - components: { - GlobalEvents +type CategoryCode = 'federation' | 'podcasts' | 'artists' | 'albums' | 'tracks' | 'tags' | 'more' +interface Category { + code: CategoryCode, + name: string, + route: RouteRecordName + getId: (obj: unknown) => string + getTitle: (obj: unknown) => string + getDescription: (obj: unknown) => string +} + +type SimpleCategory = Partial<Category> & Pick<Category, 'code' | 'name'> +const isCategoryGuard = (object: Category | SimpleCategory): object is Category => typeof object.route === 'string' + +interface Results { + name: string, + results: Result[] +} + +interface Result { + title: string + id?: string + description?: string + routerUrl: RouteLocationNamedRaw +} + +const emit = defineEmits<Events>() + +const search = ref() +const { focused } = useFocus(search) +onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true) +onKeyboardShortcut(['ctrl', 'k'], () => (focused.value = true), true) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + placeholder: $pgettext('Sidebar/Search/Input.Placeholder', 'Search for artists, albums, tracks…'), + searchContent: $pgettext('Sidebar/Search/Input.Label', 'Search for content'), + artist: $pgettext('*/*/*/Noun', 'Artist'), + album: $pgettext('*/*/*', 'Album'), + track: $pgettext('*/*/*/Noun', 'Track'), + tag: $pgettext('*/*/*/Noun', 'Tag') +})) + +const router = useRouter() +const store = useStore() +const el = useCurrentElement() +const query = ref() + +const enter = () => { + jQuery(el.value).search('cancel query') + + // Cancel any API search request to backend… + return router.push(`/search?q=${query.value}&type=artists`) +} + +const blur = () => { + search.value.blur() +} + +const categories = computed(() => [ + { + code: 'federation', + name: $pgettext('*/*/*', 'Federation') }, - computed: { - labels () { - return { - placeholder: this.$pgettext('Sidebar/Search/Input.Placeholder', 'Search for artists, albums, tracks…'), - searchContent: this.$pgettext('Sidebar/Search/Input.Label', 'Search for content') - } - } + { + code: 'podcasts', + name: $pgettext('*/*/*', 'Podcasts') }, - mounted () { - const artistLabel = this.$pgettext('*/*/*/Noun', 'Artist') - const albumLabel = this.$pgettext('*/*/*', 'Album') - const trackLabel = this.$pgettext('*/*/*/Noun', 'Track') - const tagLabel = this.$pgettext('*/*/*/Noun', 'Tag') - const self = this - let searchQuery + { + code: 'artists', + route: 'library.artists.detail', + name: labels.value.artist, + getId: (obj: Artist) => obj.id, + getTitle: (obj: Artist) => obj.name, + getDescription: () => '' + }, + { + code: 'albums', + route: 'library.albums.detail', + name: labels.value.album, + getId: (obj: Album) => obj.id, + getTitle: (obj: Album) => obj.title, + getDescription: (obj: Album) => obj.artist.name + }, + { + code: 'tracks', + route: 'library.tracks.detail', + name: labels.value.track, + getId: (obj: Track) => obj.id, + getTitle: (obj: Track) => obj.title, + getDescription: (obj: Track) => obj.album?.artist.name ?? obj.artist?.name ?? '' + }, + { + code: 'tags', + route: 'library.tags.detail', + name: labels.value.tag, + getId: (obj: Tag) => obj.name, + getTitle: (obj: Tag) => `#${obj.name}`, + getDescription: (obj: Tag) => '' + }, + { + code: 'more', + name: '' + } +] as (Category | SimpleCategory)[]) - jQuery(this.$el).keypress(function (e) { - if (e.which === 13) { - // Cancel any API search request to backend… - jQuery(this.$el).search('cancel query') - // Go direct to the artist page… - router.push(`/search?q=${searchQuery}&type=artists`) - } - }) +const objectId = computed(() => { + const trimmedQuery = trim(trim(query.value), '@') - jQuery(this.$el).search({ - type: 'category', - minCharacters: 3, - showNoResults: true, - error: { - noResultsHeader: this.$pgettext('Sidebar/Search/Error', 'No matches found'), - noResults: this.$pgettext('Sidebar/Search/Error.Label', 'Sorry, there are no results for this search') - }, - onSelect (result, response) { - jQuery(self.$el).search('set value', searchQuery) - router.push(result.routerUrl) - jQuery(self.$el).search('hide results') - return false - }, - onSearchQuery (query) { - self.$emit('search') - searchQuery = query + if (trimmedQuery.startsWith('http://') || trimmedQuery.startsWith('https://') || trimmedQuery.includes('@')) { + return query.value + } + + return null +}) + +onMounted(() => { + jQuery(el.value).search({ + type: 'category', + minCharacters: 3, + showNoResults: true, + error: { + // @ts-expect-error Semantic is broken + noResultsHeader: $pgettext('Sidebar/Search/Error', 'No matches found'), + noResults: $pgettext('Sidebar/Search/Error.Label', 'Sorry, there are no results for this search') + }, + + onSelect (result, response) { + jQuery(el.value).search('set value', query.value) + router.push(result.routerUrl) + jQuery(el.value).search('hide results') + return false + }, + onSearchQuery (value) { + // query.value = value + emit('search') + }, + apiSettings: { + url: store.getters['instance/absoluteUrl']('api/v1/search?query={query}'), + beforeXHR: function (xhrObject) { + if (!store.state.auth.authenticated) { + return xhrObject + } + + if (store.state.auth.oauth.accessToken) { + xhrObject.setRequestHeader('Authorization', store.getters['auth/header']) + } + + return xhrObject }, - apiSettings: { - beforeXHR: function (xhrObject) { - if (!self.$store.state.auth.authenticated) { - return xhrObject - } + onResponse: function (initialResponse) { + const id = objectId.value + const results: Partial<Record<CategoryCode, Results>> = {} - if (self.$store.state.auth.oauth.accessToken) { - xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) + let resultsEmpty = true + for (const category of categories.value) { + results[category.code] = { + name: category.name, + results: [] } - return xhrObject - }, - onResponse: function (initialResponse) { - const objId = self.extractObjId(searchQuery) - const results = {} - let isEmptyResults = true - const categories = [ - { - code: 'federation', - name: self.$pgettext('*/*/*', 'Federation') - }, - { - code: 'podcasts', - name: self.$pgettext('*/*/*', 'Podcasts') - }, - { - code: 'artists', - route: 'library.artists.detail', - name: artistLabel, - getTitle (r) { - return r.name - }, - getDescription (r) { - return '' - }, - getId (t) { - return t.id - } - }, - { - code: 'albums', - route: 'library.albums.detail', - name: albumLabel, - getTitle (r) { - return r.title - }, - getDescription (r) { - return r.artist.name - }, - getId (t) { - return t.id - } - }, - { - code: 'tracks', - route: 'library.tracks.detail', - name: trackLabel, - getTitle (r) { - return r.title - }, - getDescription (r) { - if (r.album) { - return `${r.album.artist.name} - ${r.album.title}` - } else { - return r.artist.name - } - }, - getId (t) { - return t.id - } - }, - { - code: 'tags', - route: 'library.tags.detail', - name: tagLabel, - getTitle (r) { - return `#${r.name}` - }, - getDescription (r) { - return '' - }, - getId (t) { - return t.name - } - }, - { - code: 'more', - name: '' - } - ] - categories.forEach(category => { - results[category.code] = { - name: category.name, - results: [] - } - if (category.code === 'federation') { - if (objId) { - isEmptyResults = false - const searchMessage = self.$pgettext('Search/*/*', 'Search on the fediverse') - results.federation = { - name: self.$pgettext('*/*/*', 'Federation'), - results: [{ - title: searchMessage, - routerUrl: { - name: 'search', - query: { - id: objId - } - } - }] - } + + if (category.code === 'federation' && id) { + resultsEmpty = false + results[category.code]?.results.push({ + title: $pgettext('Search/*/*', 'Search on the fediverse'), + routerUrl: { + name: 'search', + query: { id } } - } else if (category.code === 'podcasts') { - if (objId) { - isEmptyResults = false - const searchMessage = self.$pgettext('Search/*/*', 'Subscribe to podcast via RSS') - results.podcasts = { - name: self.$pgettext('*/*/*', 'Podcasts'), - results: [{ - title: searchMessage, - routerUrl: { - name: 'search', - query: { - id: objId, - type: 'rss' - } - } - }] - } + }) + } + + if (category.code === 'podcasts' && id) { + resultsEmpty = false + results[category.code]?.results.push({ + title: $pgettext('Search/*/*', 'Subscribe to podcast via RSS'), + routerUrl: { + name: 'search', + query: { id, type: 'rss' } } - } else if (category.code === 'more') { - const searchMessage = self.$pgettext('Search/*/*', 'More results 🡒') - results.more = { - name: '', - results: [{ - title: searchMessage, - routerUrl: { - name: 'search', - query: { - type: 'artists', - q: searchQuery - } - } - }] + }) + } + + if (category.code === 'more') { + results[category.code]?.results.push({ + title: $pgettext('Search/*/*', 'More results 🡒'), + routerUrl: { + name: 'search', + query: { type: 'artists', q: query.value } } - } else { - initialResponse[category.code].forEach(result => { - isEmptyResults = false - const id = category.getId(result) - results[category.code].results.push({ - title: category.getTitle(result), - id, - routerUrl: { - name: category.route, - params: { - id - } - }, - description: category.getDescription(result) - }) + }) + } + + if (isCategoryGuard(category)) { + for (const result of initialResponse[category.code]) { + resultsEmpty = false + const id = category.getId(result) + results[category.code]?.results.push({ + title: category.getTitle(result), + id, + routerUrl: { + name: category.route, + params: { id } + }, + description: category.getDescription(result) }) } - }) - return { - results: isEmptyResults ? {} : results } - }, - url: this.$store.getters['instance/absoluteUrl']('api/v1/search?query={query}') - } - }) - }, - methods: { - focusSearch () { - this.$refs.search.focus() - }, - extractObjId (query) { - query = lodash.trim(query) - query = lodash.trim(query, '@') - if (query.indexOf(' ') > -1) { - return - } - if (query.startsWith('http://') || query.startsWith('https://')) { - return query - } - if (query.split('@').length > 1) { - return query + } + + return { + results: resultsEmpty + ? {} + : results + } } } - } -} + }) +}) </script> + +<template> + <div + class="ui fluid category search" + @keypress.enter="enter" + > + <slot /> + <div class="ui icon input"> + <input + ref="search" + v-model="query" + :aria-label="labels.searchContent" + type="search" + class="prompt" + name="search" + :placeholder="labels.placeholder" + @keydown.esc="blur" + > + <i class="search icon" /> + </div> + <div class="results" /> + <slot name="after" /> + </div> +</template> diff --git a/front/src/components/audio/VolumeControl.vue b/front/src/components/audio/VolumeControl.vue index d8f45dccc8dc2333e5f5c536c41968c6d4b56166..1bb7ae64f00aa2c6fb39d9f779966828d514c255 100644 --- a/front/src/components/audio/VolumeControl.vue +++ b/front/src/components/audio/VolumeControl.vue @@ -1,3 +1,41 @@ +<script setup lang="ts"> +import { ref, computed } from 'vue' +import { useStore } from '~/store' +import { useGettext } from 'vue3-gettext' +import { useTimeoutFn } from '@vueuse/core' +import usePlayer from '~/composables/audio/usePlayer' + +const store = useStore() +const { volume, mute, unmute } = usePlayer() + +const expanded = ref(false) +const volumeSteps = 100 + +const sliderVolume = computed({ + get: () => volume.value * volumeSteps, + set: (value) => store.commit('player/volume', value / volumeSteps) +}) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + unmute: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Unmute'), + mute: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Mute'), + slider: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Adjust volume') +})) + +const { start, stop } = useTimeoutFn(() => (expanded.value = false), 500, { immediate: false }) + +const handleOver = () => { + stop() + expanded.value = true +} + +const handleLeave = () => { + stop() + start() +} +</script> + <template> <button class="circular control button" @@ -49,52 +87,3 @@ </div> </button> </template> -<script> -import { mapActions } from 'vuex' - -export default { - data () { - return { - expanded: false, - timeout: null, - volumeSteps: 100 - } - }, - computed: { - sliderVolume: { - get () { - return this.$store.state.player.volume * this.volumeSteps - }, - set (v) { - this.$store.commit('player/volume', v / this.volumeSteps) - } - }, - labels () { - return { - unmute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Unmute'), - mute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Mute'), - slider: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Adjust volume') - } - } - }, - methods: { - ...mapActions({ - mute: 'player/mute', - unmute: 'player/unmute', - toggleMute: 'player/toggleMute' - }), - handleOver () { - if (this.timeout) { - clearTimeout(this.timeout) - } - this.expanded = true - }, - handleLeave () { - if (this.timeout) { - clearTimeout(this.timeout) - } - this.timeout = setTimeout(() => { this.expanded = false }, 500) - } - } -} -</script> diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index 154ef4ec1e43a9c59c20bbbee56c6c7163e4f56c..dc242d6f7540413a2ac0e751cfb421fe11f65b22 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -1,3 +1,24 @@ +<script setup lang="ts"> +import type { Album } from '~/types' + +import PlayButton from '~/components/audio/PlayButton.vue' +import { momentFormat } from '~/utils/filters' +import { computed } from 'vue' +import { useStore } from '~/store' + +interface Props { + album: Album +} + +const props = defineProps<Props>() +const store = useStore() + +const imageUrl = computed(() => props.album.cover?.urls.original + ? store.getters['instance/absoluteUrl'](props.album.cover.urls.medium_square_crop) + : null +) +</script> + <template> <div class="card app-card component-album-card"> <router-link @@ -37,7 +58,7 @@ </div> </div> <div class="extra content"> - <span v-if="album.release_date">{{ album.release_date | moment('Y') }} · </span> + <span v-if="album.release_date">{{ momentFormat(new Date(album.release_date), 'Y') }} · </span> <translate translate-context="*/*/*" :translate-params="{count: album.tracks_count}" @@ -56,24 +77,3 @@ </div> </div> </template> - -<script> -import PlayButton from '@/components/audio/PlayButton.vue' - -export default { - components: { - PlayButton - }, - props: { - album: { type: Object, required: true } - }, - computed: { - imageUrl () { - if (this.album.cover && this.album.cover.urls.original) { - return this.$store.getters['instance/absoluteUrl'](this.album.cover.urls.medium_square_crop) - } - return null - } - } -} -</script> diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index a76c191169ecb69f748db60440b71991cd6c6da9..09f66b13264b94f38183afa766e805c8cb9995de 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -1,3 +1,69 @@ +<script setup lang="ts"> +import type { Album } from '~/types' + +import { reactive, ref, watch } from 'vue' +import { useStore } from '~/store' + +import axios from 'axios' + +import AlbumCard from '~/components/audio/album/Card.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + filters: Record<string, string | boolean> + showCount?: boolean + search?: boolean + limit?: number +} + +const props = withDefaults(defineProps<Props>(), { + showCount: false, + search: false, + limit: 12 +}) + +const store = useStore() + +const query = ref('') +const albums = reactive([] as Album[]) +const count = ref(0) +const nextPage = ref() + +const isLoading = ref(false) +const fetchData = async (url = 'albums/') => { + isLoading.value = true + + try { + const params = { + q: query.value, + ...props.filters, + page_size: props.limit + } + + const response = await axios.get(url, { params }) + nextPage.value = response.data.next + count.value = response.data.count + albums.push(...response.data.results) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const performSearch = () => { + albums.length = 0 + fetchData() +} + +watch( + () => store.state.moderation.lastUpdate, + () => fetchData(), + { immediate: true } +) +</script> + <template> <div class="wrapper"> <h3 @@ -14,7 +80,7 @@ <inline-search-bar v-if="search" v-model="query" - @search="albums = []; fetchData()" + @search="performSearch" /> <div class="ui hidden divider" /> <div class="ui app-cards cards"> @@ -53,70 +119,3 @@ </template> </div> </template> - -<script> -import axios from 'axios' -import AlbumCard from '@/components/audio/album/Card.vue' - -export default { - components: { - AlbumCard - }, - props: { - filters: { type: Object, required: true }, - controls: { type: Boolean, default: true }, - showCount: { type: Boolean, default: false }, - search: { type: Boolean, default: false }, - limit: { type: Number, default: 12 } - }, - data () { - return { - albums: [], - count: 0, - isLoading: false, - errors: null, - previousPage: null, - nextPage: null, - query: '' - } - }, - watch: { - offset () { - this.fetchData() - }, - '$store.state.moderation.lastUpdate': function () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData (url) { - url = url || 'albums/' - this.isLoading = true - const self = this - const params = { q: this.query, ...this.filters } - params.page_size = this.limit - params.offset = this.offset - axios.get(url, { params: params }).then((response) => { - self.previousPage = response.data.previous - self.nextPage = response.data.next - self.isLoading = false - self.albums = [...self.albums, ...response.data.results] - self.count = response.data.count - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - updateOffset (increment) { - if (increment) { - this.offset += this.limit - } else { - this.offset = Math.max(this.offset - this.limit, 0) - } - } - } -} -</script> diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index 9fd37b1e06782311f6e633bae158a1a3e929ec3a..6e4de95b43e8784c0b3bc966309291aa4e8e95cd 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -1,3 +1,30 @@ +<script setup lang="ts"> +import type { Artist } from '~/types' + +import PlayButton from '~/components/audio/PlayButton.vue' +import TagsList from '~/components/tags/List.vue' +import { computed } from 'vue' +import { useStore } from '~/store' +import { truncate } from '~/utils/filters' + +interface Props { + artist: Artist +} + +const props = defineProps<Props>() + +const cover = computed(() => !props.artist.cover?.urls.original + ? props.artist.albums.find(album => !!album.cover?.urls.original)?.cover + : props.artist.cover +) + +const store = useStore() +const imageUrl = computed(() => cover.value?.urls.original + ? store.getters['instance/absoluteUrl'](cover.value.urls.medium_square_crop) + : null +) +</script> + <template> <div class="app-card card"> <router-link @@ -22,7 +49,7 @@ class="discrete link" :to="{name: 'library.artists.detail', params: {id: artist.id}}" > - {{ artist.name|truncate(30) }} + {{ truncate(artist.name, 30) }} </router-link> </strong> @@ -63,41 +90,3 @@ </div> </div> </template> - -<script> -import PlayButton from '@/components/audio/PlayButton.vue' -import TagsList from '@/components/tags/List.vue' - -export default { - components: { - PlayButton, - TagsList - }, - props: { artist: { type: Object, required: true } }, - data () { - return { - initialAlbums: 30, - showAllAlbums: true - } - }, - computed: { - imageUrl () { - const cover = this.cover - if (cover && cover.urls.original) { - return this.$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop) - } - return null - }, - cover () { - if (this.artist.cover && this.artist.cover.urls.original) { - return this.artist.cover - } - return this.artist.albums.map((a) => { - return a.cover - }).filter((c) => { - return c && c.urls.original - })[0] - } - } -} -</script> diff --git a/front/src/components/audio/artist/Widget.vue b/front/src/components/audio/artist/Widget.vue index f12c960c04b905621d1360657e2e1431120930d4..d8c3c85f6ebb1572e7eddf64ccf823adc348e3c1 100644 --- a/front/src/components/audio/artist/Widget.vue +++ b/front/src/components/audio/artist/Widget.vue @@ -1,3 +1,69 @@ +<script setup lang="ts"> +import type { Artist } from '~/types' + +import { reactive, ref, watch } from 'vue' +import { useStore } from '~/store' + +import axios from 'axios' + +import ArtistCard from '~/components/audio/artist/Card.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + filters: Record<string, string | boolean> + search?: boolean + header?: boolean + limit?: number +} + +const props = withDefaults(defineProps<Props>(), { + search: false, + header: true, + limit: 12 +}) + +const store = useStore() + +const query = ref('') +const artists = reactive([] as Artist[]) +const count = ref(0) +const nextPage = ref() + +const isLoading = ref(false) +const fetchData = async (url = 'artists/') => { + isLoading.value = true + + try { + const params = { + q: query.value, + ...props.filters, + page_size: props.limit + } + + const response = await axios.get(url, { params }) + nextPage.value = response.data.next + count.value = response.data.count + artists.push(...response.data.results) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const performSearch = () => { + artists.length = 0 + fetchData() +} + +watch( + () => store.state.moderation.lastUpdate, + () => fetchData(), + { immediate: true } +) +</script> + <template> <div class="wrapper"> <h3 @@ -10,7 +76,7 @@ <inline-search-bar v-if="search" v-model="query" - @search="objects = []; fetchData()" + @search="performSearch" /> <div class="ui hidden divider" /> <div class="ui five app-cards cards"> @@ -21,13 +87,13 @@ <div class="ui loader" /> </div> <artist-card - v-for="artist in objects" + v-for="artist in artists" :key="artist.id" :artist="artist" /> </div> <slot - v-if="!isLoading && objects.length === 0" + v-if="!isLoading && artists.length === 0" name="empty-state" > <empty-state @@ -49,70 +115,3 @@ </template> </div> </template> - -<script> -import axios from 'axios' -import ArtistCard from '@/components/audio/artist/Card.vue' - -export default { - components: { - ArtistCard - }, - props: { - filters: { type: Object, required: true }, - controls: { type: Boolean, default: true }, - header: { type: Boolean, default: true }, - search: { type: Boolean, default: false } - }, - data () { - return { - objects: [], - limit: 12, - count: 0, - isLoading: false, - errors: null, - previousPage: null, - nextPage: null, - query: '' - } - }, - watch: { - offset () { - this.fetchData() - }, - '$store.state.moderation.lastUpdate': function () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData (url) { - url = url || 'artists/' - this.isLoading = true - const self = this - const params = { q: this.query, ...this.filters } - params.page_size = this.limit - params.offset = this.offset - axios.get(url, { params: params }).then((response) => { - self.previousPage = response.data.previous - self.nextPage = response.data.next - self.isLoading = false - self.objects = [...self.objects, ...response.data.results] - self.count = response.data.count - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - updateOffset (increment) { - if (increment) { - this.offset += this.limit - } else { - this.offset = Math.max(this.offset - this.limit, 0) - } - } - } -} -</script> diff --git a/front/src/components/audio/podcast/MobileRow.vue b/front/src/components/audio/podcast/MobileRow.vue index 40bf88389de7b0edadff13a85ddc5219b10f210e..129d764d7976a72c2ff0a9cad880fd968f5e25ac 100644 --- a/front/src/components/audio/podcast/MobileRow.vue +++ b/front/src/components/audio/podcast/MobileRow.vue @@ -1,3 +1,59 @@ +<script setup lang="ts"> +import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' +import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' +// import type { Track } from '~/types' + +import { ref, computed } from 'vue' +import { useGettext } from 'vue3-gettext' +import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' +import TrackModal from '~/components/audio/track/Modal.vue' +import usePlayOptions from '~/composables/audio/usePlayOptions' +import useQueue from '~/composables/audio/useQueue' +import usePlayer from '~/composables/audio/usePlayer' + +interface Props extends PlayOptionsProps { + track: Track + index: number + + showArt?: boolean + isArtist?: boolean + isAlbum?: boolean + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + isPlayable?: boolean + tracks?: Track[] + artist?: Artist | null + album?: Album | null + playlist?: Playlist | null + library?: Library | null + channel?: Channel | null + account?: Actor | null +} + +const props = withDefaults(defineProps<Props>(), { + showArt: true, + isArtist: false, + isAlbum: false, + + tracks: () => [], + artist: null, + album: null, + playlist: null, + library: null, + channel: null, + account: null +}) + +const showTrackModal = ref(false) + +const { currentTrack } = useQueue() +const { playing } = usePlayer() +const { activateTrack } = usePlayOptions(props) + +const { $pgettext } = useGettext() +const actionsButtonLabel = computed(() => $pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions')) +</script> + <template> <div :class="[ @@ -11,38 +67,20 @@ @click.prevent.exact="activateTrack(track, index)" > <img - v-if=" - track.album && track.album.cover && track.album.cover.urls.original - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.album.cover.urls.medium_square_crop - ) - " + v-if="track.album?.cover?.urls.original" + v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)" alt="" class="ui artist-track mini image" > <img - v-else-if=" - track.cover - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.cover.urls.medium_square_crop - ) - " + v-else-if="track.cover" + v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" alt="" class="ui artist-track mini image" > <img - v-else-if=" - track.artist.cover - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.artist.cover.urls.medium_square_crop - ) - " + v-else-if="track.artist?.cover" + v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop) " alt="" class="ui artist-track mini image" > @@ -63,13 +101,13 @@ :class="[ 'track-title', 'mobile', - { 'play-indicator': isPlaying && currentTrack && track.id === currentTrack.id }, + { 'play-indicator': playing && track.id === currentTrack?.id }, ]" > {{ track.title }} </p> <p - v-if="track.artist.content_category === 'podcast'" + v-if="track.artist?.content_category === 'podcast'" class="track-meta mobile" > <human-date @@ -86,7 +124,7 @@ v-else class="track-meta mobile" > - {{ track.artist.name }} <span>·</span> + {{ track.artist?.name }} <span>·</span> <human-duration v-if="track.uploads[0] && track.uploads[0].duration" :duration="track.uploads[0].duration" @@ -94,7 +132,7 @@ </p> </div> <div - v-if="$store.state.auth.authenticated && track.artist.content_category !== 'podcast'" + v-if="$store.state.auth.authenticated && track.artist?.content_category !== 'podcast'" :class="[ 'meta', 'right', @@ -127,76 +165,11 @@ <i class="ellipsis large vertical icon" /> </div> <track-modal - :show="showTrackModal" + v-model:show="showTrackModal" :track="track" :index="index" :is-artist="isArtist" :is-album="isAlbum" - @update:show="showTrackModal = $event;" /> </div> </template> - -<script> -import { mapActions, mapGetters } from 'vuex' -import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue' -import TrackModal from '@/components/audio/track/Modal.vue' -import PlayOptionsMixin from '@/components/mixins/PlayOptions.vue' - -export default { - - components: { - TrackFavoriteIcon, - TrackModal - }, - mixins: [PlayOptionsMixin], - props: { - tracks: { type: Array, required: true }, - showAlbum: { type: Boolean, required: false, default: true }, - showArtist: { type: Boolean, required: false, default: true }, - showPosition: { type: Boolean, required: false, default: false }, - showArt: { type: Boolean, required: false, default: true }, - search: { type: Boolean, required: false, default: false }, - filters: { type: Object, required: false, default: null }, - nextUrl: { type: String, required: false, default: null }, - displayActions: { type: Boolean, required: false, default: true }, - showDuration: { type: Boolean, required: false, default: true }, - index: { type: Number, required: true }, - track: { type: Object, required: true }, - isArtist: { type: Boolean, required: false, default: false }, - isAlbum: { type: Boolean, required: false, default: false } - }, - data () { - return { - showTrackModal: false - } - }, - computed: { - ...mapGetters({ - currentTrack: 'queue/currentTrack' - }), - - isPlaying () { - return this.$store.state.player.playing - }, - actionsButtonLabel () { - return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions') - } - }, - - methods: { - prettyPosition (position, size) { - let s = String(position) - while (s.length < (size || 2)) { - s = '0' + s - } - return s - }, - - ...mapActions({ - resumePlayback: 'player/resumePlayback', - pausePlayback: 'player/pausePlayback' - }) - } -} -</script> diff --git a/front/src/components/audio/podcast/Modal.vue b/front/src/components/audio/podcast/Modal.vue index 0a5e396fa49663b4efdb28d44f5e15209b5cacf9..421cc72bd964bd014b0058de4ef51525801798ac 100644 --- a/front/src/components/audio/podcast/Modal.vue +++ b/front/src/components/audio/podcast/Modal.vue @@ -1,17 +1,104 @@ +<script setup lang="ts"> +import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' +import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' +// import type { Track } from '~/types' + +import { useStore } from '~/store' +import { useGettext } from 'vue3-gettext' +import SemanticModal from '~/components/semantic/Modal.vue' +import { computed, ref } from 'vue' +import usePlayOptions from '~/composables/audio/usePlayOptions' +import useReport from '~/composables/moderation/useReport' +import { useVModel } from '@vueuse/core' + +interface Events { + (e: 'update:show', value: boolean): void +} + +interface Props extends PlayOptionsProps { + track: Track + index: number + show: boolean + + isArtist?: boolean + isAlbum?: boolean + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + isPlayable?: boolean + tracks?: Track[] + artist?: Artist | null + album?: Album | null + playlist?: Playlist | null + library?: Library | null + channel?: Channel | null + account?: Actor | null +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + isArtist: false, + isAlbum: false, + + tracks: () => [], + artist: null, + album: null, + playlist: null, + library: null, + channel: null, + account: null +}) + +const modal = ref() + +const show = useVModel(props, 'show', emit) + +const { report, getReportableObjects } = useReport() +const { enqueue, enqueueNext } = usePlayOptions(props) +const store = useStore() + +const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id)) + +const { $pgettext } = useGettext() +const favoriteButton = computed(() => isFavorite.value + ? $pgettext('Content/Track/Icon.Tooltip/Verb', 'Remove from favorites') + : $pgettext('Content/Track/*/Verb', 'Add to favorites') +) + +const trackDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' + ? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details') + : $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details') +) + +const albumDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' + ? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series') + : $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album') +) + +const artistDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' + ? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel') + : $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist') +) + +const labels = computed(() => ({ + startRadio: $pgettext('*/Queue/Dropdown/Button/Title', 'Play radio'), + playNow: $pgettext('*/Queue/Dropdown/Button/Title', 'Play now'), + addToQueue: $pgettext('*/Queue/Dropdown/Button/Title', 'Add to queue'), + playNext: $pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), + addToPlaylist: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…') +})) +</script> + <template> - <modal + <semantic-modal ref="modal" - :show="show" + v-model:show="show" :scrolling="true" :additional-classes="['scrolling-track-options']" - @update:show="$emit('update:show', $event)" > <div class="header"> <div class="ui large centered rounded image"> <img - v-if=" - track.album && track.album.cover && track.album.cover.urls.original - " + v-if="track.album && track.album.cover && track.album.cover.urls.original" v-lazy=" $store.getters['instance/absoluteUrl']( track.album.cover.urls.medium_square_crop @@ -31,7 +118,7 @@ class="ui centered image" > <img - v-else-if="track.artist.cover" + v-else-if="track.artist?.cover" v-lazy=" $store.getters['instance/absoluteUrl']( track.artist.cover.urls.medium_square_crop @@ -51,14 +138,14 @@ {{ track.title }} </h3> <h4 class="track-modal-subtitle"> - {{ track.artist.name }} + {{ track.artist?.name }} </h4> </div> <div class="ui hidden divider" /> <div class="content"> <div class="ui one column unstackable grid"> <div - v-if="$store.state.auth.authenticated && track.artist.content_category !== 'podcast'" + v-if="$store.state.auth.authenticated && track.artist?.content_category !== 'podcast'" class="row" > <div @@ -88,8 +175,8 @@ role="button" :aria-label="labels.addToQueue" @click.stop.prevent=" - add(); - $refs.modal.closeModal(); + enqueue(); + modal.closeModal(); " > <i class="plus icon track-modal list-icon" /> @@ -102,8 +189,8 @@ role="button" :aria-label="labels.playNext" @click.stop.prevent=" - addNext(true); - $refs.modal.closeModal(); + enqueueNext(true); + modal.closeModal(); " > <i class="step forward icon track-modal list-icon" /> @@ -120,7 +207,7 @@ type: 'similar', objectId: track.id, }); - $refs.modal.closeModal(); + modal.closeModal(); " > <i class="rss icon track-modal list-icon" /> @@ -152,7 +239,7 @@ @click.prevent.exact=" $router.push({ name: 'library.albums.detail', - params: { id: track.album.id }, + params: { id: track.album?.id }, }) " > @@ -173,7 +260,7 @@ @click.prevent.exact=" $router.push({ name: 'library.artists.detail', - params: { id: track.artist.id }, + params: { id: track.artist?.id }, }) " > @@ -203,16 +290,10 @@ </div> <div class="ui divider" /> <div - v-for="obj in getReportableObjs({ - track, - album, - artist, - })" + v-for="obj in getReportableObjects({ track, album: track.album, artist: track.artist })" :key="obj.target.type + obj.target.id" - :ref="`report${obj.target.type}${obj.target.id}`" class="row" - :data-ref="`report${obj.target.type}${obj.target.id}`" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <div class="column"> <i class="share icon track-modal list-icon" /><span @@ -222,87 +303,5 @@ </div> </div> </div> - </modal> + </semantic-modal> </template> - -<script> -import Modal from '@/components/semantic/Modal.vue' -import ReportMixin from '@/components/mixins/Report.vue' -import PlayOptionsMixin from '@/components/mixins/PlayOptions.vue' - -export default { - components: { - Modal - }, - mixins: [ReportMixin, PlayOptionsMixin], - props: { - show: { type: Boolean, required: true, default: false }, - track: { type: Object, required: true }, - index: { type: Number, required: true }, - isArtist: { type: Boolean, required: false, default: false }, - isAlbum: { type: Boolean, required: false, default: false } - }, - data () { - return { - isShowing: this.show, - tracks: [this.track], - album: this.track.album, - artist: this.track.artist - } - }, - computed: { - isFavorite () { - return this.$store.getters['favorites/isFavorite'](this.track.id) - }, - favoriteButton () { - if (this.isFavorite) { - return this.$pgettext( - 'Content/Track/Icon.Tooltip/Verb', - 'Remove from favorites' - ) - } else { - return this.$pgettext('Content/Track/*/Verb', 'Add to favorites') - } - }, - trackDetailsButton () { - if (this.track.artist.content_category === 'podcast') { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details') - } else { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details') - } - }, - albumDetailsButton () { - if (this.track.artist.content_category === 'podcast') { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series') - } else { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album') - } - }, - artistDetailsButton () { - if (this.track.artist.content_category === 'podcast') { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel') - } else { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist') - } - }, - labels () { - return { - startRadio: this.$pgettext( - '*/Queue/Dropdown/Button/Title', - 'Play radio' - ), - playNow: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play now'), - addToQueue: this.$pgettext( - '*/Queue/Dropdown/Button/Title', - 'Add to queue' - ), - playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), - addToPlaylist: this.$pgettext( - 'Sidebar/Player/Icon.Tooltip/Verb', - 'Add to playlist…' - ) - } - } - } -} -</script> diff --git a/front/src/components/audio/podcast/Row.vue b/front/src/components/audio/podcast/Row.vue index 1f9eb60c12e0eb5a907abf80ddfaf2b2524ec3e2..2fc5cffc3622ee2eb3c8ea04e22693e412399cab 100644 --- a/front/src/components/audio/podcast/Row.vue +++ b/front/src/components/audio/podcast/Row.vue @@ -1,11 +1,74 @@ +<script setup lang="ts"> +import type { Track, Artist, Album, Playlist, Library, Channel, Actor, /* Track, */ Cover } from '~/types' +import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' + +import { ref } from 'vue' + +import axios from 'axios' + +import PlayButton from '~/components/audio/PlayButton.vue' + +import usePlayOptions from '~/composables/audio/usePlayOptions' +import useErrorHandler from '~/composables/useErrorHandler' +import useQueue from '~/composables/audio/useQueue' + +interface Props extends PlayOptionsProps { + tracks: Track[] + track: Track + index: number + + showArt?: boolean + displayActions?: boolean + defaultCover?: Cover | null + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + isPlayable?: boolean + artist?: Artist | null + album?: Album | null + playlist?: Playlist | null + library?: Library | null + channel?: Channel | null + account?: Actor | null +} + +const props = withDefaults(defineProps<Props>(), { + showArt: true, + displayActions: true, + defaultCover: null, + + tracks: () => [], + artist: null, + album: null, + playlist: null, + library: null, + channel: null, + account: null +}) + +const description = ref('') + +const { currentTrack } = useQueue() +const { activateTrack } = usePlayOptions(props) + +const fetchData = async () => { + try { + const response = await axios.get(`tracks/${props.track.id}/`) + description.value = response.data.description?.text ?? '' + } catch (error) { + useErrorHandler(error as Error) + } +} + +// NOTE: Let the <Suspense> take care of showing the loader +await fetchData() +</script> + <template> <div :class="[ { active: currentTrack && track.id === currentTrack.id }, 'track-row podcast row', ]" - @mouseover="hover = track.id" - @mouseleave="hover = null" @dblclick="activateTrack(track, index)" > <div @@ -15,26 +78,14 @@ @click.prevent.exact="activateTrack(track, index)" > <img - v-if=" - track.cover && track.cover.urls.original - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.cover.urls.medium_square_crop - ) - " + v-if="track.cover?.urls.original " + v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" alt="" class="ui artist-track mini image" > <img - v-else-if=" - defaultCover - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - defaultCover.cover.urls.medium_square_crop - ) - " + v-else-if="defaultCover" + v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)" alt="" class="ui artist-track mini image" > @@ -57,7 +108,7 @@ v-if="description" class="podcast-episode-meta" > - {{ description.text }} + {{ description }} </p> </div> <div @@ -79,86 +130,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import { mapActions, mapGetters } from 'vuex' -import PlayButton from '@/components/audio/PlayButton.vue' -import PlayOptions from '@/components/mixins/PlayOptions.vue' - -export default { - - components: { - PlayButton - }, - mixins: [PlayOptions], - props: { - tracks: { type: Array, required: true }, - showAlbum: { type: Boolean, required: false, default: true }, - showArtist: { type: Boolean, required: false, default: true }, - showPosition: { type: Boolean, required: false, default: false }, - showArt: { type: Boolean, required: false, default: true }, - search: { type: Boolean, required: false, default: false }, - filters: { type: Object, required: false, default: null }, - nextUrl: { type: String, required: false, default: null }, - displayActions: { type: Boolean, required: false, default: true }, - showDuration: { type: Boolean, required: false, default: true }, - index: { type: Number, required: true }, - track: { type: Object, required: true }, - defaultCover: { type: Object, required: false, default: null } - }, - - data () { - return { - hover: null, - errors: null, - description: null - } - }, - - computed: { - ...mapGetters({ - currentTrack: 'queue/currentTrack' - }), - - isPlaying () { - return this.$store.state.player.playing - } - }, - - created () { - this.fetchData('tracks/' + this.track.id + '/') - }, - - methods: { - async fetchData (url) { - if (!url) { - return - } - this.isLoading = true - const self = this - try { - const channelsPromise = await axios.get(url) - self.description = channelsPromise.data.description - self.isLoading = false - } catch (e) { - self.isLoading = false - self.errors = e.backendErrors - } - }, - - prettyPosition (position, size) { - let s = String(position) - while (s.length < (size || 2)) { - s = '0' + s - } - return s - }, - - ...mapActions({ - resumePlayback: 'player/resumePlayback', - pausePlayback: 'player/pausePlayback' - }) - } -} -</script> diff --git a/front/src/components/audio/podcast/Table.vue b/front/src/components/audio/podcast/Table.vue index df737da8ae85f41f9e539c35c47a326031ea4474..9153d39a450db7dc502e4b4ab8ecb999b48375af 100644 --- a/front/src/components/audio/podcast/Table.vue +++ b/front/src/components/audio/podcast/Table.vue @@ -1,3 +1,40 @@ +<script setup lang="ts"> +import type { Track } from '~/types' + +import PodcastRow from '~/components/audio/podcast/Row.vue' +import TrackMobileRow from '~/components/audio/track/MobileRow.vue' +import Pagination from '~/components/vui/Pagination.vue' + +interface Props { + tracks: Track[] + showPosition?: boolean + showArt?: boolean + showDuration?: boolean + displayActions?: boolean + isArtist?: boolean + isAlbum?: boolean + isPodcast?: boolean + paginateResults?: boolean + paginateBy?: number + page?: number + total?: number +} + +withDefaults(defineProps<Props>(), { + showPosition: false, + showArt: true, + showDuration: true, + displayActions: true, + isArtist: false, + isAlbum: false, + isPodcast: true, + paginateResults: true, + paginateBy: 25, + page: 1, + total: 0 +}) +</script> + <template> <div> <div class="ui hidden divider" /> @@ -27,24 +64,15 @@ class="ui center aligned basic segment desktop-and-up" > <pagination + v-bind="$attrs" :total="total" :current="page" :paginate-by="paginateBy" - v-on="$listeners" /> </div> </div> - <div - :class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']" - > - <div - v-if="isLoading" - class="ui inverted active dimmer" - > - <div class="ui loader" /> - </div> - + <div :class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']"> <!-- For each item, build a row --> <track-mobile-row @@ -66,63 +94,12 @@ > <pagination v-if="paginateResults" + v-bind="$attrs" :total="total" :current="page" :compact="true" - v-on="$listeners" /> </div> </div> </div> </template> - -<script> -import PodcastRow from '@/components/audio/podcast/Row.vue' -import TrackMobileRow from '@/components/audio/track/MobileRow.vue' -import Pagination from '@/components/Pagination.vue' - -export default { - components: { - TrackMobileRow, - Pagination, - PodcastRow - }, - - props: { - tracks: { type: Array, required: true }, - showAlbum: { type: Boolean, required: false, default: true }, - showArtist: { type: Boolean, required: false, default: true }, - showPosition: { type: Boolean, required: false, default: false }, - showArt: { type: Boolean, required: false, default: true }, - search: { type: Boolean, required: false, default: false }, - filters: { type: Object, required: false, default: null }, - nextUrl: { type: String, required: false, default: null }, - displayActions: { type: Boolean, required: false, default: true }, - showDuration: { type: Boolean, required: false, default: true }, - isArtist: { type: Boolean, required: false, default: false }, - isAlbum: { type: Boolean, required: false, default: false }, - paginateResults: { type: Boolean, required: false, default: true }, - total: { type: Number, required: false, default: 0 }, - page: { type: Number, required: false, default: 1 }, - paginateBy: { type: Number, required: false, default: 25 }, - isPodcast: { type: Boolean, required: true }, - defaultCover: { type: Object, required: false, default: () => { return {} } } - }, - - data () { - return { - isLoading: false - } - }, - - computed: { - labels () { - return { - title: this.$pgettext('*/*/*/Noun', 'Title'), - album: this.$pgettext('*/*/*/Noun', 'Album'), - artist: this.$pgettext('*/*/*/Noun', 'Artist') - } - } - } -} -</script> diff --git a/front/src/components/audio/track/MobileRow.vue b/front/src/components/audio/track/MobileRow.vue index bf1f552afe590692949b46b6f1bd7ee6c1e211b7..c60aeeccefc836c2886388512073e96c14628a07 100644 --- a/front/src/components/audio/track/MobileRow.vue +++ b/front/src/components/audio/track/MobileRow.vue @@ -1,3 +1,59 @@ +<script setup lang="ts"> +import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' +import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' +// import type { Track } from '~/types' + +import { ref, computed } from 'vue' +import { useGettext } from 'vue3-gettext' +import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' +import TrackModal from '~/components/audio/track/Modal.vue' +import usePlayOptions from '~/composables/audio/usePlayOptions' +import useQueue from '~/composables/audio/useQueue' +import usePlayer from '~/composables/audio/usePlayer' + +interface Props extends PlayOptionsProps { + track: Track + index: number + + showArt?: boolean + isArtist?: boolean + isAlbum?: boolean + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + isPlayable?: boolean + tracks?: Track[] + artist?: Artist | null + album?: Album | null + playlist?: Playlist | null + library?: Library | null + channel?: Channel | null + account?: Actor | null +} + +const props = withDefaults(defineProps<Props>(), { + showArt: true, + isArtist: false, + isAlbum: false, + + tracks: () => [], + artist: null, + album: null, + playlist: null, + library: null, + channel: null, + account: null +}) + +const showTrackModal = ref(false) + +const { currentTrack } = useQueue() +const { playing } = usePlayer() +const { activateTrack } = usePlayOptions(props) + +const { $pgettext } = useGettext() +const actionsButtonLabel = computed(() => $pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions')) +</script> + <template> <div :class="[ @@ -11,38 +67,20 @@ @click.prevent.exact="activateTrack(track, index)" > <img - v-if=" - track.album && track.album.cover && track.album.cover.urls.original - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.album.cover.urls.medium_square_crop - ) - " + v-if="track.album?.cover?.urls.original" + v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)" alt="" class="ui artist-track mini image" > <img - v-else-if=" - track.cover - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.cover.urls.medium_square_crop - ) - " + v-else-if="track.cover" + v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" alt="" class="ui artist-track mini image" > <img - v-else-if=" - track.artist.cover - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.artist.cover.urls.medium_square_crop - ) - " + v-else-if="track.artist?.cover" + v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)" alt="" class="ui artist-track mini image" > @@ -63,13 +101,13 @@ :class="[ 'track-title', 'mobile', - { 'play-indicator': isPlaying && currentTrack && track.id === currentTrack.id }, + { 'play-indicator': playing && track.id === currentTrack?.id }, ]" > {{ track.title }} </p> <p class="track-meta mobile"> - {{ track.artist.name }} <span>·</span> + {{ track.artist?.name }} <span>·</span> <human-duration v-if="track.uploads[0] && track.uploads[0].duration" :duration="track.uploads[0].duration" @@ -110,76 +148,11 @@ <i class="ellipsis large vertical icon" /> </div> <track-modal - :show="showTrackModal" + v-model:show="showTrackModal" :track="track" :index="index" :is-artist="isArtist" :is-album="isAlbum" - @update:show="showTrackModal = $event;" /> </div> </template> - -<script> -import { mapActions, mapGetters } from 'vuex' -import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue' -import TrackModal from '@/components/audio/track/Modal.vue' -import PlayOptionsMixin from '@/components/mixins/PlayOptions.vue' - -export default { - - components: { - TrackFavoriteIcon, - TrackModal - }, - mixins: [PlayOptionsMixin], - props: { - tracks: { type: Array, required: true }, - showAlbum: { type: Boolean, required: false, default: true }, - showArtist: { type: Boolean, required: false, default: true }, - showPosition: { type: Boolean, required: false, default: false }, - showArt: { type: Boolean, required: false, default: true }, - search: { type: Boolean, required: false, default: false }, - filters: { type: Object, required: false, default: null }, - nextUrl: { type: String, required: false, default: null }, - displayActions: { type: Boolean, required: false, default: true }, - showDuration: { type: Boolean, required: false, default: true }, - index: { type: Number, required: true }, - track: { type: Object, required: true }, - isArtist: { type: Boolean, required: false, default: false }, - isAlbum: { type: Boolean, required: false, default: false } - }, - data () { - return { - showTrackModal: false - } - }, - computed: { - ...mapGetters({ - currentTrack: 'queue/currentTrack' - }), - - isPlaying () { - return this.$store.state.player.playing - }, - actionsButtonLabel () { - return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions') - } - }, - - methods: { - prettyPosition (position, size) { - let s = String(position) - while (s.length < (size || 2)) { - s = '0' + s - } - return s - }, - - ...mapActions({ - resumePlayback: 'player/resumePlayback', - pausePlayback: 'player/pausePlayback' - }) - } -} -</script> diff --git a/front/src/components/audio/track/Modal.vue b/front/src/components/audio/track/Modal.vue index 0a5e396fa49663b4efdb28d44f5e15209b5cacf9..3d693f48c248e529141f2b6c7aa05266c826ee0a 100644 --- a/front/src/components/audio/track/Modal.vue +++ b/front/src/components/audio/track/Modal.vue @@ -1,42 +1,117 @@ +<script setup lang="ts"> +import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' +import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' +// import type { Track } from '~/types' + +import { useStore } from '~/store' +import { useGettext } from 'vue3-gettext' +import SemanticModal from '~/components/semantic/Modal.vue' +import { computed, ref } from 'vue' +import usePlayOptions from '~/composables/audio/usePlayOptions' +import useReport from '~/composables/moderation/useReport' +import { useVModel } from '@vueuse/core' + +interface Events { + (e: 'update:show', value: boolean): void +} + +interface Props extends PlayOptionsProps { + track: Track + index: number + show: boolean + + isArtist?: boolean + isAlbum?: boolean + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + isPlayable?: boolean + tracks?: Track[] + artist?: Artist | null + album?: Album | null + playlist?: Playlist | null + library?: Library | null + channel?: Channel | null + account?: Actor | null +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + isArtist: false, + isAlbum: false, + + tracks: () => [], + artist: null, + album: null, + playlist: null, + library: null, + channel: null, + account: null +}) + +const modal = ref() + +const show = useVModel(props, 'show', emit) + +const { report, getReportableObjects } = useReport() +const { enqueue, enqueueNext } = usePlayOptions(props) +const store = useStore() + +const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id)) + +const { $pgettext } = useGettext() +const favoriteButton = computed(() => isFavorite.value + ? $pgettext('Content/Track/Icon.Tooltip/Verb', 'Remove from favorites') + : $pgettext('Content/Track/*/Verb', 'Add to favorites') +) + +const trackDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' + ? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details') + : $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details') +) + +const albumDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' + ? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series') + : $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album') +) + +const artistDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' + ? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel') + : $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist') +) + +const labels = computed(() => ({ + startRadio: $pgettext('*/Queue/Dropdown/Button/Title', 'Play radio'), + playNow: $pgettext('*/Queue/Dropdown/Button/Title', 'Play now'), + addToQueue: $pgettext('*/Queue/Dropdown/Button/Title', 'Add to queue'), + playNext: $pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), + addToPlaylist: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…') +})) +</script> + <template> - <modal + <semantic-modal ref="modal" - :show="show" + v-model:show="show" :scrolling="true" :additional-classes="['scrolling-track-options']" - @update:show="$emit('update:show', $event)" > <div class="header"> <div class="ui large centered rounded image"> <img - v-if=" - track.album && track.album.cover && track.album.cover.urls.original - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.album.cover.urls.medium_square_crop - ) - " + v-if="track.album?.cover?.urls.original" + v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)" alt="" class="ui centered image" > <img v-else-if="track.cover" - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.cover.urls.medium_square_crop - ) - " + v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" alt="" class="ui centered image" > <img - v-else-if="track.artist.cover" - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.artist.cover.urls.medium_square_crop - ) - " + v-else-if="track.artist?.cover" + v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)" alt="" class="ui centered image" > @@ -51,14 +126,14 @@ {{ track.title }} </h3> <h4 class="track-modal-subtitle"> - {{ track.artist.name }} + {{ track.artist?.name }} </h4> </div> <div class="ui hidden divider" /> <div class="content"> <div class="ui one column unstackable grid"> <div - v-if="$store.state.auth.authenticated && track.artist.content_category !== 'podcast'" + v-if="$store.state.auth.authenticated && track.artist?.content_category !== 'podcast'" class="row" > <div @@ -68,17 +143,7 @@ :aria-label="favoriteButton" @click.stop="$store.dispatch('favorites/toggle', track.id)" > - <i - :class="[ - 'heart', - 'favorite-icon', - { favorited: isFavorite }, - { pink: isFavorite }, - 'icon', - 'track-modal', - 'list-icon', - ]" - /> + <i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" /> <span class="track-modal list-item">{{ favoriteButton }}</span> </div> </div> @@ -87,10 +152,7 @@ class="column" role="button" :aria-label="labels.addToQueue" - @click.stop.prevent=" - add(); - $refs.modal.closeModal(); - " + @click.stop.prevent="enqueue(); modal.closeModal()" > <i class="plus icon track-modal list-icon" /> <span class="track-modal list-item">{{ labels.addToQueue }}</span> @@ -101,10 +163,7 @@ class="column" role="button" :aria-label="labels.playNext" - @click.stop.prevent=" - addNext(true); - $refs.modal.closeModal(); - " + @click.stop.prevent="enqueueNext(true);modal.closeModal()" > <i class="step forward icon track-modal list-icon" /> <span class="track-modal list-item">{{ labels.playNext }}</span> @@ -115,13 +174,7 @@ class="column" role="button" :aria-label="labels.startRadio" - @click.stop.prevent=" - $store.dispatch('radios/start', { - type: 'similar', - objectId: track.id, - }); - $refs.modal.closeModal(); - " + @click.stop.prevent="() => { $store.dispatch('radios/start', { type: 'similar', objectId: track.id }); modal.closeModal() }" > <i class="rss icon track-modal list-icon" /> <span class="track-modal list-item">{{ labels.startRadio }}</span> @@ -135,9 +188,9 @@ @click.stop="$store.commit('playlists/chooseTrack', track)" > <i class="list icon track-modal list-icon" /> - <span class="track-modal list-item">{{ - labels.addToPlaylist - }}</span> + <span class="track-modal list-item"> + {{ labels.addToPlaylist }} + </span> </div> </div> <div class="ui divider" /> @@ -149,17 +202,10 @@ class="column" role="button" :aria-label="albumDetailsButton" - @click.prevent.exact=" - $router.push({ - name: 'library.albums.detail', - params: { id: track.album.id }, - }) - " + @click.prevent.exact="$router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })" > <i class="compact disc icon track-modal list-icon" /> - <span class="track-modal list-item">{{ - albumDetailsButton - }}</span> + <span class="track-modal list-item">{{ albumDetailsButton }}</span> </div> </div> <div @@ -170,17 +216,10 @@ class="column" role="button" :aria-label="artistDetailsButton" - @click.prevent.exact=" - $router.push({ - name: 'library.artists.detail', - params: { id: track.artist.id }, - }) - " + @click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: track.artist?.id } })" > <i class="user icon track-modal list-icon" /> - <span class="track-modal list-item">{{ - artistDetailsButton - }}</span> + <span class="track-modal list-item">{{ artistDetailsButton }}</span> </div> </div> <div class="row"> @@ -188,121 +227,25 @@ class="column" role="button" :aria-label="trackDetailsButton" - @click.prevent.exact=" - $router.push({ - name: 'library.tracks.detail', - params: { id: track.id }, - }) - " + @click.prevent.exact="$router.push({ name: 'library.tracks.detail', params: { id: track.id } })" > <i class="info icon track-modal list-icon" /> - <span class="track-modal list-item">{{ - trackDetailsButton - }}</span> + <span class="track-modal list-item">{{ trackDetailsButton }}</span> </div> </div> <div class="ui divider" /> <div - v-for="obj in getReportableObjs({ - track, - album, - artist, - })" + v-for="obj in getReportableObjects({ track, album: track.album, artist: track.artist })" :key="obj.target.type + obj.target.id" - :ref="`report${obj.target.type}${obj.target.id}`" class="row" - :data-ref="`report${obj.target.type}${obj.target.id}`" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <div class="column"> - <i class="share icon track-modal list-icon" /><span - class="track-modal list-item" - >{{ obj.label }}</span> + <i class="share icon track-modal list-icon" /> + <span class="track-modal list-item">{{ obj.label }}</span> </div> </div> </div> </div> - </modal> + </semantic-modal> </template> - -<script> -import Modal from '@/components/semantic/Modal.vue' -import ReportMixin from '@/components/mixins/Report.vue' -import PlayOptionsMixin from '@/components/mixins/PlayOptions.vue' - -export default { - components: { - Modal - }, - mixins: [ReportMixin, PlayOptionsMixin], - props: { - show: { type: Boolean, required: true, default: false }, - track: { type: Object, required: true }, - index: { type: Number, required: true }, - isArtist: { type: Boolean, required: false, default: false }, - isAlbum: { type: Boolean, required: false, default: false } - }, - data () { - return { - isShowing: this.show, - tracks: [this.track], - album: this.track.album, - artist: this.track.artist - } - }, - computed: { - isFavorite () { - return this.$store.getters['favorites/isFavorite'](this.track.id) - }, - favoriteButton () { - if (this.isFavorite) { - return this.$pgettext( - 'Content/Track/Icon.Tooltip/Verb', - 'Remove from favorites' - ) - } else { - return this.$pgettext('Content/Track/*/Verb', 'Add to favorites') - } - }, - trackDetailsButton () { - if (this.track.artist.content_category === 'podcast') { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details') - } else { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details') - } - }, - albumDetailsButton () { - if (this.track.artist.content_category === 'podcast') { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series') - } else { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album') - } - }, - artistDetailsButton () { - if (this.track.artist.content_category === 'podcast') { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel') - } else { - return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist') - } - }, - labels () { - return { - startRadio: this.$pgettext( - '*/Queue/Dropdown/Button/Title', - 'Play radio' - ), - playNow: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play now'), - addToQueue: this.$pgettext( - '*/Queue/Dropdown/Button/Title', - 'Add to queue' - ), - playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), - addToPlaylist: this.$pgettext( - 'Sidebar/Player/Icon.Tooltip/Verb', - 'Add to playlist…' - ) - } - } - } -} -</script> diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index 64f9d7f2f91c5fa02853555a2bea27a3b3f0a8d2..cbd6f6c6dfb2fb71c2a2ba575886569927622e17 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -1,11 +1,66 @@ +<script setup lang="ts"> +import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' +import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' +// import type { Track } from '~/types' + +import PlayIndicator from '~/components/audio/track/PlayIndicator.vue' +import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' +import PlayButton from '~/components/audio/PlayButton.vue' +import usePlayOptions from '~/composables/audio/usePlayOptions' +import useQueue from '~/composables/audio/useQueue' +import usePlayer from '~/composables/audio/usePlayer' +import { computed } from 'vue' + +interface Props extends PlayOptionsProps { + track: Track + index: number + + showAlbum?: boolean + showArt?: boolean + showArtist?: boolean + showDuration?: boolean + showPosition?: boolean + displayActions?: boolean + + hover: boolean + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + tracks: Track[] + isPlayable?: boolean + artist?: Artist | null + album?: Album | null + playlist?: Playlist | null + library?: Library | null + channel?: Channel | null + account?: Actor | null +} + +const props = withDefaults(defineProps<Props>(), { + showAlbum: true, + showArt: true, + showArtist: true, + showDuration: true, + showPosition: false, + displayActions: true, + + artist: null, + album: null, + playlist: null, + library: null, + channel: null, + account: null +}) + +const { playing, loading } = usePlayer() +const { currentTrack } = useQueue() +const { activateTrack } = usePlayOptions(props) + +const active = computed(() => props.track.id === currentTrack.value?.id && props.track.position === currentTrack.value?.position) +</script> + <template> <div - :class="[ - { active: currentTrack && track.id === currentTrack.id }, - 'track-row row', - ]" - @mouseover="hover = track.id" - @mouseleave="hover = null" + :class="[{ active }, 'track-row row']" @dblclick="activateTrack(track, index)" > <div @@ -15,37 +70,34 @@ > <play-indicator v-if=" - !$store.state.player.isLoadingAudio && - currentTrack && - isPlaying && - track.id === currentTrack.id && - !(track.id == hover) + !loading && + playing && + active && + !hover " /> <button v-else-if=" - currentTrack && - !isPlaying && - track.id === currentTrack.id && - !track.id == hover + !playing && + active && + !hover " class="ui really tiny basic icon button play-button paused" > - <i class="pause icon" /> + <i class="play icon" /> </button> <button v-else-if=" - currentTrack && - isPlaying && - track.id === currentTrack.id && - track.id == hover + playing && + active && + hover " class="ui really tiny basic icon button play-button" > <i class="pause icon" /> </button> <button - v-else-if="track.id == hover" + v-else-if="hover" class="ui really tiny basic icon button play-button" > <i class="play icon" /> @@ -54,7 +106,7 @@ v-else-if="showPosition" class="track-position" > - {{ prettyPosition(track.position) }} + {{ `${track.position}`.padStart(2, '0') }} </span> </div> <div @@ -64,38 +116,20 @@ @click.prevent.exact="activateTrack(track, index)" > <img - v-if=" - track.album && track.album.cover && track.album.cover.urls.original - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.album.cover.urls.medium_square_crop - ) - " + v-if="track.album?.cover?.urls.original" + v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)" alt="" class="ui artist-track mini image" > <img - v-else-if=" - track.cover && track.cover.urls.original - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.cover.urls.medium_square_crop - ) - " + v-else-if="track.cover?.urls.original" + v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" alt="" class="ui artist-track mini image" > <img - v-else-if=" - track.artist && track.artist.cover && track.album.cover.urls.original - " - v-lazy=" - $store.getters['instance/absoluteUrl']( - track.cover.urls.medium_square_crop - ) - " + v-else-if="track.artist?.cover?.urls.original" + v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop) " alt="" class="ui artist-track mini image" > @@ -121,9 +155,9 @@ class="content ellipsis left floated column" > <router-link - :to="{ name: 'library.albums.detail', params: { id: track.album.id } }" + :to="{ name: 'library.albums.detail', params: { id: track.album?.id } }" > - {{ track.album.title }} + {{ track.album?.title }} </router-link> </div> <div @@ -134,10 +168,10 @@ class="artist link" :to="{ name: 'library.artists.detail', - params: { id: track.artist.id }, + params: { id: track.artist?.id }, }" > - {{ track.artist.name }} + {{ track.artist?.name }} </router-link> </div> <div @@ -178,67 +212,3 @@ </div> </div> </template> - -<script> -import PlayIndicator from '@/components/audio/track/PlayIndicator.vue' -import { mapActions, mapGetters } from 'vuex' -import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue' -import PlayButton from '@/components/audio/PlayButton.vue' -import PlayOptions from '@/components/mixins/PlayOptions.vue' - -export default { - - components: { - PlayIndicator, - TrackFavoriteIcon, - PlayButton - }, - mixins: [PlayOptions], - props: { - tracks: { type: Array, required: true }, - showAlbum: { type: Boolean, required: false, default: true }, - showArtist: { type: Boolean, required: false, default: true }, - showPosition: { type: Boolean, required: false, default: false }, - showArt: { type: Boolean, required: false, default: true }, - search: { type: Boolean, required: false, default: false }, - filters: { type: Object, required: false, default: null }, - nextUrl: { type: String, required: false, default: null }, - displayActions: { type: Boolean, required: false, default: true }, - showDuration: { type: Boolean, required: false, default: true }, - index: { type: Number, required: true }, - track: { type: Object, required: true } - }, - - data () { - return { - hover: null - } - }, - - computed: { - ...mapGetters({ - currentTrack: 'queue/currentTrack' - }), - - isPlaying () { - return this.$store.state.player.playing - } - }, - - methods: { - - prettyPosition (position, size) { - let s = String(position) - while (s.length < (size || 2)) { - s = '0' + s - } - return s - }, - - ...mapActions({ - resumePlayback: 'player/resumePlayback', - pausePlayback: 'player/pausePlayback' - }) - } -} -</script> diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index c4ebc3520994aeb753c4ce09bb6cedb9c34ee5a9..7835945b3881dad88c2abb577cf4ae04937a9f0a 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -1,14 +1,159 @@ +<script setup lang="ts"> +import type { Track } from '~/types' + +import { useElementByPoint, useMouse } from '@vueuse/core' +import { useGettext } from 'vue3-gettext' +import { clone, uniqBy } from 'lodash-es' +import { ref, computed } from 'vue' + +import axios from 'axios' + +import TrackMobileRow from '~/components/audio/track/MobileRow.vue' +import Pagination from '~/components/vui/Pagination.vue' +import TrackRow from '~/components/audio/track/Row.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'fetched'): void + (e: 'page-changed', page: number): void +} + +interface Props { + tracks?: Track[] + + showAlbum?: boolean + showArtist?: boolean + showPosition?: boolean + showArt?: boolean + showDuration?: boolean + search?: boolean + displayActions?: boolean + isArtist?: boolean + isAlbum?: boolean + isPodcast?: boolean + + filters?: object + + nextUrl?: string | null + + paginateResults?: boolean + total?: number + page?: number + paginateBy?: number, + + unique?: boolean +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + tracks: () => [], + + showAlbum: true, + showArtist: true, + showPosition: false, + showArt: true, + showDuration: true, + search: false, + displayActions: true, + isArtist: false, + isAlbum: false, + isPodcast: false, + + filters: () => ({}), + nextUrl: null, + + paginateResults: true, + total: 0, + page: 1, + paginateBy: 25, + + unique: true +}) + +const { x, y } = useMouse({ type: 'client' }) +const { element } = useElementByPoint({ x, y }) +const hover = computed(() => { + const row = element.value?.closest('.track-row') ?? null + return row && allTracks.value.find(track => { + return `${track.id}` === row.getAttribute('data-track-id') && `${track.position}` === row.getAttribute('data-track-position') + }) +}) + +const currentPage = ref(props.page) +const totalTracks = ref(props.total) +const fetchDataUrl = ref(props.nextUrl) +const additionalTracks = ref([] as Track[]) +const query = ref('') + +const allTracks = computed(() => { + const tracks = [...props.tracks, ...additionalTracks.value] + return props.unique + ? uniqBy(tracks, 'id') + : tracks +}) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('*/*/*/Noun', 'Title'), + album: $pgettext('*/*/*/Noun', 'Album'), + artist: $pgettext('*/*/*/Noun', 'Artist') +})) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + const params = { + ...clone(props.filters), + page_size: props.paginateBy, + page: currentPage.value, + include_channels: true, + q: query.value + } + + try { + const response = await axios.get('tracks/', { params }) + + // TODO (wvffle): Fetch continously? + fetchDataUrl.value = response.data.next + additionalTracks.value = response.data.results + totalTracks.value = response.data.count + emit('fetched') + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const performSearch = () => { + currentPage.value = 1 + additionalTracks.value = [] + fetchData() +} + +if (props.tracks.length === 0) { + fetchData() +} + +const updatePage = (page: number) => { + if (props.tracks.length === 0) { + currentPage.value = page + fetchData() + } else { + emit('page-changed', page) + } +} +</script> + <template> <div> <!-- Show the search bar if search is true --> <inline-search-bar v-if="search" v-model="query" - @search=" - currentPage = 1; - additionalTracks = []; - fetchData('tracks/'); - " + @search="performSearch" /> <!-- Add a header if needed --> @@ -23,7 +168,7 @@ > <empty-state :refresh="true" - @refresh="fetchData('tracks/')" + @refresh="fetchData()" /> </slot> <div v-else> @@ -89,7 +234,9 @@ <track-row v-for="(track, index) in allTracks" - :key="track.id" + :key="track.id + (track.position ?? 0)" + :data-track-id="track.id" + :data-track-position="track.position" :track="track" :index="index" :tracks="allTracks" @@ -100,6 +247,7 @@ :display-actions="displayActions" :show-duration="showDuration" :is-podcast="isPodcast" + :hover="hover === track" /> </div> <div @@ -110,7 +258,7 @@ :total="totalTracks" :current=" tracks.length > 0 ? page : currentPage" :paginate-by="paginateBy" - @page-changed="updatePage" + @update:current="updatePage" /> </div> </div> @@ -148,111 +296,11 @@ v-if="paginateResults && totalTracks > paginateBy" :paginate-by="paginateBy" :total="totalTracks" - :current="tracks.length > 0 ? page : {currentPage}" + :current="tracks.length > 0 ? page : currentPage" :compact="true" - @page-changed="updatePage" + @update:current="updatePage" /> </div> </div> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import TrackRow from '@/components/audio/track/Row.vue' -import TrackMobileRow from '@/components/audio/track/MobileRow.vue' -import Pagination from '@/components/Pagination.vue' -import { unique } from '@/filters' - -export default { - components: { - TrackRow, - TrackMobileRow, - Pagination - }, - - props: { - tracks: { type: Array, default: () => { return [] } }, - showAlbum: { type: Boolean, required: false, default: true }, - showArtist: { type: Boolean, required: false, default: true }, - showPosition: { type: Boolean, required: false, default: false }, - showArt: { type: Boolean, required: false, default: true }, - search: { type: Boolean, required: false, default: false }, - filters: { type: Object, required: false, default: () => { return {} } }, - nextUrl: { type: String, required: false, default: null }, - displayActions: { type: Boolean, required: false, default: true }, - showDuration: { type: Boolean, required: false, default: true }, - isArtist: { type: Boolean, required: false, default: false }, - isAlbum: { type: Boolean, required: false, default: false }, - isPodcast: { type: Boolean, required: false, default: false }, - paginateResults: { type: Boolean, required: false, default: true }, - total: { type: Number, required: false, default: 0 }, - page: { type: Number, required: false, default: 1 }, - paginateBy: { type: Number, required: false, default: 25 } - }, - - data () { - return { - fetchDataUrl: this.nextUrl, - isLoading: false, - additionalTracks: [], - query: '', - totalTracks: this.total, - currentPage: this.page - } - }, - computed: { - allTracks () { - const tracks = (this.tracks || []).concat(this.additionalTracks) - return unique(tracks, 'id') - }, - - labels () { - return { - title: this.$pgettext('*/*/*/Noun', 'Title'), - album: this.$pgettext('*/*/*/Noun', 'Album'), - artist: this.$pgettext('*/*/*/Noun', 'Artist') - } - } - }, - created () { - if (this.tracks.length === 0) { - this.fetchData('tracks/') - } - }, - methods: { - async fetchData (url) { - if (!url) { - return - } - this.isLoading = true - const self = this - const params = _.clone(this.filters) - params.page_size = this.paginateBy - params.page = this.currentPage - params.include_channels = true - params.q = this.query - const tracksPromise = await axios.get(url, { params: params }) - try { - self.fetchDataUrl = tracksPromise.data.next - self.additionalTracks = tracksPromise.data.results - self.totalTracks = tracksPromise.data.count - self.$emit('fetched', tracksPromise.data) - self.isLoading = false - } catch (e) { - self.isLoading = false - self.errors = e.backendErrors - } - }, - updatePage: function (page) { - if (this.tracks.length === 0) { - this.currentPage = page - this.fetchData('tracks/') - } else { - this.$emit('page-changed', page) - } - } - } -} -</script> diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue index 4cc7f0920fd49e01c1a3246ca375ab6a9314bdc0..88cdc97e5ea6c9ee08222c37c1108ec2dd164282 100644 --- a/front/src/components/audio/track/Widget.vue +++ b/front/src/components/audio/track/Widget.vue @@ -1,3 +1,92 @@ +<script setup lang="ts"> +import type { Track, Listening } from '~/types' + +import { ref, reactive, watch } from 'vue' +import { useStore } from '~/store' +import { clone } from 'lodash-es' + +import axios from 'axios' + +import useWebSocketHandler from '~/composables/useWebSocketHandler' +import PlayButton from '~/components/audio/PlayButton.vue' +import TagsList from '~/components/tags/List.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'count', count: number): void +} + +interface Props { + filters: Record<string, string | boolean> + url: string + isActivity?: boolean + showCount?: boolean + limit?: number + itemClasses?: string + websocketHandlers?: string[] +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + isActivity: true, + showCount: false, + limit: 5, + itemClasses: '', + websocketHandlers: () => [] +}) + +const store = useStore() + +const objects = reactive([] as Listening[]) +const count = ref(0) +const nextPage = ref<string | null>(null) + +const isLoading = ref(false) +const fetchData = async (url = props.url) => { + isLoading.value = true + + const params = { + ...clone(props.filters), + page_size: props.limit + } + + try { + const response = await axios.get(url, { params }) + nextPage.value = response.data.next + count.value = response.data.count + + const newObjects = !props.isActivity + ? response.data.results.map((track: Track) => ({ track })) + : response.data.results + + objects.push(...newObjects) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +watch( + () => store.state.moderation.lastUpdate, + () => fetchData(), + { immediate: true } +) + +watch(count, (to) => emit('count', to)) + +watch(() => props.websocketHandlers.includes('Listen'), (to) => { + useWebSocketHandler('Listen', (event) => { + // TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534) + // count.value += 1 + + // objects.unshift(event as Listening) + // objects.pop() + }) +}) +</script> + <template> <div class="component-track-widget"> <h3 v-if="!!$slots.title"> @@ -28,7 +117,7 @@ alt="" > <img - v-else-if="object.track.artist.cover" + v-else-if="object.track.artist?.cover" v-lazy="$store.getters['instance/absoluteUrl'](object.track.artist.cover.urls.medium_square_crop)" alt="" > @@ -52,7 +141,10 @@ {{ object.track.title }} </router-link> </div> - <div class="meta ellipsis"> + <div + v-if="object.track.artist" + class="meta ellipsis" + > <span> <router-link class="discrete link" @@ -122,9 +214,8 @@ <template v-if="nextPage"> <div class="ui hidden divider" /> <button - v-if="nextPage" :class="['ui', 'basic', 'button']" - @click="fetchData(nextPage)" + @click="fetchData(nextPage as string)" > <translate translate-context="*/*/Button,Label"> Show more @@ -133,87 +224,3 @@ </template> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import PlayButton from '@/components/audio/PlayButton.vue' -import TagsList from '@/components/tags/List.vue' - -export default { - components: { - PlayButton, - TagsList - }, - props: { - filters: { type: Object, required: true }, - url: { type: String, required: true }, - isActivity: { type: Boolean, default: true }, - showCount: { type: Boolean, default: false }, - limit: { type: Number, default: 5 }, - itemClasses: { type: String, default: '' } - }, - data () { - return { - objects: [], - count: 0, - isLoading: false, - errors: null, - previousPage: null, - nextPage: null - } - }, - watch: { - offset () { - this.fetchData() - }, - '$store.state.moderation.lastUpdate': function () { - this.fetchData(this.url) - }, - count (v) { - this.$emit('count', v) - } - }, - created () { - this.fetchData(this.url) - }, - methods: { - fetchData (url) { - if (!url) { - return - } - this.isLoading = true - const self = this - const params = _.clone(this.filters) - params.page_size = this.limit - params.offset = this.offset - axios.get(url, { params: params }).then((response) => { - self.previousPage = response.data.previous - self.nextPage = response.data.next - self.isLoading = false - self.count = response.data.count - let newObjects - if (self.isActivity) { - // we have listening/favorites objects, not directly tracks - newObjects = response.data.results - } else { - newObjects = response.data.results.map((r) => { - return { track: r } - }) - } - self.objects = [...self.objects, ...newObjects] - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - updateOffset (increment) { - if (increment) { - this.offset += this.limit - } else { - this.offset = Math.max(this.offset - this.limit, 0) - } - } - } -} -</script> diff --git a/front/src/components/auth/ApplicationEdit.vue b/front/src/components/auth/ApplicationEdit.vue index 2e86ba2850399a57847693b6936f6067a79eb03d..b011b721c45eee983df09e0e79c9a8d8f0675994 100644 --- a/front/src/components/auth/ApplicationEdit.vue +++ b/front/src/components/auth/ApplicationEdit.vue @@ -1,3 +1,57 @@ +<script setup lang="ts"> +import { useGettext } from 'vue3-gettext' +import { computed, ref } from 'vue' + +import axios from 'axios' + +import ApplicationForm from '~/components/auth/ApplicationForm.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() + +const application = ref() + +const labels = computed(() => ({ + title: $pgettext('Content/Applications/Title', 'Edit application') +})) + +const isLoading = ref(false) +const fetchApplication = async () => { + isLoading.value = true + + try { + const response = await axios.get(`oauth/apps/${props.id}/`) + application.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const refreshToken = async () => { + isLoading.value = true + + try { + const response = await axios.post(`oauth/apps/${props.id}/refresh-token`) + application.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchApplication() +</script> + <template> <main v-title="labels.title" @@ -74,51 +128,3 @@ </div> </main> </template> - -<script> -import axios from 'axios' - -import ApplicationForm from '@/components/auth/ApplicationForm.vue' - -export default { - components: { - ApplicationForm - }, - props: { id: { type: Number, required: true } }, - data () { - return { - application: null, - isLoading: false - } - }, - computed: { - labels () { - return { - title: this.$pgettext('Content/Applications/Title', 'Edit application') - } - } - }, - created () { - this.fetchApplication() - }, - methods: { - fetchApplication () { - this.isLoading = true - const self = this - axios.get(`oauth/apps/${this.id}/`).then((response) => { - self.isLoading = false - self.application = response.data - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - async refreshToken () { - self.isLoading = true - const response = await axios.post(`oauth/apps/${this.id}/refresh-token`) - this.application = response.data - self.isLoading = false - } - } -} -</script> diff --git a/front/src/components/auth/ApplicationForm.vue b/front/src/components/auth/ApplicationForm.vue index cbaccb486a8446d47eb4997654d29ec9b744c24e..3255778a8c6e32aff0d3daf7420f3163da74a954 100644 --- a/front/src/components/auth/ApplicationForm.vue +++ b/front/src/components/auth/ApplicationForm.vue @@ -1,3 +1,94 @@ +<script setup lang="ts"> +import type { BackendError, Application } from '~/types' + +import axios from 'axios' +import { ref, reactive, computed } from 'vue' +import { computedEager } from '@vueuse/core' +import { useGettext } from 'vue3-gettext' +import { uniq } from 'lodash-es' + +import useScopes from '~/composables/auth/useScopes' + +interface Events { + (e: 'updated', application: Application): void + (e: 'created', application: Application): void +} + +interface Props { + app?: Application | null + defaults?: Partial<Application> +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + app: null, + defaults: () => ({}) +}) + +const { $pgettext } = useGettext() +const scopes = useScopes() + .filter(scope => !['reports', 'security'].includes(scope.id)) + +const fields = reactive({ + name: props.app?.name ?? props.defaults.name ?? '', + redirect_uris: props.app?.redirect_uris ?? props.defaults.redirect_uris ?? 'urn:ietf:wg:oauth:2.0:oob', + scopes: props.app?.scopes ?? props.defaults.scopes ?? 'read' +}) + +const errors = ref([] as string[]) +const isLoading = ref(false) +const submit = async () => { + errors.value = [] + isLoading.value = true + + try { + const isUpdating = props.app !== null + const request = isUpdating + ? () => axios.patch(`oauth/apps/${props.app?.client_id}/`, fields) + : () => axios.post('oauth/apps/', fields) + + const response = await request() + + if (isUpdating) emit('updated', response.data as Application) + else emit('created', response.data as Application) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const scopeArray = computed({ + get: () => fields.scopes.split(' '), + set: (scopes: string[]) => uniq(scopes).join(' ') +}) + +const scopeParents = computedEager(() => [ + { + id: 'read', + label: $pgettext('Content/OAuth Scopes/Label/Verb', 'Read'), + description: $pgettext('Content/OAuth Scopes/Help Text', 'Read-only access to user data'), + value: scopeArray.value.includes('read') + }, + { + id: 'write', + label: $pgettext('Content/OAuth Scopes/Label/Verb', 'Write'), + description: $pgettext('Content/OAuth Scopes/Help Text', 'Write-only access to user data'), + value: scopeArray.value.includes('write') + } +]) + +const allScopes = computed(() => { + return scopeParents.value.map(parent => ({ + ...parent, + children: scopes.map(scope => { + const id = `${parent.id}:${scope.id}` + return { id, value: scopeArray.value.includes(id) } + }) + })) +}) +</script> + <template> <form class="ui form component-form" @@ -75,8 +166,8 @@ </div> <div - v-for="(child, index) in parent.children" - :key="index" + v-for="child in parent.children" + :key="child.id" > <div class="ui child checkbox"> <input @@ -87,9 +178,6 @@ > <label :for="child.id"> {{ child.id }} - <p class="help"> - {{ child.description }} - </p> </label> </div> </div> @@ -101,15 +189,13 @@ type="submit" > <translate - v-if="updating" - key="2" + v-if="app !== null" translate-context="Content/Applications/Button.Label/Verb" > Update application </translate> <translate v-else - key="3" translate-context="Content/Applications/Button.Label/Verb" > Create application @@ -117,108 +203,3 @@ </button> </form> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import TranslationsMixin from '@/components/mixins/Translations.vue' - -export default { - mixins: [TranslationsMixin], - props: { - app: { type: Object, required: false, default: () => { return null } }, - defaults: { type: Object, required: false, default: () => { return {} } } - }, - data () { - const defaults = this.defaults || {} - const app = this.app || {} - return { - isLoading: false, - errors: [], - fields: { - name: app.name || defaults.name || '', - redirect_uris: app.redirect_uris || defaults.redirect_uris || 'urn:ietf:wg:oauth:2.0:oob', - scopes: app.scopes || defaults.scopes || 'read' - }, - scopes: [ - { id: 'profile', icon: 'user' }, - { id: 'libraries', icon: 'book' }, - { id: 'favorites', icon: 'heart' }, - { id: 'listenings', icon: 'music' }, - { id: 'follows', icon: 'users' }, - { id: 'playlists', icon: 'list' }, - { id: 'radios', icon: 'rss' }, - { id: 'filters', icon: 'eye slash' }, - { id: 'notifications', icon: 'bell' }, - { id: 'edits', icon: 'pencil alternate' } - ] - } - }, - computed: { - updating () { - return this.app - }, - scopeArray: { - get () { - return this.fields.scopes.split(' ') - }, - set (v) { - this.fields.scopes = _.uniq(v).join(' ') - } - }, - allScopes () { - const self = this - const parents = [ - { - id: 'read', - label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Read'), - description: this.$pgettext('Content/OAuth Scopes/Help Text', 'Read-only access to user data'), - value: this.scopeArray.indexOf('read') > -1 - }, - { - id: 'write', - label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Write'), - description: this.$pgettext('Content/OAuth Scopes/Help Text', 'Write-only access to user data'), - value: this.scopeArray.indexOf('write') > -1 - } - ] - parents.forEach((p) => { - p.children = self.scopes.map(s => { - const id = `${p.id}:${s.id}` - return { - id, - value: this.scopeArray.indexOf(id) > -1 - } - }) - }) - return parents - } - }, - methods: { - submit () { - this.errors = [] - const self = this - self.isLoading = true - const payload = this.fields - let event, promise - if (this.updating) { - event = 'updated' - promise = axios.patch(`oauth/apps/${this.app.client_id}/`, payload) - } else { - event = 'created' - promise = axios.post('oauth/apps/', payload) - } - return promise.then( - response => { - self.isLoading = false - self.$emit(event, response.data) - }, - error => { - self.isLoading = false - self.errors = error.backendErrors - } - ) - } - } -} -</script> diff --git a/front/src/components/auth/ApplicationNew.vue b/front/src/components/auth/ApplicationNew.vue index d85580e37254c09fbb71385255393d7e8bf2f5bd..74119d1f44eda9ae009c1efd0a241055c8f51299 100644 --- a/front/src/components/auth/ApplicationNew.vue +++ b/front/src/components/auth/ApplicationNew.vue @@ -1,3 +1,32 @@ +<script setup lang="ts"> +import ApplicationForm from '~/components/auth/ApplicationForm.vue' +import { computed, reactive } from 'vue' +import { useGettext } from 'vue3-gettext' + +interface Props { + name?: string + scopes?: string + redirectUris?: string +} + +const props = withDefaults(defineProps<Props>(), { + name: '', + scopes: '', + redirectUris: '' +}) + +const defaults = reactive({ + name: props.name, + scopes: props.scopes, + redirectUris: props.redirectUris +}) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Content/Settings/Button.Label', 'Create a new application') +})) +</script> + <template> <main v-title="labels.title" @@ -23,36 +52,3 @@ </div> </main> </template> - -<script> -import ApplicationForm from '@/components/auth/ApplicationForm.vue' - -export default { - components: { - ApplicationForm - }, - props: { - name: { type: String, default: '' }, - redirectUris: { type: String, default: '' }, - scopes: { type: String, default: '' } - }, - data () { - return { - application: null, - isLoading: false, - defaults: { - name: this.name, - redirectUris: this.redirectUris, - scopes: this.scopes - } - } - }, - computed: { - labels () { - return { - title: this.$pgettext('Content/Settings/Button.Label', 'Create a new application') - } - } - } -} -</script> diff --git a/front/src/components/auth/Authorize.vue b/front/src/components/auth/Authorize.vue index 004dbc680866bc044c7a2f2d2906af6852c66979..c387f7cc2d93760cede88ffa1054730b605d0012 100644 --- a/front/src/components/auth/Authorize.vue +++ b/front/src/components/auth/Authorize.vue @@ -1,3 +1,116 @@ +<script setup lang="ts"> +import type { BackendError, Application } from '~/types' + +import axios from 'axios' +import { useGettext } from 'vue3-gettext' +import { whenever } from '@vueuse/core' +import { ref, computed } from 'vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useScopes from '~/composables/auth/useScopes' +import useFormData from '~/composables/useFormData' + +interface Props { + clientId: string + redirectUri: string + scope: 'me' | 'all' + responseType: string + nonce: string + state: string +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() +const sharedLabels = useSharedLabels() +const knownScopes = useScopes() + +const supportedScopes = ['read', 'write'] +for (const scope of knownScopes) { + supportedScopes.push(`read:${scope.id}`) + supportedScopes.push(`write:${scope.id}`) +} + +const application = ref() + +const errors = ref([] as string[]) +const isLoading = ref(false) +const fetchApplication = async () => { + isLoading.value = true + + try { + const response = await axios.get(`oauth/apps/${props.clientId}/`) + application.value = response.data as Application + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const code = ref() +const submit = async () => { + isLoading.value = true + + try { + const data = useFormData({ + redirect_uri: props.redirectUri, + scope: props.scope, + allow: 'true', + client_id: props.clientId, + response_type: props.responseType, + state: props.state, + nonce: props.nonce + }) + + const response = await axios.post('oauth/authorize/', data, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest' + } + }) + + if (props.redirectUri !== 'urn:ietf:wg:oauth:2.0:oob') { + window.location.href = response.data.redirect_uri + return + } + + code.value = response.data.code + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const labels = computed(() => ({ + title: $pgettext('Head/Authorize/Title', 'Allow application') +})) + +const requestedScopes = computed(() => props.scope.split(' ')) +const unknownRequestedScopes = computed(() => requestedScopes.value.filter(scope => !supportedScopes.includes(scope))) +const topicScopes = computed(() => { + const requested = requestedScopes.value + + const write = requested.includes('write') + const read = requested.includes('read') + + return knownScopes.map(scope => { + const { id } = scope + return { + id, + icon: scope.icon, + label: sharedLabels.scopes[id].label, + description: sharedLabels.scopes[id].description, + read: read || requested.includes(`read:${id}`), + write: write || requested.includes(`write:${id}`) + } + }).filter(scope => scope.read || scope.write) +}) + +whenever(() => props.clientId, fetchApplication, { immediate: true }) +</script> + <template> <main v-title="labels.title" @@ -116,7 +229,6 @@ </button> <p v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'" - key="1" v-translate translate-context="Content/Auth/Paragraph" > @@ -124,7 +236,6 @@ </p> <p v-else - key="2" v-translate="{url: redirectUri}" translate-context="Content/Auth/Paragraph" :translate-params="{url: redirectUri}" @@ -140,136 +251,3 @@ </section> </main> </template> - -<script> -import TranslationsMixin from '@/components/mixins/Translations.vue' - -import axios from 'axios' - -import { checkRedirectToLogin } from '@/utils' -export default { - mixins: [TranslationsMixin], - props: { - clientId: { type: String, required: true }, - redirectUri: { type: String, required: true }, - scope: { type: String, required: true }, - responseType: { type: String, required: true }, - nonce: { type: String, required: true }, - state: { type: String, required: true } - }, - data () { - return { - application: null, - isLoading: false, - errors: [], - code: null, - knownScopes: [ - { id: 'profile', icon: 'user' }, - { id: 'libraries', icon: 'book' }, - { id: 'favorites', icon: 'heart' }, - { id: 'listenings', icon: 'music' }, - { id: 'follows', icon: 'users' }, - { id: 'playlists', icon: 'list' }, - { id: 'radios', icon: 'rss' }, - { id: 'filters', icon: 'eye slash' }, - { id: 'notifications', icon: 'bell' }, - { id: 'edits', icon: 'pencil alternate' }, - { id: 'security', icon: 'lock' }, - { id: 'reports', icon: 'warning sign' } - ] - } - }, - computed: { - labels () { - return { - title: this.$pgettext('Head/Authorize/Title', 'Allow application') - } - }, - requestedScopes () { - return (this.scope || '').split(' ') - }, - supportedScopes () { - const supported = ['read', 'write'] - this.knownScopes.forEach(s => { - supported.push(`read:${s.id}`) - supported.push(`write:${s.id}`) - }) - return supported - }, - unknownRequestedScopes () { - const self = this - return this.requestedScopes.filter(s => { - return self.supportedScopes.indexOf(s) < 0 - }) - }, - topicScopes () { - const self = this - const requested = this.requestedScopes - let write = false - let read = false - if (requested.indexOf('read') > -1) { - read = true - } - if (requested.indexOf('write') > -1) { - write = true - } - - return this.knownScopes.map(s => { - const id = s.id - return { - id: id, - icon: s.icon, - label: self.sharedLabels.scopes[s.id].label, - description: self.sharedLabels.scopes[s.id].description, - read: read || requested.indexOf(`read:${id}`) > -1, - write: write || requested.indexOf(`write:${id}`) > -1 - } - }).filter(c => { - return c.read || c.write - }) - } - }, - created () { - checkRedirectToLogin(this.$store, this.$router) - if (this.clientId) { - this.fetchApplication() - } - }, - methods: { - fetchApplication () { - this.isLoading = true - const self = this - axios.get(`oauth/apps/${this.clientId}/`).then((response) => { - self.isLoading = false - self.application = response.data - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - submit () { - this.isLoading = true - const self = this - const data = new FormData() - data.set('redirect_uri', this.redirectUri) - data.set('scope', this.scope) - data.set('allow', true) - data.set('client_id', this.clientId) - data.set('response_type', this.responseType) - data.set('state', this.state) - data.set('nonce', this.nonce) - axios.post('oauth/authorize/', data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' } }).then((response) => { - if (self.redirectUri === 'urn:ietf:wg:oauth:2.0:oob') { - self.isLoading = false - self.code = response.data.code - } else { - window.location.href = response.data.redirect_uri - } - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/auth/LoginForm.vue b/front/src/components/auth/LoginForm.vue index a92b3a3769798788afd092ea22d5d242027057b4..e791141c3d49384e03883c7f56ea2b45c6420584 100644 --- a/front/src/components/auth/LoginForm.vue +++ b/front/src/components/auth/LoginForm.vue @@ -1,10 +1,76 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' +import type { RouteLocationRaw } from 'vue-router' + +import { ref, reactive, computed, onMounted, nextTick } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import PasswordInput from '~/components/forms/PasswordInput.vue' + +interface Props { + next?: RouteLocationRaw + buttonClasses?: string + showSignup?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + next: '/library', + buttonClasses: 'success', + showSignup: true +}) + +const domain = location.hostname +const { $pgettext } = useGettext() +const store = useStore() + +const credentials = reactive({ + username: '', + password: '' +}) + +const labels = computed(() => ({ + usernamePlaceholder: $pgettext('Content/Login/Input.Placeholder', 'Enter your username or e-mail address') +})) + +const username = ref() +onMounted(async () => { + await nextTick() + username.value?.focus() +}) + +const isLoading = ref(false) +const errors = ref([] as string[]) +const submit = async () => { + isLoading.value = true + + try { + if (domain === store.getters['instance/domain']) { + await store.dispatch('auth/login', { credentials }) + } else { + await store.dispatch('auth/oauthLogin', props.next) + } + } catch (error) { + const backendError = error as BackendError + + if (backendError.response?.status === 400) { + errors.value = ['invalid_credentials'] + } else { + errors.value = backendError.backendErrors + } + } + + isLoading.value = false +} +</script> + <template> <form class="ui form" @submit.prevent="submit()" > <div - v-if="error" + v-if="errors.length > 0" role="alert" class="ui negative message" > @@ -14,22 +80,22 @@ </translate> </h4> <ul class="list"> - <li v-if="error == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value"> + <li v-if="errors[0] == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value"> <translate translate-context="Content/Login/Error message.List item/Call to action"> If you signed-up recently, you may need to wait before our moderation team review your account, or verify your e-mail address. </translate> </li> - <li v-else-if="error == 'invalid_credentials'"> + <li v-else-if="errors[0] == 'invalid_credentials'"> <translate translate-context="Content/Login/Error message.List item/Call to action"> Please double-check that your username and password combination is correct and make sure you verified your e-mail address. </translate> </li> <li v-else> - {{ error }} + {{ errors[0] }} </li> </ul> </div> - <template v-if="$store.getters['instance/appDomain'] === $store.getters['instance/domain']"> + <template v-if="domain === $store.getters['instance/domain']"> <div class="field"> <label for="username-field"> <translate translate-context="Content/Login/Input.Label/Noun">Username or e-mail address</translate> @@ -88,82 +154,3 @@ </button> </form> </template> - -<script> -import PasswordInput from '@/components/forms/PasswordInput.vue' - -export default { - components: { - PasswordInput - }, - props: { - next: { type: String, default: '/library' }, - buttonClasses: { type: String, default: 'success' }, - showSignup: { type: Boolean, default: true } - }, - data () { - return { - // We need to initialize the component with any - // properties that will be used in it - credentials: { - username: '', - password: '' - }, - error: '', - isLoading: false - } - }, - computed: { - labels () { - const usernamePlaceholder = this.$pgettext('Content/Login/Input.Placeholder', 'Enter your username or e-mail address') - return { - usernamePlaceholder - } - } - }, - created () { - if (this.$store.state.auth.authenticated) { - this.$router.push(this.next) - } - }, - mounted () { - if (this.$refs.username) { - this.$refs.username.focus() - } - }, - methods: { - async submit () { - if (this.$store.getters['instance/appDomain'] === this.$store.getters['instance/domain']) { - return await this.submitSession() - } else { - this.isLoading = true - await this.$store.dispatch('auth/oauthLogin', this.next) - } - }, - async submitSession () { - const self = this - self.isLoading = true - this.error = '' - const credentials = { - username: this.credentials.username, - password: this.credentials.password - } - this.$store - .dispatch('auth/login', { - credentials, - next: this.next, - onError: error => { - if (error.response.status === 400) { - self.error = 'invalid_credentials' - } else { - self.error = error.backendErrors[0] - } - } - }) - .then(e => { - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/components/auth/Logout.vue b/front/src/components/auth/Logout.vue index aa1a98041d8cc104f604858fdcea93a77fc4bb14..789149cc13dc0e4afe000747169fa55917610112 100644 --- a/front/src/components/auth/Logout.vue +++ b/front/src/components/auth/Logout.vue @@ -1,3 +1,13 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/Login/Title', 'Log Out') +})) +</script> + <template> <main v-title="labels.title" @@ -49,15 +59,3 @@ </section> </main> </template> - -<script> -export default { - computed: { - labels () { - return { - title: this.$pgettext('Head/Login/Title', 'Log Out') - } - } - } -} -</script> diff --git a/front/src/components/auth/Plugin.vue b/front/src/components/auth/Plugin.vue index 20d3b7cb991f46ab03f5ef45c7f884164cb37dd2..68cf0596a9b8cb99af8096852656dccd0cc2c245 100644 --- a/front/src/components/auth/Plugin.vue +++ b/front/src/components/auth/Plugin.vue @@ -1,12 +1,66 @@ +<script setup lang="ts"> +import type { Library, Plugin, BackendError } from '~/types' + +import axios from 'axios' +import { clone } from 'lodash-es' +import useMarkdown, { useMarkdownRaw } from '~/composables/useMarkdown' +import { ref } from 'vue' + +interface Props { + plugin: Plugin + libraries: Library[] +} + +const props = defineProps<Props>() + +const description = useMarkdown(() => props.plugin.description ?? '') +const enabled = ref(props.plugin.enabled) +const values = clone(props.plugin.values ?? {}) + +const errors = ref([] as string[]) +const isLoading = ref(false) +const submit = async () => { + isLoading.value = true + errors.value = [] + + try { + await axios.post(`plugins/${props.plugin.name}/${enabled.value ? 'enable' : 'disable'}`) + await axios.post(`plugins/${props.plugin.name}`, values) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const scan = async () => { + isLoading.value = true + errors.value = [] + + try { + await axios.post(`plugins/${props.plugin.name}/scan`, values) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const submitAndScan = async () => { + await submit() + await scan() +} +</script> + <template> <form :class="['ui segment form', {loading: isLoading}]" @submit.prevent="submit" > <h3>{{ plugin.label }}</h3> - <div + <sanitized-html v-if="plugin.description" - v-html="markdown.makeHtml(plugin.description)" + :html="description" /> <template v-if="plugin.homepage"> <div class="ui small hidden divider" /> @@ -72,75 +126,73 @@ </translate> </div> </div> - <template - v-for="(field, key) in plugin.conf" - v-if="plugin.conf && plugin.conf.length > 0" - > - <div - v-if="field.type === 'text'" - :key="key" - class="field" + <template v-if="(plugin.conf?.length ?? 0) > 0"> + <template + v-for="field in plugin.conf" + :key="field.name" > - <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> - <input - :id="`plugin-${field.name}`" - v-model="values[field.name]" - type="text" - > <div - v-if="field.help" - v-html="markdown.makeHtml(field.help)" - /> - </div> - <div - v-if="field.type === 'long_text'" - :key="key" - class="field" - > - <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> - <textarea - :id="`plugin-${field.name}`" - v-model="values[field.name]" - type="text" - rows="5" - /> + v-if="field.type === 'text'" + class="field" + > + <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> + <input + :id="`plugin-${field.name}`" + v-model="values[field.name]" + type="text" + > + <sanitized-html + v-if="field.help" + :html="useMarkdownRaw(field.help)" + /> + </div> <div - v-if="field.help" - v-html="markdown.makeHtml(field.help)" - /> - </div> - <div - v-if="field.type === 'url'" - :key="key" - class="field" - > - <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> - <input - :id="`plugin-${field.name}`" - v-model="values[field.name]" - type="url" + v-if="field.type === 'long_text'" + class="field" > + <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> + <textarea + :id="`plugin-${field.name}`" + v-model="values[field.name]" + type="text" + rows="5" + /> + <sanitized-html + v-if="field.help" + :html="useMarkdownRaw(field.help)" + /> + </div> <div - v-if="field.help" - v-html="markdown.makeHtml(field.help)" - /> - </div> - <div - v-if="field.type === 'password'" - :key="key" - class="field" - > - <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> - <input - :id="`plugin-${field.name}`" - v-model="values[field.name]" - type="password" + v-if="field.type === 'url'" + class="field" > + <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> + <input + :id="`plugin-${field.name}`" + v-model="values[field.name]" + type="url" + > + <sanitized-html + v-if="field.help" + :html="useMarkdownRaw(field.help)" + /> + </div> <div - v-if="field.help" - v-html="markdown.makeHtml(field.help)" - /> - </div> + v-if="field.type === 'password'" + class="field" + > + <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> + <input + :id="`plugin-${field.name}`" + v-model="values[field.name]" + type="password" + > + <sanitized-html + v-if="field.help" + :html="useMarkdownRaw(field.help)" + /> + </div> + </template> </template> <button type="submit" @@ -152,7 +204,6 @@ </button> <button v-if="plugin.source" - type="scan" :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" @click.prevent="submitAndScan" > @@ -163,54 +214,3 @@ <div class="ui clearing hidden divider" /> </form> </template> - -<script> -import axios from 'axios' -import lodash from 'lodash' -import showdown from 'showdown' -export default { - props: { - plugin: { type: Object, required: true }, - libraries: { type: Array, required: true } - }, - data () { - return { - markdown: new showdown.Converter(), - isLoading: false, - enabled: this.plugin.enabled, - values: lodash.clone(this.plugin.values || {}), - errors: [] - } - }, - methods: { - async submit () { - this.isLoading = true - this.errors = [] - const url = `plugins/${this.plugin.name}` - const enableUrl = this.enabled ? `${url}/enable` : `${url}/disable` - await axios.post(enableUrl) - try { - await axios.post(url, this.values) - } catch (e) { - this.errors = e.backendErrors - } - this.isLoading = false - }, - async scan () { - this.isLoading = true - this.errors = [] - const url = `plugins/${this.plugin.name}/scan` - try { - await axios.post(url, this.values) - } catch (e) { - this.errors = e.backendErrors - } - this.isLoading = false - }, - async submitAndScan () { - await this.submit() - await this.scan() - } - } -} -</script> diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 92b3e724aa4dd1fdbefca85df500d2dad19ace60..495a8ac8ae9cf7114bef693b40979f49a2b9561a 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -1,3 +1,273 @@ +<script setup lang="ts"> +import type { BackendError, Application, PrivacyLevel } from '~/types' +import type { $ElementType } from 'utility-types' + +import axios from 'axios' +import $ from 'jquery' + +import { computed, reactive, ref, onMounted } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { useStore } from '~/store' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useLogger from '~/composables/useLogger' + +import SubsonicTokenForm from '~/components/auth/SubsonicTokenForm.vue' +import AttachmentInput from '~/components/common/AttachmentInput.vue' +import PasswordInput from '~/components/forms/PasswordInput.vue' + +const SETTINGS_ORDER: FieldId[] = ['summary', 'privacy_level'] + +type Field = { id: 'summary', type: 'content', value: { text: string, content_type: 'text/markdown' } } + | { id: 'privacy_level', type: 'dropdown', choices: PrivacyLevel[], value: string } +type FieldId = $ElementType<Field, 'id'> + +interface Settings { + success: boolean + errors: string[] + order: FieldId[] + fields: Record<FieldId, Field> +} + +const { $pgettext } = useGettext() +const sharedLabels = useSharedLabels() +const logger = useLogger() +const router = useRouter() +const store = useStore() + +const settings = reactive({ + success: false, + errors: [] as string[], + fields: { + summary: { + id: 'summary', + type: 'content', + value: store.state.auth.profile?.summary ?? { text: '', content_type: 'text/markdown' } + }, + privacy_level: { + id: 'privacy_level', + type: 'dropdown', + value: store.state.auth.profile?.privacy_level, + choices: ['me', 'instance', 'everyone'] + } + } +} as Settings) + +const orderedSettingsFields = SETTINGS_ORDER.map(id => settings.fields[id]) + +const labels = computed(() => ({ + title: $pgettext('Head/Settings/Title', 'Account Settings') +})) + +const isLoading = ref(false) +const submitSettings = async () => { + settings.success = false + settings.errors = [] + isLoading.value = true + + const payload = {} as Record<FieldId, string | null | { text: string }> + for (const id of SETTINGS_ORDER) { + const field = settings.fields[id] + payload[id] = field.type === 'content' && !field.value.text + ? null + : field.value + } + + try { + await axios.patch(`users/${store.state.auth.username}/`, payload) + + logger.info('Updated settings successfully') + settings.success = true + + const me = await axios.get('users/me/') + store.dispatch('auth/updateProfile', me.data) + } catch (error) { + logger.error('Error while updating settings') + settings.errors.push(...(error as BackendError).backendErrors) + } + + isLoading.value = false +} + +const apps = ref([] as Application[]) +const isLoadingApps = ref(false) +const fetchApps = async () => { + apps.value = [] + isLoadingApps.value = true + + try { + const response = await axios.get('oauth/grants/') + apps.value = response.data as Application[] + } catch (error) { + logger.error('Error while fetching Apps') + settings.errors.push(...(error as BackendError).backendErrors) + } + + isLoadingApps.value = false +} + +const ownedApps = ref([] as Application[]) +const fetchOwnedApps = async () => { + ownedApps.value = [] + // TODO: Add loader + + try { + const response = await axios.get('oauth/apps/') + ownedApps.value = response.data as Application[] + } catch (error) { + logger.error('Error while fetching owned Apps') + settings.errors.push(...(error as BackendError).backendErrors) + } +} + +const isRevoking = reactive(new Set()) +const revokeApp = async (id: string) => { + isRevoking.add(id) + + try { + await axios.delete(`oauth/grants/${id}/`) + apps.value = apps.value.filter(app => app.client_id !== id) + } catch (error) { + logger.error('Error while revoking App') + settings.errors.length = 0 + settings.errors.push(...(error as BackendError).backendErrors) + } + + isRevoking.delete(id) +} + +const isDeleting = reactive(new Set()) +const deleteApp = async (id: string) => { + isDeleting.add(id) + + try { + await axios.delete(`oauth/apps/${id}/`) + ownedApps.value = ownedApps.value.filter(app => app.client_id !== id) + } catch (error) { + logger.error('Error while deleting App') + settings.errors.length = 0 + settings.errors.push(...(error as BackendError).backendErrors) + } + + isDeleting.delete(id) +} + +const avatar = ref({ uuid: null, ...(store.state.auth.profile?.avatar ?? {}) }) +// TODO (wvffle): Maybe should be reactive? +const initialAvatar = avatar.value.uuid ?? undefined +const avatarErrors = ref([] as string[]) +const isLoadingAvatar = ref(false) +const submitAvatar = async (uuid: string | null) => { + if (!uuid) return + + isLoadingAvatar.value = true + + try { + const response = await axios.patch(`users/${store.state.auth.username}/`, { avatar: uuid }) + avatar.value = response.data.avatar + store.commit('auth/avatar', response.data.avatar) + } catch (error) { + avatarErrors.value = (error as BackendError).backendErrors + } + + avatarErrors.value = [] + isLoadingAvatar.value = false +} + +const passwordError = ref('') +const credentials = reactive({ + oldPassword: '', + newPassword: '' +}) +const isLoadingPassword = ref(false) +const submitPassword = async () => { + isLoadingPassword.value = true + passwordError.value = '' + + try { + await axios.post('auth/registration/change-password/', { + old_password: credentials.oldPassword, + new_password1: credentials.newPassword, + new_password2: credentials.newPassword + }) + + logger.info('Password successfully changed') + return router.push({ + name: 'profile.overview', + params: { username: store.state.auth.username } + }) + } catch (error) { + if ((error as BackendError).response?.status === 400) { + passwordError.value = 'invalid_credentials' + } else { + passwordError.value = 'unknown_error' + } + } + + isLoadingPassword.value = false +} + +const deleteAccountPassword = ref('') +const isDeletingAccount = ref(false) +const accountDeleteErrors = ref([] as string[]) +const deleteAccount = async () => { + isDeletingAccount.value = true + accountDeleteErrors.value = [] + + try { + const payload = { + confirm: true, + password: deleteAccountPassword.value + } + + await axios.delete('users/me/', { data: payload }) + + store.commit('ui/addMessage', { + content: $pgettext('*/Auth/Message', 'Your deletion request was submitted, your account and content will be deleted shortly'), + date: new Date() + }) + + store.dispatch('auth/logout') + } catch (error) { + accountDeleteErrors.value = (error as BackendError).backendErrors + } + + deleteAccountPassword.value = '' + isDeletingAccount.value = false +} + +const isChangingEmail = ref(false) +const emailPassword = ref('') +const newEmail = ref('') +const changeEmailErrors = ref([] as string[]) +const changeEmail = async () => { + isChangingEmail.value = true + changeEmailErrors.value = [] + + try { + await axios.post('users/users/change-email/', { + password: emailPassword.value, + email: newEmail.value + }) + + newEmail.value = '' + } catch (error) { + changeEmailErrors.value = (error as BackendError).backendErrors + } + + emailPassword.value = '' + isChangingEmail.value = false +} + +onMounted(() => { + $('select.dropdown').dropdown() +}) + +fetchApps() +fetchOwnedApps() +</script> + <template> <main v-title="labels.title" @@ -49,7 +319,7 @@ class="field" > <label :for="f.id">{{ sharedLabels.fields[f.id].label }}</label> - <p v-if="f.help"> + <p v-if="sharedLabels.fields[f.id].help"> {{ sharedLabels.fields[f.id].help }} </p> <select @@ -63,7 +333,7 @@ :key="key" :value="c" > - {{ sharedLabels.fields[f.id].choices[c] }} + {{ sharedLabels.fields[f.id].choices?.[c] }} </option> </select> <content-form @@ -73,7 +343,7 @@ /> </div> <button - :class="['ui', {'loading': isLoading}, 'button']" + :class="['ui', { loading: isLoading }, 'button']" type="submit" > <translate translate-context="Content/Settings/Button.Label/Verb"> @@ -109,18 +379,13 @@ </li> </ul> </div> - {{ }} <attachment-input - :value="avatar.uuid" + v-model="avatar.uuid" :initial-value="initialAvatar" - :required="false" - @input="submitAvatar($event)" + @update:model-value="submitAvatar($event)" @delete="avatar = {uuid: null}" > - <translate - slot="label" - translate-context="Content/Channel/*" - > + <translate translate-context="Content/Channel/*"> Avatar </translate> </attachment-input> @@ -166,7 +431,7 @@ <div class="field"> <label for="old-password-field"><translate translate-context="Content/Settings/Input.Label">Current password</translate></label> <password-input - v-model="old_password" + v-model="credentials.oldPassword" field-id="old-password-field" required /> @@ -174,47 +439,53 @@ <div class="field"> <label for="new-password-field"><translate translate-context="Content/Settings/Input.Label">New password</translate></label> <password-input - v-model="new_password" + v-model="credentials.newPassword" field-id="new-password-field" required /> </div> <dangerous-button - :class="['ui', {'loading': isLoading}, {disabled: !new_password || !old_password}, 'warning', 'button']" + :class="['ui', {'loading': isLoadingPassword}, {disabled: !credentials.newPassword || !credentials.oldPassword}, 'warning', 'button']" :action="submitPassword" > <translate translate-context="Content/Settings/Button.Label"> Change password </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Settings/Title"> - Change your password? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Popup/Settings/Paragraph"> - Changing your password will have the following consequences: + <translate translate-context="Popup/Settings/Title"> + Change your password? </translate> </p> - <ul> - <li> - <translate translate-context="Popup/Settings/List item"> - You will be logged out from this session and have to log in with the new one + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Popup/Settings/Paragraph"> + Changing your password will have the following consequences: </translate> - </li> - <li> - <translate translate-context="Popup/Settings/List item"> - Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password - </translate> - </li> - </ul> - </div> - <div slot="modal-confirm"> - <translate translate-context="Popup/Settings/Button.Label"> - Disable access - </translate> - </div> + </p> + <ul> + <li> + <translate translate-context="Popup/Settings/List item"> + You will be logged out from this session and have to log in with the new one + </translate> + </li> + <li> + <translate translate-context="Popup/Settings/List item"> + Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password + </translate> + </li> + </ul> + </div> + </template> + <template #modal-confirm> + <div> + <translate translate-context="Popup/Settings/Button.Label"> + Disable access + </translate> + </div> + </template> </dangerous-button> </form> <div class="ui hidden divider" /> @@ -316,7 +587,7 @@ </translate> </p> <button - class="ui icon button" + :class="['ui', 'icon', { loading: isLoadingApps }, 'button']" @click="fetchApps()" > <i class="refresh icon" /> @@ -356,41 +627,45 @@ </td> <td> <dangerous-button - class="ui tiny danger button" + :class="['ui', 'tiny', 'danger', { loading: isRevoking.has(app.client_id) }, 'button']" @confirm="revokeApp(app.client_id)" > <translate translate-context="*/*/*/Verb"> Revoke </translate> - <p - slot="modal-header" - v-translate="{application: app.name}" - translate-context="Popup/Settings/Title" - > - Revoke access for application "%{ application }"? - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Settings/Paragraph"> - This will prevent this application from accessing the service on your behalf. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="*/Settings/Button.Label/Verb"> - Revoke access - </translate> - </div> + <template #modal-header> + <p + v-translate="{application: app.name}" + translate-context="Popup/Settings/Title" + > + Revoke access for application "%{ application }"? + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Settings/Paragraph"> + This will prevent this application from accessing the service on your behalf. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="*/Settings/Button.Label/Verb"> + Revoke access + </translate> + </div> + </template> </dangerous-button> </td> </tr> </tbody> </table> <empty-state v-else> - <translate - slot="title" - translate-context="Content/Applications/Paragraph" - > - You don't have any application connected with your account. - </translate> + <template #title> + <translate translate-context="Content/Applications/Paragraph"> + You don't have any application connected with your account. + </translate> + </template> <translate translate-context="Content/Applications/Paragraph"> If you authorize third-party applications to access your data, those applications will be listed here. </translate> @@ -472,41 +747,45 @@ </translate> </router-link> <dangerous-button - class="ui tiny danger button" + :class="['ui', 'tiny', 'danger', { loading: isDeleting.has(app.client_id) }, 'button']" @confirm="deleteApp(app.client_id)" > <translate translate-context="*/*/*/Verb"> Remove </translate> - <p - slot="modal-header" - v-translate="{application: app.name}" - translate-context="Popup/Settings/Title" - > - Remove application "%{ application }"? - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Settings/Paragraph"> - This will permanently remove the application and all the associated tokens. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="*/Settings/Button.Label/Verb"> - Remove application - </translate> - </div> + <template #modal-header> + <p + v-translate="{application: app.name}" + translate-context="Popup/Settings/Title" + > + Remove application "%{ application }"? + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Settings/Paragraph"> + This will permanently remove the application and all the associated tokens. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="*/Settings/Button.Label/Verb"> + Remove application + </translate> + </div> + </template> </dangerous-button> </td> </tr> </tbody> </table> <empty-state v-else> - <translate - slot="title" - translate-context="Content/Applications/Paragraph" - > - You don't have registered any application yet. - </translate> + <template #title> + <translate translate-context="Content/Applications/Paragraph"> + You don't have registered any application yet. + </translate> + </template> <translate translate-context="Content/Applications/Paragraph"> Register one to integrate Funkwhale with third-party applications. </translate> @@ -557,7 +836,7 @@ </p> <p> <translate - :translate-params="{email: $store.state.auth.profile.email}" + :translate-params="{email: $store.state.auth.profile?.email}" translate-context="Content/Settings/Paragraph'" > Your current e-mail address is %{ email }. @@ -659,318 +938,44 @@ <div class="field"> <label for="current-password-field"><translate translate-context="*/*/*">Password</translate></label> <password-input - v-model="password" + v-model="deleteAccountPassword" field-id="current-password-field" required /> </div> <dangerous-button - :class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, {danger: password}, 'button']" + :class="['ui', {'loading': isDeletingAccount}, {disabled: !deleteAccountPassword}, {danger: deleteAccountPassword}, 'button']" :action="deleteAccount" > <translate translate-context="*/*/Button.Label"> Delete my account… </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Settings/Title"> - Do you want to delete your account? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Popup/Settings/Paragraph"> - This is irreversible and will permanently remove your data from our servers. You will we immediatly logged out. + <translate translate-context="Popup/Settings/Title"> + Do you want to delete your account? </translate> </p> - </div> - <div slot="modal-confirm"> - <translate translate-context="*/*/Button.Label"> - Delete my account - </translate> - </div> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Popup/Settings/Paragraph"> + This is irreversible and will permanently remove your data from our servers. You will we immediatly logged out. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <div> + <translate translate-context="*/*/Button.Label"> + Delete my account + </translate> + </div> + </template> </dangerous-button> </div> </section> </div> </main> </template> - -<script> -import $ from 'jquery' -import axios from 'axios' -import logger from '@/logging' -import PasswordInput from '@/components/forms/PasswordInput.vue' -import SubsonicTokenForm from '@/components/auth/SubsonicTokenForm.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import AttachmentInput from '@/components/common/AttachmentInput.vue' - -export default { - components: { - PasswordInput, - SubsonicTokenForm, - AttachmentInput - }, - mixins: [TranslationsMixin], - data () { - const d = { - // We need to initialize the component with any - // properties that will be used in it - old_password: '', - new_password: '', - avatar: { ...(this.$store.state.auth.profile.avatar || { uuid: null }) }, - passwordError: '', - password: '', - isLoading: false, - isLoadingAvatar: false, - isDeletingAccount: false, - changeEmailErrors: [], - isChangingEmail: false, - newEmail: null, - emailPassword: null, - accountDeleteErrors: [], - avatarErrors: [], - apps: [], - ownedApps: [], - settings: { - success: false, - errors: [], - order: ['summary', 'privacy_level'], - fields: { - summary: { - type: 'content', - initial: this.$store.state.auth.profile.summary || { text: '', content_type: 'text/markdown' } - }, - privacy_level: { - type: 'dropdown', - initial: this.$store.state.auth.profile.privacy_level, - choices: ['me', 'instance', 'everyone'] - } - } - } - } - d.initialAvatar = d.avatar.uuid - d.settings.order.forEach(id => { - d.settings.fields[id].value = d.settings.fields[id].initial - d.settings.fields[id].id = id - }) - return d - }, - computed: { - labels () { - return { - title: this.$pgettext('Head/Settings/Title', 'Account Settings') - } - }, - orderedSettingsFields () { - const self = this - return this.settings.order.map(id => { - return self.settings.fields[id] - }) - }, - settingsValues () { - const self = this - const s = {} - this.settings.order.forEach(setting => { - const conf = self.settings.fields[setting] - s[setting] = conf.value - if (setting === 'summary' && !conf.value.text) { - s[setting] = null - } - }) - return s - } - }, - created () { - this.fetchApps() - this.fetchOwnedApps() - }, - mounted () { - $('select.dropdown').dropdown() - }, - methods: { - submitSettings () { - this.settings.success = false - this.settings.errors = [] - const self = this - const payload = this.settingsValues - const url = `users/${this.$store.state.auth.username}/` - return axios.patch(url, payload).then( - response => { - logger.default.info('Updated settings successfully') - self.settings.success = true - return axios.get('users/me/').then(response => { - self.$store.dispatch('auth/updateProfile', response.data) - }) - }, - error => { - logger.default.error('Error while updating settings') - self.isLoading = false - self.settings.errors = error.backendErrors - } - ) - }, - fetchApps () { - this.apps = [] - const self = this - const url = 'oauth/grants/' - return axios.get(url).then( - response => { - self.apps = response.data - }, - error => { - logger.default.error('Error while fetching Apps') - self.isLoading = false - self.settings.errors = error.backendErrors - } - ) - }, - fetchOwnedApps () { - this.ownedApps = [] - const self = this - const url = 'oauth/apps/' - return axios.get(url).then( - response => { - self.ownedApps = response.data.results - }, - error => { - logger.default.error('Error while fetching owned Apps') - self.isLoading = false - self.settings.errors = error.backendErrors - } - ) - }, - revokeApp (id) { - const self = this - const url = `oauth/grants/${id}/` - return axios.delete(url).then( - response => { - self.apps = self.apps.filter(a => { - return a.client_id !== id - }) - }, - error => { - logger.default.error('Error while revoking App') - self.isLoading = false - self.settings.errors = error.backendErrors - } - ) - }, - deleteApp (id) { - const self = this - const url = `oauth/apps/${id}/` - return axios.delete(url).then( - response => { - self.ownedApps = self.ownedApps.filter(a => { - return a.client_id !== id - }) - }, - error => { - logger.default.error('Error while deleting App') - self.isLoading = false - self.settings.errors = error.backendErrors - } - ) - }, - submitAvatar (uuid) { - this.isLoadingAvatar = true - this.avatarErrors = [] - const self = this - axios - .patch(`users/${this.$store.state.auth.username}/`, { avatar: uuid }) - .then( - response => { - this.isLoadingAvatar = false - self.avatar = response.data.avatar - self.$store.commit('auth/avatar', response.data.avatar) - }, - error => { - self.isLoadingAvatar = false - self.avatarErrors = error.backendErrors - } - ) - }, - submitPassword () { - const self = this - self.isLoading = true - this.error = '' - const credentials = { - old_password: this.old_password, - new_password1: this.new_password, - new_password2: this.new_password - } - const url = 'auth/registration/change-password/' - return axios.post(url, credentials).then( - response => { - logger.default.info('Password successfully changed') - self.$router.push({ - name: 'profile.overview', - params: { - username: self.$store.state.auth.username - } - }) - }, - error => { - if (error.response.status === 400) { - self.passwordError = 'invalid_credentials' - } else { - self.passwordError = 'unknown_error' - } - self.isLoading = false - } - ) - }, - deleteAccount () { - this.isDeletingAccount = true - this.accountDeleteErrors = [] - const self = this - const payload = { - confirm: true, - password: this.password - } - axios.delete('users/me/', { data: payload }) - .then( - response => { - self.isDeletingAccount = false - const msg = self.$pgettext('*/Auth/Message', 'Your deletion request was submitted, your account and content will be deleted shortly') - self.$store.commit('ui/addMessage', { - content: msg, - date: new Date() - }) - self.$store.dispatch('auth/logout') - }, - error => { - self.isDeletingAccount = false - self.accountDeleteErrors = error.backendErrors - } - ) - }, - - changeEmail () { - this.isChangingEmail = true - this.changeEmailErrors = [] - const self = this - const payload = { - password: this.emailPassword, - email: this.newEmail - } - axios.post('users/users/change-email/', payload) - .then( - response => { - self.isChangingEmail = false - self.newEmail = null - self.emailPassword = null - const msg = self.$pgettext('*/Auth/Message', 'Your e-mail address has been changed, please check your inbox for our confirmation message.') - self.$store.commit('ui/addMessage', { - content: msg, - date: new Date() - }) - }, - error => { - self.isChangingEmail = false - self.changeEmailErrors = error.backendErrors - } - ) - } - } -} -</script> diff --git a/front/src/components/auth/SignupForm.vue b/front/src/components/auth/SignupForm.vue index a21f1c88a1c1e99df2395144fe65cee59e2ce65f..2796abbc0d28bb9ffdec27feb5a936cf757d952f 100644 --- a/front/src/components/auth/SignupForm.vue +++ b/front/src/components/auth/SignupForm.vue @@ -1,3 +1,88 @@ +<script setup lang="ts"> +import type { RouteLocationRaw } from 'vue-router' +import type { BackendError, Form } from '~/types' + +import { computed, reactive, ref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' + +import LoginForm from '~/components/auth/LoginForm.vue' +import PasswordInput from '~/components/forms/PasswordInput.vue' +import useLogger from '~/composables/useLogger' + +interface Props { + defaultInvitation?: string | null + next?: RouteLocationRaw + buttonClasses?: string + customization?: Form | null + fetchDescriptionHtml?: boolean + signupApprovalEnabled?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultInvitation: null, + next: '/', + buttonClasses: 'success', + customization: null, + fetchDescriptionHtml: false, + signupApprovalEnabled: false +}) + +const { $pgettext } = useGettext() +const logger = useLogger() +const store = useStore() + +const labels = computed(() => ({ + placeholder: $pgettext('Content/Signup/Form/Placeholder', 'Enter your invitation code (case insensitive)'), + usernamePlaceholder: $pgettext('Content/Signup/Form/Placeholder', 'Enter your username'), + emailPlaceholder: $pgettext('Content/Signup/Form/Placeholder', 'Enter your e-mail address') +})) + +const signupRequiresApproval = computed(() => props.signupApprovalEnabled ?? store.state.instance.settings.moderation.signup_approval_enabled.value) +const formCustomization = computed(() => props.customization ?? store.state.instance.settings.moderation.signup_form_customization.value) + +const payload = reactive({ + username: '', + password1: '', + email: '', + invitation: props.defaultInvitation, + request_fields: {} as Record<string, string | number | string[]> +}) + +const submitted = ref(false) +const isLoading = ref(false) +const errors = ref([] as string[]) +const submit = async () => { + isLoading.value = true + errors.value = [] + + try { + await axios.post('auth/registration/', { + ...payload, + password2: payload.password1 + }) + + logger.info('Successfully created account') + submitted.value = true + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const isLoadingInstanceSetting = ref(false) +const fetchInstanceSettings = async () => { + isLoadingInstanceSetting.value = true + await store.dispatch('instance/fetchSettings') + isLoadingInstanceSetting.value = false +} + +fetchInstanceSettings() +</script> + <template> <div v-if="submitted"> <div class="ui success message"> @@ -75,7 +160,7 @@ <input id="username-field" ref="username" - v-model="username" + v-model="payload.username" name="username" required type="text" @@ -88,7 +173,7 @@ <input id="email-field" ref="email" - v-model="email" + v-model="payload.email" name="email" required type="email" @@ -98,7 +183,7 @@ <div class="required field"> <label for="password-field"><translate translate-context="*/*/*">Password</translate></label> <password-input - v-model="password" + v-model="payload.password1" field-id="password-field" /> </div> @@ -109,7 +194,7 @@ <label for="invitation-code"><translate translate-context="Content/*/Input.Label">Invitation code</translate></label> <input id="invitation-code" - v-model="invitation" + v-model="payload.invitation" required type="text" name="invitation" @@ -126,18 +211,16 @@ <textarea v-if="field.input_type === 'long_text'" :id="`custom-field-${idx}`" - :value="customFields[field.label]" + v-model="payload.request_fields[field.label]" :required="field.required" rows="5" - @input="$set(customFields, field.label, $event.target.value)" /> <input v-else :id="`custom-field-${idx}`" + v-model="payload.request_fields[field.label]" type="text" - :value="customFields[field.label]" :required="field.required" - @input="$set(customFields, field.label, $event.target.value)" > </div> </template> @@ -151,97 +234,3 @@ </button> </form> </template> - -<script> -import axios from 'axios' -import logger from '@/logging' - -import LoginForm from '@/components/auth/LoginForm.vue' -import PasswordInput from '@/components/forms/PasswordInput.vue' - -export default { - components: { - LoginForm, - PasswordInput - }, - props: { - defaultInvitation: { type: String, required: false, default: null }, - next: { type: String, default: '/' }, - buttonClasses: { type: String, default: 'success' }, - customization: { type: Object, default: null }, - fetchDescriptionHtml: { type: Boolean, default: false }, - signupApprovalEnabled: { type: Boolean, default: null, required: false } - }, - data () { - return { - username: '', - email: '', - password: '', - isLoadingInstanceSetting: true, - errors: [], - isLoading: false, - invitation: this.defaultInvitation, - customFields: {}, - submitted: false - } - }, - computed: { - labels () { - const placeholder = this.$pgettext( - 'Content/Signup/Form/Placeholder', - 'Enter your invitation code (case insensitive)' - ) - const usernamePlaceholder = this.$pgettext('Content/Signup/Form/Placeholder', 'Enter your username') - const emailPlaceholder = this.$pgettext('Content/Signup/Form/Placeholder', 'Enter your e-mail address') - return { - usernamePlaceholder, - emailPlaceholder, - placeholder - } - }, - formCustomization () { - return this.customization || this.$store.state.instance.settings.moderation.signup_form_customization.value - }, - signupRequiresApproval () { - if (this.signupApprovalEnabled === null) { - return this.$store.state.instance.settings.moderation.signup_approval_enabled.value - } - return this.signupApprovalEnabled - } - }, - created () { - const self = this - this.$store.dispatch('instance/fetchSettings', { - callback: function () { - self.isLoadingInstanceSetting = false - } - }) - }, - methods: { - submit () { - const self = this - self.isLoading = true - this.errors = [] - const payload = { - username: this.username, - password1: this.password, - password2: this.password, - email: this.email, - invitation: this.invitation, - request_fields: this.customFields - } - return axios.post('auth/registration/', payload).then( - response => { - logger.default.info('Successfully created account') - self.submitted = true - self.isLoading = false - }, - error => { - self.errors = error.backendErrors - self.isLoading = false - } - ) - } - } -} -</script> diff --git a/front/src/components/auth/SubsonicTokenForm.vue b/front/src/components/auth/SubsonicTokenForm.vue index a5e56913cf47bd2bc0d6b3bc4385cf23eea19916..843ad0a4f2e8c8a19d4eebf40214af276cb552da 100644 --- a/front/src/components/auth/SubsonicTokenForm.vue +++ b/front/src/components/auth/SubsonicTokenForm.vue @@ -1,3 +1,81 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { computed, ref } from 'vue' +import { useStore } from '~/store' +import axios from 'axios' + +import PasswordInput from '~/components/forms/PasswordInput.vue' + +const { $pgettext } = useGettext() +const store = useStore() + +const subsonicEnabled = computed(() => store.state.instance.settings.subsonic.enabled.value) +const labels = computed(() => ({ + subsonicField: $pgettext('Content/Password/Input.label', 'Your subsonic API password') +})) + +const errors = ref([] as string[]) +const success = ref(false) +const isLoading = ref(false) +const token = ref() +const fetchToken = async () => { + success.value = false + errors.value = [] + isLoading.value = true + + try { + const response = await axios.get(`users/${store.state.auth.username}/subsonic-token/`) + token.value = response.data.subsonic_api_token + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const showToken = ref(false) +const successMessage = ref('') +const requestNewToken = async () => { + successMessage.value = $pgettext('Content/Settings/Message', 'Password updated') + success.value = false + errors.value = [] + isLoading.value = true + + try { + const response = await axios.post(`users/${store.state.auth.username}/subsonic-token/`) + showToken.value = true + token.value = response.data.subsonic_api_token + success.value = true + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const disable = async () => { + successMessage.value = $pgettext('Content/Settings/Message', 'Access disabled') + success.value = false + errors.value = [] + isLoading.value = true + + try { + await axios.delete(`users/${store.state.auth.username}/subsonic-token/`) + token.value = null + success.value = true + showToken.value = false + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +fetchToken() +</script> + <template> <form class="ui form" @@ -89,21 +167,27 @@ <translate translate-context="*/Settings/Button.Label/Verb"> Request a new password </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Settings/Title"> - Request a new Subsonic API password? - </translate> - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Settings/Paragraph"> - This will log you out from existing devices that use the current password. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="*/Settings/Button.Label/Verb"> - Request a new password - </translate> - </div> + <template #modal-header> + <p> + <translate translate-context="Popup/Settings/Title"> + Request a new Subsonic API password? + </translate> + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Settings/Paragraph"> + This will log you out from existing devices that use the current password. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="*/Settings/Button.Label/Verb"> + Request a new password + </translate> + </div> + </template> </dangerous-button> <button v-else @@ -123,105 +207,28 @@ <translate translate-context="Content/Settings/Button.Label/Verb"> Disable Subsonic access </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Settings/Title"> - Disable Subsonic API access? - </translate> - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Settings/Paragraph"> - This will completely disable access to the Subsonic API using from account. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="Popup/Settings/Button.Label"> - Disable access - </translate> - </div> + <template #modal-header> + <p> + <translate translate-context="Popup/Settings/Title"> + Disable Subsonic API access? + </translate> + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Settings/Paragraph"> + This will completely disable access to the Subsonic API using from account. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="Popup/Settings/Button.Label"> + Disable access + </translate> + </div> + </template> </dangerous-button> </template> </form> </template> - -<script> -import axios from 'axios' -import PasswordInput from '@/components/forms/PasswordInput.vue' - -export default { - components: { - PasswordInput - }, - data () { - return { - token: null, - errors: [], - success: false, - isLoading: false, - successMessage: '', - showToken: false - } - }, - computed: { - subsonicEnabled () { - return this.$store.state.instance.settings.subsonic.enabled.value - }, - labels () { - return { - subsonicField: this.$pgettext('Content/Password/Input.label', 'Your subsonic API password') - } - } - }, - created () { - this.fetchToken() - }, - methods: { - fetchToken () { - this.success = false - this.errors = [] - this.isLoading = true - const self = this - const url = `users/${this.$store.state.auth.username}/subsonic-token/` - return axios.get(url).then(response => { - self.token = response.data.subsonic_api_token - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - requestNewToken () { - this.successMessage = this.$pgettext('Content/Settings/Message', 'Password updated') - this.success = false - this.errors = [] - this.isLoading = true - const self = this - const url = `users/${this.$store.state.auth.username}/subsonic-token/` - return axios.post(url, {}).then(response => { - self.showToken = true - self.token = response.data.subsonic_api_token - self.isLoading = false - self.success = true - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - disable () { - this.successMessage = this.$pgettext('Content/Settings/Message', 'Access disabled') - this.success = false - this.errors = [] - this.isLoading = true - const self = this - const url = `users/${this.$store.state.auth.username}/subsonic-token/` - return axios.delete(url).then(response => { - self.isLoading = false - self.token = null - self.success = true - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/channels/AlbumForm.vue b/front/src/components/channels/AlbumForm.vue index 1e042b5cd15ec354d36299877258058ea0c8f7de..3402734636e44451e9ffc7626e3953d141c873b2 100644 --- a/front/src/components/channels/AlbumForm.vue +++ b/front/src/components/channels/AlbumForm.vue @@ -1,3 +1,53 @@ +<script setup lang="ts"> +import type { BackendError, Channel } from '~/types' + +import { computed, watch, ref } from 'vue' +import axios from 'axios' + +interface Events { + (e: 'submittable', value: boolean): void + (e: 'loading', value: boolean): void + (e: 'created'): void +} + +interface Props { + channel: Channel +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const title = ref('') + +const errors = ref([] as string[]) +const isLoading = ref(false) +const submit = async () => { + isLoading.value = true + errors.value = [] + + try { + await axios.post('albums/', { + title: title.value, + artist: props.channel.artist?.id + }) + + emit('created') + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const submittable = computed(() => title.value.length > 0) +watch(submittable, (value) => emit('submittable', value)) +watch(isLoading, (value) => emit('loading', value)) + +defineExpose({ + submit +}) +</script> + <template> <form :class="['ui', {loading: isLoading}, 'form']" @@ -27,63 +77,9 @@ <translate translate-context="*/*/*/Noun">Title</translate> </label> <input - v-model="values.title" + v-model="title" type="text" > </div> </form> </template> -<script> -import axios from 'axios' - -export default { - components: {}, - props: { - channel: { type: Object, required: true } - }, - data () { - return { - errors: [], - isLoading: false, - values: { - title: '' - } - } - }, - computed: { - submittable () { - return this.values.title.length > 0 - } - }, - watch: { - submittable (v) { - this.$emit('submittable', v) - }, - isLoading (v) { - this.$emit('loading', v) - } - }, - methods: { - - submit () { - const self = this - self.isLoading = true - self.errors = [] - const payload = { - ...this.values, - artist: this.channel.artist.id - } - return axios.post('albums/', payload).then( - response => { - self.isLoading = false - self.$emit('created') - }, - error => { - self.errors = error.backendErrors - self.isLoading = false - } - ) - } - } -} -</script> diff --git a/front/src/components/channels/AlbumModal.vue b/front/src/components/channels/AlbumModal.vue index 586bcba85e4b753bac4b78af4be9d395549ce613..2dc95c1c9fc12e344491032fc192ad02af4b9e22 100644 --- a/front/src/components/channels/AlbumModal.vue +++ b/front/src/components/channels/AlbumModal.vue @@ -1,19 +1,46 @@ +<script setup lang="ts"> +import type { Channel } from '~/types' +import SemanticModal from '~/components/semantic/Modal.vue' +import ChannelAlbumForm from '~/components/channels/AlbumForm.vue' +import { watch, ref } from 'vue' + +interface Events { + (e: 'created'): void +} + +interface Props { + channel: Channel +} + +const emit = defineEmits<Events>() +defineProps<Props>() + +const isLoading = ref(false) +const submittable = ref(false) +const show = ref(false) + +watch(show, () => { + isLoading.value = false + submittable.value = false +}) + +const albumForm = ref() +</script> + <template> - <modal + <semantic-modal + v-model:show="show" class="small" - :show.sync="show" > <h4 class="header"> <translate - v-if="channel.content_category === 'podcasts'" - key="1" + v-if="channel.content_category === 'podcast'" translate-context="Popup/Channels/Title/Verb" > New series </translate> <translate v-else - key="2" translate-context="Popup/Channels/Title" > New album @@ -25,7 +52,7 @@ :channel="channel" @loading="isLoading = $event" @submittable="submittable = $event" - @created="$emit('created', $event)" + @created="emit('created')" /> </div> <div class="actions"> @@ -37,38 +64,12 @@ <button :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="!submittable" - @click.stop.prevent="$refs.albumForm.submit()" + @click.stop.prevent="albumForm.submit()" > <translate translate-context="*/*/Button.Label"> Create </translate> </button> </div> - </modal> + </semantic-modal> </template> - -<script> -import Modal from '@/components/semantic/Modal.vue' -import ChannelAlbumForm from '@/components/channels/AlbumForm.vue' - -export default { - components: { - Modal, - ChannelAlbumForm - }, - props: { channel: { type: Object, required: true } }, - data () { - return { - isLoading: false, - submittable: false, - show: false - } - }, - watch: { - show () { - this.isLoading = false - this.submittable = false - } - } -} -</script> diff --git a/front/src/components/channels/AlbumSelect.vue b/front/src/components/channels/AlbumSelect.vue index f307dc86461b40429cce742e62a6e1a642f8abe7..3542aa3432231284ecd47ffe98e81487e39f12e7 100644 --- a/front/src/components/channels/AlbumSelect.vue +++ b/front/src/components/channels/AlbumSelect.vue @@ -1,22 +1,65 @@ +<script setup lang="ts"> +import type { Album, Channel } from '~/types' + +import axios from 'axios' +import { useVModel } from '@vueuse/core' +import { reactive, ref, watch } from 'vue' + +interface Events { + (e: 'update:modelValue', value: string): void +} + +interface Props { + modelValue: string | null + channel: Channel | null +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + modelValue: null, + channel: null +}) + +const value = useVModel(props, 'modelValue', emit) + +const albums = reactive<Album[]>([]) + +const isLoading = ref(false) +const fetchData = async () => { + albums.length = 0 + if (!props.channel?.artist) return + + isLoading.value = true + const response = await axios.get('albums/', { + params: { + artist: props.channel?.artist.id, + include_channels: 'true' + } + }) + + albums.push(...response.data.results) + isLoading.value = false +} + +watch(() => props.channel, fetchData, { immediate: true }) +</script> + <template> <div> <label for="album-dropdown"> <translate v-if="channel && channel.artist && channel.artist.content_category === 'podcast'" - key="1" translate-context="*/*/*" >Series</translate> <translate v-else - key="2" translate-context="*/*/*" >Album</translate> </label> <select id="album-dropdown" - :value="value" + v-model="value" class="ui search normal dropdown" - @input="$emit('input', $event.target.value)" > <option value=""> <translate translate-context="*/*/*"> @@ -40,39 +83,3 @@ </select> </div> </template> -<script> -import axios from 'axios' - -export default { - props: { - value: { type: Number, default: null }, - channel: { type: Object, default: () => ({}) } - }, - data () { - return { - albums: [], - isLoading: false - } - }, - watch: { - async channel () { - await this.fetchData() - } - }, - async created () { - await this.fetchData() - }, - methods: { - async fetchData () { - this.albums = [] - if (!this.channel || !this.channel.artist) { - return - } - this.isLoading = true - const response = await axios.get('albums/', { params: { artist: this.channel.artist.id, include_channels: 'true' } }) - this.albums = response.data.results - this.isLoading = false - } - } -} -</script> diff --git a/front/src/components/channels/LicenseSelect.vue b/front/src/components/channels/LicenseSelect.vue index 63a598bd1012aea85e9481f639042a2b23cda586..4fad1f83c4479ee01e5d00fefea4f8a5f12ca030 100644 --- a/front/src/components/channels/LicenseSelect.vue +++ b/front/src/components/channels/LicenseSelect.vue @@ -1,3 +1,57 @@ +<script setup lang="ts"> +import type { License } from '~/types' + +import { computed, reactive, ref } from 'vue' +import axios from 'axios' +import { useVModel } from '@vueuse/core' + +interface Events { + (e: 'update:modelValue', value: string): void +} + +interface Props { + modelValue: string | null +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + modelValue: null +}) + +const value = useVModel(props, 'modelValue', emit) + +const availableLicenses = reactive<License[]>([]) +const featuredLicensesIds = [ + 'cc0-1.0', + 'cc-by-4.0', + 'cc-by-sa-4.0', + 'cc-by-nc-4.0', + 'cc-by-nc-sa-4.0', + 'cc-by-nc-nd-4.0', + 'cc-by-nd-4.0' +] + +const featuredLicenses = computed(() => { + return availableLicenses.filter(({ code }) => featuredLicensesIds.includes(code)) +}) + +const currentLicense = computed(() => { + if (!value.value) return null + return availableLicenses.find(({ code }) => code === value.value) ?? null +}) + +const isLoading = ref(false) +const fetchLicenses = async () => { + isLoading.value = true + const response = await axios.get('licenses/') + availableLicenses.length = 0 + availableLicenses.push(...response.data.results) + isLoading.value = false +} + +fetchLicenses() +</script> + <template> <div> <label for="license-dropdown"> @@ -5,9 +59,8 @@ </label> <select id="license-dropdown" - :value="value" + v-model="value" class="ui search normal dropdown" - @input="$emit('input', $event.target.value)" > <option value=""> <translate translate-context="*/*/*"> @@ -29,7 +82,7 @@ > <a v-if="value" - :href="currentLicense.url" + :href="currentLicense?.url" target="_blank" rel="noreferrer noopener" > @@ -38,53 +91,3 @@ </p> </div> </template> -<script> -import axios from 'axios' - -export default { - props: { value: { type: String, default: null } }, - data () { - return { - availableLicenses: [], - featuredLicensesIds: [ - 'cc0-1.0', - 'cc-by-4.0', - 'cc-by-sa-4.0', - 'cc-by-nc-4.0', - 'cc-by-nc-sa-4.0', - 'cc-by-nc-nd-4.0', - 'cc-by-nd-4.0' - ], - isLoading: false - } - }, - computed: { - featuredLicenses () { - const self = this - return this.availableLicenses.filter((l) => { - return self.featuredLicensesIds.indexOf(l.code) > -1 - }) - }, - currentLicense () { - const self = this - if (this.value) { - return this.availableLicenses.filter((l) => { - return l.code === self.value - })[0] - } - return null - } - }, - async created () { - await this.fetchLicenses() - }, - methods: { - async fetchLicenses () { - this.isLoading = true - const response = await axios.get('licenses/') - this.availableLicenses = response.data.results - this.isLoading = false - } - } -} -</script> diff --git a/front/src/components/channels/SubscribeButton.vue b/front/src/components/channels/SubscribeButton.vue index 3d371e97a9b219d5d4655b8d3fc9a5212feead10..39dd8e7c65b26a26504f1d34a2731b3c7b84bd9d 100644 --- a/front/src/components/channels/SubscribeButton.vue +++ b/front/src/components/channels/SubscribeButton.vue @@ -1,3 +1,47 @@ +<script setup lang="ts"> +import type { Channel } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { computed, ref } from 'vue' +import { useStore } from '~/store' + +import LoginModal from '~/components/common/LoginModal.vue' + +interface Events { + (e: 'unsubscribed'): void + (e: 'subscribed'): void +} + +interface Props { + channel: Channel +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const { $pgettext } = useGettext() +const store = useStore() + +const isSubscribed = computed(() => store.getters['channels/isSubscribed'](props.channel.uuid)) +const title = computed(() => isSubscribed.value + ? $pgettext('Content/Channel/Button/Verb', 'Unsubscribe') + : $pgettext('Content/Channel/Button/Verb', 'Subscribe') +) + +const message = computed(() => ({ + authMessage: $pgettext('Popup/Message/Paragraph', 'You need to be logged in to subscribe to this channel') +})) + +const toggle = async () => { + await store.dispatch('channels/toggle', props.channel.uuid) + + if (isSubscribed.value) emit('unsubscribed') + else emit('subscribed') +} + +const loginModal = ref() +</script> + <template> <button v-if="$store.state.auth.authenticated" @@ -5,76 +49,22 @@ @click.stop="toggle" > <i class="heart icon" /> - <translate - v-if="isSubscribed" - translate-context="Content/Track/Button.Message" - > - Unsubscribe - </translate> - <translate - v-else - translate-context="Content/Track/*/Verb" - > - Subscribe - </translate> + {{ title }} </button> <button v-else :class="['ui', 'pink', 'icon', 'labeled', 'button']" - @click="$refs.loginModal.show = true" + @click="loginModal.show = true" > <i class="heart icon" /> - <translate translate-context="Content/Track/*/Verb"> - Subscribe - </translate> + {{ title }} <login-modal ref="loginModal" class="small" :next-route="$route.fullPath" :message="message.authMessage" - :cover="channel.artist.cover" - @created="$refs.loginModal.show = false;" + :cover="channel.artist?.cover!" + @created="loginModal.show = false" /> </button> </template> - -<script> -import LoginModal from '@/components/common/LoginModal.vue' - -export default { - components: { - LoginModal - }, - props: { - channel: { type: Object, required: true } - }, - computed: { - title () { - if (this.isSubscribed) { - return this.$pgettext('Content/Channel/Button/Verb', 'Subscribe') - } else { - return this.$pgettext('Content/Channel/Button/Verb', 'Unsubscribe') - } - }, - isSubscribed () { - return this.$store.getters['channels/isSubscribed'](this.channel.uuid) - }, - message () { - return { - authMessage: this.$pgettext('Popup/Message/Paragraph', 'You need to be logged in to subscribe to this channel') - } - } - }, - methods: { - toggle () { - if (this.isSubscribed) { - this.$emit('unsubscribed') - } else { - this.$emit('subscribed') - } - this.$store.dispatch('channels/toggle', this.channel.uuid) - } - } - -} -</script> diff --git a/front/src/components/channels/UploadForm.vue b/front/src/components/channels/UploadForm.vue index 79fc49dbf3cea3414abcee61b7eb8e460507ec8a..82128e36500b0409967c1050fa76dc9600a58093 100644 --- a/front/src/components/channels/UploadForm.vue +++ b/front/src/components/channels/UploadForm.vue @@ -1,6 +1,408 @@ +<script setup lang="ts"> +import type { BackendError, Channel, Upload, Track } from '~/types' +import type { VueUploadItem } from 'vue-upload-component' + +import { computed, ref, reactive, watchEffect, watch } from 'vue' +import { whenever, useCurrentElement } from '@vueuse/core' +import { humanSize } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' +import $ from 'jquery' + +import UploadMetadataForm from '~/components/channels/UploadMetadataForm.vue' +import FileUploadWidget from '~/components/library/FileUploadWidget.vue' +import LicenseSelect from '~/components/channels/LicenseSelect.vue' +import AlbumSelect from '~/components/channels/AlbumSelect.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'status', status: UploadStatus): void + (e: 'step', step: 1 | 2 | 3): void +} + +interface Props { + channel?: Channel | null +} + +interface QuotaStatus { + remaining: number +} + +interface UploadStatus { + totalSize: number + totalFiles: number + progress: number + speed: number + quotaStatus: QuotaStatus + uploadedSize: number + canSubmit: boolean +} + +interface UploadedFile extends VueUploadItem { + _fileObj?: VueUploadItem + removed: boolean + metadata: Metadata +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + channel: null +}) + +const { $pgettext } = useGettext() +const store = useStore() + +const errors = ref([] as string[]) + +const values = reactive({ + channel: props.channel?.uuid ?? null, + license: null, + album: null +}) + +// +// Channels +// +const availableChannels = reactive({ + channels: [] as Channel[], + count: 0, + loading: false +}) + +const fetchChannels = async () => { + availableChannels.loading = true + + try { + const response = await axios.get('channels/', { params: { scope: 'me' } }) + availableChannels.channels = response.data.results + availableChannels.count = response.data.count + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + availableChannels.loading = false +} + +const selectedChannel = computed(() => availableChannels.channels.find((channel) => channel.uuid === values.channel) ?? null) + +// +// Quota and space +// +const quotaStatus = ref() +const fetchQuota = async () => { + try { + const response = await axios.get('users/me/') + quotaStatus.value = response.data.quota_status as QuotaStatus + } catch (error) { + errors.value = (error as BackendError).backendErrors + } +} + +const uploadedSize = computed(() => { + let uploaded = 0 + + for (const file of uploadedFiles.value) { + if (file._fileObj && !file.error) { + uploaded += (file.size ?? 0) * +(file.progress ?? 0) / 100 + } + } + + return uploaded +}) + +const remainingSpace = computed(() => Math.max( + (quotaStatus.value?.remaining ?? 0) - uploadedSize.value / 1e6, + 0 +)) + +// +// Draft uploads +// +const includeDraftUploads = ref() +const draftUploads = ref([] as Upload[]) +whenever(() => values.channel !== null, async () => { + files.value = [] + draftUploads.value = [] + + try { + const response = await axios.get('uploads', { + params: { import_status: 'draft', channel: values.channel } + }) + + draftUploads.value = response.data.results as Upload[] + for (const upload of response.data.results as Upload[]) { + // @ts-expect-error TODO (wvffle): Resolve type errors when API client is done + uploadImportData[upload.uuid] = upload.import_metadata ?? {} + } + } catch (error) { + errors.value = (error as BackendError).backendErrors + } +}, { immediate: true }) + +// +// Uploading files +// +const upload = ref() +const beforeFileUpload = (newFile: VueUploadItem) => { + if (!newFile) return + if (remainingSpace.value < (newFile.size ?? Infinity) / 1e6) { + newFile.error = 'denied' + } else { + upload.value.active = true + } +} + +const baseImportMetadata = computed(() => ({ + channel: values.channel, + import_status: 'draft', + import_metadata: { license: values.license, album: values.album } +})) + +// +// Uploaded files +// +const files = ref([] as VueUploadItem[]) +const removed = reactive(new Set<string>()) +const uploadedFiles = computed(() => { + const uploadedFiles = files.value.map(file => { + const data = { + ...file, + _fileObj: file, + removed: false, + metadata: {} + } as UploadedFile + + if (file.response?.uuid) { + const uuid = file.response.uuid as string + data.metadata = uploadImportData[uuid] ?? uploadData[uuid].import_metadata ?? {} + data.removed = removed.has(uuid) + } + + return data + }) + + if (includeDraftUploads.value) { + // We have two different objects: draft uploads (so already uploaded in a previous) + // session, and files uploaded in the current session + // so we ensure we have a similar structure for both. + uploadedFiles.unshift(...draftUploads.value.map(upload => ({ + id: upload.uuid, + response: upload, + __filename: null, + size: upload.size, + progress: '100.00', + name: upload.source?.replace('upload://', '') ?? '', + active: false, + removed: removed.has(upload.uuid), + metadata: uploadImportData[upload.uuid] ?? audioMetadata[upload.uuid] ?? upload.import_metadata ?? {} + } as UploadedFile))) + } + + return uploadedFiles.filter(file => !file.removed) as UploadedFile[] +}) + +const uploadedFilesById = computed(() => uploadedFiles.value.reduce((acc: Record<string, VueUploadItem>, file) => { + acc[file.response?.uuid] = file + return acc +}, {})) + +// +// Metadata +// +type Metadata = Pick<Track, 'title' | 'description' | 'position' | 'tags'> & { cover: string } +const uploadImportData = reactive({} as Record<string, Metadata>) +const audioMetadata = reactive({} as Record<string, Record<string, string>>) +const uploadData = reactive({} as Record<string, { import_metadata: Metadata }>) +const patchUpload = async (id: string, data: Record<string, Metadata>) => { + const response = await axios.patch(`uploads/${id}/`, data) + uploadData[id] = response.data + uploadImportData[id] = response.data.import_metadata +} + +const fetchAudioMetadata = async (uuid: string) => { + delete audioMetadata[uuid] + + const response = await axios.get(`uploads/${uuid}/audio-file-metadata/`) + audioMetadata[uuid] = response.data + + const uploadedFile = uploadedFilesById.value[uuid] + if (uploadedFile.response?.import_metadata.title === uploadedFile._fileObj?.name.replace(/\.[^/.]+$/, '') && response.data.title) { + // Replace existing title deduced from file by the one in audio file metadata, if any + uploadImportData[uuid].title = response.data.title + } + + for (const key of ['title', 'position', 'tags'] as const) { + if (uploadImportData[uuid][key] === undefined) { + uploadImportData[uuid][key] = response.data[key] as never + } + } + + if (uploadImportData[uuid].description === undefined) { + uploadImportData[uuid].description = (response.data.description ?? {}).text + } + + await patchUpload(uuid, { import_metadata: uploadImportData[uuid] }) +} + +watchEffect(async () => { + for (const file of files.value) { + if (file.response?.uuid && audioMetadata[file.response.uuid] === undefined) { + uploadData[file.response.uuid] = file.response as { import_metadata: Metadata } + uploadImportData[file.response.uuid] = file.response.import_metadata + fetchAudioMetadata(file.response.uuid) + } + } +}) + +// +// Select upload +// +const selectedUploadId = ref() +const selectedUpload = computed(() => { + if (!selectedUploadId.value) return null + + const selected = uploadedFiles.value.find(file => file.response?.uuid === selectedUploadId.value) + if (!selected) return null + + return { + ...(selected.response ?? {}), + _fileObj: selected._fileObj + } as Upload & { _fileObj?: VueUploadItem } +}) + +// +// Actions +// +const remove = async (file: VueUploadItem) => { + if (file.response?.uuid) { + removed.add(file.response.uuid) + try { + await axios.delete(`uploads/${file.response.uuid}/`) + } catch (error) { + useErrorHandler(error as Error) + } + } else { + upload.value.remove(file) + } +} + +const retry = async (file: VueUploadItem) => { + upload.value.update(file, { error: '', progress: '0.00' }) + upload.value.active = true +} + +// +// Init +// +fetchChannels() +fetchQuota() + +// +// Dropdown +// +const el = useCurrentElement() +watch(() => availableChannels.channels, () => { + $(el.value).find('#channel-dropdown').dropdown({ + onChange (value) { + values.channel = value + }, + values: availableChannels.channels.map((channel) => { + const value = { + name: channel.artist?.name ?? '', + value: channel.uuid, + selected: props.channel?.uuid === channel.uuid + } as { + name: string + value: string + selected: boolean + image?: string + imageClass?: string + icon?: string + iconClass?: string + } + + if (channel.artist?.cover?.urls.medium_square_crop) { + value.image = store.getters['instance/absoluteUrl'](channel.artist.cover.urls.medium_square_crop) + value.imageClass = channel.artist.content_category !== 'podcast' + ? 'ui image avatar' + : 'ui image' + } else { + value.icon = 'user' + value.iconClass = channel.artist?.content_category !== 'podcast' + ? 'circular icon' + : 'bordered icon' + } + + return value + }) + }) + + $(el.value).find('#channel-dropdown').dropdown('hide') +}) + +// +// Step +// +const step = ref<1 | 2 | 3>(1) +watchEffect(() => { + emit('step', step.value) + + if (step.value === 2) { + selectedUploadId.value = null + } +}) + +watch(selectedUploadId, async (to, from) => { + if (to) { + step.value = 3 + } + + if (!to && step.value !== 2) { + step.value = 2 + } + + if (from) { + await patchUpload(from, { import_metadata: uploadImportData[from] }) + } +}) + +// +// Status +// + +watchEffect(() => { + const uploaded = uploadedFiles.value + const totalSize = uploaded.reduce( + (acc, uploadedFile) => !uploadedFile.error + ? acc + (uploadedFile.size ?? 0) + : acc, + 0 + ) + + const activeFile = files.value.find(file => file.active) + + emit('status', { + totalSize, + totalFiles: uploaded.length, + progress: Math.floor(uploadedSize.value / totalSize * 100), + speed: activeFile?.speed ?? 0, + quotaStatus: quotaStatus.value, + uploadedSize: uploadedSize.value, + canSubmit: activeFile !== undefined && uploadedFiles.value.length > 0 + }) +}) + +const labels = computed(() => ({ + editTitle: $pgettext('Content/*/Button.Label/Verb', 'Edit') +})) +</script> + <template> <form - :class="['ui', {loading: isLoadingStep1}, 'form component-file-upload']" + :class="['ui', { loading: availableChannels.loading }, 'form component-file-upload']" @submit.stop.prevent > <div @@ -53,7 +455,7 @@ </p> </div> </div> - <template v-if="step >= 2 && step < 4"> + <template v-if="step === 2 || step === 3"> <div v-if="remainingSpace === 0" role="alert" @@ -70,7 +472,7 @@ </div> <template v-else> <div - v-if="step === 2 && draftUploads && draftUploads.length > 0 && includeDraftUploads === null" + v-if="step === 2 && draftUploads?.length > 0 && includeDraftUploads === undefined" class="ui visible info message" > <p> @@ -101,25 +503,25 @@ :class="[{hidden: step === 3}]" > <div - v-for="(file, idx) in uploadedFiles" - :key="idx" + v-for="file in uploadedFiles" + :key="file.id" class="channel-file" > <div class="content"> <div - v-if="file.response.uuid" + v-if="file.response?.uuid" role="button" class="ui basic icon button" :title="labels.editTitle" - @click.stop.prevent="selectedUploadId = file.response.uuid" + @click.stop.prevent="selectedUploadId = file.response?.uuid" > <i class="pencil icon" /> </div> <div v-if="file.error" class="ui basic danger icon label" - :title="file.error" - @click.stop.prevent="selectedUploadId = file.response.uuid" + :title="file.error.toString()" + @click.stop.prevent="selectedUploadId = file.response?.uuid" > <i class="warning sign icon" /> </div> @@ -136,8 +538,8 @@ {{ file.name }} </template> <div class="sub header"> - <template v-if="file.response.uuid"> - {{ file.size | humanSize }} + <template v-if="file.response?.uuid"> + {{ humanSize(file.size ?? 0) }} <template v-if="file.response.duration"> · <human-duration :duration="file.response.duration" /> </template> @@ -145,27 +547,24 @@ <template v-else> <translate v-if="file.active" - key="1" translate-context="Channels/*/*" > Uploading </translate> <translate v-else-if="file.error" - key="2" translate-context="Channels/*/*" > Errored </translate> <translate v-else - key="3" translate-context="Channels/*/*" > Pending </translate> - · {{ file.size | humanSize }} - · {{ parseInt(file.progress) }}% + · {{ humanSize(file.size ?? 0) }} + · {{ parseFloat(file.progress ?? '0') }}% </template> · <a @click.stop.prevent="remove(file)"> <translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate> @@ -182,10 +581,8 @@ </div> <upload-metadata-form v-if="selectedUpload" - :key="selectedUploadId" + v-model:values="uploadImportData[selectedUploadId]" :upload="selectedUpload" - :values="uploadImportData[selectedUploadId]" - @values="setDynamic('uploadImportData', selectedUploadId, $event)" /> <div v-if="step === 2" @@ -205,17 +602,16 @@ </div> <file-upload-widget ref="upload" + v-model="files" :class="['ui', 'icon', 'basic', 'button', 'channels', {hidden: step === 3}]" - :post-action="uploadUrl" + :post-action="$store.getters['instance/absoluteUrl']('/api/v1/uploads/')" :multiple="true" :data="baseImportMetadata" :drop="true" :extensions="$store.state.ui.supportedExtensions" - :value="files" name="audio_file" :thread="1" - @input="updateFiles" - @input-file="inputFile" + @input-file="beforeFileUpload" > <div> <i class="upload icon" /> @@ -235,385 +631,3 @@ </template> </form> </template> -<script> -import axios from 'axios' -import $ from 'jquery' - -import LicenseSelect from '@/components/channels/LicenseSelect.vue' -import AlbumSelect from '@/components/channels/AlbumSelect.vue' -import FileUploadWidget from '@/components/library/FileUploadWidget.vue' -import UploadMetadataForm from '@/components/channels/UploadMetadataForm.vue' - -function setIfEmpty (obj, k, v) { - if (obj[k] !== undefined) { - return - } - obj[k] = v -} - -export default { - components: { - AlbumSelect, - LicenseSelect, - FileUploadWidget, - UploadMetadataForm - }, - props: { - channel: { type: Object, default: null, required: false } - }, - data () { - return { - availableChannels: { - results: [], - count: 0 - }, - audioMetadata: {}, - uploadData: {}, - uploadImportData: {}, - draftUploads: null, - files: [], - errors: [], - removed: [], - includeDraftUploads: null, - uploadUrl: this.$store.getters['instance/absoluteUrl']('/api/v1/uploads/'), - quotaStatus: null, - isLoadingStep1: true, - step: 1, - values: { - channel: (this.channel || {}).uuid, - license: null, - album: null - }, - selectedUploadId: null - } - }, - computed: { - labels () { - return { - editTitle: this.$pgettext('Content/*/Button.Label/Verb', 'Edit') - - } - }, - baseImportMetadata () { - return { - channel: this.values.channel, - import_status: 'draft', - import_metadata: { license: this.values.license, album: this.values.album || null } - } - }, - remainingSpace () { - if (!this.quotaStatus) { - return 0 - } - return Math.max(0, this.quotaStatus.remaining - (this.uploadedSize / (1000 * 1000))) - }, - selectedChannel () { - const self = this - return this.availableChannels.results.filter((c) => { - return c.uuid === self.values.channel - })[0] - }, - selectedUpload () { - const self = this - if (!this.selectedUploadId) { - return null - } - const selected = this.uploadedFiles.filter((f) => { - return f.response && f.response.uuid === self.selectedUploadId - })[0] - return { - ...selected.response, - _fileObj: selected._fileObj - } - }, - uploadedFilesById () { - const data = {} - this.uploadedFiles.forEach((u) => { - data[u.response.uuid] = u - }) - return data - }, - uploadedFiles () { - const self = this - const files = this.files.map((f) => { - const data = { - ...f, - _fileObj: f, - metadata: {} - } - if (f.response && f.response.uuid) { - const uploadImportMetadata = self.uploadImportData[f.response.uuid] || self.uploadData[f.response.uuid].import_metadata - data.metadata = { - ...uploadImportMetadata - } - data.removed = self.removed.indexOf(f.response.uuid) >= 0 - } - return data - }) - let final = [] - if (this.includeDraftUploads) { - // we have two different objects: draft uploads (so already uploaded in a previous) - // session, and files uploaded in the current session - // so we ensure we have a similar structure for both. - - final = [ - ...this.draftUploads.map((u) => { - return { - response: u, - _fileObj: null, - size: u.size, - progress: 100, - name: u.source.replace('upload://', ''), - active: false, - removed: self.removed.indexOf(u.uuid) >= 0, - metadata: self.uploadImportData[u.uuid] || self.audioMetadata[u.uuid] || u.import_metadata - } - }), - ...files - ] - } else { - final = files - } - return final.filter((f) => { - return !f.removed - }) - }, - summaryData () { - let speed = null - let remaining = null - if (this.activeFile) { - speed = this.activeFile.speed - remaining = parseInt(this.totalSize / speed) - } - return { - totalFiles: this.uploadedFiles.length, - totalSize: this.totalSize, - uploadedSize: this.uploadedSize, - progress: parseInt(this.uploadedSize * 100 / this.totalSize), - canSubmit: !this.activeFile && this.uploadedFiles.length > 0, - speed, - remaining, - quotaStatus: this.quotaStatus - } - }, - totalSize () { - let total = 0 - this.uploadedFiles.forEach((f) => { - if (!f.error) { - total += f.size - } - }) - return total - }, - uploadedSize () { - let uploaded = 0 - this.uploadedFiles.forEach((f) => { - if (f._fileObj && !f.error) { - uploaded += f.size * (f.progress / 100) - } - }) - return uploaded - }, - activeFile () { - return this.files.filter((f) => { - return f.active - })[0] - } - }, - watch: { - 'availableChannels.results' () { - this.setupChannelsDropdown() - }, - 'values.channel': { - async handler (v) { - this.files = [] - if (v) { - await this.fetchDraftUploads(v) - } - }, - immediate: true - }, - step: { - handler (value) { - this.$emit('step', value) - if (value === 2) { - this.selectedUploadId = null - } - }, - immediate: true - }, - async selectedUploadId (v, o) { - if (v) { - this.step = 3 - } else { - this.step = 2 - } - if (o) { - await this.patchUpload(o, { import_metadata: this.uploadImportData[o] }) - } - }, - summaryData: { - handler (v) { - this.$emit('status', v) - }, - immediate: true - - } - }, - async created () { - this.isLoadingStep1 = true - const p1 = this.fetchChannels() - await p1 - this.isLoadingStep1 = false - this.fetchQuota() - }, - methods: { - async fetchChannels () { - const response = await axios.get('channels/', { params: { scope: 'me' } }) - this.availableChannels = response.data - }, - async patchUpload (id, data) { - const response = await axios.patch(`uploads/${id}/`, data) - this.uploadData[id] = response.data - this.uploadImportData[id] = response.data.import_metadata - }, - fetchQuota () { - const self = this - axios.get('users/me/').then((response) => { - self.quotaStatus = response.data.quota_status - }) - }, - publish () { - const self = this - self.isLoading = true - self.errors = [] - const ids = this.uploadedFiles.map((f) => { - return f.response.uuid - }) - const payload = { - action: 'publish', - objects: ids - } - return axios.post('uploads/action/', payload).then( - response => { - self.isLoading = false - self.$emit('published', { - uploads: self.uploadedFiles.map((u) => { - return { - ...u.response, - import_status: 'pending' - } - }), - channel: self.selectedChannel - }) - }, - error => { - self.errors = error.backendErrors - } - ) - }, - setupChannelsDropdown () { - const self = this - $(this.$el).find('#channel-dropdown').dropdown({ - onChange (value, text, $choice) { - self.values.channel = value - }, - values: this.availableChannels.results.map((c) => { - const d = { - name: c.artist.name, - value: c.uuid, - selected: self.channel && self.channel.uuid === c.uuid - } - if (c.artist.cover && c.artist.cover.urls.medium_square_crop) { - const coverUrl = self.$store.getters['instance/absoluteUrl'](c.artist.cover.urls.medium_square_crop) - d.image = coverUrl - if (c.artist.content_category === 'podcast') { - d.imageClass = 'ui image' - } else { - d.imageClass = 'ui avatar image' - } - } else { - d.icon = 'user' - if (c.artist.content_category === 'podcast') { - d.iconClass = 'bordered icon' - } else { - d.iconClass = 'circular icon' - } - } - return d - }) - }) - $(this.$el).find('#channel-dropdown').dropdown('hide') - }, - inputFile (newFile, oldFile) { - if (!newFile) { - return - } - if (this.remainingSpace < newFile.size / (1000 * 1000)) { - newFile.error = 'denied' - } else { - this.$refs.upload.active = true - } - }, - fetchAudioMetadata (uuid) { - const self = this - self.audioMetadata[uuid] = null - axios.get(`uploads/${uuid}/audio-file-metadata/`).then((response) => { - self.setDynamic('audioMetadata', uuid, response.data) - const uploadedFile = self.uploadedFilesById[uuid] - if (uploadedFile._fileObj && uploadedFile.response.import_metadata.title === uploadedFile._fileObj.name.replace(/\.[^/.]+$/, '') && response.data.title) { - // replace existing title deduced from file by the one in audio file metadat, if any - self.uploadImportData[uuid].title = response.data.title - } else { - setIfEmpty(self.uploadImportData[uuid], 'title', response.data.title) - } - setIfEmpty(self.uploadImportData[uuid], 'title', response.data.title) - setIfEmpty(self.uploadImportData[uuid], 'position', response.data.position) - setIfEmpty(self.uploadImportData[uuid], 'tags', response.data.tags) - setIfEmpty(self.uploadImportData[uuid], 'description', (response.data.description || {}).text) - self.patchUpload(uuid, { import_metadata: self.uploadImportData[uuid] }) - }) - }, - setDynamic (objName, key, data) { - // cf https://vuejs.org/v2/guide/reactivity.html#Change-Detection-Caveats - const newData = {} - newData[key] = data - this[objName] = Object.assign({}, this[objName], newData) - }, - updateFiles (value) { - const self = this - this.files = value - this.files.forEach((f) => { - if (f.response && f.response.uuid && self.audioMetadata[f.response.uuid] === undefined) { - self.uploadData[f.response.uuid] = f.response - self.setDynamic('uploadImportData', f.response.uuid, { - ...f.response.import_metadata - }) - self.fetchAudioMetadata(f.response.uuid) - } - }) - }, - async fetchDraftUploads (channel) { - const self = this - this.draftUploads = null - const response = await axios.get('uploads', { params: { import_status: 'draft', channel: channel } }) - this.draftUploads = response.data.results - this.draftUploads.forEach((u) => { - self.uploadImportData[u.uuid] = u.import_metadata - }) - }, - remove (file) { - if (file.response && file.response.uuid) { - axios.delete(`uploads/${file.response.uuid}/`) - this.removed.push(file.response.uuid) - } else { - this.$refs.upload.remove(file) - } - }, - retry (file) { - this.$refs.upload.update(file, { error: '', progress: '0.00' }) - this.$refs.upload.active = true - } - } -} -</script> diff --git a/front/src/components/channels/UploadMetadataForm.vue b/front/src/components/channels/UploadMetadataForm.vue index 127a275e631949fad23f51ee8fd4a71a8294f997..06c8d72284416087962b5af751aae74a2cedd5e5 100644 --- a/front/src/components/channels/UploadMetadataForm.vue +++ b/front/src/components/channels/UploadMetadataForm.vue @@ -1,3 +1,34 @@ +<script setup lang="ts"> +import type { Upload, Track } from '~/types' + +import { reactive, computed, watch } from 'vue' + +import TagsSelector from '~/components/library/TagsSelector.vue' +import AttachmentInput from '~/components/common/AttachmentInput.vue' + +type Values = Pick<Track, 'title' | 'description' | 'position' | 'tags'> & { cover: string } +interface Events { + (e: 'update:values', values: Values): void +} + +interface Props { + upload: Upload + values: Values | null +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + values: null +}) + +const newValues = reactive<Values>({ + ...(props.values ?? props.upload.import_metadata ?? {}) as Values +}) + +const isLoading = computed(() => !props.upload) +watch(newValues, (values) => emit('update:values', values), { immediate: true }) +</script> + <template> <div :class="['ui', {loading: isLoading}, 'form']"> <div class="ui required field"> @@ -11,13 +42,9 @@ </div> <attachment-input v-model="newValues.cover" - :required="false" - @delete="newValues.cover = null" + @delete="newValues.cover = ''" > - <translate - slot="label" - translate-context="Content/Channel/*" - > + <translate translate-context="Content/Channel/*"> Track Picture </translate> </attachment-input> @@ -56,37 +83,3 @@ </div> </div> </template> - -<script> -import TagsSelector from '@/components/library/TagsSelector.vue' -import AttachmentInput from '@/components/common/AttachmentInput.vue' - -export default { - components: { - TagsSelector, - AttachmentInput - }, - props: { - upload: { type: Object, required: true }, - values: { type: Object, required: true } - }, - data () { - return { - newValues: { ...this.values } || this.upload.import_metadata - } - }, - computed: { - isLoading () { - return !!this.metadata - } - }, - watch: { - newValues: { - handler (v) { - this.$emit('values', v) - }, - immediate: true - } - } -} -</script> diff --git a/front/src/components/channels/UploadModal.vue b/front/src/components/channels/UploadModal.vue index a981098157e740eeeafb33513357fc3c080861e6..44f6238775e9e3d32e83c6f3da03d5615b931ebd 100644 --- a/front/src/components/channels/UploadModal.vue +++ b/front/src/components/channels/UploadModal.vue @@ -1,34 +1,79 @@ +<script setup lang="ts"> +import SemanticModal from '~/components/semantic/Modal.vue' +import ChannelUploadForm from '~/components/channels/UploadForm.vue' +import { humanSize } from '~/utils/filters' +import { useRouter } from 'vue-router' +import { useStore } from '~/store' +import { ref, computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +const store = useStore() +const router = useRouter() +router.beforeEach(() => store.commit('channels/showUploadModal', { show: false })) + +const update = (value: boolean) => store.commit('channels/showUploadModal', { show: value }) + +const { $npgettext, $gettext } = useGettext() + +const uploadForm = ref() + +const statusData = ref() +const statusInfo = computed(() => { + if (!statusData.value) { + return [] + } + + const info = [] + if (statusData.value.totalSize) { + info.push(humanSize(statusData.value.totalSize)) + } + + if (statusData.value.totalFiles) { + const msg = $npgettext('*/*/*', '%{ count } file', '%{ count } files', statusData.value.totalFiles) + info.push($gettext(msg, { count: statusData.value.totalFiles })) + } + + if (statusData.value.progress) { + info.push(`${statusData.value.progress}%`) + } + + if (statusData.value.speed) { + info.push(`${humanSize(statusData.value.speed)}/s`) + } + + return info +}) + +const step = ref(1) +const isLoading = ref(false) +</script> + <template> - <modal + <semantic-modal + v-model:show="$store.state.channels.showUploadModal" class="small" - :show="$store.state.channels.showUploadModal" - @update:show="update" > <h4 class="header"> <translate v-if="step === 1" - key="1" translate-context="Popup/Channels/Title/Verb" > Publish audio </translate> <translate v-else-if="step === 2" - key="2" translate-context="Popup/Channels/Title" > Files to upload </translate> <translate v-else-if="step === 3" - key="3" translate-context="Popup/Channels/Title" > Upload details </translate> <translate v-else-if="step === 4" - key="4" translate-context="Popup/Channels/Title" > Processing uploads @@ -37,12 +82,10 @@ <div class="scrolling content"> <channel-upload-form ref="uploadForm" - :channel="$store.state.channels.uploadModalConfig.channel" + :channel="$store.state.channels.uploadModalConfig.channel ?? null" @step="step = $event" @loading="isLoading = $event" - @published="$store.commit('channels/publish', $event)" @status="statusData = $event" - @submittable="submittable = $event" /> </div> <div class="actions"> @@ -55,7 +98,7 @@ <translate translate-context="Content/Library/Paragraph"> Remaining storage space: </translate> - {{ (statusData.quotaStatus.remaining * 1000 * 1000) - statusData.uploadedSize | humanSize }} + {{ humanSize((statusData.quotaStatus.remaining - statusData.uploadedSize) * 1000 * 1000) }} </template> </div> <div class="ui hidden clearing divider mobile-only" /> @@ -70,7 +113,7 @@ <button v-else-if="step < 3" class="ui basic button" - @click.stop.prevent="$refs.uploadForm.step -= 1" + @click.stop.prevent="uploadForm.step -= 1" > <translate translate-context="*/*/Button.Label/Verb"> Previous step @@ -79,7 +122,7 @@ <button v-else-if="step === 3" class="ui basic button" - @click.stop.prevent="$refs.uploadForm.step -= 1" + @click.stop.prevent="uploadForm.step -= 1" > <translate translate-context="*/*/Button.Label/Verb"> Update @@ -88,7 +131,7 @@ <button v-if="step === 1" class="ui primary button" - @click.stop.prevent="$refs.uploadForm.step += 1" + @click.stop.prevent="uploadForm.step += 1" > <translate translate-context="*/*/Button.Label"> Next step @@ -101,8 +144,8 @@ <button :class="['ui', 'primary button', {loading: isLoading}]" type="submit" - :disabled="!statusData || !statusData.canSubmit" - @click.prevent.stop="$refs.uploadForm.publish" + :disabled="!statusData?.canSubmit || undefined" + @click.prevent.stop="uploadForm.publish" > <translate translate-context="*/Channels/Button.Label"> Publish @@ -112,7 +155,7 @@ ref="dropdown" v-dropdown class="ui floating dropdown icon button" - :disabled="!statusData || !statusData.canSubmit" + :disabled="!statusData?.canSubmit || undefined" > <i class="dropdown icon" /> <div class="menu"> @@ -138,63 +181,5 @@ </translate> </button> </div> - </modal> + </semantic-modal> </template> - -<script> -import Modal from '@/components/semantic/Modal.vue' -import ChannelUploadForm from '@/components/channels/UploadForm.vue' -import { humanSize } from '@/filters' - -export default { - components: { - Modal, - ChannelUploadForm - }, - data () { - return { - step: 1, - isLoading: false, - submittable: true, - statusData: null - } - }, - computed: { - labels () { - return {} - }, - statusInfo () { - if (!this.statusData) { - return [] - } - const info = [] - if (this.statusData.totalSize) { - info.push(humanSize(this.statusData.totalSize)) - } - if (this.statusData.totalFiles) { - const msg = this.$npgettext('*/*/*', '%{ count } file', '%{ count } files', this.statusData.totalFiles) - info.push( - this.$gettextInterpolate(msg, { count: this.statusData.totalFiles }) - ) - } - if (this.statusData.progress) { - info.push(`${this.statusData.progress}%`) - } - if (this.statusData.speed) { - info.push(`${humanSize(this.statusData.speed)}/s`) - } - return info - } - }, - watch: { - '$store.state.route.path' () { - this.$store.commit('channels/showUploadModal', { show: false }) - } - }, - methods: { - update (v) { - this.$store.commit('channels/showUploadModal', { show: v }) - } - } -} -</script> diff --git a/front/src/components/common/ActionFeedback.vue b/front/src/components/common/ActionFeedback.vue index e2a343f64df4d7b3d5f60e0e5549c1cc106a0178..78b5d1f46a72ee6305b0736c721154c4377c3e8f 100644 --- a/front/src/components/common/ActionFeedback.vue +++ b/front/src/components/common/ActionFeedback.vue @@ -1,3 +1,24 @@ +<script setup lang="ts"> +import { refAutoReset, toRefs } from '@vueuse/core' +import { watch } from 'vue' + +interface Props { + isLoading: boolean + size?: string +} + +const props = withDefaults(defineProps<Props>(), { + size: 'small' +}) + +const { isLoading, size } = toRefs(props) + +const isDone = refAutoReset(false, 2000) +watch(isLoading, loading => { + isDone.value = !loading +}) +</script> + <template> <span v-if="isLoading || isDone" @@ -13,40 +34,3 @@ /> </span> </template> - -<script> - -export default { - props: { - isLoading: { type: Boolean, required: true }, - size: { type: String, default: 'small' } - }, - data () { - return { - timer: null, - isDone: false - } - }, - watch: { - isLoading (v) { - const self = this - if (v && this.timer) { - clearTimeout(this.timer) - } - if (v) { - this.isDone = false - } else { - this.isDone = true - this.timer = setTimeout(() => { - self.isDone = false - }, (2000)) - } - } - }, - destroyed () { - if (this.timer) { - clearTimeout(this.timer) - } - } -} -</script> diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index 68a0c85941b2bb0747358229bf6ad55a2ea8f03d..959ccd6ad41b7852d6bb981f685463a798d9d4c3 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -1,3 +1,161 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { ref, computed, reactive, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +interface Action { + name: string + label: string + isDangerous?: boolean + allowAll?: boolean + confirmColor?: string + confirmationMessage?: string + filterChackable?: (item: any) => boolean +} + +interface Events { + (e: 'action-launched', data: any): void + (e: 'refresh'): void +} + +interface Props { + objectsData: { results: any[], count: number } + actions: Action[] + actionUrl: string + idField?: string + refreshable?: boolean + needsRefresh?: boolean + filters?: object + customObjects?: Record<string, unknown>[] +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + idField: 'id', + refreshable: false, + needsRefresh: false, + filters: () => ({}), + customObjects: () => [] +}) + +const { $pgettext } = useGettext() + +const currentActionName = ref(props.actions[0]?.name ?? null) +const currentAction = computed(() => props.actions.find(action => action.name === currentActionName.value)) + +const checkable = computed(() => { + if (!currentAction.value) return [] + + return props.objectsData.results + .filter(currentAction.value.filterChackable ?? (() => true)) + .map(item => item[props.idField] as string) +}) + +const objects = computed(() => props.objectsData.results.map(object => { + return props.customObjects.find(custom => custom[props.idField] === object[props.idField]) + ?? object +})) + +const selectAll = ref(false) +const checked = reactive([] as string[]) +const affectedObjectsCount = computed(() => selectAll.value ? props.objectsData.count : checked.length) +watch(() => props.objectsData, () => { + checked.length = 0 + selectAll.value = false +}) + +// We update checked status as some actions have specific filters +// on what is checkable or not +watch(currentActionName, () => { + const ableToCheck = checkable.value + const replace = checked.filter(object => ableToCheck.includes(object)) + + checked.length = 0 + checked.push(...replace) +}) + +const lastCheckedIndex = ref(-1) +const toggleCheckAll = () => { + lastCheckedIndex.value = -1 + + if (checked.length === checkable.value.length) { + checked.length = 0 + return + } + + checked.length = 0 + checked.push(...checkable.value) +} + +const toggleCheck = (event: MouseEvent, id: string, index: number) => { + const affectedIds = new Set([id]) + + const wasChecked = checked.includes(id) + if (wasChecked) { + selectAll.value = false + } + + // Add inbetween ids to the list of affected ids + if (event.shiftKey && lastCheckedIndex.value !== -1) { + const boundaries = [index, lastCheckedIndex.value].sort((a, b) => a - b) + for (const object of props.objectsData.results.slice(boundaries[0], boundaries[1] + 1)) { + affectedIds.add(object[props.idField]) + } + } + + for (const id of affectedIds) { + const isChecked = checked.includes(id) + + if (!wasChecked && !isChecked && checkable.value.includes(id)) { + checked.push(id) + continue + } + + if (wasChecked && isChecked) { + checked.splice(checked.indexOf(id), 1) + } + } + + lastCheckedIndex.value = index +} + +const labels = computed(() => ({ + refresh: $pgettext('Content/*/Button.Tooltip/Verb', 'Refresh table content'), + selectAllItems: $pgettext('Content/*/Select/Verb', 'Select all items'), + performAction: $pgettext('Content/*/Button.Label', 'Perform actions'), + selectItem: $pgettext('Content/*/Select/Verb', 'Select') +})) + +const errors = ref([] as string[]) +const isLoading = ref(false) +const result = ref() +const launchAction = async () => { + isLoading.value = true + result.value = undefined + errors.value = [] + + try { + const response = await axios.post(props.actionUrl, { + action: currentActionName.value, + filters: props.filters, + objects: !selectAll.value + ? checked + : 'all' + }) + + result.value = response.data + emit('action-launched', response.data) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> <div class="table-wrapper component-action-table"> <table class="ui compact very basic unstackable table"> @@ -34,8 +192,8 @@ class="ui dropdown" > <option - v-for="(action, key) in actions" - :key="key" + v-for="action in actions" + :key="action.name" :value="action.name" > {{ action.label }} @@ -44,51 +202,54 @@ </div> <div class="field"> <dangerous-button - v-if="selectAll || currentAction.isDangerous" - :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']" - :confirm-color="currentAction.confirmColor || 'success'" + v-if="selectAll || currentAction?.isDangerous" + :class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']" + :confirm-color="currentAction?.confirmColor ?? 'success'" :aria-label="labels.performAction" @confirm="launchAction" > <translate translate-context="Content/*/Button.Label/Short, Verb"> Go </translate> - <p slot="modal-header"> - <translate - key="1" - translate-context="Modal/*/Title" - :translate-n="affectedObjectsCount" - :translate-params="{count: affectedObjectsCount, action: currentActionName}" - translate-plural="Do you want to launch %{ action } on %{ count } elements?" - > - Do you want to launch %{ action } on %{ count } element? - </translate> - </p> - <p slot="modal-content"> - <template v-if="currentAction.confirmationMessage"> - {{ currentAction.confirmationMessage }} - </template> - <translate - v-else - translate-context="Modal/*/Paragraph" - > - This may affect a lot of elements or have irreversible consequences, please double check this is really what you want. - </translate> - </p> - <div - slot="modal-confirm" - :aria-label="labels.performAction" - > - <translate translate-context="Modal/*/Button.Label/Short, Verb"> - Launch - </translate> - </div> + <template #modal-header> + <p> + <translate + key="1" + translate-context="Modal/*/Title" + :translate-n="affectedObjectsCount" + :translate-params="{count: affectedObjectsCount, action: currentActionName}" + translate-plural="Do you want to launch %{ action } on %{ count } elements?" + > + Do you want to launch %{ action } on %{ count } element? + </translate> + </p> + </template> + <template #modal-content> + <p> + <template v-if="currentAction?.confirmationMessage"> + {{ currentAction?.confirmationMessage }} + </template> + <translate + v-else + translate-context="Modal/*/Paragraph" + > + This may affect a lot of elements or have irreversible consequences, please double check this is really what you want. + </translate> + </p> + </template> + <template #modal-confirm> + <div :aria-label="labels.performAction"> + <translate translate-context="Modal/*/Button.Label/Short, Verb"> + Launch + </translate> + </div> + </template> </dangerous-button> <button v-else :disabled="checked.length === 0" :aria-label="labels.performAction" - :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']" + :class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']" @click="launchAction" > <translate translate-context="Content/*/Button.Label/Short, Verb"> @@ -99,7 +260,6 @@ <div class="count field"> <translate v-if="selectAll" - key="1" translate-context="Content/*/Paragraph" tag="span" :translate-n="objectsData.count" @@ -110,7 +270,6 @@ </translate> <translate v-else - key="2" translate-context="Content/*/Paragraph" tag="span" :translate-n="checked.length" @@ -119,7 +278,7 @@ > %{ count } on %{ total } selected </translate> - <template v-if="currentAction.allowAll && checkable.length > 0 && checkable.length === checked.length"> + <template v-if="currentAction?.allowAll && checkable.length > 0 && checkable.length === checked.length"> <a v-if="!selectAll" href="" @@ -149,7 +308,7 @@ </div> </div> <div - v-if="actionErrors.length > 0" + v-if="errors.length > 0" role="alert" class="ui negative message" > @@ -160,7 +319,7 @@ </h4> <ul class="list"> <li - v-for="(error, key) in actionErrors" + v-for="(error, key) in errors" :key="key" > {{ error }} @@ -168,14 +327,14 @@ </ul> </div> <div - v-if="actionResult" + v-if="result" class="ui positive message" > <p> <translate translate-context="Content/*/Paragraph" - :translate-n="actionResult.updated" - :translate-params="{count: actionResult.updated, action: actionResult.action}" + :translate-n="result.updated" + :translate-params="{count: result.updated, action: result.action}" translate-plural="Action %{ action } was launched successfully on %{ count } elements" > Action %{ action } was launched successfully on %{ count } element @@ -184,7 +343,7 @@ <slot name="action-success-footer" - :result="actionResult" + :result="result" /> </div> </div> @@ -193,6 +352,7 @@ <tr> <th v-if="actions.length > 0"> <div class="ui checkbox"> + <!-- TODO (wvffle): Check if we don't have to migrate to v-model --> <input type="checkbox" :aria-label="labels.selectAllItems" @@ -214,12 +374,13 @@ v-if="actions.length > 0" class="collapsing" > + <!-- TODO (wvffle): Check if we don't have to migrate to v-model --> <input type="checkbox" :aria-label="labels.selectItem" - :disabled="checkable.indexOf(getId(obj)) === -1" - :checked="checked.indexOf(getId(obj)) > -1" - @click="toggleCheck($event, getId(obj), index)" + :disabled="checkable.indexOf(obj[idField]) === -1" + :checked="checked.indexOf(obj[idField]) > -1" + @click="toggleCheck($event, obj[idField], index)" > </td> <slot @@ -231,166 +392,3 @@ </table> </div> </template> -<script> -import axios from 'axios' - -export default { - components: {}, - props: { - actionUrl: { type: String, required: false, default: null }, - idField: { type: String, required: false, default: 'id' }, - refreshable: { type: Boolean, required: false, default: false }, - needsRefresh: { type: Boolean, required: false, default: false }, - objectsData: { type: Object, required: true }, - actions: { type: Array, required: true, default: () => { return [] } }, - filters: { type: Object, required: false, default: () => { return {} } }, - customObjects: { type: Array, required: false, default: () => { return [] } } - }, - data () { - const d = { - checked: [], - actionLoading: false, - actionResult: null, - actionErrors: [], - currentActionName: null, - selectAll: false, - lastCheckedIndex: -1 - } - if (this.actions.length > 0) { - d.currentActionName = this.actions[0].name - } - return d - }, - computed: { - currentAction () { - const self = this - return this.actions.filter((a) => { - return a.name === self.currentActionName - })[0] - }, - checkable () { - const self = this - if (!this.currentAction) { - return [] - } - let objs = this.objectsData.results - const filter = this.currentAction.filterCheckable - if (filter) { - objs = objs.filter((o) => { - return filter(o) - }) - } - return objs.map((o) => { return self.getId(o) }) - }, - objects () { - const self = this - return this.objectsData.results.map((o) => { - const custom = self.customObjects.filter((co) => { - return self.getId(co) === self.getId(o) - })[0] - if (custom) { - return custom - } - return o - }) - }, - labels () { - return { - refresh: this.$pgettext('Content/*/Button.Tooltip/Verb', 'Refresh table content'), - selectAllItems: this.$pgettext('Content/*/Select/Verb', 'Select all items'), - performAction: this.$pgettext('Content/*/Button.Label', 'Perform actions'), - selectItem: this.$pgettext('Content/*/Select/Verb', 'Select') - } - }, - affectedObjectsCount () { - if (this.selectAll) { - return this.objectsData.count - } - return this.checked.length - } - }, - watch: { - objectsData: { - handler () { - this.checked = [] - this.selectAll = false - }, - deep: true - }, - currentActionName () { - // we update checked status as some actions have specific filters - // on what is checkable or not - const self = this - this.checked = this.checked.filter(r => { - return self.checkable.indexOf(r) > -1 - }) - } - }, - methods: { - toggleCheckAll () { - this.lastCheckedIndex = -1 - if (this.checked.length === this.checkable.length) { - // we uncheck - this.checked = [] - } else { - this.checked = this.checkable.map(i => { return i }) - } - }, - toggleCheck (event, id, index) { - const self = this - let affectedIds = [id] - let newValue = null - if (this.checked.indexOf(id) > -1) { - // we uncheck - this.selectAll = false - newValue = false - } else { - newValue = true - } - if (event.shiftKey && this.lastCheckedIndex > -1) { - // we also add inbetween ids to the list of affected ids - const idxs = [index, this.lastCheckedIndex] - idxs.sort((a, b) => a - b) - const objs = this.objectsData.results.slice(idxs[0], idxs[1] + 1) - affectedIds = affectedIds.concat(objs.map((o) => { return o.id })) - } - affectedIds.forEach((i) => { - const checked = self.checked.indexOf(i) > -1 - if (newValue && !checked && self.checkable.indexOf(i) > -1) { - return self.checked.push(i) - } - if (!newValue && checked) { - self.checked.splice(self.checked.indexOf(i), 1) - } - }) - this.lastCheckedIndex = index - }, - launchAction () { - const self = this - self.actionLoading = true - self.result = null - self.actionErrors = [] - const payload = { - action: this.currentActionName, - filters: this.filters - } - if (this.selectAll) { - payload.objects = 'all' - } else { - payload.objects = this.checked - } - axios.post(this.actionUrl, payload).then((response) => { - self.actionResult = response.data - self.actionLoading = false - self.$emit('action-launched', response.data) - }, error => { - self.actionLoading = false - self.actionErrors = error.backendErrors - }) - }, - getId (obj) { - return obj[this.idField] - } - } -} -</script> diff --git a/front/src/components/common/ActorAvatar.vue b/front/src/components/common/ActorAvatar.vue index d4f2188cb51155d82a4016b3a019e82962c842f5..cdd69f948db2b1a973bc244b841f9ad2492ccf30 100644 --- a/front/src/components/common/ActorAvatar.vue +++ b/front/src/components/common/ActorAvatar.vue @@ -1,3 +1,19 @@ +<script setup lang="ts"> +import type { Actor } from '~/types' + +import { hashCode, intToRGB } from '~/utils/color' +import { computed } from 'vue' + +interface Props { + actor: Actor +} + +const props = defineProps<Props>() + +const actorColor = computed(() => intToRGB(hashCode(props.actor.full_username))) +const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${actorColor.value}` })) +</script> + <template> <img v-if="actor.icon && actor.icon.urls.original" @@ -11,21 +27,3 @@ class="ui avatar circular label" >{{ actor.preferred_username[0] }}</span> </template> - -<script> -import { hashCode, intToRGB } from '@/utils/color' - -export default { - props: { actor: { type: Object, required: true } }, - computed: { - actorColor () { - return intToRGB(hashCode(this.actor.full_username)) - }, - defaultAvatarStyle () { - return { - 'background-color': `#${this.actorColor}` - } - } - } -} -</script> diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue index a30c717ca9f1e693c15c266d4866285feccce9e0..49c8197449e6e6f6138fe55c2504ae3a15bec0ef 100644 --- a/front/src/components/common/ActorLink.vue +++ b/front/src/components/common/ActorLink.vue @@ -1,42 +1,64 @@ +<script setup lang="ts"> +import type { Actor } from '~/types' + +import { toRefs } from '@vueuse/core' +import { computed } from 'vue' +import { truncate } from '~/utils/filters' + +interface Props { + actor: Actor + avatar?: boolean + admin?: boolean + displayName?: boolean + truncateLength?: number +} + +const props = withDefaults(defineProps<Props>(), { + avatar: true, + admin: false, + displayName: false, + truncateLength: 30 +}) + +const { displayName, actor, truncateLength, admin, avatar } = toRefs(props) + +const repr = computed(() => { + const name = displayName.value || actor.value.is_local + ? actor.value.preferred_username + : actor.value.full_username + + return truncate(name, truncateLength.value) +}) + +const url = computed(() => { + if (admin.value) { + return { name: 'manage.moderation.accounts.detail', params: { id: actor.value.full_username } } + } + + if (actor.value.is_local) { + return { name: 'profile.overview', params: { username: actor.value.preferred_username } } + } + + return { + name: 'profile.full.overview', + params: { + username: actor.value.preferred_username, + domain: actor.value.domain + } + } +}) +</script> + <template> <router-link :to="url" :title="actor.full_username" > - <template v-if="avatar"> - <actor-avatar :actor="actor" /><span> </span> - </template><slot>{{ repr | truncate(truncateLength) }}</slot> + <actor-avatar + v-if="avatar" + :actor="actor" + /> + <span> </span> + <slot>{{ repr }}</slot> </router-link> </template> - -<script> - -export default { - props: { - actor: { type: Object, required: true }, - avatar: { type: Boolean, default: true }, - admin: { type: Boolean, default: false }, - displayName: { type: Boolean, default: false }, - truncateLength: { type: Number, default: 30 } - }, - computed: { - url () { - if (this.admin) { - return { name: 'manage.moderation.accounts.detail', params: { id: this.actor.full_username } } - } - if (this.actor.is_local) { - return { name: 'profile.overview', params: { username: this.actor.preferred_username } } - } else { - return { name: 'profile.full.overview', params: { username: this.actor.preferred_username, domain: this.actor.domain } } - } - }, - repr () { - if (this.displayName || this.actor.is_local) { - return this.actor.preferred_username - } else { - return this.actor.full_username - } - } - } -} -</script> diff --git a/front/src/components/common/AjaxButton.vue b/front/src/components/common/AjaxButton.vue index 85172b13ae230f6f9f37fdbae6c78347128fae24..2a9503f79fa3dde390dde64ae6946585211b7996 100644 --- a/front/src/components/common/AjaxButton.vue +++ b/front/src/components/common/AjaxButton.vue @@ -1,3 +1,38 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { ref } from 'vue' + +import axios from 'axios' + +interface Events { + (e: 'action-done', data: any): void + (e: 'action-error', error: BackendError): void +} + +interface Props { + method: 'get' | 'post' | 'put' | 'patch' | 'delete' + url: string +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const isLoading = ref(false) +const ajaxCall = async () => { + isLoading.value = true + + try { + const response = await axios[props.method](props.url) + emit('action-done', response.data) + } catch (error) { + emit('action-error', error as BackendError) + } + + isLoading.value = false +} +</script> + <template> <button :class="['ui', {loading: isLoading}, 'button']" @@ -6,31 +41,3 @@ <slot /> </button> </template> -<script> -import axios from 'axios' - -export default { - props: { - url: { type: String, required: true }, - method: { type: String, required: true } - }, - data () { - return { - isLoading: false - } - }, - methods: { - ajaxCall () { - const self = this - this.isLoading = true - axios[this.method](this.url).then(response => { - self.$emit('action-done', response.data) - self.isLoading = false - }, error => { - self.isLoading = false - self.$emit('action-error', error) - }) - } - } -} -</script> diff --git a/front/src/components/common/AttachmentInput.vue b/front/src/components/common/AttachmentInput.vue index 0a52dcbb26afdb057a8002a26be3679fa7ffa812..089818813604d7f5def58583d3a525f5be49835a 100644 --- a/front/src/components/common/AttachmentInput.vue +++ b/front/src/components/common/AttachmentInput.vue @@ -1,3 +1,104 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import axios from 'axios' +import { useVModel } from '@vueuse/core' +import { reactive, ref, watch } from 'vue' +import { useStore } from '~/store' +import useFormData from '~/composables/useFormData' + +interface Events { + (e: 'update:modelValue', value: string | null): void + (e: 'delete'): void +} + +interface Props { + modelValue: string | null + imageClass?: string + required?: boolean + name?: string | undefined + initialValue?: string | undefined +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + imageClass: '', + required: false, + name: undefined, + initialValue: undefined +}) + +const value = useVModel(props, 'modelValue', emit) + +const attachment = ref() +const isLoading = ref(false) +const errors = reactive([] as string[]) +const attachmentId = Math.random().toString(36).substring(7) + +const input = ref() +const file = ref() +const submit = async () => { + isLoading.value = true + errors.length = 0 + file.value = input.value.files[0] + + const formData = useFormData({ file: file.value }) + + try { + const { data } = await axios.post('attachments/', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + + attachment.value = data + value.value = data.uuid + } catch (error) { + if (error as BackendError) { + const { backendErrors } = error as BackendError + errors.push(...backendErrors) + } + } finally { + isLoading.value = false + } +} + +const remove = async (uuid: string, sendEvent = true) => { + isLoading.value = true + errors.length = 0 + + try { + await axios.delete(`attachments/${uuid}/`) + attachment.value = null + + if (sendEvent) emit('delete') + } catch (error) { + if (error as BackendError) { + const { backendErrors } = error as BackendError + errors.push(...backendErrors) + } + } finally { + isLoading.value = false + } +} + +const initialValue = ref(props.initialValue ?? props.modelValue) +watch(value, (to, from) => { + // NOTE: Remove old attachment if it's not the original one + if (from !== initialValue.value && typeof from === 'string') { + remove(from, false) + } + + // NOTE: We want to bring back the original attachment, let's delete the current one + if (attachment.value && to === initialValue.value) { + remove(attachment.value.uuid) + } +}) + +const store = useStore() +const getAttachmentUrl = (uuid: string) => { + return store.getters['instance/absoluteUrl'](`api/v1/attachments/${uuid}/proxy?next=medium_square_crop`) +} +</script> + <template> <div class="ui form"> <div @@ -21,7 +122,7 @@ </div> <div class="ui field"> <span id="avatarLabel"> - <slot name="label" /> + <slot /> </span> <div class="ui stackable grid row"> <div class="three wide column"> @@ -29,13 +130,13 @@ v-if="value && value === initialValue" alt="" :class="['ui', imageClass, 'image']" - :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" + :src="getAttachmentUrl(value)" > <img v-else-if="attachment" alt="" :class="['ui', imageClass, 'image']" - :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${attachment.uuid}/proxy?next=medium_square_crop`)" + :src="getAttachmentUrl(attachment.uuid)" > <div v-else @@ -49,10 +150,12 @@ </label> <input :id="attachmentId" - ref="attachment" + ref="input" + :name="name" + :required="required || undefined" class="ui input" type="file" - accept="image/x-png,image/jpeg" + accept="image/png,image/jpeg" @change="submit" > </div> @@ -65,7 +168,7 @@ <button v-if="value" class="ui basic tiny button" - @click.stop.prevent="remove(value)" + @click.stop.prevent="remove(value as string)" > <translate translate-context="Content/Radio/Button.Label/Verb"> Remove @@ -86,74 +189,3 @@ </div> </div> </template> -<script> -import axios from 'axios' - -export default { - props: { - value: { type: String, default: null }, - imageClass: { type: String, default: '', required: false } - }, - data () { - return { - attachment: null, - isLoading: false, - errors: [], - initialValue: this.value, - attachmentId: Math.random().toString(36).substring(7) - } - }, - watch: { - value (v) { - if (this.attachment && v === this.initialValue) { - // we had a reset to initial value - this.remove(this.attachment.uuid) - } - } - }, - methods: { - submit () { - this.isLoading = true - this.errors = [] - const self = this - this.file = this.$refs.attachment.files[0] - const formData = new FormData() - formData.append('file', this.file) - axios - .post('attachments/', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - } - }) - .then( - response => { - this.isLoading = false - self.attachment = response.data - self.$emit('input', self.attachment.uuid) - }, - error => { - self.isLoading = false - self.errors = error.backendErrors - } - ) - }, - remove (uuid) { - this.isLoading = true - this.errors = [] - const self = this - axios.delete(`attachments/${uuid}/`) - .then( - response => { - this.isLoading = false - self.attachment = null - self.$emit('delete') - }, - error => { - self.isLoading = false - self.errors = error.backendErrors - } - ) - } - } -} -</script> diff --git a/front/src/components/common/CollapseLink.vue b/front/src/components/common/CollapseLink.vue index 30d6dc326ec9e722c95859a0dd6eed02e53e2cee..35c0627c3f4b121121953aef6219253a77de2ea9 100644 --- a/front/src/components/common/CollapseLink.vue +++ b/front/src/components/common/CollapseLink.vue @@ -1,32 +1,37 @@ +<script setup lang="ts"> +import { useVModel } from '@vueuse/core' + +interface Events { + (e: 'update:modelValue', value: boolean): void +} + +interface Props { + modelValue: boolean +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() +const value = useVModel(props, 'modelValue', emit) +</script> + <template> <a role="button" class="collapse link" - @click.prevent="$emit('input', !value)" + @click.prevent="value = !value" > <translate - v-if="isCollapsed" - key="1" + v-if="value" translate-context="*/*/Button,Label" - >Expand</translate> + > + Expand + </translate> <translate v-else - key="2" translate-context="*/*/Button,Label" - >Collapse</translate> - <i :class="[{down: !isCollapsed}, {right: isCollapsed}, 'angle', 'icon']" /> + > + Collapse + </translate> + <i :class="[{ down: !value, right: value }, 'angle', 'icon']" /> </a> </template> -<script> - -export default { - props: { - value: { type: Boolean, required: true } - }, - computed: { - isCollapsed () { - return this.value - } - } -} -</script> diff --git a/front/src/components/common/ContentForm.vue b/front/src/components/common/ContentForm.vue index e263d092d8f738085258eb7f147a516a706da280..998da5600f4649203ed63d218e2207460bf9661e 100644 --- a/front/src/components/common/ContentForm.vue +++ b/front/src/components/common/ContentForm.vue @@ -1,3 +1,82 @@ +<script setup lang="ts"> +import axios from 'axios' +import { useVModel, watchDebounced, useTextareaAutosize, syncRef } from '@vueuse/core' +import { ref, computed, watchEffect, onMounted, nextTick } from 'vue' +import { useGettext } from 'vue3-gettext' + +interface Events { + (e: 'update:modelValue', value: string): void +} + +interface Props { + modelValue: string + placeholder?: string + autofocus?: boolean + permissive?: boolean + required?: boolean + charLimit?: number +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + placeholder: undefined, + autofocus: false, + charLimit: 5000, + permissive: false, + required: false +}) + +const { $pgettext } = useGettext() +const { textarea, input } = useTextareaAutosize() +const value = useVModel(props, 'modelValue', emit) +syncRef(value, input) + +const isPreviewing = ref(false) +const preview = ref() +const isLoadingPreview = ref(false) + +const labels = computed(() => ({ + placeholder: props.placeholder ?? $pgettext('*/Form/Placeholder', 'Write a few words here…') +})) + +const remainingChars = computed(() => props.charLimit - props.modelValue.length) + +const loadPreview = async () => { + isLoadingPreview.value = true + try { + const response = await axios.post('text-preview/', { text: value.value, permissive: props.permissive }) + preview.value = response.data.rendered + } catch (error) { + console.error(error) + } + isLoadingPreview.value = false +} + +watchDebounced(value, async () => { + await loadPreview() +}, { immediate: true, debounce: 500 }) + +watchEffect(async () => { + if (isPreviewing.value) { + if (value.value && !preview.value && !isLoadingPreview.value) { + await loadPreview() + } + + return + } + + await nextTick() + textarea.value.focus() +}) + +onMounted(async () => { + if (props.autofocus) { + await nextTick() + textarea.value.focus() + } +}) +</script> + <template> <div class="content-form ui segments"> <div class="ui segment"> @@ -31,26 +110,23 @@ <div class="line" /> </div> </div> - <p v-else-if="preview === null"> + <p v-else-if="!preview"> <translate translate-context="*/Form/Paragraph"> Nothing to preview. </translate> </p> - <div + <sanitized-html v-else - v-html="preview" + :html="preview" /> </template> <template v-else> <div class="ui transparent input"> <textarea - :id="fieldId" ref="textarea" - v-model="newValue" - :name="fieldId" - :rows="rows" + v-model="value" :required="required" - :placeholder="placeholder || labels.placeholder" + :placeholder="labels.placeholder" /> </div> <div class="ui very small hidden divider" /> @@ -71,83 +147,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' - -export default { - props: { - value: { type: String, default: '' }, - fieldId: { type: String, default: 'change-content' }, - placeholder: { type: String, default: null }, - autofocus: { type: Boolean, default: false }, - charLimit: { type: Number, default: 5000, required: false }, - rows: { type: Number, default: 5, required: false }, - permissive: { type: Boolean, default: false }, - required: { type: Boolean, default: false } - }, - data () { - return { - isPreviewing: false, - preview: null, - newValue: this.value, - isLoadingPreview: false - } - }, - computed: { - labels () { - return { - placeholder: this.$pgettext('*/Form/Placeholder', 'Write a few words here…') - } - }, - remainingChars () { - return this.charLimit - (this.value || '').length - } - }, - watch: { - newValue (v) { - this.preview = null - this.$emit('input', v) - }, - value: { - async handler (v) { - this.preview = null - this.newValue = v - if (this.isPreviewing) { - await this.loadPreview() - } - }, - immediate: true - }, - async isPreviewing (v) { - if (v && !!this.value && this.preview === null && !this.isLoadingPreview) { - await this.loadPreview() - } - if (!v) { - this.$nextTick(() => { - this.$refs.textarea.focus() - }) - } - } - }, - mounted () { - if (this.autofocus) { - this.$nextTick(() => { - this.$refs.textarea.focus() - }) - } - }, - methods: { - async loadPreview () { - this.isLoadingPreview = true - try { - const response = await axios.post('text-preview/', { text: this.newValue, permissive: this.permissive }) - this.preview = response.data.rendered - } catch (error) { - console.error(error) - } - this.isLoadingPreview = false - } - } -} -</script> diff --git a/front/src/components/common/CopyInput.vue b/front/src/components/common/CopyInput.vue index 614b81382d5fc72cd8f607a869172838fd779290..a76a2fb5d4b503569cf350a2cf3629662e493e53 100644 --- a/front/src/components/common/CopyInput.vue +++ b/front/src/components/common/CopyInput.vue @@ -1,3 +1,21 @@ +<script setup lang="ts"> +import { toRefs, useClipboard } from '@vueuse/core' + +interface Props { + value: string + buttonClasses?: string + id?: string +} + +const props = withDefaults(defineProps<Props>(), { + buttonClasses: 'accent', + id: 'copy-input' +}) + +const { value } = toRefs(props) +const { copy, isSupported: canCopy, copied } = useClipboard({ source: value, copiedDuring: 5000 }) +</script> + <template> <div class="ui fluid action input component-copy-input"> <p @@ -10,15 +28,15 @@ </p> <input :id="id" - ref="input" - :name="id" :value="value" + :name="id" type="text" readonly > <button :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']" - @click="copy" + :disabled="!canCopy || undefined" + @click="copy()" > <i class="copy icon" /> <translate translate-context="*/*/Button.Label/Short, Verb"> @@ -27,32 +45,3 @@ </button> </div> </template> -<script> -export default { - props: { - value: { type: String, required: true }, - buttonClasses: { type: String, default: 'accent' }, - id: { type: String, default: 'copy-input' } - }, - data () { - return { - copied: false, - timeout: null - } - }, - methods: { - copy () { - if (this.timeout) { - clearTimeout(this.timeout) - } - this.$refs.input.select() - document.execCommand('Copy') - const self = this - self.copied = true - this.timeout = setTimeout(() => { - self.copied = false - }, 5000) - } - } -} -</script> diff --git a/front/src/components/common/DangerousButton.vue b/front/src/components/common/DangerousButton.vue index 3f4a2ec04a6917ed2571937b4ecdc6cbfcce15b1..92a5a843bd96b264ebb54f84369c9f8cd456e6c6 100644 --- a/front/src/components/common/DangerousButton.vue +++ b/front/src/components/common/DangerousButton.vue @@ -1,3 +1,33 @@ +<script setup lang="ts"> +import SemanticModal from '~/components/semantic/Modal.vue' +import { ref } from 'vue' + +interface Events { + (e: 'confirm'): void +} + +interface Props { + action?: () => void + disabled?: boolean + confirmColor?: 'danger' | 'success' +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + action: () => undefined, + disabled: false, + confirmColor: 'danger' +}) + +const showModal = ref(false) + +const confirm = () => { + showModal.value = false + emit('confirm') + props.action?.() +} +</script> + <template> <button :class="[{disabled: disabled}]" @@ -6,9 +36,9 @@ > <slot /> - <modal + <semantic-modal + v-model:show="showModal" class="small" - :show.sync="showModal" > <h4 class="header"> <slot name="modal-header"> @@ -29,7 +59,7 @@ </translate> </button> <button - :class="['ui', 'confirm', confirmButtonColor, 'button']" + :class="['ui', 'confirm', confirmColor, 'button']" @click="confirm" > <slot name="modal-confirm"> @@ -39,42 +69,6 @@ </slot> </button> </div> - </modal> + </semantic-modal> </button> </template> -<script> -import Modal from '@/components/semantic/Modal.vue' - -export default { - components: { - Modal - }, - props: { - action: { type: Function, required: false, default: () => {} }, - disabled: { type: Boolean, default: false }, - confirmColor: { type: String, default: 'danger', required: false } - }, - data () { - return { - showModal: false - } - }, - computed: { - confirmButtonColor () { - if (this.confirmColor) { - return this.confirmColor - } - return this.color - } - }, - methods: { - confirm () { - this.showModal = false - this.$emit('confirm') - if (this.action) { - this.action() - } - } - } -} -</script> diff --git a/front/src/components/common/Duration.vue b/front/src/components/common/Duration.vue index a1c652695e4e860ef3d14187ea045abbe90ee83b..b2c66b42581cfe1d8c5879ce7ab11c0f6930a3f0 100644 --- a/front/src/components/common/Duration.vue +++ b/front/src/components/common/Duration.vue @@ -1,26 +1,32 @@ +<script setup lang="ts"> +import moment from 'moment' +import { computed } from 'vue' + +interface Props { + seconds?: number +} + +const props = withDefaults(defineProps<Props>(), { + seconds: 0 +}) + +const duration = computed(() => { + const momentDuration = moment.duration(props.seconds, 'seconds') + return { minutes: momentDuration.minutes(), hours: momentDuration.hours() } +}) +</script> + <template> <span> <translate - v-if="durationData.hours > 0" + v-if="duration.hours > 0" translate-context="Content/*/Paragraph" - :translate-params="{minutes: durationData.minutes, hours: durationData.hours}" + :translate-params="duration" >%{ hours } h %{ minutes } min</translate> <translate v-else translate-context="Content/*/Paragraph" - :translate-params="{minutes: durationData.minutes}" + :translate-params="duration" >%{ minutes } min</translate> </span> </template> -<script> -import { secondsToObject } from '@/filters' - -export default { - props: { seconds: { type: Number, default: null } }, - computed: { - durationData () { - return secondsToObject(this.seconds) - } - } -} -</script> diff --git a/front/src/components/common/EmptyState.vue b/front/src/components/common/EmptyState.vue index 54e1b13e3765cdc99d66ae548d0dc4cf724d893b..d7ef475d75934bce9a78cfa22ec8ee0c1bebbbd8 100644 --- a/front/src/components/common/EmptyState.vue +++ b/front/src/components/common/EmptyState.vue @@ -1,3 +1,18 @@ +<script setup lang="ts"> +interface Events { + (e: 'refresh'): void +} + +interface Props { + refresh?: boolean +} + +const emit = defineEmits<Events>() +withDefaults(defineProps<Props>(), { + refresh: false +}) +</script> + <template> <div class="ui small placeholder segment component-placeholder component-empty-state"> <h4 class="ui header"> @@ -15,7 +30,7 @@ <button v-if="refresh" class="ui button" - @click="$emit('refresh')" + @click="emit('refresh')" > <translate translate-context="Content/*/Button.Label/Short, Verb"> Refresh @@ -24,10 +39,3 @@ </div> </div> </template> -<script> -export default { - props: { - refresh: { type: Boolean, default: false } - } -} -</script> diff --git a/front/src/components/common/ExpandableDiv.vue b/front/src/components/common/ExpandableDiv.vue index 54ddc7b36b6e0b01685b3d2f6d16724473b59cb9..e7e16144b68a9a9916c8fa3a2f019f35c2a8ab24 100644 --- a/front/src/components/common/ExpandableDiv.vue +++ b/front/src/components/common/ExpandableDiv.vue @@ -1,44 +1,39 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { useToggle } from '@vueuse/core' + +interface Props { + content: string + length?: number +} + +const props = withDefaults(defineProps<Props>(), { + length: 150 +}) + +const [expanded, toggleExpanded] = useToggle(false) +const truncated = computed(() => props.content.slice(0, props.length)) +</script> + <template> <div class="expandable-wrapper"> - <div :class="['expandable-content', {expandable: truncated.length < content.length}, {expanded: isExpanded}]"> + <div :class="['expandable-content', { expandable: truncated.length < content.length, expanded }]"> <slot>{{ content }}</slot> </div> <a v-if="truncated.length < content.length" role="button" - @click.prevent="isExpanded = !isExpanded" + @click.prevent="toggleExpanded()" > <br> <translate - v-if="isExpanded" - key="1" + v-if="expanded" translate-context="*/*/Button,Label" >Show less</translate> <translate v-else - key="2" translate-context="*/*/Button,Label" >Show more</translate> </a> </div> </template> -<script> -// import sanitize from "@/sanitize" - -export default { - props: { - content: { type: String, required: true }, - length: { type: Number, default: 150, required: false } - }, - data () { - return { - isExpanded: false - } - }, - computed: { - truncated () { - return this.content.substring(0, this.length) - } - } -} -</script> diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue index 5dc267adf3913dbecbf76160347721b9ece10730..881762abacd25e40dc3a582f90cde67dcdfa2d67 100644 --- a/front/src/components/common/HumanDate.vue +++ b/front/src/components/common/HumanDate.vue @@ -1,32 +1,31 @@ +<script setup lang="ts"> +import { momentFormat } from '~/utils/filters' +import { useTimeAgo } from '@vueuse/core' +import { computed } from 'vue' + +interface Props { + date: string, + icon?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + icon: false +}) + +const date = computed(() => new Date(props.date)) +// TODO (wvffle): Translate useTimeAgo +const realDate = useTimeAgo(date) +</script> + <template> <time :datetime="date" - :title="date | moment" + :title="momentFormat(date)" > <i - v-if="icon" + v-if="props.icon" class="outline clock icon" /> - {{ realDate | ago($store.state.ui.momentLocale) }} + {{ realDate }} </time> </template> -<script> -import { mapState } from 'vuex' -export default { - props: { - date: { type: String, required: true }, - icon: { type: Boolean, required: false, default: false } - }, - computed: { - ...mapState({ - lastDate: state => state.ui.lastDate - }), - realDate () { - if (this.lastDate) { - // dummy code to trigger a recompute to update the ago render - } - return this.date - } - } -} -</script> diff --git a/front/src/components/common/HumanDuration.vue b/front/src/components/common/HumanDuration.vue index 83119c82e12546861cff4d2c796bafc9de365f61..81d0542dbc63d4757d20dbdbf36b51890789e72b 100644 --- a/front/src/components/common/HumanDuration.vue +++ b/front/src/components/common/HumanDuration.vue @@ -1,12 +1,19 @@ +<script setup lang="ts"> +import { toRefs } from '@vueuse/core' +import { computed } from 'vue' +import time from '~/utils/time' + +interface Props { + duration: number +} + +const props = defineProps<Props>() +const { duration } = toRefs(props) +const parsedDuration = computed(() => time.parse(duration.value)) +</script> + <template> <time :datetime="`${duration}s`"> - {{ duration | duration }} + {{ parsedDuration }} </time> </template> -<script> -export default { - props: { - duration: { type: Number, required: true } - } -} -</script> diff --git a/front/src/components/common/InlineSearchBar.vue b/front/src/components/common/InlineSearchBar.vue index 590e989616f5319e25434253a43f9a05d84d0183..ef082d32e99cf7f3e71e671f9f88ffb475130187 100644 --- a/front/src/components/common/InlineSearchBar.vue +++ b/front/src/components/common/InlineSearchBar.vue @@ -1,9 +1,43 @@ +<script setup lang="ts"> +import { useVModel } from '@vueuse/core' +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +interface Events { + (e: 'update:modelValue', value: string): void + (e: 'search', query: string): void +} + +interface Props { + modelValue: string + placeholder?: string +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + placeholder: '' +}) + +const value = useVModel(props, 'modelValue', emit) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search…'), + clear: $pgettext('Content/Library/Button.Label', 'Clear') +})) + +const search = () => { + value.value = '' + emit('search', value.value) +} +</script> + <template> <form class="ui inline form" - @submit.stop.prevent="$emit('search', value)" + @submit.stop.prevent="emit('search', value)" > - <div :class="['ui', 'action', {icon: isClearable}, 'input']"> + <div :class="['ui', 'action', {icon: value}, 'input']"> <label for="search-query" class="hidden" @@ -12,17 +46,16 @@ </label> <input id="search-query" + v-model="value" name="search-query" type="text" :placeholder="placeholder || labels.searchPlaceholder" - :value="value" - @input="$emit('input', $event.target.value)" > <i - v-if="isClearable" + v-if="value" class="x link icon" :title="labels.clear" - @click.stop.prevent="$emit('input', ''); $emit('search', value)" + @click.stop.prevent="search" /> <button type="submit" @@ -33,22 +66,3 @@ </div> </form> </template> -<script> -export default { - props: { - value: { type: String, required: true }, - placeholder: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search…'), - clear: this.$pgettext('Content/Library/Button.Label', 'Clear') - } - }, - isClearable () { - return !!this.value - } - } -} -</script> diff --git a/front/src/components/common/LoginModal.vue b/front/src/components/common/LoginModal.vue index 5f51b3283623fba554b16e979c21a9397981a37c..5598d0bf5566d449c0c49f31d259b23e164cd55e 100644 --- a/front/src/components/common/LoginModal.vue +++ b/front/src/components/common/LoginModal.vue @@ -1,5 +1,32 @@ +<script setup lang="ts"> +import type { RouteLocationRaw } from 'vue-router' +import type { Cover } from '~/types' + +import SemanticModal from '~/components/semantic/Modal.vue' +import { ref, computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +interface Props { + nextRoute: RouteLocationRaw + message: string + cover: Cover +} + +defineProps<Props>() + +const show = ref(false) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + header: $pgettext('Popup/Title/Noun', 'Unauthenticated'), + login: $pgettext('*/*/Button.Label/Verb', 'Log in'), + signup: $pgettext('*/*/Button.Label/Verb', 'Sign up'), + description: $pgettext('Popup/*/Paragraph', "You don't have access!") +})) +</script> + <template> - <modal :show.sync="show"> + <semantic-modal v-model:show="show"> <h4 class="header"> {{ labels.header }} </h4> @@ -32,7 +59,7 @@ </div> <div class="actions"> <router-link - :to="{path: '/login', query: { next: nextRoute }}" + :to="{path: '/login', query: { next: nextRoute as string }}" class="ui labeled icon button" > <i class="key icon" /> @@ -47,36 +74,5 @@ {{ labels.signup }} </router-link> </div> - </modal> + </semantic-modal> </template> - -<script> -import Modal from '@/components/semantic/Modal.vue' - -export default { - components: { - Modal - }, - props: { - nextRoute: { type: String, required: true }, - message: { type: String, required: true }, - cover: { type: Object, required: true } - }, - data () { - return { - show: false - } - }, - computed: { - labels () { - return { - header: this.$pgettext('Popup/Title/Noun', 'Unauthenticated'), - login: this.$pgettext('*/*/Button.Label/Verb', 'Log in'), - signup: this.$pgettext('*/*/Button.Label/Verb', 'Sign up'), - description: this.$pgettext('Popup/*/Paragraph', "You don't have access!") - } - } - } -} - -</script> diff --git a/front/src/components/common/Message.vue b/front/src/components/common/Message.vue index 7c57e30d1196fffa6e28c12c7e4f18a6f1dc0d14..acfb95b06ed873658d79b257183b425f6a7c4ed6 100644 --- a/front/src/components/common/Message.vue +++ b/front/src/components/common/Message.vue @@ -1,27 +1,35 @@ -<template> - <div /> -</template> -<script> +<script setup lang="ts"> import $ from 'jquery' +import { onMounted } from 'vue' +import { useStore } from '~/store' -export default { - props: { message: { type: Object, required: true } }, - mounted () { - const self = this - const params = { - context: '#app', - message: this.message.content, - showProgress: 'top', - position: 'bottom right', - progressUp: true, - onRemove () { - self.$store.commit('ui/removeMessage', self.message.key) - }, - ...this.message - } - $('body').toast(params) +interface Message { + content: string + key: string +} + +const props = defineProps<{ message: Message }>() - $('.ui.toast.visible').last().attr('role', 'alert') +const store = useStore() +onMounted(() => { + const params = { + context: '#app', + message: props.message.content, + showProgress: 'top', + position: 'bottom right', + progressUp: true, + onRemove () { + store.commit('ui/removeMessage', props.message.key) + }, + ...props.message } -} + + // @ts-expect-error fomantic ui + $('body').toast(params) + $('.ui.toast.visible').last().attr('role', 'alert') +}) </script> + +<template> + <div /> +</template> diff --git a/front/src/components/common/RenderedDescription.vue b/front/src/components/common/RenderedDescription.vue index 2c4797350b849dd3e5e1ce2a0deec19dd9baf6e5..ad90c90bc454f8d5d2ed4e6e3e24342b76296045 100644 --- a/front/src/components/common/RenderedDescription.vue +++ b/front/src/components/common/RenderedDescription.vue @@ -1,7 +1,89 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { ref, computed } from 'vue' +import { whenever } from '@vueuse/core' + +import axios from 'axios' +import clip from 'text-clipper' + +interface Events { + (e: 'updated', data: unknown): void +} + +interface Props { + content?: { text?: string, html?: string } | null + fieldName?: string + updateUrl?: string + canUpdate?: boolean + fetchHtml?: boolean + permissive?: boolean + truncateLength?: number +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + content: null, + fieldName: 'description', + updateUrl: '', + canUpdate: true, + fetchHtml: false, + permissive: false, + truncateLength: 500 +}) + +const preview = ref('') +const fetchPreview = async () => { + const response = await axios.post('text-preview/', { text: props.content?.text ?? '', permissive: props.permissive }) + preview.value = response.data.rendered +} + +whenever(() => props.fetchHtml, fetchPreview) + +const truncatedHtml = computed(() => clip(props.content?.html ?? '', props.truncateLength, { + html: true, + maxLines: 3 +})) + +const showMore = ref(false) +const html = computed(() => props.fetchHtml + ? preview.value + : props.truncateLength > 0 && !showMore.value + ? truncatedHtml.value + : props.content?.html ?? '' +) + +const isTruncated = computed(() => props.truncateLength > 0 && truncatedHtml.value.length < (props.content?.html ?? '').length) + +const isUpdating = ref(false) +const text = ref(props.content?.text ?? '') +const isLoading = ref(false) +const errors = ref([] as string[]) +const submit = async () => { + errors.value = [] + isLoading.value = true + + try { + const response = await axios.patch(props.updateUrl, { + [props.fieldName]: text.value + ? { content_type: 'text/markdown', text: text.value } + : null + }) + + emit('updated', response.data) + isUpdating.value = false + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> <div> <template v-if="content && !isUpdating"> - <div v-html="html" /> + <sanitized-html :html="html" /> <template v-if="isTruncated"> <div class="ui small hidden divider" /> <a @@ -60,7 +142,7 @@ </ul> </div> <content-form - v-model="newText" + v-model="text" :autofocus="true" /> <a @@ -82,80 +164,3 @@ </form> </div> </template> - -<script> -import axios from 'axios' -import clip from 'text-clipper' - -export default { - props: { - content: { type: Object, required: false, default: null }, - fieldName: { type: String, required: false, default: 'description' }, - updateUrl: { required: false, type: String, default: '' }, - canUpdate: { required: false, default: true, type: Boolean }, - fetchHtml: { required: false, default: false, type: Boolean }, - permissive: { required: false, default: false, type: Boolean }, - truncateLength: { required: false, default: 500, type: Number } - - }, - data () { - return { - isUpdating: false, - showMore: false, - newText: (this.content || { text: '' }).text, - isLoading: false, - errors: [], - preview: null - } - }, - computed: { - html () { - if (this.fetchHtml) { - return this.preview - } - if (this.truncateLength > 0 && !this.showMore) { - return this.truncatedHtml - } - return this.content.html - }, - truncatedHtml () { - return clip(this.content.html, this.truncateLength, { html: true, maxLines: 3 }) - }, - isTruncated () { - return this.truncateLength > 0 && this.truncatedHtml.length < this.content.html.length - } - }, - async created () { - if (this.fetchHtml) { - await this.fetchPreview() - } - }, - methods: { - async fetchPreview () { - const response = await axios.post('text-preview/', { text: this.content.text, permissive: this.permissive }) - this.preview = response.data.rendered - }, - submit () { - const self = this - this.isLoading = true - this.errors = [] - const payload = {} - payload[this.fieldName] = null - if (this.newText) { - payload[this.fieldName] = { - content_type: 'text/markdown', - text: this.newText - } - } - axios.patch(this.updateUrl, payload).then((response) => { - self.$emit('updated', response.data) - self.isLoading = false - self.isUpdating = false - }, error => { - self.errors = error.backendErrors - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/components/common/Tooltip.vue b/front/src/components/common/Tooltip.vue index d59d5ef66756e0a75fd196a1be3e3590f8d0efde..b8bce11ae5499eb0a819a931276dca83f7c6301b 100644 --- a/front/src/components/common/Tooltip.vue +++ b/front/src/components/common/Tooltip.vue @@ -1,15 +1,15 @@ +<script setup lang="ts"> + +interface Props { + content: string +} + +defineProps<Props>() +</script> + <template> <span class="tooltip" :data-tooltip="content" ><i class="question circle icon" /></span> </template> - -<script> - -export default { - props: { - content: { type: String, required: true } - } -} -</script> diff --git a/front/src/components/common/UserLink.vue b/front/src/components/common/UserLink.vue index d04640381a1e7dc64be32e31dfc716be91646b4b..78c840a8d8c6b07f27a5bce02f6291cd311d9f57 100644 --- a/front/src/components/common/UserLink.vue +++ b/front/src/components/common/UserLink.vue @@ -1,3 +1,22 @@ +<script setup lang="ts"> +import type { User } from '~/types' + +import { hashCode, intToRGB } from '~/utils/color' +import { computed } from 'vue' + +interface Props { + user: User + avatar?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + avatar: true +}) + +const userColor = computed(() => intToRGB(hashCode(props.user.username + props.user.id))) +const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${userColor.value}` })) +</script> + <template> <span class="component-user-link"> <template v-if="avatar"> @@ -17,24 +36,3 @@ @{{ user.username }} </span> </template> - -<script> -import { hashCode, intToRGB } from '@/utils/color' - -export default { - props: { - user: { type: Object, required: true }, - avatar: { type: Boolean, default: true } - }, - computed: { - userColor () { - return intToRGB(hashCode(this.user.username + String(this.user.id))) - }, - defaultAvatarStyle () { - return { - 'background-color': `#${this.userColor}` - } - } - } -} -</script> diff --git a/front/src/components/common/UserMenu.vue b/front/src/components/common/UserMenu.vue index bfbf2a91c7f5fed5f75a44b51de2a6ae68d3a92c..6037eccea810105466101b4b8773b9692004a318 100644 --- a/front/src/components/common/UserMenu.vue +++ b/front/src/components/common/UserMenu.vue @@ -1,3 +1,39 @@ +<script setup lang="ts"> +import { useGettext } from 'vue3-gettext' + +import useThemeList from '~/composables/useThemeList' +import useTheme from '~/composables/useTheme' +import { computed } from 'vue' + +interface Events { + (e: 'show:shortcuts-modal'): void +} + +const emit = defineEmits<Events>() + +const { $pgettext } = useGettext() +const themes = useThemeList() +const theme = useTheme() + +const labels = computed(() => ({ + profile: $pgettext('*/*/*/Noun', 'Profile'), + settings: $pgettext('*/*/*/Noun', 'Settings'), + logout: $pgettext('Sidebar/Login/List item.Link/Verb', 'Log out'), + about: $pgettext('Sidebar/About/List item.Link', 'About'), + shortcuts: $pgettext('*/*/*/Noun', 'Keyboard shortcuts'), + support: $pgettext('Sidebar/*/Listitem.Link', 'Help'), + forum: $pgettext('Sidebar/*/Listitem.Link', 'Forum'), + docs: $pgettext('Sidebar/*/Listitem.Link', 'Documentation'), + language: $pgettext('Footer/Settings/Dropdown.Label/Short, Verb', 'Change language'), + theme: $pgettext('Footer/Settings/Dropdown.Label/Short, Verb', 'Change theme'), + chat: $pgettext('Sidebar/*/Listitem.Link', 'Chat room'), + git: $pgettext('Footer/*/List item.Link', 'Issue tracker'), + login: $pgettext('*/*/Button.Label/Verb', 'Log in'), + signup: $pgettext('*/*/Button.Label/Verb', 'Sign up'), + notifications: $pgettext('*/Notifications/*', 'Notifications') +})) +</script> + <template> <div class="ui menu"> <div class="ui scrolling dropdown item"> @@ -26,14 +62,14 @@ class="menu" > <a - v-for="theme in themes" - :key="theme.key" - :class="[{'active': $store.state.ui.theme === theme.key}, 'item']" - :value="theme.key" - @click="$store.dispatch('ui/theme', theme.key)" + v-for="t in themes" + :key="t.key" + :class="[{'active': theme === t.key}, 'item']" + :value="t.key" + @click="theme = t.key" > - <i :class="theme.icon" /> - {{ theme.name }} + <i :class="t.icon" /> + {{ t.name }} </a> </div> </div> @@ -52,6 +88,13 @@ :to="{name: 'notifications'}" > <i class="bell icon" /> + <div + v-if="$store.state.ui.notifications.inbox > 0" + :title="labels.notifications" + :class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']" + > + {{ $store.state.ui.notifications.inbox }} + </div> {{ labels.notifications }} </router-link> <router-link @@ -105,7 +148,7 @@ <a href="" class="item" - @click.prevent="showShortcuts" + @click.prevent="emit('show:shortcuts-modal')" > <i class="keyboard icon" /> {{ labels.shortcuts }} @@ -150,44 +193,3 @@ </template> </div> </template> - -<script> - -import { mapGetters } from 'vuex' - -import ThemesMixin from '@/components/mixins/Themes.vue' - -export default { - mixins: [ThemesMixin], - computed: { - labels () { - return { - profile: this.$pgettext('*/*/*/Noun', 'Profile'), - settings: this.$pgettext('*/*/*/Noun', 'Settings'), - logout: this.$pgettext('Sidebar/Login/List item.Link/Verb', 'Log out'), - about: this.$pgettext('Sidebar/About/List item.Link', 'About'), - shortcuts: this.$pgettext('*/*/*/Noun', 'Keyboard shortcuts'), - support: this.$pgettext('Sidebar/*/Listitem.Link', 'Help'), - forum: this.$pgettext('Sidebar/*/Listitem.Link', 'Forum'), - docs: this.$pgettext('Sidebar/*/Listitem.Link', 'Documentation'), - language: this.$pgettext('Footer/Settings/Dropdown.Label/Short, Verb', 'Change language'), - theme: this.$pgettext('Footer/Settings/Dropdown.Label/Short, Verb', 'Change theme'), - chat: this.$pgettext('Sidebar/*/Listitem.Link', 'Chat room'), - git: this.$pgettext('Footer/*/List item.Link', 'Issue tracker'), - login: this.$pgettext('*/*/Button.Label/Verb', 'Log in'), - signup: this.$pgettext('*/*/Button.Label/Verb', 'Sign up'), - notifications: this.$pgettext('*/Notifications/*', 'Notifications') - } - }, - ...mapGetters({ - additionalNotifications: 'ui/additionalNotifications' - }) - }, - methods: { - showShortcuts () { - this.$emit('show:shortcuts-modal') - console.log(this.$store.getters['ui/windowSize']) - } - } -} -</script> diff --git a/front/src/components/common/UserModal.vue b/front/src/components/common/UserModal.vue index 7b7cf377e43419236fb30637dc15e6bf163efaa1..58e261980d81bf18c184526792a0669ace94a2ad 100644 --- a/front/src/components/common/UserModal.vue +++ b/front/src/components/common/UserModal.vue @@ -1,18 +1,66 @@ +<script setup lang="ts"> +import SemanticModal from '~/components/semantic/Modal.vue' +import useThemeList from '~/composables/useThemeList' +import useTheme from '~/composables/useTheme' +import { useVModel } from '@vueuse/core' +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +interface Events { + (e: 'update:show', value: boolean): void + (e: 'showLanguageModalEvent'): void + (e: 'showThemeModalEvent'): void +} + +interface Props { + show: boolean +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const show = useVModel(props, 'show', emit) + +const theme = useTheme() +const themes = useThemeList() + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + header: $pgettext('Popup/Title/Noun', 'Options'), + profile: $pgettext('*/*/*/Noun', 'Profile'), + settings: $pgettext('*/*/*/Noun', 'Settings'), + logout: $pgettext('Sidebar/Login/List item.Link/Verb', 'Log out'), + about: $pgettext('Sidebar/About/List item.Link', 'About'), + shortcuts: $pgettext('*/*/*/Noun', 'Keyboard shortcuts'), + support: $pgettext('Sidebar/*/Listitem.Link', 'Help'), + forum: $pgettext('Sidebar/*/Listitem.Link', 'Forum'), + docs: $pgettext('Sidebar/*/Listitem.Link', 'Documentation'), + help: $pgettext('Sidebar/*/Listitem.Link', 'Help'), + language: $pgettext('Sidebar/Settings/Dropdown.Label/Short, Verb', 'Language'), + theme: $pgettext('Sidebar/Settings/Dropdown.Label/Short, Verb', 'Theme'), + chat: $pgettext('Sidebar/*/Listitem.Link', 'Chat room'), + git: $pgettext('Sidebar/*/List item.Link', 'Issue tracker'), + login: $pgettext('*/*/Button.Label/Verb', 'Log in'), + signup: $pgettext('*/*/Button.Label/Verb', 'Sign up'), + notifications: $pgettext('*/Notifications/*', 'Notifications'), + useOtherInstance: $pgettext('Sidebar/*/List item.Link', 'Use another instance') +})) +</script> + <template> <!-- TODO make generic and move to semantic/modal? --> - <modal - :show="show" + <semantic-modal + v-model:show="show" :scrolling="true" :fullscreen="false" - @update:show="$emit('update:show', $event)" > <div v-if="$store.state.auth.authenticated" class="header" > <img - v-if="$store.state.auth.profile.avatar && $store.state.auth.profile.avatar.urls.medium_square_crop" - v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.urls.medium_square_crop)" + v-if="$store.state.auth.profile?.avatar && $store.state.auth.profile?.avatar.urls.medium_square_crop" + v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)" alt="" class="ui centered small circular image" > @@ -38,7 +86,7 @@ <div class="column" role="button" - @click="[$emit('update:show', false), $emit('showLanguageModalEvent')]" + @click="[$emit('update:show', false), emit('showLanguageModalEvent')]" > <i class="language icon user-modal list-icon" /> <span class="user-modal list-item">{{ labels.language }}:</span> @@ -52,12 +100,12 @@ <div class="column" role="button" - @click="[$emit('update:show', false), $emit('showThemeModalEvent')]" + @click="[$emit('update:show', false), emit('showThemeModalEvent')]" > <i class="palette icon user-modal list-icon" /> <span class="user-modal list-item">{{ labels.theme }}:</span> <div class="right floated"> - <span class="user-modal list-item"> {{ themes.find(x => x.key ===$store.state.ui.theme).name }}</span> + <span class="user-modal list-item"> {{ themes.find(x => x.key === theme)?.name }}</span> <i class="action-hint chevron right icon user-modal" /> </div> </div> @@ -77,24 +125,36 @@ <div class="row"> <router-link v-if="$store.state.auth.authenticated" - tag="div" - class="column" - :to="{name: 'notifications'}" - role="button" + v-slot="{ navigate }" + custom + :to="{ name: 'notifications' }" > - <i class="user-modal list-icon bell icon" /> - <span class="user-modal list-item">{{ labels.notifications }}</span> + <div + class="column" + role="button" + @click="navigate" + @keypress.enter="navigate()" + > + <i class="user-modal list-icon bell icon" /> + <span class="user-modal list-item">{{ labels.notifications }}</span> + </div> </router-link> </div> <div class="row"> <router-link - tag="div" - class="column" + v-slot="{ navigate }" + custom :to="{ path: '/settings' }" - role="button" > - <i class="user-modal list-icon cog icon" /> - <span class="user-modal list-item">{{ labels.settings }}</span> + <div + class="column" + role="button" + @click="navigate" + @keypress.enter="navigate()" + > + <i class="user-modal list-icon cog icon" /> + <span class="user-modal list-item">{{ labels.settings }}</span> + </div> </router-link> </div> <div class="ui divider" /> @@ -121,110 +181,76 @@ </div> <div class="row"> <router-link - tag="div" - class="column" + v-slot="{ navigate }" + custom :to="{ name: 'about' }" - role="button" > - <i class="user-modal list-icon question circle outline icon" /> - <span class="user-modal list-item">{{ labels.about }}</span> + <div + class="column" + role="button" + @click="navigate" + @keypress.enter="navigate()" + > + <i class="user-modal list-icon question circle outline icon" /> + <span class="user-modal list-item">{{ labels.about }}</span> + </div> </router-link> </div> <div class="ui divider" /> - <template v-if="$store.state.auth.authenticated"> - <router-link - tag="div" + + <router-link + v-if="$store.state.auth.authenticated" + v-slot="{ navigate }" + custom + :to="{ name: 'logout' }" + > + <div class="column" - :to="{ name: 'logout' }" role="button" + @click="navigate" + @keypress.enter="navigate()" > <i class="user-modal list-icon sign out alternate icon" /> <span class="user-modal list-item">{{ labels.logout }}</span> - </router-link> - </template> - <template v-if="!$store.state.auth.authenticated"> - <router-link - tag="div" + </div> + </router-link> + <router-link + v-else + v-slot="{ navigate }" + custom + :to="{ name: 'login' }" + > + <div class="column" - :to="{ name: 'login' }" role="button" + @click="navigate" + @keypress.enter="navigate()" > <i class="user-modal list-icon sign in alternate icon" /> <span class="user-modal list-item">{{ labels.login }}</span> - </router-link> - </template> - <template - v-if="!$store.state.auth.authenticated" - && - $store.state.instance.settings.users.registration_enabled.value + </div> + </router-link> + <router-link + v-if="!$store.state.auth.authenticated && $store.state.instance.settings.users.registration_enabled.value" + v-slot="{ navigate }" + custom + :to="{ name: 'signup' }" > - <router-link - tag="div" + <div class="column" - :to="{ name: 'signup' }" role="button" + @click="navigate" + @keypress.enter="navigate()" > <i class="user-modal list-item user icon" /> <span class="user-modal list-item">{{ labels.signup }}</span> - </router-link> - </template> + </div> + </router-link> </div> </div> - </modal> + </semantic-modal> </template> -<script> -import Modal from '@/components/semantic/Modal.vue' -import ThemesMixin from '@/components/mixins/Themes.vue' -import { mapGetters } from 'vuex' - -export default { - components: { - Modal - }, - mixins: [ThemesMixin], - props: { - show: { type: Boolean, required: true } - }, - computed: { - labels () { - return { - header: this.$pgettext('Popup/Title/Noun', 'Options'), - profile: this.$pgettext('*/*/*/Noun', 'Profile'), - settings: this.$pgettext('*/*/*/Noun', 'Settings'), - logout: this.$pgettext('Sidebar/Login/List item.Link/Verb', 'Log out'), - about: this.$pgettext('Sidebar/About/List item.Link', 'About'), - shortcuts: this.$pgettext('*/*/*/Noun', 'Keyboard shortcuts'), - support: this.$pgettext('Sidebar/*/Listitem.Link', 'Help'), - forum: this.$pgettext('Sidebar/*/Listitem.Link', 'Forum'), - docs: this.$pgettext('Sidebar/*/Listitem.Link', 'Documentation'), - help: this.$pgettext('Sidebar/*/Listitem.Link', 'Help'), - language: this.$pgettext( - 'Sidebar/Settings/Dropdown.Label/Short, Verb', - 'Language' - ), - theme: this.$pgettext( - 'Sidebar/Settings/Dropdown.Label/Short, Verb', - 'Theme' - ), - chat: this.$pgettext('Sidebar/*/Listitem.Link', 'Chat room'), - git: this.$pgettext('Sidebar/*/List item.Link', 'Issue tracker'), - login: this.$pgettext('*/*/Button.Label/Verb', 'Log in'), - signup: this.$pgettext('*/*/Button.Label/Verb', 'Sign up'), - notifications: this.$pgettext('*/Notifications/*', 'Notifications'), - useOtherInstance: this.$pgettext( - 'Sidebar/*/List item.Link', - 'Use another instance' - ) - } - }, - ...mapGetters({ - additionalNotifications: 'ui/additionalNotifications' - }) - } -} -</script> - <style> .action-hint { margin-left: 1rem !important; diff --git a/front/src/components/common/Username.vue b/front/src/components/common/Username.vue index 7c25a124fd8d2665fcb4c617967f2cd1e9167a2e..415244b3b13d068753d33b70e96beccbb91e0e3b 100644 --- a/front/src/components/common/Username.vue +++ b/front/src/components/common/Username.vue @@ -1,8 +1,11 @@ +<script setup lang="ts"> +interface Props { + username: string +} + +defineProps<Props>() +</script> + <template> <span>{{ username }}</span> </template> -<script> -export default { - props: { username: { type: String, required: true } } -} -</script> diff --git a/front/src/components/favorites/List.vue b/front/src/components/favorites/List.vue index 7a4ea9acbef8b421d19f3fa4d900dc0c89dc36a2..30ae19b0367c67c8bb346570d42efaf292f76988 100644 --- a/front/src/components/favorites/List.vue +++ b/front/src/components/favorites/List.vue @@ -1,10 +1,116 @@ +<script setup lang="ts"> +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' +import type { Track } from '~/types' + +import { computed, onMounted, reactive, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' +import { sortedUniq } from 'lodash-es' +import { useStore } from '~/store' + +import axios from 'axios' +import $ from 'jquery' + +import TrackTable from '~/components/audio/track/Table.vue' +import RadioButton from '~/components/radios/Button.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' +import useLogger from '~/composables/useLogger' + +interface Props extends OrderingProps { + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName +} + +const props = withDefaults(defineProps<Props>(), { + defaultPage: 1, + orderingConfigName: undefined +}) + +const store = useStore() + +const page = usePage() + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['title', 'track_title'], + ['album__title', 'album_title'], + ['artist__name', 'artist_name'] +] + +const logger = useLogger() +const sharedLabels = useSharedLabels() + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const results = reactive<Track[]>([]) +const nextLink = ref() +const previousLink = ref() +const count = ref(0) + +const isLoading = ref(false) +const fetchFavorites = async () => { + isLoading.value = true + + const params = { + favorites: 'true', + page: page.value, + page_size: paginateBy.value, + ordering: orderingString.value + } + + try { + logger.time('Loading user favorites') + const response = await axios.get('tracks/', { params }) + + results.length = 0 + results.push(...response.data.results) + + for (const track of results) { + store.commit('favorites/track', { id: track.id, value: true }) + } + + count.value = response.data.count + nextLink.value = response.data.next + previousLink.value = response.data.previous + } catch (error) { + useErrorHandler(error as Error) + } finally { + logger.timeEnd('Loading user favorites') + isLoading.value = false + } +} + +watch(page, fetchFavorites) +fetchFavorites() + +onOrderingUpdate(() => { + page.value = 1 + fetchFavorites() +}) + +onMounted(() => $('.ui.dropdown').dropdown()) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/Favorites/Title', 'Your Favorites') +})) + +const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value].sort((a, b) => a - b))) +</script> + <template> <main v-title="labels.title" class="main pusher" > <section class="ui vertical center aligned stripe segment"> - <div :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']"> + <div :class="['ui', { 'active': isLoading }, 'inverted', 'dimmer']"> <div class="ui text loader"> <translate translate-context="Content/Favorites/Message"> Loading your favorites… @@ -19,25 +125,27 @@ <translate translate-plural="%{ count } favorites" :translate-n="$store.state.favorites.count" - :translate-params="{count: results.count}" + :translate-params="{ count }" translate-context="Content/Favorites/Title" > %{ count } favorite </translate> </h2> <radio-button - v-if="hasFavorites" + v-if="$store.state.favorites.count > 0" type="favorites" /> </section> <section - v-if="hasFavorites" + v-if="$store.state.favorites.count > 0" class="ui vertical stripe segment" > - <div :class="['ui', {'loading': isLoading}, 'form']"> + <div :class="['ui', { 'loading': isLoading }, 'form']"> <div class="fields"> <div class="field"> - <label for="favorites-ordering"><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> + <label for="favorites-ordering"> + <translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate> + </label> <select id="favorites-ordering" v-model="ordering" @@ -53,7 +161,9 @@ </select> </div> <div class="field"> - <label for="favorites-ordering-direction"><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label> + <label for="favorites-ordering-direction"> + <translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate> + </label> <select id="favorites-ordering-direction" v-model="orderingDirection" @@ -72,20 +182,20 @@ </select> </div> <div class="field"> - <label for="favorites-results"><translate translate-context="Content/Search/Dropdown.Label/Noun">Results per page</translate></label> + <label for="favorites-results"> + <translate translate-context="Content/Search/Dropdown.Label/Noun">Results per page</translate> + </label> <select id="favorites-results" v-model="paginateBy" class="ui dropdown" > - <option :value="parseInt(12)"> - 12 - </option> - <option :value="parseInt(25)"> - 25 - </option> - <option :value="parseInt(50)"> - 50 + <option + v-for="opt in paginateOptions" + :key="opt" + :value="opt" + > + {{ opt }} </option> </select> </div> @@ -95,15 +205,14 @@ v-if="results" :show-artist="true" :show-album="true" - :tracks="results.results" + :tracks="results" /> <div class="ui center aligned basic segment"> <pagination - v-if="results && results.count > paginateBy" - :current="page" + v-if="results && count > paginateBy" + v-model:current="page" :paginate-by="paginateBy" - :total="results.count" - @page-changed="selectPage" + :total="count" /> </div> </section> @@ -131,108 +240,3 @@ </div> </main> </template> - -<script> -import axios from 'axios' -import $ from 'jquery' -import logger from '@/logging' -import RadioButton from '@/components/radios/Button.vue' -import Pagination from '@/components/Pagination.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import PaginationMixin from '@/components/mixins/Pagination.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import { checkRedirectToLogin } from '@/utils' -import TrackTable from '@/components/audio/track/Table.vue' -const FAVORITES_URL = 'tracks/' - -export default { - components: { - RadioButton, - Pagination, - TrackTable - }, - mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], - data () { - return { - results: null, - isLoading: false, - nextLink: null, - previousLink: null, - page: parseInt(this.defaultPage), - orderingOptions: [ - ['creation_date', 'creation_date'], - ['title', 'track_title'], - ['album__title', 'album_title'], - ['artist__name', 'artist_name'] - ] - } - }, - computed: { - labels () { - return { - title: this.$pgettext('Head/Favorites/Title', 'Your Favorites') - } - }, - hasFavorites () { - return this.$store.state.favorites.count > 0 - } - }, - watch: { - page: function () { - this.updateQueryString() - }, - paginateBy: function () { - this.updateQueryString() - }, - orderingDirection: function () { - this.updateQueryString() - }, - ordering: function () { - this.updateQueryString() - } - }, - created () { - checkRedirectToLogin(this.$store, this.$router) - this.fetchFavorites(FAVORITES_URL) - }, - mounted () { - $('.ui.dropdown').dropdown() - }, - methods: { - updateQueryString: function () { - this.$router.replace({ - query: { - page: this.page, - paginateBy: this.paginateBy, - ordering: this.getOrderingAsString() - } - }) - this.fetchFavorites(FAVORITES_URL) - }, - fetchFavorites (url) { - const self = this - this.isLoading = true - const params = { - favorites: 'true', - page: this.page, - page_size: this.paginateBy, - ordering: this.getOrderingAsString() - } - logger.default.time('Loading user favorites') - axios.get(url, { params: params }).then(response => { - self.results = response.data - self.nextLink = response.data.next - self.previousLink = response.data.previous - self.results.results.forEach(track => { - self.$store.commit('favorites/track', { id: track.id, value: true }) - }) - logger.default.timeEnd('Loading user favorites') - self.isLoading = false - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/favorites/TrackFavoriteIcon.vue b/front/src/components/favorites/TrackFavoriteIcon.vue index 80db865f54f6fe3f6b2160cd71eb564a6e11edc3..edf09372ccde7ec8b3de661461d6505ed22f5011 100644 --- a/front/src/components/favorites/TrackFavoriteIcon.vue +++ b/front/src/components/favorites/TrackFavoriteIcon.vue @@ -1,3 +1,32 @@ +<script setup lang="ts"> +import type { Track } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' +import { computed } from 'vue' + +interface Props { + track?: Track + button?: boolean + border?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + track: () => ({} as Track), + button: false, + border: false +}) + +const { $pgettext } = useGettext() +const store = useStore() + +const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id)) +const title = computed(() => isFavorite.value + ? $pgettext('Content/Track/Icon.Tooltip/Verb', 'Remove from favorites') + : $pgettext('Content/Track/*/Verb', 'Add to favorites') +) +</script> + <template> <button v-if="button" @@ -28,26 +57,3 @@ <i :class="['heart', {'pink': isFavorite}, 'basic', 'icon']" /> </button> </template> - -<script> -export default { - props: { - track: { type: Object, default: () => { return {} } }, - button: { type: Boolean, default: false }, - border: { type: Boolean, default: false } - }, - computed: { - title () { - if (this.isFavorite) { - return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Remove from favorites') - } else { - return this.$pgettext('Content/Track/*/Verb', 'Add to favorites') - } - }, - isFavorite () { - return this.$store.getters['favorites/isFavorite'](this.track.id) - } - } - -} -</script> diff --git a/front/src/components/federation/FetchButton.vue b/front/src/components/federation/FetchButton.vue index bb909a70354d8e01addc0acf1e6c785e94f22454..6da79ba643f6b613ae4ea7edb0911a3a3e86b382 100644 --- a/front/src/components/federation/FetchButton.vue +++ b/front/src/components/federation/FetchButton.vue @@ -1,14 +1,83 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import axios from 'axios' +import SemanticModal from '~/components/semantic/Modal.vue' +import { useTimeoutFn } from '@vueuse/core' +import { ref } from 'vue' + +interface Events { + (e: 'refresh'): void +} + +interface Props { + url: string +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const MAX_POLLS = 15 + +const pollsCount = ref(0) +const showModal = ref(false) +const data = ref() +const errors = ref([] as string[]) + +const isLoading = ref(false) +const isPolling = ref(false) + +const fetch = async () => { + showModal.value = true + isLoading.value = true + isPolling.value = false + errors.value = [] + pollsCount.value = 0 + data.value = undefined + + try { + const response = await axios.post(props.url) + data.value = response.data + startPolling() + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const poll = async () => { + isPolling.value = true + showModal.value = true + + try { + const response = await axios.get(`federation/fetches/${data.value?.id}/`) + data.value = response.data + + if (response.data.status === 'pending' && pollsCount.value++ < MAX_POLLS) { + startPolling() + } + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isPolling.value = false +} + +const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false }) +</script> + <template> <div role="button" - @click="createFetch" + @click="fetch" > <div> <slot /> </div> - <modal + <semantic-modal + v-model:show="showModal" class="small" - :show.sync="showModal" > <h3 class="header"> <translate translate-context="Popup/*/Title"> @@ -16,9 +85,9 @@ </translate> </h3> <div class="scrolling content"> - <template v-if="fetch && fetch.status != 'pending'"> + <template v-if="data && data.status != 'pending'"> <div - v-if="fetch.status === 'skipped'" + v-if="data.status === 'skipped'" class="ui message" > <h4 class="header"> @@ -33,7 +102,7 @@ </p> </div> <div - v-else-if="fetch.status === 'finished'" + v-else-if="data.status === 'finished'" class="ui success message" > <h4 class="header"> @@ -48,7 +117,7 @@ </p> </div> <div - v-else-if="fetch.status === 'errored'" + v-else-if="data.status === 'errored'" class="ui error message" > <h4 class="header"> @@ -70,7 +139,7 @@ </translate> </td> <td> - {{ fetch.detail.error_code }} + {{ data.detail.error_code }} </td> </tr> <tr> @@ -81,44 +150,44 @@ </td> <td> <translate - v-if="fetch.detail.error_code === 'http' && fetch.detail.status_code" - :translate-params="{status: fetch.detail.status_code}" + v-if="data.detail.error_code === 'http' && data.detail.status_code" + :translate-params="{status: data.detail.status_code}" translate-context="*/*/Error" > The remote server answered with HTTP %{ status } </translate> <translate - v-else-if="['http', 'request'].indexOf(fetch.detail.error_code) > -1" + v-else-if="['http', 'request'].indexOf(data.detail.error_code) > -1" translate-context="*/*/Error" > An HTTP error occurred while contacting the remote server </translate> <translate - v-else-if="fetch.detail.error_code === 'timeout'" + v-else-if="data.detail.error_code === 'timeout'" translate-context="*/*/Error" > The remote server didn't respond quickly enough </translate> <translate - v-else-if="fetch.detail.error_code === 'connection'" + v-else-if="data.detail.error_code === 'connection'" translate-context="*/*/Error" > Impossible to connect to the remote server </translate> <translate - v-else-if="['invalid_json', 'invalid_jsonld', 'missing_jsonld_type'].indexOf(fetch.detail.error_code) > -1" + v-else-if="['invalid_json', 'invalid_jsonld', 'missing_jsonld_type'].indexOf(data.detail.error_code) > -1" translate-context="*/*/Error" > The remote server returned invalid JSON or JSON-LD data </translate> <translate - v-else-if="fetch.detail.error_code === 'validation'" + v-else-if="data.detail.error_code === 'validation'" translate-context="*/*/Error" > Data returned by the remote server had invalid or missing attributes </translate> <translate - v-else-if="fetch.detail.error_code === 'unhandled'" + v-else-if="data.detail.error_code === 'unhandled'" translate-context="*/*/Error" > Unknown error @@ -136,7 +205,7 @@ </div> </template> <div - v-else-if="isCreatingFetch" + v-else-if="isLoading" class="ui active inverted dimmer" > <div class="ui text loader"> @@ -146,7 +215,7 @@ </div> </div> <div - v-else-if="isWaitingFetch" + v-else-if="isPolling" class="ui active inverted dimmer" > <div class="ui text loader"> @@ -175,7 +244,7 @@ </ul> </div> <div - v-else-if="fetch && fetch.status === 'pending' && pollsCount >= maxPolls" + v-else-if="data && data.status === 'pending' && pollsCount >= MAX_POLLS" role="alert" class="ui warning message" > @@ -198,78 +267,15 @@ </translate> </button> <button - v-if="fetch && fetch.status === 'finished'" + v-if="data && data.status === 'finished'" class="ui confirm success button" - @click.prevent="showModal = false; $emit('refresh')" + @click.prevent="showModal = false; emit('refresh')" > <translate translate-context="*/*/Button.Label/Verb"> Close and reload page </translate> </button> </div> - </modal> + </semantic-modal> </div> </template> - -<script> -import axios from 'axios' -import Modal from '@/components/semantic/Modal.vue' - -export default { - components: { - Modal - }, - props: { url: { type: String, required: true } }, - data () { - return { - fetch: null, - isCreatingFetch: false, - errors: [], - showModal: false, - isWaitingFetch: false, - maxPolls: 15, - pollsCount: 0 - } - }, - methods: { - createFetch () { - const self = this - this.fetch = null - this.pollsCount = 0 - this.errors = [] - this.isCreatingFetch = true - this.isWaitingFetch = false - self.showModal = true - axios.post(this.url).then((response) => { - self.isCreatingFetch = false - self.fetch = response.data - self.pollFetch() - }, (error) => { - self.isCreatingFetch = false - self.errors = error.backendErrors - }) - }, - pollFetch () { - this.isWaitingFetch = true - this.pollsCount += 1 - const url = `federation/fetches/${this.fetch.id}/` - const self = this - self.showModal = true - axios.get(url).then((response) => { - self.isCreatingFetch = false - self.fetch = response.data - if (self.fetch.status === 'pending' && self.pollsCount < self.maxPolls) { - setTimeout(() => { - self.pollFetch() - }, 1000) - } else { - self.isWaitingFetch = false - } - }, (error) => { - self.errors = error.backendErrors - self.isWaitingFetch = false - }) - } - } -} -</script> diff --git a/front/src/components/federation/LibraryWidget.vue b/front/src/components/federation/LibraryWidget.vue index f2ae6f05c9e245d0746a25b7df93179055c53117..261a77b76d261f069b39cd3adfad764e79b55606 100644 --- a/front/src/components/federation/LibraryWidget.vue +++ b/front/src/components/federation/LibraryWidget.vue @@ -1,3 +1,51 @@ +<script setup lang="ts"> +import type { Library } from '~/types' + +import { ref, reactive } from 'vue' + +import axios from 'axios' + +import LibraryCard from '~/views/content/remote/Card.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'loaded', libraries: Library[]): void +} + +interface Props { + url: string +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const nextPage = ref() +const libraries = reactive([] as Library[]) +const isLoading = ref(false) +const fetchData = async (url = props.url) => { + isLoading.value = true + + try { + const response = await axios.get(url, { + params: { + page_size: 6 + } + }) + + nextPage.value = response.data.next + libraries.push(...response.data.results) + emit('loaded', libraries) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() +</script> + <template> <div class="wrapper"> <h3 @@ -10,7 +58,7 @@ v-if="!isLoading && libraries.length > 0" class="ui subtitle" > - <slot name="subtitle" /> + <slot /> </p> <p v-if="!isLoading && libraries.length === 0" @@ -51,62 +99,3 @@ </template> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import LibraryCard from '@/views/content/remote/Card.vue' - -export default { - components: { - LibraryCard - }, - props: { - url: { type: String, required: true } - }, - data () { - return { - libraries: [], - limit: 6, - isLoading: false, - errors: null, - previousPage: null, - nextPage: null - } - }, - watch: { - offset () { - this.fetchData() - } - }, - created () { - this.fetchData(this.url) - }, - methods: { - fetchData (url) { - this.isLoading = true - const self = this - const params = _.clone({}) - params.page_size = this.limit - params.offset = this.offset - axios.get(url, { params: params }).then((response) => { - self.previousPage = response.data.previous - self.nextPage = response.data.next - self.isLoading = false - self.libraries = [...self.libraries, ...response.data.results] - self.$emit('loaded', self.libraries) - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - updateOffset (increment) { - if (increment) { - this.offset += this.limit - } else { - this.offset = Math.max(this.offset - this.limit, 0) - } - } - } -} -</script> diff --git a/front/src/components/forms/PasswordInput.vue b/front/src/components/forms/PasswordInput.vue index b9dab0e4c4ab8f963acad33fe149699afe8b7115..623552028d242a830ab95651ebbf7c170674aff9 100644 --- a/front/src/components/forms/PasswordInput.vue +++ b/front/src/components/forms/PasswordInput.vue @@ -1,12 +1,57 @@ +<script setup lang="ts"> +import { computed, ref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useClipboard, useVModel } from '@vueuse/core' +import { useStore } from '~/store' + +interface Events { + (e: 'update:modelValue', value: string): void +} + +interface Props { + modelValue: string + defaultShow?: boolean + copyButton?: boolean + fieldId: string +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + defaultShow: false, + copyButton: false +}) + +const value = useVModel(props, 'modelValue', emit) + +const showPassword = ref(props.defaultShow) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Content/Settings/Button.Tooltip/Verb', 'Show/hide password'), + copy: $pgettext('*/*/Button.Label/Short, Verb', 'Copy') +})) + +const passwordInputType = computed(() => showPassword.value ? 'text' : 'password') + +const store = useStore() +const { isSupported: canCopy, copy } = useClipboard({ source: value }) +const copyPassword = () => { + copy() + store.commit('ui/addMessage', { + content: $pgettext('Content/*/Paragraph', 'Text copied to clipboard!'), + date: new Date() + }) +} +</script> + <template> <div class="ui fluid action input"> <input :id="fieldId" + v-model="value" required name="password" :type="passwordInputType" - :value="value" - @input="$emit('input', $event.target.value)" > <button type="button" @@ -17,7 +62,7 @@ <i class="eye icon" /> </button> <button - v-if="copyButton" + v-if="copyButton && canCopy" type="button" class="ui icon button" :title="labels.copy" @@ -27,62 +72,3 @@ </button> </div> </template> -<script> -export default { - props: { - value: { type: String, required: true }, - defaultShow: { type: Boolean, default: false }, - copyButton: { type: Boolean, default: false }, - fieldId: { type: String, required: true } - }, - data () { - return { - showPassword: this.defaultShow || false - } - }, - computed: { - labels () { - return { - title: this.$pgettext( - 'Content/Settings/Button.Tooltip/Verb', - 'Show/hide password' - ), - copy: this.$pgettext('*/*/Button.Label/Short, Verb', 'Copy') - } - }, - passwordInputType () { - if (this.showPassword) { - return 'text' - } - return 'password' - } - }, - methods: { - copyPassword () { - try { - this._copyStringToClipboard(this.value) - this.$store.commit('ui/addMessage', { - content: this.$pgettext( - 'Content/*/Paragraph', - 'Text copied to clipboard!' - ), - date: new Date() - }) - } catch ($e) { - console.error('Cannot copy', $e) - } - }, - _copyStringToClipboard (str) { - // cf https://techoverflow.net/2018/03/30/copying-strings-to-the-clipboard-using-pure-javascript/ - const el = document.createElement('textarea') - el.value = str - el.setAttribute('readonly', '') - el.style = { position: 'absolute', left: '-9999px' } - document.body.appendChild(el) - el.select() - document.execCommand('copy') - document.body.removeChild(el) - } - } -} -</script> diff --git a/front/src/components/globals.js b/front/src/components/globals.js deleted file mode 100644 index f69bde4d5fd8473bea21198d4394175b13e7f15f..0000000000000000000000000000000000000000 --- a/front/src/components/globals.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue' -import HumanDate from '@/components/common/HumanDate.vue' -import HumanDuration from '@/components/common/HumanDuration.vue' -import Username from '@/components/common/Username.vue' -import UserLink from '@/components/common/UserLink.vue' -import ActorLink from '@/components/common/ActorLink.vue' -import ActorAvatar from '@/components/common/ActorAvatar.vue' -import Duration from '@/components/common/Duration.vue' -import DangerousButton from '@/components/common/DangerousButton.vue' -import Message from '@/components/common/Message.vue' -import CopyInput from '@/components/common/CopyInput.vue' -import AjaxButton from '@/components/common/AjaxButton.vue' -import Tooltip from '@/components/common/Tooltip.vue' -import EmptyState from '@/components/common/EmptyState.vue' -import ExpandableDiv from '@/components/common/ExpandableDiv.vue' -import CollapseLink from '@/components/common/CollapseLink.vue' -import ActionFeedback from '@/components/common/ActionFeedback.vue' -import RenderedDescription from '@/components/common/RenderedDescription.vue' -import ContentForm from '@/components/common/ContentForm.vue' -import InlineSearchBar from '@/components/common/InlineSearchBar.vue' - -Vue.component('HumanDate', HumanDate) -Vue.component('HumanDuration', HumanDuration) -Vue.component('Username', Username) -Vue.component('UserLink', UserLink) -Vue.component('ActorLink', ActorLink) -Vue.component('ActorAvatar', ActorAvatar) -Vue.component('Duration', Duration) -Vue.component('DangerousButton', DangerousButton) -Vue.component('Message', Message) -Vue.component('CopyInput', CopyInput) -Vue.component('AjaxButton', AjaxButton) -Vue.component('Tooltip', Tooltip) -Vue.component('EmptyState', EmptyState) -Vue.component('ExpandableDiv', ExpandableDiv) -Vue.component('CollapseLink', CollapseLink) -Vue.component('ActionFeedback', ActionFeedback) -Vue.component('RenderedDescription', RenderedDescription) -Vue.component('ContentForm', ContentForm) -Vue.component('InlineSearchBar', InlineSearchBar) - -export default {} diff --git a/front/src/components/library/AlbumBase.vue b/front/src/components/library/AlbumBase.vue index 948f01c255bfca48732354a87f129d6f813f1295..bd32a5d82176b3bf003d7e3fbb8b6045a3374a9e 100644 --- a/front/src/components/library/AlbumBase.vue +++ b/front/src/components/library/AlbumBase.vue @@ -1,3 +1,106 @@ +<script setup lang="ts"> +import type { Track, Album, Artist, Library } from '~/types' + +import { momentFormat } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { computed, ref, watch } from 'vue' +import { useRouter } from 'vue-router' +import { sum } from 'lodash-es' + +import axios from 'axios' + +import ArtistLabel from '~/components/audio/ArtistLabel.vue' +import PlayButton from '~/components/audio/PlayButton.vue' +import TagsList from '~/components/tags/List.vue' +import AlbumDropdown from './AlbumDropdown.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'deleted'): void +} + +interface Props { + id: number +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const object = ref<Album | null>(null) +const artist = ref<Artist | null>(null) +const discs = ref([] as Track[][]) +const libraries = ref([] as Library[]) +const page = ref(1) +const paginateBy = ref(50) + +const totalTracks = computed(() => object.value?.tracks_count ?? 0) +const isChannel = computed(() => !!object.value?.artist.channel) +const isAlbum = computed(() => object.value?.artist.content_category === 'music') +const isSerie = computed(() => object.value?.artist.content_category === 'podcast') +const totalDuration = computed(() => sum((object.value?.tracks ?? []).map(track => track.uploads[0]?.duration ?? 0))) +const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? []) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('*/*/*', 'Album') +})) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + const albumResponse = await axios.get(`albums/${props.id}/`, { params: { refresh: 'true' } }) + const [artistResponse, tracksResponse] = await Promise.all([ + axios.get(`artists/${albumResponse.data.artist.id}/`), + axios.get('tracks/', { + params: { + ordering: 'disc_number,position', + album: props.id, + page_size: paginateBy.value, + page: page.value, + include_channels: true + } + }) + ]) + + artist.value = artistResponse.data + if (artist.value?.channel) { + artist.value.channel.artist = artist.value + } + + object.value = albumResponse.data + if (object.value) { + object.value.tracks = tracksResponse.data.results + discs.value = object.value.tracks.reduce((acc: Track[][], track: Track) => { + const discNumber = track.disc_number - (object.value?.tracks[0]?.disc_number ?? 1) + acc[discNumber] ??= [] + acc[discNumber].push(track) + return acc + }, []) + } + + isLoading.value = false +} + +watch(() => props.id, fetchData, { immediate: true }) +watch(page, fetchData) + +const router = useRouter() +const remove = async () => { + isLoading.value = true + try { + await axios.delete(`albums/${object.value?.id}`) + emit('deleted') + router.push({ name: 'library.artists.detail', params: { id: artist.value?.id } }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} +</script> + <template> <main> <div @@ -61,7 +164,6 @@ <div class="ui hidden very small divider" /> <translate v-if="isSerie" - key="1" translate-context="Content/Channel/Paragraph" translate-plural="%{ count } episodes" :translate-n="totalTracks" @@ -94,6 +196,7 @@ :is-serie="isSerie" :is-channel="isChannel" :artist="artist" + @remove="remove" /> </div> </div> @@ -105,7 +208,10 @@ > {{ object.title }} </h2> - <artist-label :artist="artist" /> + <artist-label + v-if="artist" + :artist="artist" + /> </header> </div> <div @@ -133,19 +239,19 @@ {{ object.title }} </h2> <artist-label - class="rounded" + v-if="artist" :artist="artist" + class="rounded" /> </header> <div v-if="object.release_date || (totalTracks > 0)" class="ui small hidden divider" /> - <span v-if="object.release_date">{{ object.release_date | moment('Y') }} · </span> + <span v-if="object.release_date">{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }} · </span> <template v-if="totalTracks > 0"> <translate v-if="isSerie" - key="1" translate-context="Content/Channel/Paragraph" translate-plural="%{ count } episodes" :translate-n="totalTracks" @@ -182,6 +288,7 @@ :is-serie="isSerie" :is-channel="isChannel" :artist="artist" + @remove="remove" /> <div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local"> <div class="ui small hidden divider" /> @@ -246,119 +353,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' -import lodash from 'lodash' -import PlayButton from '@/components/audio/PlayButton.vue' -import TagsList from '@/components/tags/List.vue' -import ArtistLabel from '@/components/audio/ArtistLabel.vue' -import AlbumDropdown from './AlbumDropdown.vue' - -function groupByDisc (initial) { - function inner (acc, track) { - const dn = track.disc_number - initial - if (acc[dn] === undefined) { - acc.push([track]) - } else { - acc[dn].push(track) - } - return acc - } - return inner -} - -export default { - components: { - PlayButton, - TagsList, - ArtistLabel, - AlbumDropdown - }, - props: { id: { type: [String, Number], required: true } }, - data () { - return { - isLoading: true, - object: null, - artist: null, - discs: [], - libraries: [], - page: 1, - paginateBy: 50 - } - }, - computed: { - totalTracks () { - return this.object.tracks_count - }, - isChannel () { - return !!this.object.artist.channel - }, - isSerie () { - return this.object.artist.content_category === 'podcast' - }, - isAlbum () { - return this.object.artist.content_category === 'music' - }, - totalDuration () { - const durations = [0] - this.object.tracks.forEach((t) => { - if (t.uploads[0] && t.uploads[0].duration) { - durations.push(t.uploads[0].duration) - } - }) - return lodash.sum(durations) - }, - labels () { - return { - title: this.$pgettext('*/*/*', 'Album') - } - }, - publicLibraries () { - return this.libraries.filter(l => { - return l.privacy_level === 'everyone' - }) - } - }, - watch: { - id () { - this.fetchData() - }, - page () { - this.fetchData() - } - }, - async created () { - await this.fetchData() - }, - methods: { - async fetchData () { - this.isLoading = true - let tracksResponse = axios.get('tracks/', { params: { ordering: 'disc_number,position', album: this.id, page_size: this.paginateBy, page: this.page, include_channels: 'true' } }) - const albumResponse = await axios.get(`albums/${this.id}/`, { params: { refresh: 'true' } }) - const artistResponse = await axios.get(`artists/${albumResponse.data.artist.id}/`) - this.artist = artistResponse.data - if (this.artist.channel) { - this.artist.channel.artist = this.artist - } - tracksResponse = await tracksResponse - this.object = albumResponse.data - this.object.tracks = tracksResponse.data.results - this.discs = this.object.tracks.reduce(groupByDisc(this.object.tracks[0].disc_number), []) - this.isLoading = false - }, - remove () { - const self = this - self.isLoading = true - axios.delete(`albums/${this.object.id}`).then((response) => { - self.isLoading = false - self.$emit('deleted') - self.$router.push({ name: 'library.artists.detail', params: { id: this.artist.id } }) - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/library/AlbumDetail.vue b/front/src/components/library/AlbumDetail.vue index c8bb360ccbfc6e538f9eef7deafa4b9cb8172854..7f579317b7a13d3feef97d992aa84d166c8ebf6c 100644 --- a/front/src/components/library/AlbumDetail.vue +++ b/front/src/components/library/AlbumDetail.vue @@ -1,16 +1,45 @@ +<script setup lang="ts"> +import type { Artist, Album, Library, Track } from '~/types' + +import LibraryWidget from '~/components/federation/LibraryWidget.vue' +import ChannelEntries from '~/components/audio/ChannelEntries.vue' +import TrackTable from '~/components/audio/track/Table.vue' +import PlayButton from '~/components/audio/PlayButton.vue' + +interface Events { + (e: 'page-changed', page: number): void + (e: 'libraries-loaded', libraries: Library[]): void +} + +interface Props { + object: Album + + discs: Track[][] + + isSerie: boolean + artist: Artist + page: number + paginateBy: number + totalTracks: number +} + +const emit = defineEmits<Events>() +defineProps<Props>() + +const getDiscKey = (disc: Track[]) => disc.map(track => track.id).join('|') +</script> + <template> <div v-if="object"> <h2 class="ui header"> <translate v-if="isSerie" - key="1" translate-context="Content/Channels/*" > Episodes </translate> <translate v-else - key="2" translate-context="*/*/*" > Tracks @@ -22,10 +51,10 @@ :limit="50" :filters="{channel: artist.channel.uuid, album: object.id, ordering: '-creation_date'}" /> - <template v-else-if="discs && discs.length > 1"> + <template v-else-if="discs.length > 1"> <div v-for="tracks in discs" - :key="tracks.disc_number" + :key="getDiscKey(tracks)" > <div class="ui hidden divider" /> <play-button @@ -50,7 +79,7 @@ :total="totalTracks" :paginate-by="paginateBy" :page="page" - @page-changed="updatePage" + @page-changed="emit('page-changed', page)" /> </div> </template> @@ -66,7 +95,7 @@ :total="totalTracks" :paginate-by="paginateBy" :page="page" - @page-changed="updatePage" + @page-changed="emit('page-changed', page)" /> </template> <template v-if="!artist.channel && !isSerie"> @@ -77,53 +106,12 @@ </h2> <library-widget :url="'albums/' + object.id + '/libraries/'" - @loaded="$emit('libraries-loaded', $event)" + @loaded="emit('libraries-loaded', $event)" > - <translate - slot="subtitle" - translate-context="Content/Album/Paragraph" - > + <translate translate-context="Content/Album/Paragraph"> This album is present in the following libraries: </translate> </library-widget> </template> </div> </template> - -<script> - -import time from '@/utils/time.js' -import LibraryWidget from '@/components/federation/LibraryWidget.vue' -import ChannelEntries from '@/components/audio/ChannelEntries.vue' -import TrackTable from '@/components/audio/track/Table.vue' -import PlayButton from '@/components/audio/PlayButton.vue' - -export default { - components: { - LibraryWidget, - TrackTable, - ChannelEntries, - PlayButton - }, - props: { - object: { type: Object, required: true }, - discs: { type: Array, required: true }, - isSerie: { type: Boolean, required: true }, - artist: { type: Object, required: true }, - page: { type: Number, required: true }, - paginateBy: { type: Number, required: true }, - totalTracks: { type: Number, required: true } - }, - data () { - return { - time, - id: this.object.id - } - }, - methods: { - updatePage: function (page) { - this.$emit('page-changed', page) - } - } -} -</script> diff --git a/front/src/components/library/AlbumDropdown.vue b/front/src/components/library/AlbumDropdown.vue index a5591bc4e336b8baff0930bf8e8384b45a24dfe2..a134e85a70771f6677265ee91800303d01b221fd 100644 --- a/front/src/components/library/AlbumDropdown.vue +++ b/front/src/components/library/AlbumDropdown.vue @@ -1,9 +1,54 @@ +<script setup lang="ts"> +import type { Album, Artist, Library } from '~/types' + +import EmbedWizard from '~/components/audio/EmbedWizard.vue' +import SemanticModal from '~/components/semantic/Modal.vue' +import useReport from '~/composables/moderation/useReport' +import { computed, ref } from 'vue' +import { useGettext } from 'vue3-gettext' + +import { getDomain } from '~/utils' + +interface Events { + (e: 'remove'): void +} + +interface Props { + isLoading: boolean + artist: Artist | null + object: Album + publicLibraries: Library[] + isAlbum: boolean + isChannel: boolean + isSerie: boolean +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() +const { report, getReportableObjects } = useReport() + +const showEmbedModal = ref(false) + +const domain = computed(() => getDomain(props.object.fid)) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + more: $pgettext('*/*/Button.Label/Noun', 'More…') +})) + +const isEmbedable = computed(() => (props.isChannel && props.artist?.channel?.actor) || props.publicLibraries.length) +const musicbrainzUrl = computed(() => props.object?.mbid ? `https://musicbrainz.org/release/${props.object.mbid}` : null) +const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(props.object?.title)}&artist=${encodeURI(props.object?.artist.name)}`) + +const remove = () => emit('remove') +</script> + <template> <span> - <modal + <semantic-modal v-if="isEmbedable" - :show.sync="showEmbedModal" + v-model:show="showEmbedModal" > <h4 class="header"> <translate translate-context="Popup/Album/Title/Verb">Embed this album on your website</translate> @@ -22,7 +67,7 @@ <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> </button> </div> - </modal> + </semantic-modal> <button v-dropdown="{direction: 'downward'}" class="ui floating dropdown circular icon basic button" @@ -87,19 +132,33 @@ > <i class="ui trash icon" /> <translate translate-context="*/*/*/Verb">Delete…</translate> - <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p> - <div slot="modal-content"> - <p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p> - </div> - <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p> + <template #modal-header> + <p> + <translate translate-context="Popup/Channel/Title">Delete this album?</translate> + </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The album will be deleted, as well as any related files and data. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb">Delete</translate> + </p> + </template> </dangerous-button> <div class="divider" /> <div - v-for="obj in getReportableObjs({album: object, channel: artist.channel})" + v-for="obj in getReportableObjects({album: object, channel: artist?.channel})" :key="obj.target.type + obj.target.id" role="button" class="basic item" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <i class="share icon" /> {{ obj.label }} </div> @@ -113,7 +172,7 @@ <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> </router-link> <a - v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile && $store.state.auth.profile?.is_superuser" class="basic item" :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)" target="_blank" @@ -126,62 +185,3 @@ </button> </span> </template> -<script> -import EmbedWizard from '@/components/audio/EmbedWizard.vue' -import Modal from '@/components/semantic/Modal.vue' -import ReportMixin from '@/components/mixins/Report.vue' - -import { getDomain } from '@/utils' - -export default { - components: { - EmbedWizard, - Modal - }, - mixins: [ReportMixin], - props: { - isLoading: Boolean, - artist: { type: Object, required: true }, - object: { type: Object, required: true }, - publicLibraries: { type: Array, required: true }, - isAlbum: Boolean, - isChannel: Boolean, - isSerie: Boolean - }, - data () { - return { - showEmbedModal: false - } - }, - computed: { - domain () { - if (this.object) { - return getDomain(this.object.fid) - } - return null - }, - labels () { - return { - more: this.$pgettext('*/*/Button.Label/Noun', 'More…') - } - }, - isEmbedable () { - return (this.isChannel && this.artist.channel.actor) || this.publicLibraries.length > 0 - }, - - musicbrainzUrl () { - if (this.object.mbid) { - return 'https://musicbrainz.org/release/' + this.object.mbid - } - return null - }, - discogsUrl () { - return ( - 'https://discogs.com/search/?type=release&title=' + - encodeURI(this.object.title) + '&artist=' + - encodeURI(this.object.artist.name) - ) - } - } -} -</script> diff --git a/front/src/components/library/AlbumEdit.vue b/front/src/components/library/AlbumEdit.vue index b5b8007436698847b64ec788c5b17078e43429bf..f8f41d3ecbc568a15c292292e4ad7722083aee1a 100644 --- a/front/src/components/library/AlbumEdit.vue +++ b/front/src/components/library/AlbumEdit.vue @@ -1,17 +1,35 @@ +<script setup lang="ts"> +import type { EditObjectType } from '~/composables/moderation/useEditConfigs' +import type { Album, Library, Actor } from '~/types' + +import { useStore } from '~/store' + +import EditForm from '~/components/library/EditForm.vue' + +interface Props { + objectType: EditObjectType + object: Album & { attributed_to: Actor } + libraries: Library[] +} + +defineProps<Props>() + +const store = useStore() +const canEdit = store.state.auth.availablePermissions.library +</script> + <template> <section class="ui vertical stripe segment"> <div class="ui text container"> <h2> <translate v-if="canEdit" - key="1" translate-context="Content/*/Title" > Edit this album </translate> <translate v-else - key="2" translate-context="Content/*/Title" > Suggest an edit on this album @@ -29,32 +47,7 @@ v-else :object-type="objectType" :object="object" - :can-edit="canEdit" /> </div> </section> </template> - -<script> -import EditForm from '@/components/library/EditForm.vue' -export default { - components: { - EditForm - }, - props: { - objectType: { type: String, required: true }, - object: { type: Object, required: true }, - libraries: { type: Array, required: true } - }, - data () { - return { - id: this.object.id - } - }, - computed: { - canEdit () { - return true - } - } -} -</script> diff --git a/front/src/components/library/Albums.vue b/front/src/components/library/Albums.vue index f2e9dec3fbe189935521a86d10a6ad4020555bd5..43df74a74a5d93ade9729ab4e0c14a3b426f9b48 100644 --- a/front/src/components/library/Albums.vue +++ b/front/src/components/library/Albums.vue @@ -1,3 +1,123 @@ +<script setup lang="ts"> +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { Album, BackendResponse } from '~/types' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { computed, onMounted, ref, watch } from 'vue' +import { useRouteQuery } from '@vueuse/router' +import { useGettext } from 'vue3-gettext' +import { syncRef } from '@vueuse/core' +import { sortedUniq } from 'lodash-es' +import { useStore } from '~/store' + +import axios from 'axios' +import $ from 'jquery' +import qs from 'qs' + +import TagsSelector from '~/components/library/TagsSelector.vue' +import AlbumCard from '~/components/audio/album/Card.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' +import useLogger from '~/composables/useLogger' + +interface Props extends OrderingProps { + scope?: 'me' | 'all' + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName +} + +const props = withDefaults(defineProps<Props>(), { + scope: 'all', + orderingConfigName: undefined +}) + +const page = usePage() + +const tags = useRouteQuery<string[]>('tag', []) + +const q = useRouteQuery('query', '') +const query = ref(q.value) +syncRef(q, query, { direction: 'ltr' }) + +const result = ref<BackendResponse<Album>>() + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['title', 'album_title'], + ['release_date', 'release_date'] +] + +const logger = useLogger() +const sharedLabels = useSharedLabels() + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + scope: props.scope, + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + playable: 'true', + tag: tags.value, + include_channels: 'true', + content_category: 'music' + } + + logger.time('Fetching albums') + try { + const response = await axios.get('albums/', { + params, + paramsSerializer: function (params) { + return qs.stringify(params, { indices: false }) + } + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = undefined + } finally { + logger.timeEnd('Fetching albums') + isLoading.value = false + } +} + +const store = useStore() +watch(() => store.state.moderation.lastUpdate, fetchData) +watch([page, tags, q], fetchData) +fetchData() + +const search = () => { + page.value = 1 + q.value = query.value +} + +onOrderingUpdate(() => { + page.value = 1 + fetchData() +}) + +onMounted(() => $('.ui.dropdown').dropdown()) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Enter album title…'), + title: $pgettext('*/*/*', 'Albums') +})) + +const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value].sort((a, b) => a - b))) +</script> + <template> <main v-title="labels.title"> <section class="ui vertical stripe segment"> @@ -8,7 +128,7 @@ </h2> <form :class="['ui', {'loading': isLoading}, 'form']" - @submit.prevent="updatePage();updateQueryString();fetchData()" + @submit.prevent="search" > <div class="fields"> <div class="field"> @@ -78,14 +198,12 @@ v-model="paginateBy" class="ui dropdown" > - <option :value="parseInt(12)"> - 12 - </option> - <option :value="parseInt(25)"> - 25 - </option> - <option :value="parseInt(50)"> - 50 + <option + v-for="opt in paginateOptions" + :key="opt" + :value="opt" + > + {{ opt }} </option> </select> </div> @@ -136,132 +254,11 @@ <div class="ui center aligned basic segment"> <pagination v-if="result && result.count > paginateBy" - :current="page" + v-model:current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> </div> </section> </main> </template> - -<script> -import qs from 'qs' -import axios from 'axios' -import $ from 'jquery' - -import logger from '@/logging' - -import OrderingMixin from '@/components/mixins/Ordering.vue' -import PaginationMixin from '@/components/mixins/Pagination.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import AlbumCard from '@/components/audio/album/Card.vue' -import Pagination from '@/components/Pagination.vue' -import TagsSelector from '@/components/library/TagsSelector.vue' - -const FETCH_URL = 'albums/' - -export default { - components: { - AlbumCard, - Pagination, - TagsSelector - }, - mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], - props: { - defaultQuery: { type: String, required: false, default: '' }, - defaultTags: { type: Array, required: false, default: () => { return [] } }, - scope: { type: String, required: false, default: 'all' } - }, - data () { - return { - isLoading: true, - result: null, - page: parseInt(this.defaultPage), - query: this.defaultQuery, - tags: (this.defaultTags || []).filter((t) => { return t.length > 0 }), - orderingOptions: [['creation_date', 'creation_date'], ['title', 'album_title'], ['release_date', 'release_date']] - } - }, - computed: { - labels () { - const searchPlaceholder = this.$pgettext('Content/Search/Input.Placeholder', 'Enter album title…') - const title = this.$pgettext('*/*/*', 'Albums') - return { - searchPlaceholder, - title - } - } - }, - watch: { - page () { - this.updateQueryString() - this.fetchData() - }, - '$store.state.moderation.lastUpdate': function () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - mounted () { - $('.ui.dropdown').dropdown() - }, - methods: { - updateQueryString: function () { - history.pushState( - {}, - null, - this.$route.path + '?' + new URLSearchParams( - { - query: this.query, - page: this.page, - tag: this.tags, - paginateBy: this.paginateBy, - ordering: this.getOrderingAsString() - }).toString() - ) - }, - fetchData: function () { - const self = this - this.isLoading = true - const url = FETCH_URL - const params = { - scope: this.scope, - page: this.page, - page_size: this.paginateBy, - q: this.query, - ordering: this.getOrderingAsString(), - playable: 'true', - tag: this.tags, - include_channels: 'true', - content_category: 'music' - } - logger.default.debug('Fetching albums') - axios.get( - url, - { - params: params, - paramsSerializer: function (params) { - return qs.stringify(params, { indices: false }) - } - } - ).then(response => { - self.result = response.data - self.isLoading = false - }, () => { - self.result = null - self.isLoading = false - }) - }, - selectPage: function (page) { - this.page = page - }, - updatePage () { - this.page = this.defaultPage - } - } -} -</script> diff --git a/front/src/components/library/ArtistBase.vue b/front/src/components/library/ArtistBase.vue index 5ec8f3efbdef061774c6010d72eeb635ed526869..54faffe0258799d9c2ad6c0f620734074e03d734 100644 --- a/front/src/components/library/ArtistBase.vue +++ b/front/src/components/library/ArtistBase.vue @@ -1,3 +1,99 @@ +<script setup lang="ts"> +import type { Track, Album, Artist, Library } from '~/types' + +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { getDomain } from '~/utils' +import { useStore } from '~/store' + +import axios from 'axios' + +import EmbedWizard from '~/components/audio/EmbedWizard.vue' +import SemanticModal from '~/components/semantic/Modal.vue' +import PlayButton from '~/components/audio/PlayButton.vue' +import RadioButton from '~/components/radios/Button.vue' +import TagsList from '~/components/tags/List.vue' + +import useReport from '~/composables/moderation/useReport' +import useLogger from '~/composables/useLogger' + +interface Props { + id: number +} + +const props = defineProps<Props>() +const { report, getReportableObjects } = useReport() + +const object = ref<Artist | null>(null) +const libraries = ref([] as Library[]) +const albums = ref([] as Album[]) +const tracks = ref([] as Track[]) +const showEmbedModal = ref(false) + +const nextAlbumsUrl = ref(null) +const nextTracksUrl = ref(null) +const totalAlbums = ref(0) +const totalTracks = ref(0) + +const dropdown = ref() + +const logger = useLogger() +const store = useStore() +const router = useRouter() + +const domain = computed(() => getDomain(object.value?.fid ?? '')) +const isPlayable = computed(() => !!object.value?.albums.some(album => album.is_playable)) +const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(object.value?.name ?? '')}`) +const musicbrainzUrl = computed(() => object.value?.mbid ? `https://musicbrainz.org/artist/${object.value.mbid}` : null) +const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`) +const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? []) +const cover = computed(() => object.value?.cover?.urls.original + ? object.value.cover + : object.value?.albums.find(album => album.cover?.urls.original)?.cover +) +const headerStyle = computed(() => cover.value?.urls.original + ? { backgroundImage: `url(${store.getters['instance/absoluteUrl'](cover.value.urls.original)})` } + : '' +) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('*/*/*', 'Artist') +})) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + logger.debug(`Fetching artist "${props.id}"`) + + const artistsResponse = await axios.get(`artists/${props.id}/`, { params: { refresh: 'true' } }) + if (artistsResponse.data.channel) { + return router.replace({ name: 'channels.detail', params: { id: artistsResponse.data.channel.uuid } }) + } + + object.value = artistsResponse.data + + const [tracksResponse, albumsResponse] = await Promise.all([ + axios.get('tracks/', { params: { artist: props.id, hidden: '', ordering: '-creation_date' } }), + axios.get('albums/', { params: { artist: props.id, hidden: '', ordering: '-release_date' } }) + ]) + + tracks.value = tracksResponse.data.results + nextTracksUrl.value = tracksResponse.data.next + totalTracks.value = tracksResponse.data.count + + nextAlbumsUrl.value = albumsResponse.data.next + totalAlbums.value = albumsResponse.data.count + + albums.value = albumsResponse.data.results + + isLoading.value = false +} + +watch(() => props.id, fetchData, { immediate: true }) +</script> + <template> <main v-title="labels.title"> <div @@ -42,7 +138,7 @@ <div class="ui buttons"> <radio-button type="artist" - :object-id="String(object.id)" + :object-id="object.id" /> </div> <div class="ui buttons"> @@ -57,9 +153,9 @@ </play-button> </div> - <modal + <semantic-modal v-if="publicLibraries.length > 0" - :show.sync="showEmbedModal" + v-model:show="showEmbedModal" > <h4 class="header"> <translate translate-context="Popup/Artist/Title/Verb"> @@ -81,11 +177,11 @@ </translate> </button> </div> - </modal> + </semantic-modal> <div class="ui buttons"> <button class="ui button" - @click="$refs.dropdown.click()" + @click="dropdown.click()" > <translate translate-context="*/*/Button.Label/Noun"> More… @@ -162,11 +258,11 @@ </router-link> <div class="divider" /> <div - v-for="obj in getReportableObjs({artist: object})" + v-for="obj in getReportableObjects({artist: object})" :key="obj.target.type + obj.target.id" role="button" class="basic item" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <i class="share icon" /> {{ obj.label }} </div> @@ -204,7 +300,7 @@ :next-tracks-url="nextTracksUrl" :next-albums-url="nextAlbumsUrl" :albums="albums" - :is-loading-albums="isLoadingAlbums" + :is-loading-albums="isLoading" :object="object" object-type="artist" @libraries-loaded="libraries = $event" @@ -212,158 +308,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' -import logger from '@/logging.js' -import PlayButton from '@/components/audio/PlayButton.vue' -import EmbedWizard from '@/components/audio/EmbedWizard.vue' -import Modal from '@/components/semantic/Modal.vue' -import RadioButton from '@/components/radios/Button.vue' -import TagsList from '@/components/tags/List.vue' -import ReportMixin from '@/components/mixins/Report.vue' - -import { getDomain } from '@/utils.js' - -export default { - components: { - PlayButton, - EmbedWizard, - Modal, - RadioButton, - TagsList - }, - mixins: [ReportMixin], - props: { id: { type: [String, Number], required: true } }, - data () { - return { - isLoading: true, - isLoadingAlbums: true, - object: null, - albums: null, - libraries: [], - showEmbedModal: false, - tracks: [], - nextAlbumsUrl: null, - nextTracksUrl: null, - totalAlbums: null, - totalTracks: null - } - }, - computed: { - domain () { - if (this.object) { - return getDomain(this.object.fid) - } - return null - }, - isPlayable () { - return ( - this.object.albums.filter(a => { - return a.is_playable - }).length > 0 - ) - }, - labels () { - return { - title: this.$pgettext('*/*/*', 'Album') - } - }, - wikipediaUrl () { - return ( - 'https://en.wikipedia.org/w/index.php?search=' + - encodeURI(this.object.name) - ) - }, - musicbrainzUrl () { - if (this.object.mbid) { - return 'https://musicbrainz.org/artist/' + this.object.mbid - } - return null - }, - discogsUrl () { - return ( - 'https://discogs.com/search/?type=artist&title=' + - encodeURI(this.object.name) - ) - }, - cover () { - if (this.object.cover && this.object.cover.urls.original) { - return this.object.cover - } - return this.object.albums - .filter(album => { - return album.cover && album.cover.urls.original - }) - .map(album => { - return album.cover - })[0] - }, - - publicLibraries () { - return this.libraries.filter(l => { - return l.privacy_level === 'everyone' - }) - }, - headerStyle () { - if (!this.cover || !this.cover.urls.original) { - return '' - } - return ( - 'background-image: url(' + - this.$store.getters['instance/absoluteUrl'](this.cover.urls.original) + - ')' - ) - }, - contentFilter () { - return this.$store.getters['moderation/artistFilters']().filter((e) => { - return e.target.id === this.object.id - })[0] - } - }, - watch: { - id () { - this.fetchData() - } - }, - async created () { - await this.fetchData() - }, - methods: { - async fetchData () { - const self = this - this.isLoading = true - logger.default.debug('Fetching artist "' + this.id + '"') - - const artistPromise = axios.get('artists/' + this.id + '/', { params: { refresh: 'true' } }).then(response => { - if (response.data.channel) { - self.$router.replace({ name: 'channels.detail', params: { id: response.data.channel.uuid } }) - } else { - self.object = response.data - } - }) - await artistPromise - if (!self.object) { - return - } - const trackPromise = axios.get('tracks/', { params: { artist: this.id, hidden: '', ordering: '-creation_date' } }).then(response => { - self.tracks = response.data.results - self.nextTracksUrl = response.data.next - self.totalTracks = response.data.count - }) - const albumPromise = axios.get('albums/', { - params: { artist: self.id, ordering: '-release_date', hidden: '' } - }).then(response => { - self.nextAlbumsUrl = response.data.next - self.totalAlbums = response.data.count - const parsed = JSON.parse(JSON.stringify(response.data.results)) - self.albums = parsed - }) - await trackPromise - await albumPromise - self.isLoadingAlbums = false - self.isLoading = false - } - } -} -</script> diff --git a/front/src/components/library/ArtistDetail.vue b/front/src/components/library/ArtistDetail.vue index 98f0ad8d1baa088b70e53ce5f552a7a6fb00d677..e6171f2322f3d4c44e8152fd7bef57c6ffdacfb8 100644 --- a/front/src/components/library/ArtistDetail.vue +++ b/front/src/components/library/ArtistDetail.vue @@ -1,3 +1,61 @@ +<script setup lang="ts"> +import type { Artist, Track, Album, Library } from '~/types' +import type { ContentFilter } from '~/store/moderation' + +import { ref, computed, reactive } from 'vue' +import { useStore } from '~/store' + +import axios from 'axios' + +import LibraryWidget from '~/components/federation/LibraryWidget.vue' +import TrackTable from '~/components/audio/track/Table.vue' +import AlbumCard from '~/components/audio/album/Card.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'libraries-loaded', libraries: Library[]): void +} + +interface Props { + object: Artist + tracks: Track[] + albums: Album[] + isLoadingAlbums: boolean + nextTracksUrl?: string | null + nextAlbumsUrl?: string | null +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + nextTracksUrl: null, + nextAlbumsUrl: null +}) + +const store = useStore() + +const additionalAlbums = reactive([] as Album[]) +const contentFilter = computed(() => store.getters['moderation/artistFilters']().find((filter: ContentFilter) => filter.target.id === props.object.id)) +const allAlbums = computed(() => [...props.albums, ...additionalAlbums]) + +const isLoadingMoreAlbums = ref(false) +const loadMoreAlbumsUrl = ref(props.nextAlbumsUrl) +const loadMoreAlbums = async () => { + if (loadMoreAlbumsUrl.value === null) return + isLoadingMoreAlbums.value = true + + try { + const response = await axios.get(loadMoreAlbumsUrl.value) + additionalAlbums.push(...additionalAlbums.concat(response.data.results)) + loadMoreAlbumsUrl.value = response.data.next + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingMoreAlbums.value = false +} +</script> + <template> <div v-if="object"> <div @@ -53,9 +111,9 @@ </div> <div class="ui hidden divider" /> <button - v-if="nextAlbumsUrl && loadMoreAlbumsUrl" + v-if="loadMoreAlbumsUrl !== null" :class="['ui', {loading: isLoadingMoreAlbums}, 'button']" - @click="loadMoreAlbums(loadMoreAlbumsUrl)" + @click="loadMoreAlbums()" > <translate translate-context="Content/*/Button.Label"> Load more… @@ -72,7 +130,7 @@ :track-only="true" :tracks="tracks.slice(0,5)" > - <template slot="header"> + <template #header> <h2> <translate translate-context="Content/Artist/Title"> New tracks by this artist @@ -90,68 +148,12 @@ </h2> <library-widget :url="'artists/' + object.id + '/libraries/'" - @loaded="$emit('libraries-loaded', $event)" + @loaded="emit('libraries-loaded', $event)" > - <translate - slot="subtitle" - translate-context="Content/Artist/Paragraph" - > + <translate translate-context="Content/Artist/Paragraph"> This artist is present in the following libraries: </translate> </library-widget> </section> </div> </template> - -<script> -import axios from 'axios' -import AlbumCard from '@/components/audio/album/Card.vue' -import TrackTable from '@/components/audio/track/Table.vue' -import LibraryWidget from '@/components/federation/LibraryWidget.vue' - -export default { - components: { - AlbumCard, - TrackTable, - LibraryWidget - }, - props: { - object: { type: Object, required: true }, - tracks: { type: Array, required: true }, - albums: { type: Array, required: true }, - isLoadingAlbums: { type: Boolean, required: true }, - nextTracksUrl: { type: String, default: null }, - nextAlbumsUrl: { type: String, default: null } - }, - data () { - return { - loadMoreAlbumsUrl: this.nextAlbumsUrl, - additionalAlbums: [], - isLoadingMoreAlbums: false - } - }, - computed: { - contentFilter () { - return this.$store.getters['moderation/artistFilters']().filter((e) => { - return e.target.id === this.object.id - })[0] - }, - allAlbums () { - return this.albums.concat(this.additionalAlbums) - } - }, - methods: { - loadMoreAlbums (url) { - const self = this - self.isLoadingMoreAlbums = true - axios.get(url).then((response) => { - self.additionalAlbums = self.additionalAlbums.concat(response.data.results) - self.loadMoreAlbumsUrl = response.data.next - self.isLoadingMoreAlbums = false - }, () => { - self.isLoadingMoreAlbums = false - }) - } - } -} -</script> diff --git a/front/src/components/library/ArtistEdit.vue b/front/src/components/library/ArtistEdit.vue index b1d0e5400814f18f0380dfd888c1502ca65331fd..b19282fb40be50bc75fe585916d04ace96592301 100644 --- a/front/src/components/library/ArtistEdit.vue +++ b/front/src/components/library/ArtistEdit.vue @@ -1,17 +1,35 @@ +<script setup lang="ts"> +import type { EditObjectType } from '~/composables/moderation/useEditConfigs' +import type { Artist, Library } from '~/types' + +import { useStore } from '~/store' + +import EditForm from '~/components/library/EditForm.vue' + +interface Props { + objectType: EditObjectType + object: Artist + libraries: Library[] +} + +defineProps<Props>() + +const store = useStore() +const canEdit = store.state.auth.availablePermissions.library +</script> + <template> <section class="ui vertical stripe segment"> <div class="ui text container"> <h2> <translate v-if="canEdit" - key="1" translate-context="Content/*/Title" > Edit this artist </translate> <translate v-else - key="2" translate-context="Content/*/Title" > Suggest an edit on this artist @@ -29,32 +47,7 @@ v-else :object-type="objectType" :object="object" - :can-edit="canEdit" /> </div> </section> </template> - -<script> -import EditForm from '@/components/library/EditForm.vue' -export default { - components: { - EditForm - }, - props: { - objectType: { type: String, required: true }, - object: { type: Object, required: true }, - libraries: { type: Array, required: true } - }, - data () { - return { - id: this.object.id - } - }, - computed: { - canEdit () { - return true - } - } -} -</script> diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 56afd95a01eae3fe3300dcdc4d7788b38adabd27..1b2ec8f3955214155125eb49f89c3920228b7343 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -1,3 +1,124 @@ +<script setup lang="ts"> +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { Artist, BackendResponse } from '~/types' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { computed, ref, watch, onMounted } from 'vue' +import { useRouteQuery } from '@vueuse/router' +import { useGettext } from 'vue3-gettext' +import { syncRef } from '@vueuse/core' +import { sortedUniq } from 'lodash-es' +import { useStore } from '~/store' + +import axios from 'axios' +import $ from 'jquery' +import qs from 'qs' + +import TagsSelector from '~/components/library/TagsSelector.vue' +import ArtistCard from '~/components/audio/artist/Card.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' +import useLogger from '~/composables/useLogger' + +interface Props extends OrderingProps { + scope?: 'me' | 'all' + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName +} + +const props = withDefaults(defineProps<Props>(), { + scope: 'all', + orderingConfigName: undefined +}) + +const page = usePage() + +const tags = useRouteQuery<string[]>('tag', []) + +const q = useRouteQuery('query', '') +const query = ref(q.value) +syncRef(q, query, { direction: 'ltr' }) + +const result = ref<BackendResponse<Artist>>() +const excludeCompilation = ref(true) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['name', 'name'] +] + +const logger = useLogger() +const sharedLabels = useSharedLabels() + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + scope: props.scope, + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + playable: 'true', + tag: tags.value, + include_channels: 'true', + content_category: 'music', + has_albums: excludeCompilation.value + } + + logger.time('Fetching artists') + try { + const response = await axios.get('artists/', { + params, + paramsSerializer: function (params) { + return qs.stringify(params, { indices: false }) + } + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = undefined + } finally { + logger.timeEnd('Fetching artists') + isLoading.value = false + } +} + +const store = useStore() +watch([() => store.state.moderation.lastUpdate, excludeCompilation], fetchData) +watch([page, tags, q], fetchData) +fetchData() + +const search = () => { + page.value = 1 + q.value = query.value +} + +onOrderingUpdate(() => { + page.value = 1 + fetchData() +}) + +onMounted(() => $('.ui.dropdown').dropdown()) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search…'), + title: $pgettext('*/*/*/Noun', 'Artists') +})) + +const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value].sort((a, b) => a - b))) +</script> + <template> <main v-title="labels.title"> <section class="ui vertical stripe segment"> @@ -8,7 +129,7 @@ </h2> <form :class="['ui', {'loading': isLoading}, 'form']" - @submit.prevent="updatePage();updateQueryString();fetchData()" + @submit.prevent="search" > <div class="fields"> <div class="field"> @@ -78,14 +199,12 @@ v-model="paginateBy" class="ui dropdown" > - <option :value="parseInt(12)"> - 12 - </option> - <option :value="parseInt(30)"> - 30 - </option> - <option :value="parseInt(50)"> - 50 + <option + v-for="opt in paginateOptions" + :key="opt" + :value="opt" + > + {{ opt }} </option> </select> </div> @@ -152,139 +271,11 @@ <div class="ui center aligned basic segment"> <pagination v-if="result && result.count > paginateBy" - :current="page" + v-model:current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> </div> </section> </main> </template> - -<script> -import qs from 'qs' -import axios from 'axios' -import $ from 'jquery' - -import logger from '@/logging.js' - -import OrderingMixin from '@/components/mixins/Ordering.vue' -import PaginationMixin from '@/components/mixins/Pagination.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import ArtistCard from '@/components/audio/artist/Card.vue' -import Pagination from '@/components/Pagination.vue' -import TagsSelector from '@/components/library/TagsSelector.vue' - -const FETCH_URL = 'artists/' - -export default { - components: { - ArtistCard, - Pagination, - TagsSelector - }, - mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], - props: { - defaultQuery: { type: String, required: false, default: '' }, - defaultTags: { type: Array, required: false, default: () => { return [] } }, - scope: { type: String, required: false, default: 'all' } - }, - data () { - return { - isLoading: true, - result: null, - excludeCompilation: true, - page: parseInt(this.defaultPage), - query: this.defaultQuery, - tags: (this.defaultTags || []).filter((t) => { return t.length > 0 }), - orderingOptions: [['creation_date', 'creation_date'], ['name', 'name']] - } - }, - computed: { - labels () { - const searchPlaceholder = this.$pgettext('Content/Search/Input.Placeholder', 'Search…') - const title = this.$pgettext('*/*/*/Noun', 'Artists') - return { - searchPlaceholder, - title - } - } - }, - watch: { - page () { - this.updateQueryString() - this.fetchData() - }, - '$store.state.moderation.lastUpdate': function () { - this.fetchData() - }, - excludeCompilation () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - mounted () { - $('.ui.dropdown').dropdown() - }, - methods: { - updateQueryString: function () { - history.pushState( - {}, - null, - this.$route.path + '?' + new URLSearchParams( - { - query: this.query, - page: this.page, - tag: this.tags, - paginateBy: this.paginateBy, - ordering: this.getOrderingAsString(), - content_category: 'music', - include_channels: true - }).toString() - ) - }, - fetchData: function () { - const self = this - this.isLoading = true - const url = FETCH_URL - const params = { - scope: this.scope, - page: this.page, - page_size: this.paginateBy, - has_albums: this.excludeCompilation, - q: this.query, - ordering: this.getOrderingAsString(), - playable: 'true', - tag: this.tags, - include_channels: 'true', - content_category: 'music' - } - logger.default.debug('Fetching artists') - axios.get( - url, - { - params: params, - paramsSerializer: function (params) { - return qs.stringify(params, { indices: false }) - } - } - ).then(response => { - self.result = response.data - self.isLoading = false - }, () => { - self.result = null - self.isLoading = false - }) - }, - selectPage: function (page) { - this.page = page - }, - updatePage () { - this.page = this.defaultPage - } - } -} -</script> diff --git a/front/src/components/library/EditCard.vue b/front/src/components/library/EditCard.vue index 21cdc71a916d86eb628580c95c0b53a2304a50dc..d84f08af768167936171ca623ffee31102e8cac8 100644 --- a/front/src/components/library/EditCard.vue +++ b/front/src/components/library/EditCard.vue @@ -1,3 +1,156 @@ +<script setup lang="ts"> +import type { ConfigField } from '~/composables/moderation/useEditConfigs' +import type { Review, ReviewState, ReviewStatePayload } from '~/types' +import type { Change } from 'diff' + +import { diffWordsWithSpace } from 'diff' +import { useRouter } from 'vue-router' +import { computed, ref } from 'vue' +import { useStore } from '~/store' + +import axios from 'axios' + +import useEditConfigs from '~/composables/moderation/useEditConfigs' +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'approved', isApproved: boolean): void + (e: 'deleted'): void +} + +interface Props { + obj: Review + currentState?: ReviewState +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + currentState: () => ({}) +}) + +const configs = useEditConfigs() +const router = useRouter() +const store = useStore() + +const canApprove = computed(() => props.obj.is_applied || store.state.auth.authenticated + ? false + : store.state.auth.availablePermissions.library +) + +const canDelete = computed(() => { + if (props.obj.is_applied || props.obj.is_approved) return false + if (!store.state.auth.authenticated) return false + + return props.obj.created_by.full_username === store.state.auth.fullUsername + || store.state.auth.availablePermissions.library +}) + +const previousState = computed(() => props.obj.is_applied + // mutation was applied, we use the previous state that is stored + // on the mutation itself + ? props.obj.previous_state + // mutation is not applied yet, so we use the current state that was + // passed to the component, if any + : props.currentState +) + +const detailUrl = computed(() => { + if (!props.obj.target) return '' + + const name = props.obj.target.type === 'track' + ? 'library.tracks.edit.detail' + : props.obj.target.type === 'album' + ? 'library.albums.edit.detail' + : props.obj.target.type === 'artist' + ? 'library.artists.edit.detail' + : undefined + + return router.resolve({ + name, + params: { + id: props.obj.target.id, + editId: props.obj.uuid + } + }).href +}) + +const updatedFields = computed(() => { + if (!props.obj?.target) return [] + + const payload = props.obj.payload + const fields = Object.keys(payload) + + const state = previousState.value + + return fields.map((id) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const config = configs[props.obj.target!.type].fields.find((field) => id === field.id) + const getValueRepr = config?.getValueRepr ?? (v => v) + + const result = { + id, + config, + new: payload[id], + newRepr: getValueRepr(payload[id]) ?? '', + old: undefined, + oldRepr: '', + diff: [] + } as { + id: string + config: ConfigField + old?: ReviewStatePayload + new: ReviewStatePayload + oldRepr: string + newRepr: string + diff: Change[] + } + + if (state?.[id]) { + const oldState = state[id] + result.old = oldState + result.oldRepr = getValueRepr(('value' in oldState && oldState.value) ?? oldState) ?? '' + + // we compute the diffs between the old and new values + result.diff = diffWordsWithSpace(result.oldRepr, result.newRepr) + } + + return result + }) +}) + +const isLoading = ref(false) +const remove = async () => { + isLoading.value = true + + try { + await axios.delete(`mutations/${props.obj.uuid}/`) + emit('deleted') + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const approve = async (approved: boolean) => { + const url = approved + ? `mutations/${props.obj.uuid}/approve/` + : `mutations/${props.obj.uuid}/reject/` + + isLoading.value = true + + try { + await axios.post(url) + emit('approved', approved) + store.commit('ui/incrementNotifications', { count: -1, type: 'pendingReviewEdits' }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} +</script> + <template> <div class="ui fluid card"> <div class="content"> @@ -88,7 +241,7 @@ <td>{{ field.id }}</td> <td v-if="field.diff"> - <template v-if="field.config.type === 'attachment' && field.oldRepr"> + <template v-if="field.config?.type === 'attachment' && field.oldRepr"> <img class="ui image" alt="" @@ -97,8 +250,7 @@ </template> <template v-else> <span - v-for="(part, key) in field.diff" - v-if="!part.added" + v-for="(part, key) in field.diff.filter(p => !p.added)" :key="key" :class="['diff', {removed: part.removed}]" > @@ -116,7 +268,7 @@ v-if="field.diff" :title="field.newRepr" > - <template v-if="field.config.type === 'attachment' && field.newRepr"> + <template v-if="field.config?.type === 'attachment' && field.newRepr"> <img class="ui image" alt="" @@ -125,8 +277,7 @@ </template> <template v-else> <span - v-for="(part, key) in field.diff" - v-if="!part.removed" + v-for="(part, key) in field.diff.filter(p => !p.removed)" :key="key" :class="['diff', {added: part.added}]" > @@ -138,7 +289,7 @@ v-else :title="field.newRepr" > - <template v-if="field.config.type === 'attachment' && field.newRepr"> + <template v-if="field.config?.type === 'attachment' && field.newRepr"> <img class="ui image" alt="" @@ -189,141 +340,30 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Delete this suggestion? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Popup/Library/Paragraph"> - The suggestion will be completely removed, this action is irreversible. + <translate translate-context="Popup/Library/Title"> + Delete this suggestion? </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Popup/Library/Paragraph"> + The suggestion will be completely removed, this action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb"> + Delete + </translate> + </p> + </template> </dangerous-button> </div> </div> </template> - -<script> -import axios from 'axios' -import { diffWordsWithSpace } from 'diff' - -import edits from '@/edits.js' - -function castValue (value) { - if (value === null || value === undefined) { - return '' - } - return String(value) -} - -export default { - props: { - obj: { type: Object, required: true }, - currentState: { type: Object, required: false, default: function () { return { } } } - }, - data () { - return { - isLoading: false - } - }, - computed: { - configs: edits.getConfigs, - canApprove: edits.getCanApprove, - canDelete: edits.getCanDelete, - previousState () { - if (this.obj.is_applied) { - // mutation was applied, we use the previous state that is stored - // on the mutation itself - return this.obj.previous_state - } - // mutation is not applied yet, so we use the current state that was - // passed to the component, if any - return this.currentState - }, - detailUrl () { - if (!this.obj.target) { - return '' - } - let namespace - const id = this.obj.target.id - if (this.obj.target.type === 'track') { - namespace = 'library.tracks.edit.detail' - } - if (this.obj.target.type === 'album') { - namespace = 'library.albums.edit.detail' - } - if (this.obj.target.type === 'artist') { - namespace = 'library.artists.edit.detail' - } - return this.$router.resolve({ name: namespace, params: { id, editId: this.obj.uuid } }).href - }, - - updatedFields () { - if (!this.obj || !this.obj.target) { - return [] - } - const payload = this.obj.payload - const previousState = this.previousState - const fields = Object.keys(payload) - const self = this - return fields.map((f) => { - const fieldConfig = edits.getFieldConfig(self.configs, this.obj.target.type, f) - const dummyRepr = (v) => { return v } - const getValueRepr = fieldConfig.getValueRepr || dummyRepr - const d = { - id: f, - config: fieldConfig - } - if (previousState && previousState[f]) { - d.old = previousState[f] - d.oldRepr = castValue(getValueRepr(d.old.value)) - } - d.new = payload[f] - d.newRepr = castValue(getValueRepr(d.new)) - if (d.old) { - // we compute the diffs between the old and new values - d.diff = diffWordsWithSpace(d.oldRepr, d.newRepr) - } - return d - }) - } - }, - methods: { - remove () { - const self = this - this.isLoading = true - axios.delete(`mutations/${this.obj.uuid}/`).then((response) => { - self.$emit('deleted') - self.isLoading = false - }, () => { - self.isLoading = false - }) - }, - approve (approved) { - let url - if (approved) { - url = `mutations/${this.obj.uuid}/approve/` - } else { - url = `mutations/${this.obj.uuid}/reject/` - } - const self = this - this.isLoading = true - axios.post(url).then((response) => { - self.$emit('approved', approved) - self.isLoading = false - self.$store.commit('ui/incrementNotifications', { count: -1, type: 'pendingReviewEdits' }) - }, () => { - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/components/library/EditDetail.vue b/front/src/components/library/EditDetail.vue index 375e6cbe12440e1cddcbad0fbbcd1daffde099a4..016ff13b0bf1e5e82b0d92a48f1dbbe99354ec39 100644 --- a/front/src/components/library/EditDetail.vue +++ b/front/src/components/library/EditDetail.vue @@ -1,5 +1,53 @@ +<script setup lang="ts"> +import type { EditObject, EditObjectType } from '~/composables/moderation/useEditConfigs' +import type { ReviewState } from '~/types' + +import { computed, ref } from 'vue' + +import axios from 'axios' + +import EditCard from '~/components/library/EditCard.vue' + +import useEditConfigs from '~/composables/moderation/useEditConfigs' +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + object: EditObject + objectType: EditObjectType + editId: number +} + +const props = defineProps<Props>() + +const configs = useEditConfigs() +const config = computed(() => configs[props.objectType]) + +const currentState = computed(() => config.value.fields.reduce((state: ReviewState, field) => { + state[field.id] = { value: field.getValue(props.object) } + return state +}, {})) + +const isLoading = ref(false) +const obj = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`mutations/${props.editId}/`) + obj.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } finally { + isLoading.value = false + } +} + +fetchData() +// TODO (wvffle): Check if we want to watch for editId change and refetch data +</script> + <template> - <section :class="['ui', 'vertical', 'stripe', {loading: isLoading}, 'segment']"> + <section :class="['ui', 'vertical', 'stripe', { loading: isLoading }, 'segment']"> <div class="ui text container"> <edit-card v-if="obj" @@ -9,50 +57,3 @@ </div> </section> </template> - -<script> -import axios from 'axios' -import edits from '@/edits.js' -import EditCard from '@/components/library/EditCard.vue' -export default { - components: { - EditCard - }, - props: { - object: { type: Object, required: true }, - objectType: { type: String, required: true }, - editId: { type: Number, required: true } - }, - data () { - return { - isLoading: true, - obj: null - } - }, - computed: { - configs: edits.getConfigs, - config: edits.getConfig, - currentState () { - const self = this - const s = {} - this.config.fields.forEach(f => { - s[f.id] = { value: f.getValue(self.object) } - }) - return s - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - axios.get(`mutations/${this.editId}/`).then(response => { - self.obj = response.data - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue index fdbf83733568daf2c53bc2df047fab2e7029a370..d6810106f69b152d7aa809be65826468d092b0b7 100644 --- a/front/src/components/library/EditForm.vue +++ b/front/src/components/library/EditForm.vue @@ -1,3 +1,149 @@ +<script setup lang="ts"> +import type { EditObject, EditObjectType } from '~/composables/moderation/useEditConfigs' +import type { BackendError, License, ReviewState } from '~/types' + +import { computed, onMounted, reactive, ref, watchEffect } from 'vue' +import { isEqual, clone } from 'lodash-es' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' +import $ from 'jquery' + +import AttachmentInput from '~/components/common/AttachmentInput.vue' +import useEditConfigs from '~/composables/moderation/useEditConfigs' +import TagsSelector from '~/components/library/TagsSelector.vue' +import EditList from '~/components/library/EditList.vue' +import EditCard from '~/components/library/EditCard.vue' + +interface Props { + objectType: EditObjectType + object: EditObject + licenses?: License[] +} + +const props = withDefaults(defineProps<Props>(), { + licenses: () => [] +}) + +const { $pgettext } = useGettext() +const configs = useEditConfigs() +const store = useStore() + +const config = computed(() => configs[props.objectType]) +const currentState = computed(() => config.value.fields.reduce((state: ReviewState, field) => { + state[field.id] = { value: field.getValue(props.object) } + return state +}, {})) + +const canEdit = computed(() => { + if (!store.state.auth.authenticated) return false + + const isOwner = props.object.attributed_to + && store.state.auth.fullUsername === props.object.attributed_to.full_username + + return isOwner || store.state.auth.availablePermissions.library +}) + +const labels = computed(() => ({ + summaryPlaceholder: $pgettext('*/*/Placeholder', 'A short summary describing your changes.') +})) + +const mutationsUrl = computed(() => props.objectType === 'track' + ? `tracks/${props.object.id}/mutations/` + : props.objectType === 'album' + ? `albums/${props.object.id}/mutations/` + : props.objectType === 'artist' + ? `artists/${props.object.id}/mutations/` + : '' +) + +const mutationPayload = computed(() => { + const changedFields = config.value.fields.filter(f => { + return !isEqual(values[f.id], initialValues[f.id]) + }) + + if (changedFields.length === 0) { + return {} + } + + const data = { + type: 'update', + payload: {} as Record<string, unknown>, + summary: summary.value + } + + for (const field of changedFields) { + data.payload[field.id] = values[field.id] + } + + return data +}) + +const showPendingReview = ref(true) +const editListFilters = computed(() => showPendingReview.value + ? { is_approved: 'null' } + : {} +) + +const values = reactive({} as Record<string, any>) +const initialValues = reactive({} as Record<string, any>) +for (const { id, getValue } of config.value.fields) { + values[id] = clone(getValue(props.object)) + initialValues[id] = clone(values[id]) +} + +const license = ref() +watchEffect(() => { + if (values.license === null) { + $(license.value).dropdown('clear') + return + } + + $(license.value).dropdown('set selected', values.license) +}) + +onMounted(() => { + $('.ui.dropdown').dropdown({ fullTextSearch: true }) +}) + +const submittedMutation = ref() +const summary = ref('') + +const errors = ref([] as string[]) +const isLoading = ref(false) +const submit = async () => { + const url = mutationsUrl.value + if (!url) return + + isLoading.value = true + errors.value = [] + + try { + const response = await axios.post(url, { + ...mutationPayload.value, + is_approved: canEdit.value + ? true + : undefined + }) + + submittedMutation.value = response.data + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const fieldValuesChanged = (fieldId: string) => { + return !isEqual(values[fieldId], initialValues[fieldId]) +} + +const resetField = (fieldId: string) => { + values[fieldId] = clone(initialValues[fieldId]) +} +</script> + <template> <div v-if="submittedMutation"> <div class="ui positive message"> @@ -27,7 +173,7 @@ :obj="object" :current-state="currentState" > - <div slot="title"> + <div> <template v-if="showPendingReview"> <translate translate-context="Content/Library/Paragraph"> Recent edits awaiting review @@ -55,11 +201,13 @@ </button> </template> </div> - <empty-state slot="empty-state"> - <translate translate-context="Content/Library/Paragraph"> - Suggest a change using the form below. - </translate> - </empty-state> + <template #empty-state> + <empty-state> + <translate translate-context="Content/Library/Paragraph"> + Suggest a change using the form below. + </translate> + </empty-state> + </template> </edit-list> <form class="ui form" @@ -93,108 +241,109 @@ You don't have the permission to edit this object, but you can suggest changes. Once submitted, suggestions will be reviewed before approval. </translate> </div> - <div - v-for="fieldConfig in config.fields" - v-if="values" - :key="fieldConfig.id" - class="ui field" - > - <template v-if="fieldConfig.type === 'text'"> - <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> - <input - :id="fieldConfig.id" - v-model="values[fieldConfig.id]" - :type="fieldConfig.inputType || 'text'" - :required="fieldConfig.required" - :name="fieldConfig.id" - > - </template> - <template v-else-if="fieldConfig.type === 'license'"> - <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> - - <select - :id="fieldConfig.id" - ref="license" - v-model="values[fieldConfig.id]" - :required="fieldConfig.required" - class="ui fluid search dropdown" - > - <option :value="null"> - <translate translate-context="*/*/*"> - N/A + <template v-if="values"> + <div + v-for="fieldConfig in config.fields" + :key="fieldConfig.id" + class="ui field" + > + <template v-if="fieldConfig.type === 'text'"> + <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> + <input + :id="fieldConfig.id" + v-model="values[fieldConfig.id]" + :type="fieldConfig.inputType || 'text'" + :required="fieldConfig.required" + :name="fieldConfig.id" + > + </template> + <template v-else-if="fieldConfig.type === 'license'"> + <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> + + <select + :id="fieldConfig.id" + ref="license" + v-model="values[fieldConfig.id]" + :required="fieldConfig.required" + class="ui fluid search dropdown" + > + <option :value="null"> + <translate translate-context="*/*/*"> + N/A + </translate> + </option> + <option + v-for="{ code, name } in licenses" + :key="code" + :value="code" + > + {{ name }} + </option> + </select> + <button + class="ui tiny basic left floated button" + form="noop" + @click.prevent="values[fieldConfig.id] = null" + > + <i class="x icon" /> + <translate translate-context="Content/Library/Button.Label"> + Clear </translate> - </option> - <option - v-for="license in licenses" - :key="license.code" - :value="license.code" + </button> + </template> + <template v-else-if="fieldConfig.type === 'content'"> + <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> + <content-form + v-model="values[fieldConfig.id].text" + :field-id="fieldConfig.id" + :rows="3" + /> + </template> + <template v-else-if="fieldConfig.type === 'attachment'"> + <attachment-input + :id="fieldConfig.id" + v-model="values[fieldConfig.id]" + :initial-value="initialValues[fieldConfig.id]" + :required="fieldConfig.required" + :name="fieldConfig.id" + @delete="values[fieldConfig.id] = initialValues[fieldConfig.id]" > - {{ license.name }} - </option> - </select> - <button - class="ui tiny basic left floated button" - form="noop" - @click.prevent="values[fieldConfig.id] = null" - > - <i class="x icon" /> - <translate translate-context="Content/Library/Button.Label"> - Clear - </translate> - </button> - </template> - <template v-else-if="fieldConfig.type === 'content'"> - <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> - <content-form - v-model="values[fieldConfig.id].text" - :field-id="fieldConfig.id" - :rows="3" - /> - </template> - <template v-else-if="fieldConfig.type === 'attachment'"> - <attachment-input - :id="fieldConfig.id" - v-model="values[fieldConfig.id]" - :initial-value="initialValues[fieldConfig.id]" - :required="fieldConfig.required" - :name="fieldConfig.id" - @delete="values[fieldConfig.id] = initialValues[fieldConfig.id]" - > - <span slot="label">{{ fieldConfig.label }}</span> - </attachment-input> - </template> - <template v-else-if="fieldConfig.type === 'tags'"> - <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> - <tags-selector - :id="fieldConfig.id" - ref="tags" - v-model="values[fieldConfig.id]" - required="fieldConfig.required" - /> - <button - class="ui tiny basic left floated button" - form="noop" - @click.prevent="values[fieldConfig.id] = []" - > - <i class="x icon" /> - <translate translate-context="Content/Library/Button.Label"> - Clear - </translate> - </button> - </template> - <div v-if="fieldValuesChanged(fieldConfig.id)"> - <button - class="ui tiny basic right floated reset button" - form="noop" - @click.prevent="resetField(fieldConfig.id)" - > - <i class="undo icon" /> - <translate translate-context="Content/Library/Button.Label"> - Reset to initial value - </translate> - </button> + <span>{{ fieldConfig.label }}</span> + </attachment-input> + </template> + <template v-else-if="fieldConfig.type === 'tags'"> + <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> + <tags-selector + :id="fieldConfig.id" + ref="tags" + v-model="values[fieldConfig.id]" + required="fieldConfig.required" + /> + <button + class="ui tiny basic left floated button" + form="noop" + @click.prevent="values[fieldConfig.id] = []" + > + <i class="x icon" /> + <translate translate-context="Content/Library/Button.Label"> + Clear + </translate> + </button> + </template> + <div v-if="fieldValuesChanged(fieldConfig.id)"> + <button + class="ui tiny basic right floated reset button" + form="noop" + @click.prevent="resetField(fieldConfig.id)" + > + <i class="undo icon" /> + <translate translate-context="Content/Library/Button.Label"> + Reset to initial value + </translate> + </button> + </div> </div> - </div> + </template> <div class="field"> <label for="summary"><translate translate-context="*/*/*">Summary (optional)</translate></label> <textarea @@ -221,14 +370,12 @@ > <translate v-if="canEdit" - key="1" translate-context="Content/Library/Button.Label/Verb" > Submit and apply edit </translate> <translate v-else - key="2" translate-context="Content/Library/Button.Label/Verb" > Submit suggestion @@ -237,138 +384,3 @@ </form> </div> </template> - -<script> -import $ from 'jquery' -import _ from 'lodash' -import axios from 'axios' -import AttachmentInput from '@/components/common/AttachmentInput.vue' -import EditList from '@/components/library/EditList.vue' -import EditCard from '@/components/library/EditCard.vue' -import TagsSelector from '@/components/library/TagsSelector.vue' -import edits from '@/edits.js' - -export default { - components: { - EditList, - EditCard, - TagsSelector, - AttachmentInput - }, - props: { - objectType: { type: String, required: true }, - object: { type: Object, required: true }, - licenses: { type: Array, required: true } - }, - data () { - return { - isLoading: false, - errors: [], - values: {}, - initialValues: {}, - summary: '', - submittedMutation: null, - showPendingReview: true - } - }, - computed: { - configs: edits.getConfigs, - config: edits.getConfig, - currentState: edits.getCurrentState, - canEdit: edits.getCanEdit, - labels () { - return { - summaryPlaceholder: this.$pgettext('*/*/Placeholder', 'A short summary describing your changes.') - } - }, - mutationsUrl () { - if (this.objectType === 'track') { - return `tracks/${this.object.id}/mutations/` - } - if (this.objectType === 'album') { - return `albums/${this.object.id}/mutations/` - } - if (this.objectType === 'artist') { - return `artists/${this.object.id}/mutations/` - } - return null - }, - mutationPayload () { - const self = this - const changedFields = this.config.fields.filter(f => { - return !_.isEqual(self.values[f.id], self.initialValues[f.id]) - }) - if (changedFields.length === 0) { - return null - } - const payload = { - type: 'update', - payload: {}, - summary: this.summary - } - changedFields.forEach((f) => { - payload.payload[f.id] = self.values[f.id] - }) - return payload - }, - editListFilters () { - if (this.showPendingReview) { - return { is_approved: 'null' } - } else { - return {} - } - } - }, - watch: { - 'values.license' (newValue) { - if (newValue === null) { - $(this.$refs.license).dropdown('clear') - } else { - $(this.$refs.license).dropdown('set selected', newValue) - } - } - }, - created () { - this.setValues() - }, - mounted () { - $('.ui.dropdown').dropdown({ fullTextSearch: true }) - }, - - methods: { - setValues () { - const self = this - this.config.fields.forEach(f => { - self.$set(self.values, f.id, _.clone(f.getValue(self.object))) - self.$set(self.initialValues, f.id, _.clone(self.values[f.id])) - }) - }, - submit () { - const self = this - self.isLoading = true - self.errors = [] - const payload = _.clone(this.mutationPayload || {}) - if (this.canEdit) { - payload.is_approved = true - } - return axios.post(this.mutationsUrl, payload).then( - response => { - self.isLoading = false - self.submittedMutation = response.data - }, - error => { - self.errors = error.backendErrors - self.isLoading = false - } - ) - }, - fieldValuesChanged (fieldId) { - return !_.isEqual(this.values[fieldId], this.initialValues[fieldId]) - }, - resetField (fieldId) { - this.values[fieldId] = _.clone(this.initialValues[fieldId]) - } - } - -} -</script> diff --git a/front/src/components/library/EditList.vue b/front/src/components/library/EditList.vue index b486302383174c257df7c1e2e184901f474d42b6..ede66661c108053cece20310573717bd80921cec 100644 --- a/front/src/components/library/EditList.vue +++ b/front/src/components/library/EditList.vue @@ -1,7 +1,53 @@ +<script setup lang="ts"> +import type { BackendError, ReviewState, Review } from '~/types' +import { ref, watchEffect } from 'vue' + +import axios from 'axios' + +import EditCard from '~/components/library/EditCard.vue' + +interface Props { + url: string + filters?: object + currentState?: ReviewState +} + +const props = withDefaults(defineProps<Props>(), { + filters: () => ({}), + currentState: () => ({}) +}) + +const errors = ref([] as string[]) +const previousPage = ref() +const nextPage = ref() +const objects = ref([] as Review[]) +const isLoading = ref(false) +const fetchData = async (url = props.url) => { + isLoading.value = true + const params = { + ...props.filters, + page_size: 5 + } + + try { + const response = await axios.get(url, { params }) + previousPage.value = response.data.previous + nextPage.value = response.data.next + objects.value = response.data.results + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +watchEffect(() => fetchData()) +</script> + <template> <div class="wrapper"> <h3 class="ui header"> - <slot name="title" /> + <slot /> </h3> <slot v-if="!isLoading && objects.length === 0" @@ -40,62 +86,3 @@ /> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' - -import EditCard from '@/components/library/EditCard.vue' - -export default { - components: { - EditCard - }, - props: { - url: { type: String, required: true }, - filters: { type: Object, required: false, default: () => { return {} } }, - currentState: { type: Object, required: false, default: () => { return { } } } - }, - data () { - return { - objects: [], - limit: 5, - isLoading: false, - errors: null, - previousPage: null, - nextPage: null - } - }, - watch: { - filters: { - handler () { - this.fetchData(this.url) - }, - deep: true - } - }, - created () { - this.fetchData(this.url) - }, - methods: { - fetchData (url) { - if (!url) { - return - } - this.isLoading = true - const self = this - const params = _.clone(this.filters) - params.page_size = this.limit - axios.get(url, { params: params }).then((response) => { - self.previousPage = response.data.previous - self.nextPage = response.data.next - self.isLoading = false - self.objects = response.data.results - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/library/FileUpload.vue b/front/src/components/library/FileUpload.vue index bed31c9e04fd91eb49869cccb56e8a4f82ce53dd..d3688a3821f09cacc0f2f8b61b7e682409af8ee6 100644 --- a/front/src/components/library/FileUpload.vue +++ b/front/src/components/library/FileUpload.vue @@ -1,3 +1,292 @@ +<script setup lang="ts"> +import type { BackendError, Library, FileSystem } from '~/types' +import type { VueUploadItem } from 'vue-upload-component' + +import { computed, ref, reactive, watch, nextTick } from 'vue' +import { useEventListener, useIntervalFn } from '@vueuse/core' +import { humanSize, truncate } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { sortBy } from 'lodash-es' +import { useStore } from '~/store' + +import axios from 'axios' + +import LibraryFilesTable from '~/views/content/libraries/FilesTable.vue' +import FileUploadWidget from './FileUploadWidget.vue' +import FsBrowser from './FsBrowser.vue' +import FsLogs from './FsLogs.vue' + +import useWebSocketHandler from '~/composables/useWebSocketHandler' +import updateQueryString from '~/composables/updateQueryString' +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'uploads-finished', delta: number):void +} + +interface Props { + library: Library + defaultImportReference?: string +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + defaultImportReference: '' +}) + +const { $pgettext } = useGettext() +const store = useStore() + +const upload = ref() +const currentTab = ref('uploads') +const supportedExtensions = computed(() => store.state.ui.supportedExtensions) + +const labels = computed(() => ({ + tooltips: { + denied: $pgettext('Content/Library/Help text', 'Upload denied, ensure the file is not too big and that you have not reached your quota'), + server: $pgettext('Content/Library/Help text', 'Cannot upload this file, ensure it is not too big'), + network: $pgettext('Content/Library/Help text', 'A network error occurred while uploading this file'), + timeout: $pgettext('Content/Library/Help text', 'Upload timeout, please try again'), + retry: $pgettext('*/*/*/Verb', 'Retry'), + extension: $pgettext( + 'Content/Library/Help text', + 'Invalid file type, ensure you are uploading an audio file. Supported file extensions are %{ extensions }', + { extensions: supportedExtensions.value.join(', ') } + ) + } as Record<string, string> +})) + +const uploads = reactive({ + pending: 0, + finished: 0, + skipped: 0, + errored: 0, + objects: {} as Record<string, any> +}) + +// +// File counts +// +const files = ref([] as VueUploadItem[]) +const processedFilesCount = computed(() => uploads.skipped + uploads.errored + uploads.finished) +const uploadedFilesCount = computed(() => files.value.filter(file => file.success).length) +const retryableFiles = computed(() => files.value.filter(file => file.error)) +const erroredFilesCount = computed(() => retryableFiles.value.length) +const processableFiles = computed(() => uploads.pending + + uploads.skipped + + uploads.errored + + uploads.finished + + uploadedFilesCount.value +) + +// +// Uploading +// +const importReference = ref(props.defaultImportReference || new Date().toISOString()) +history.replaceState(history.state, '', updateQueryString(location.href, 'import', importReference.value)) +const uploadData = computed(() => ({ + library: props.library.uuid, + import_reference: importReference +})) + +watch(() => uploads.finished, (newValue, oldValue) => { + if (newValue > oldValue) { + emit('uploads-finished', newValue - oldValue) + } +}) + +// +// Upload status +// +const fetchStatus = async () => { + for (const status of Object.keys(uploads)) { + if (status === 'objects') continue + + try { + const response = await axios.get('uploads/', { + params: { + import_reference: importReference.value, + import_status: status, + page_size: 1 + } + }) + + uploads[status as keyof typeof uploads] = response.data.count + } catch (error) { + useErrorHandler(error as Error) + } + } +} + +fetchStatus() + +const needsRefresh = ref(false) +useWebSocketHandler('import.status_updated', async (event) => { + if (event.upload.import_reference !== importReference.value) { + return + } + + // TODO (wvffle): Why? + await nextTick() + + uploads[event.old_status] -= 1 + uploads[event.new_status] += 1 + uploads.objects[event.upload.uuid] = event.upload + needsRefresh.value = true +}) + +// +// Files +// +const sortedFiles = computed(() => { + const filesToSort = files.value + + return [ + ...sortBy(filesToSort.filter(file => file.errored), ['name']), + ...sortBy(filesToSort.filter(file => !file.errored && !file.success), ['name']), + ...sortBy(filesToSort.filter(file => file.success), ['name']) + ] +}) + +const hasActiveUploads = computed(() => files.value.some(file => file.active)) + +// +// Quota status +// +const quotaStatus = ref() + +const uploadedSize = computed(() => { + let uploaded = 0 + + for (const file of files.value) { + if (!file.error) { + uploaded += (file.size ?? 0) * +(file.progress ?? 0) / 100 + } + } + + return uploaded +}) + +const remainingSpace = computed(() => Math.max( + (quotaStatus.value?.remaining ?? 0) - uploadedSize.value / 1e6, + 0 +)) + +watch(remainingSpace, space => { + if (space <= 0) { + upload.value.active = false + } +}) + +const isLoadingQuota = ref(false) +const fetchQuota = async () => { + isLoadingQuota.value = true + + try { + const response = await axios.get('users/me/') + quotaStatus.value = response.data.quota_status + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingQuota.value = false +} + +fetchQuota() + +// +// Filesystem +// +const fsPath = reactive([]) +const fsStatus = ref({ + import: { + status: 'pending' + } +} as FileSystem) +watch(fsPath, () => fetchFilesystem(true)) + +const { pause, resume } = useIntervalFn(() => { + fetchFilesystem(false) +}, 5000, { immediate: false }) + +const isLoadingFs = ref(false) +const fetchFilesystem = async (updateLoading: boolean) => { + if (updateLoading) isLoadingFs.value = true + pause() + + try { + const response = await axios.get('libraries/fs-import', { params: { path: fsPath.join('/') } }) + fsStatus.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + if (updateLoading) isLoadingFs.value = false + if (store.state.auth.availablePermissions.library) resume() +} + +if (store.state.auth.availablePermissions.library) { + fetchFilesystem(true) +} + +const fsErrors = ref([] as string[]) +const importFs = async () => { + isLoadingFs.value = true + + try { + const response = await axios.post('libraries/fs-import', { + path: fsPath.join('/'), + library: props.library.uuid, + import_reference: importReference.value + }) + + fsStatus.value = response.data + } catch (error) { + fsErrors.value = (error as BackendError).backendErrors + } + + isLoadingFs.value = false +} + +// TODO (wvffle): Maybe use AbortController? +const cancelFsScan = async () => { + try { + await axios.delete('libraries/fs-import') + fetchFilesystem(false) + } catch (error) { + useErrorHandler(error as Error) + } +} + +const inputFile = (newFile: VueUploadItem) => { + if (!newFile) return + + if (remainingSpace.value < (newFile.size ?? Infinity) / 1e6) { + newFile.error = 'denied' + } else { + upload.value.active = true + } +} + +// NOTE: For some weird reason typescript thinks that xhr field is not compatible with the same type +const retry = (files: Omit<VueUploadItem, 'xhr'>[]) => { + for (const file of files) { + upload.value.update(file, { error: '', progress: '0.00' }) + } + + upload.value.active = true +} + +// +// Before unload +// +useEventListener(window, 'beforeunload', (event) => { + if (!hasActiveUploads.value) return null + event.preventDefault() + return (event.returnValue = $pgettext('*/*/*', 'This page is asking you to confirm that you want to leave - data you have entered may not be saved.')) +}) +</script> + <template> <div class="component-file-upload"> <div class="ui top attached tabular menu"> @@ -61,13 +350,13 @@ </translate> </div> <div class="value"> - {{ remainingSpace * 1000 * 1000 | humanSize }} + {{ humanSize(remainingSpace * 1000 * 1000) }} </div> </div> <div class="ui divider" /> <h2 class="ui header"> <translate translate-context="Content/Library/Title/Verb"> - Upload music from your local storage + Upload music from '~/your local storage </translate> </h2> <div class="ui message"> @@ -102,7 +391,7 @@ ref="upload" v-model="files" :class="['ui', 'icon', 'basic', 'button']" - :post-action="uploadUrl" + :post-action="$store.getters['instance/absoluteUrl']('/api/v1/uploads/')" :multiple="true" :data="uploadData" :drop="true" @@ -117,10 +406,14 @@ </translate> <br> <br> - <i><translate - translate-context="Content/Library/Paragraph" - :translate-params="{extensions: supportedExtensions.join(', ')}" - >Supported extensions: %{ extensions }</translate></i> + <i> + <translate + translate-context="Content/Library/Paragraph" + :translate-params="{extensions: supportedExtensions.join(', ')}" + > + Supported extensions: %{ extensions } + </translate> + </i> </file-upload-widget> </div> <div @@ -174,12 +467,12 @@ :key="file.id" > <td :title="file.name"> - {{ file.name | truncate(60) }} + {{ truncate(file.name ?? '', 60) }} </td> - <td>{{ file.size | humanSize }}</td> + <td>{{ humanSize(file.size ?? 0) }}</td> <td> <span - v-if="file.error" + v-if="typeof file.error === 'string' && file.error" class="ui tooltip" :data-tooltip="labels.tooltips[file.error]" > @@ -203,23 +496,29 @@ <translate key="2" translate-context="Content/Library/Table" - >Uploading…</translate> - ({{ parseInt(file.progress) }}%) + > + Uploading… + </translate> + ({{ parseFloat(file.progress ?? '0.00') }}%) </span> <span v-else class="ui label" - ><translate - key="3" - translate-context="Content/Library/*/Short" - >Pending</translate></span> + > + <translate + key="3" + translate-context="Content/Library/*/Short" + > + Pending + </translate> + </span> </td> <td> <template v-if="file.error"> <button - v-if="retryableFiles.indexOf(file) > -1" + v-if="retryableFiles.includes(file)" class="ui tiny basic icon right floated button" - :title="labels.retry" + :title="labels.tooltips.retry" @click.prevent="retry([file])" > <i class="redo icon" /> @@ -228,7 +527,7 @@ <template v-else-if="!file.success"> <button class="ui tiny basic danger icon right floated button" - @click.prevent="$refs.upload.remove(file)" + @click.prevent="upload.remove(file)" > <i class="delete icon" /> </button> @@ -275,7 +574,7 @@ Import status </translate> </h3> - <p v-if="fsStatus.import.reference != importReference"> + <p v-if="fsStatus.import.reference !== importReference"> <translate translate-context="Content/Library/Paragraph"> Results of your previous import: </translate> @@ -309,301 +608,3 @@ </div> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import FileUploadWidget from './FileUploadWidget.vue' -import FsBrowser from './FsBrowser.vue' -import FsLogs from './FsLogs.vue' -import LibraryFilesTable from '@/views/content/libraries/FilesTable.vue' -import moment from 'moment' - -export default { - components: { - FileUploadWidget, - LibraryFilesTable, - FsBrowser, - FsLogs - }, - props: { - library: { type: Object, required: true }, - defaultImportReference: { type: String, required: false, default: '' } - }, - data () { - const importReference = this.defaultImportReference || moment().format() - // Since $router.replace is pushing the same route, it raises NavigationDuplicated - this.$router.replace({ query: { import: importReference } }).catch((error) => { - if (error.name !== 'NavigationDuplicated') { - throw error - } - }) - return { - files: [], - needsRefresh: false, - currentTab: 'uploads', - uploadUrl: this.$store.getters['instance/absoluteUrl']('/api/v1/uploads/'), - importReference, - isLoadingQuota: false, - quotaStatus: null, - uploads: { - pending: 0, - finished: 0, - skipped: 0, - errored: 0, - objects: {} - }, - processTimestamp: new Date(), - fsStatus: {}, - fsPath: [], - isLoadingFs: false, - fsInterval: null, - fsErrors: [] - } - }, - computed: { - supportedExtensions () { - return this.$store.state.ui.supportedExtensions - }, - labels () { - const denied = this.$pgettext('Content/Library/Help text', - 'Upload denied, ensure the file is not too big and that you have not reached your quota' - ) - const server = this.$pgettext('Content/Library/Help text', - 'Cannot upload this file, ensure it is not too big' - ) - const network = this.$pgettext('Content/Library/Help text', - 'A network error occurred while uploading this file' - ) - const timeout = this.$pgettext('Content/Library/Help text', 'Upload timeout, please try again') - const extension = this.$pgettext('Content/Library/Help text', - 'Invalid file type, ensure you are uploading an audio file. Supported file extensions are %{ extensions }' - ) - return { - tooltips: { - denied, - server, - network, - timeout, - retry: this.$pgettext('*/*/*/Verb', 'Retry'), - extension: this.$gettextInterpolate(extension, { - extensions: this.supportedExtensions.join(', ') - }) - } - } - }, - uploadedFilesCount () { - return this.files.filter(f => { - return f.success - }).length - }, - uploadingFilesCount () { - return this.files.filter(f => { - return !f.success && !f.error - }).length - }, - erroredFilesCount () { - return this.files.filter(f => { - return f.error - }).length - }, - retryableFiles () { - return this.files.filter(f => { - return f.error - }) - }, - processableFiles () { - return ( - this.uploads.pending + - this.uploads.skipped + - this.uploads.errored + - this.uploads.finished + - this.uploadedFilesCount - ) - }, - processedFilesCount () { - return ( - this.uploads.skipped + this.uploads.errored + this.uploads.finished - ) - }, - uploadData: function () { - return { - library: this.library.uuid, - import_reference: this.importReference - } - }, - sortedFiles () { - // return errored files on top - - return _.sortBy(this.files.map(f => { - let statusIndex = 0 - if (f.errored) { - statusIndex = -1 - } - if (f.success) { - statusIndex = 1 - } - f.statusIndex = statusIndex - return f - }), ['statusIndex', 'name']) - }, - hasActiveUploads () { - return this.sortedFiles.filter((f) => { return f.active }).length > 0 - }, - remainingSpace () { - if (!this.quotaStatus) { - return 0 - } - return Math.max(0, this.quotaStatus.remaining - (this.uploadedSize / (1000 * 1000))) - }, - uploadedSize () { - let uploaded = 0 - this.files.forEach((f) => { - if (!f.error) { - uploaded += f.size * (f.progress / 100) - } - }) - return uploaded - } - }, - watch: { - importReference: _.debounce(function () { - this.$router.replace({ query: { import: this.importReference } }) - }, 500), - remainingSpace (newValue) { - if (newValue <= 0) { - this.$refs.upload.active = false - } - }, - 'uploads.finished' (v, o) { - if (v > o) { - this.$emit('uploads-finished', v - o) - } - }, - 'fsPath' () { - this.fetchFs(true) - } - }, - created () { - this.fetchStatus() - if (this.$store.state.auth.availablePermissions.library) { - this.fetchFs(true) - this.fsInterval = setInterval(() => { - this.fetchFs(false) - }, 5000) - } - this.fetchQuota() - this.$store.commit('ui/addWebsocketEventHandler', { - eventName: 'import.status_updated', - id: 'fileUpload', - handler: this.handleImportEvent - }) - window.onbeforeunload = e => this.onBeforeUnload(e) - }, - destroyed () { - this.$store.commit('ui/removeWebsocketEventHandler', { - eventName: 'import.status_updated', - id: 'fileUpload' - }) - window.onbeforeunload = null - if (this.fsInterval) { - clearInterval(this.fsInterval) - } - }, - methods: { - onBeforeUnload (e = {}) { - const returnValue = ('This page is asking you to confirm that you want to leave - data you have entered may not be saved.') - if (!this.hasActiveUploads) return null - Object.assign(e, { - returnValue - }) - return returnValue - }, - fetchQuota () { - const self = this - self.isLoadingQuota = true - axios.get('users/me/').then((response) => { - self.quotaStatus = response.data.quota_status - self.isLoadingQuota = false - }) - }, - fetchFs (updateLoading) { - const self = this - if (updateLoading) { - self.isLoadingFs = true - } - axios.get('libraries/fs-import', { params: { path: this.fsPath.join('/') } }).then((response) => { - self.fsStatus = response.data - if (updateLoading) { - self.isLoadingFs = false - } - }) - }, - importFs () { - const self = this - self.isLoadingFs = true - const payload = { - path: this.fsPath.join('/'), - library: this.library.uuid, - import_reference: this.importReference - } - axios.post('libraries/fs-import', payload).then((response) => { - self.fsStatus = response.data - self.isLoadingFs = false - }, error => { - self.isLoadingFs = false - self.fsErrors = error.backendErrors - }) - }, - async cancelFsScan () { - await axios.delete('libraries/fs-import') - this.fetchFs() - }, - inputFile (newFile, oldFile) { - if (!newFile) { - return - } - if (this.remainingSpace < newFile.size / (1000 * 1000)) { - newFile.error = 'denied' - } else { - this.$refs.upload.active = true - } - }, - fetchStatus () { - const self = this - const statuses = ['pending', 'errored', 'skipped', 'finished'] - statuses.forEach(status => { - axios - .get('uploads/', { - params: { - import_reference: self.importReference, - import_status: status, - page_size: 1 - } - }) - .then(response => { - self.uploads[status] = response.data.count - }) - }) - }, - handleImportEvent (event) { - const self = this - if (event.upload.import_reference !== self.importReference) { - return - } - this.$nextTick(() => { - self.uploads[event.old_status] -= 1 - self.uploads[event.new_status] += 1 - self.uploads.objects[event.upload.uuid] = event.upload - self.needsRefresh = true - }) - }, - retry (files) { - files.forEach((file) => { - this.$refs.upload.update(file, { error: '', progress: '0.00' }) - }) - this.$refs.upload.active = true - } - } -} -</script> diff --git a/front/src/components/library/FileUploadWidget.vue b/front/src/components/library/FileUploadWidget.vue index 082e7adf41edd15183143f8530c6b9511f5e54ea..69faf04f51f089e3ac6cfdc671211342497ccba2 100644 --- a/front/src/components/library/FileUploadWidget.vue +++ b/front/src/components/library/FileUploadWidget.vue @@ -1,44 +1,100 @@ -<script> +<script setup lang="ts"> +import type { VueUploadItem } from 'vue-upload-component' + +import { useCookies } from '@vueuse/integrations/useCookies' +import { computed, ref, watch, getCurrentInstance } from 'vue' +import { useStore } from '~/store' + import FileUpload from 'vue-upload-component' -import { setCsrf } from '@/utils.js' - -export default { - extends: FileUpload, - methods: { - uploadHtml5 (file) { - const form = new window.FormData() - const filename = file.file.filename || file.name - let value - const data = { ...file.data } - if (data.import_metadata) { - data.import_metadata = { ...(data.import_metadata || {}) } - if (data.channel && !data.import_metadata.title) { - data.import_metadata.title = filename.replace(/\.[^/.]+$/, '') - } - data.import_metadata = JSON.stringify(data.import_metadata) - } - for (const key in data) { - value = data[key] - if (value && typeof value === 'object' && typeof value.toString !== 'function') { - if (value instanceof File) { - form.append(key, value, value.name) - } else { - form.append(key, JSON.stringify(value)) - } - } else if (value !== null && value !== undefined) { - form.append(key, value) - } - } - form.append('source', `upload://${filename}`) - form.append(this.name, file.file, filename) - const xhr = new XMLHttpRequest() - xhr.open('POST', file.postAction) - setCsrf(xhr) - if (this.$store.state.auth.oauth.accessToken) { - xhr.setRequestHeader('Authorization', this.$store.getters['auth/header']) - } - return this.uploadXhr(xhr, file, form) + +const { get } = useCookies() +const instance = getCurrentInstance() +const attrs = instance?.attrs ?? {} + +const store = useStore() +const headers = computed(() => { + const headers: Record<string, string> = typeof attrs.headers === 'object' + ? { ...attrs.headers } + : {} + + if (store.state.auth.oauth.accessToken) { + headers.Authorization ??= store.getters['auth/header'] + } + + const csrf = get('csrftoken') + if (csrf) headers['X-CSRFToken'] = csrf + + return headers +}) + +const patchFileData = (file: VueUploadItem, data: Record<string, unknown> = {}) => { + let metadata = data.import_metadata as Record<string, unknown> + + // @ts-expect-error Taken from 3.1.2 + const filename: string = file.file.name || file.file.filename || file.name + data.source = `upload://${filename}` + + if (metadata) { + metadata = { ...metadata } + if (data.channel && !metadata.title) { + metadata.title = filename.replace(/\.[^/.]+$/, '') } + + data.import_metadata = JSON.stringify(metadata) } + + return data } + +const uploadAction = async (file: VueUploadItem, self: any): Promise<VueUploadItem> => { + file.data = patchFileData(file, file.data) + + // NOTE: We're only patching the file data. The rest of the process should remain the same: + // https://github.com/lian-yue/vue-upload-component/blob/1bd3be3a56e8ed2934dbe0beae151e9026ca51f9/src/FileUpload.vue#L973-L987 + if (self.features.html5) { + if (self.shouldUseChunkUpload(file)) return self.uploadChunk(file) + if (file.putAction) return self.uploadPut(file) + if (file.postAction) return self.uploadHtml5(file) + } + + if (file.postAction) return self.uploadHtml4(file) + return Promise.reject(new Error('No action configured')) +} + +// NOTE: We need to expose the data and methods that we use +const upload = ref() + +const active = ref(false) +watch(active, () => (upload.value.active = active.value)) + +const update = (file: VueUploadItem, data: Partial<VueUploadItem>) => upload.value.update(file, data) +const remove = (file: VueUploadItem) => upload.value.remove(file) + +defineExpose({ + active, + update, + remove +}) </script> + +<script lang="ts"> +// NOTE: We're disallowing overriding `custom-action` and `headers` props +export default { inheritAttrs: false } +</script> + +<template> + <!-- <component + ref="fileUpload" + :is="FileUpload" + > + <slot /> + </component> --> + <file-upload + ref="upload" + v-bind="$attrs" + :custom-action="uploadAction" + :headers="headers" + > + <slot /> + </file-upload> +</template> diff --git a/front/src/components/library/FsBrowser.vue b/front/src/components/library/FsBrowser.vue index d97b01e5d820c7606cb912e40ef30b1388aceb92..4e6e33a2a0ac0449f79fba94917058f4295af34a 100644 --- a/front/src/components/library/FsBrowser.vue +++ b/front/src/components/library/FsBrowser.vue @@ -1,14 +1,46 @@ +<script setup lang="ts"> +import type { FileSystem, FSEntry } from '~/types' + +import { useVModel } from '@vueuse/core' + +interface Events { + (e: 'update:modelValue', value: string[]): void + (e: 'import'): void +} + +interface Props { + data: FileSystem + loading: boolean + modelValue: string[] +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const value = useVModel(props, 'modelValue', emit) +const handleClick = (entry: FSEntry) => { + if (!entry.dir) return + + if (entry.name === '..') { + value.value.pop() + return + } + + value.value.push(entry.name) +} +</script> + <template> - <div :class="['ui', {loading}, 'segment']"> + <div :class="['ui', { loading }, 'segment']"> <div class="ui fluid action input"> <input class="ui disabled" disabled - :value="data.root + '/' + value.join('/')" + :value="props.data.root + '/' + value.join('/')" > <button class="ui button" - @click.prevent="$emit('import')" + @click.prevent="emit('import')" > <translate translate-context="Content/Library/Button/Verb"> Import @@ -20,7 +52,7 @@ v-if="value.length > 0" class="item" href="" - @click.prevent="handleClick({name: '..', dir: true})" + @click.prevent="handleClick({ name: '..', dir: true })" > <i class="folder icon" /> <div class="content"> @@ -49,26 +81,3 @@ </div> </div> </template> -<script> -export default { - props: { - data: { type: Object, required: true }, - loading: { type: Boolean, required: true }, - value: { type: Array, required: true } - }, - methods: { - handleClick (element) { - if (!element.dir) { - return - } - if (element.name === '..') { - const newValue = [...this.value] - newValue.pop() - this.$emit('input', newValue) - } else { - this.$emit('input', [...this.value, element.name]) - } - } - } -} -</script> diff --git a/front/src/components/library/FsLogs.vue b/front/src/components/library/FsLogs.vue index 5180767087a379fac2045d427006e141bb1d9f3d..4459645ea9937546b2c1bce5364e91243990ce34 100644 --- a/front/src/components/library/FsLogs.vue +++ b/front/src/components/library/FsLogs.vue @@ -1,3 +1,13 @@ +<script setup lang="ts"> +import type { FSLogs } from '~/types' + +interface Props { + data: FSLogs +} + +defineProps<Props>() +</script> + <template> <div class="ui segment component-fs-logs"> <div @@ -10,18 +20,13 @@ </translate> </div> </div> - <template - v-for="(row, idx) in data.logs" - v-else - > - <p :key="idx"> + <template v-else> + <p + v-for="row in data.logs" + :key="row" + > {{ row }} </p> </template> </div> </template> -<script> -export default { - props: { data: { type: Object, required: true } } -} -</script> diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index a9482ed24c6b8e6f06f257e0f630ea11615af158..bbed52395797e5d6134ac4225da28cbb0e7366f0 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -1,6 +1,61 @@ +<script setup lang="ts"> +import { useGettext } from 'vue3-gettext' +import { ref, computed } from 'vue' + +import axios from 'axios' + +import ChannelsWidget from '~/components/audio/ChannelsWidget.vue' +import PlaylistWidget from '~/components/playlists/Widget.vue' +import TrackWidget from '~/components/audio/track/Widget.vue' +import AlbumWidget from '~/components/audio/album/Widget.vue' + +import useErrorHandler from '~/composables/useErrorHandler' +import useLogger from '~/composables/useLogger' + +interface Props { + scope?: string +} + +withDefaults(defineProps<Props>(), { + scope: 'all' +}) + +const artists = ref([]) + +const logger = useLogger() + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/Home/Title', 'Library') +})) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + logger.time('Loading latest artists') + + const params = { + ordering: '-creation_date', + playable: true + } + + try { + const response = await axios.get('artists/', { params }) + artists.value = response.data.results + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false + logger.timeEnd('Loading latest artists') +} + +fetchData() +</script> + <template> <main - :key="$router.currentRoute.name" + :key="$route?.name ?? undefined" v-title="labels.title" > <section class="ui vertical stripe segment"> @@ -8,9 +63,10 @@ <div class="column"> <track-widget :url="'history/listenings/'" - :filters="{scope: scope, ordering: '-creation_date'}" + :filters="{ scope, ordering: '-creation_date' }" + :websocket-handlers="['Listen']" > - <template slot="title"> + <template #title> <translate translate-context="Content/Home/Title"> Recently listened </translate> @@ -22,7 +78,7 @@ :url="'favorites/tracks/'" :filters="{scope: scope, ordering: '-creation_date'}" > - <template slot="title"> + <template #title> <translate translate-context="Content/Home/Title"> Recently favorited </translate> @@ -34,7 +90,7 @@ :url="'playlists/'" :filters="{scope: scope, playable: true, ordering: '-modification_date'}" > - <template slot="title"> + <template #title> <translate translate-context="*/*/*"> Playlists </translate> @@ -46,7 +102,7 @@ <div class="ui stackable one column grid"> <div class="column"> <album-widget :filters="{scope: scope, playable: true, ordering: '-creation_date'}"> - <template slot="title"> + <template #title> <translate translate-context="Content/Home/Title"> Recently added </translate> @@ -69,60 +125,3 @@ </section> </main> </template> - -<script> -import axios from 'axios' -import logger from '@/logging.js' -import ChannelsWidget from '@/components/audio/ChannelsWidget.vue' -import TrackWidget from '@/components/audio/track/Widget.vue' -import AlbumWidget from '@/components/audio/album/Widget.vue' -import PlaylistWidget from '@/components/playlists/Widget.vue' - -const ARTISTS_URL = 'artists/' - -export default { - name: 'Library', - components: { - TrackWidget, - AlbumWidget, - PlaylistWidget, - ChannelsWidget - }, - props: { - scope: { type: String, default: 'all' } - }, - data () { - return { - artists: [], - isLoadingArtists: false - } - }, - computed: { - labels () { - return { - title: this.$pgettext('Head/Home/Title', 'Library') - } - } - }, - created () { - this.fetchArtists() - }, - methods: { - fetchArtists () { - const self = this - this.isLoadingArtists = true - const params = { - ordering: '-creation_date', - playable: true - } - const url = ARTISTS_URL - logger.default.time('Loading latest artists') - axios.get(url, { params: params }).then(response => { - self.artists = response.data.results - logger.default.timeEnd('Loading latest artists') - self.isLoadingArtists = false - }) - } - } -} -</script> diff --git a/front/src/components/library/ImportStatusModal.vue b/front/src/components/library/ImportStatusModal.vue index 50e83b5d347fcc6feaf11a9a8c10bcf9c36cb985..982e927cfd2ede592f71b1d204bee06bf6a6e267 100644 --- a/front/src/components/library/ImportStatusModal.vue +++ b/front/src/components/library/ImportStatusModal.vue @@ -1,5 +1,81 @@ +<script setup lang="ts"> +import type { Upload } from '~/types' + +import SemanticModal from '~/components/semantic/Modal.vue' +import { useVModel } from '@vueuse/core' +import { useGettext } from 'vue3-gettext' + +interface ErrorEntry { + key: string + value: string +} + +interface Events { + (e: 'update:show', value: boolean): void +} + +interface Props { + upload: Upload + show: boolean +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const show = useVModel(props, 'show', emit) + +const getErrors = (details: object): ErrorEntry[] => { + const errors = [] + + for (const [key, value] of Object.entries(details)) { + if (Array.isArray(value)) { + errors.push({ key, value: value.join(', ') }) + continue + } + + if (typeof value === 'object') { + errors.push(...getErrors(value).map(error => ({ + ...error, + key: `${key} / ${error.key}` + }))) + } + } + + return errors +} + +const { $pgettext } = useGettext() + +const getErrorData = (upload: Upload) => { + const payload = upload.import_details ?? { error_code: '', detail: {} } + + const errorCode = payload.error_code + ? payload.error_code + : 'unknown_error' + + return { + errorCode, + supportUrl: 'https://forum.funkwhale.audio/t/support', + documentationUrl: `https://docs.funkwhale.audio/users/upload.html#${errorCode}`, + label: errorCode === 'invalid_metadata' + ? $pgettext('Popup/Import/Error.Label', 'Invalid metadata') + : $pgettext('*/*/Error', 'Unknown error'), + detail: errorCode === 'invalid_metadata' + ? $pgettext('Popup/Import/Error.Label', 'The metadata included in the file is invalid or some mandatory fields are missing.') + : $pgettext('Popup/Import/Error.Label', 'An unknown error occurred'), + errorRows: errorCode === 'invalid_metadata' + ? getErrors(payload.detail ?? {}) + : [], + debugInfo: { + source: upload.source, + ...payload + } + } +} +</script> + <template> - <modal :show.sync="showModal"> + <semantic-modal v-model:show="show"> <h4 class="header"> <translate translate-context="Popup/Import/Title"> Import detail @@ -112,7 +188,7 @@ <textarea class="ui textarea" rows="10" - :value="getErrorData(upload).debugInfo" + :value="JSON.stringify(getErrorData(upload).debugInfo)" /> </div> </td> @@ -129,87 +205,5 @@ </translate> </button> </div> - </modal> + </semantic-modal> </template> -<script> -import Modal from '@/components/semantic/Modal.vue' - -function getErrors (payload) { - const errors = [] - for (const k in payload) { - if (Object.prototype.hasOwnProperty.call(payload, k)) { - const value = payload[k] - if (Array.isArray(value)) { - errors.push({ - key: k, - value: value.join(', ') - }) - } else { - // possibly artists, so nested errors - if (typeof value === 'object') { - getErrors(value).forEach((e) => { - errors.push({ - key: `${k} / ${e.key}`, - value: e.value - }) - }) - } - } - } - } - return errors -} - -export default { - components: { - Modal - }, - props: { - upload: { type: Object, required: true }, - show: { type: Boolean } - }, - data () { - return { - showModal: this.show - } - }, - watch: { - showModal (v) { - this.$emit('update:show', v) - }, - show (v) { - this.showModal = v - } - }, - methods: { - getErrorData (upload) { - const payload = upload.import_details || {} - const d = { - supportUrl: 'https://forum.funkwhale.audio/t/support', - errorRows: [] - } - if (!payload.error_code) { - d.errorCode = 'unknown_error' - } else { - d.errorCode = payload.error_code - } - d.documentationUrl = `https://docs.funkwhale.audio/users/upload.html#${d.errorCode}` - if (d.errorCode === 'invalid_metadata') { - d.label = this.$pgettext('Popup/Import/Error.Label', 'Invalid metadata') - d.detail = this.$pgettext('Popup/Import/Error.Label', 'The metadata included in the file is invalid or some mandatory fields are missing.') - const detail = payload.detail || {} - d.errorRows = getErrors(detail) - } else { - d.label = this.$pgettext('*/*/Error', 'Unknown error') - d.detail = this.$pgettext('Popup/Import/Error.Label', 'An unknown error occurred') - } - const debugInfo = { - source: upload.source, - ...payload - } - d.debugInfo = JSON.stringify(debugInfo, null, 4) - return d - } - } -} -</script> diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index 784cc5aba466ec64eb7541c10040ee1a5581ea53..5cbd9cdc2c29ef2465f3d41395ca76f1486a6bbe 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -1,23 +1,5 @@ <template> <div class="main pusher page-library"> - <router-view :key="$router.currentRoute.fullPath" /> + <router-view /> </div> </template> - -<script> -export default { - computed: { - showImports () { - return ( - this.$store.state.auth.availablePermissions.upload || - this.$store.state.auth.availablePermissions.library - ) - }, - labels () { - return { - secondaryMenu: this.$pgettext('Menu/*/Hidden text', 'Secondary menu') - } - } - } -} -</script> diff --git a/front/src/components/library/Podcasts.vue b/front/src/components/library/Podcasts.vue index c0730477bae87fb24696678aeb4a07537ab2ec1b..0f33471fa207e539c40733f4133e2b522b0fee82 100644 --- a/front/src/components/library/Podcasts.vue +++ b/front/src/components/library/Podcasts.vue @@ -1,3 +1,125 @@ +<script setup lang="ts"> +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { Artist, BackendResponse } from '~/types' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { computed, ref, watch, onMounted } from 'vue' +import { useRouteQuery } from '@vueuse/router' +import { useGettext } from 'vue3-gettext' +import { syncRef } from '@vueuse/core' +import { sortedUniq } from 'lodash-es' +import { useStore } from '~/store' + +import axios from 'axios' +import $ from 'jquery' +import qs from 'qs' + +import TagsSelector from '~/components/library/TagsSelector.vue' +import RemoteSearchForm from '~/components/RemoteSearchForm.vue' +import SemanticModal from '~/components/semantic/Modal.vue' +import ArtistCard from '~/components/audio/artist/Card.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' +import useLogger from '~/composables/useLogger' + +interface Props extends OrderingProps { + scope?: 'me' | 'all' + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName +} + +const props = withDefaults(defineProps<Props>(), { + scope: 'all', + orderingConfigName: undefined +}) + +const page = usePage() + +const tags = useRouteQuery<string[]>('tag', []) + +const q = useRouteQuery('query', '') +const query = ref(q.value) +syncRef(q, query, { direction: 'ltr' }) + +const result = ref<BackendResponse<Artist>>() +const showSubscribeModal = ref(false) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['name', 'name'] +] + +const logger = useLogger() +const sharedLabels = useSharedLabels() + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + scope: props.scope, + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + playable: 'true', + tag: tags.value, + include_channels: 'true', + content_category: 'podcast' + } + + logger.time('Fetching podcasts') + try { + const response = await axios.get('artists/', { + params, + paramsSerializer: function (params) { + return qs.stringify(params, { indices: false }) + } + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = undefined + } finally { + logger.timeEnd('Fetching podcasts') + isLoading.value = false + } +} + +const store = useStore() +watch(() => store.state.moderation.lastUpdate, fetchData) +watch([page, tags, q], fetchData) +fetchData() + +const search = () => { + page.value = 1 + q.value = query.value +} + +onOrderingUpdate(() => { + page.value = 1 + fetchData() +}) + +onMounted(() => $('.ui.dropdown').dropdown()) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search…'), + title: $pgettext('*/*/*/Noun', 'Podcasts') +})) + +const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value].sort((a, b) => a - b))) +</script> + <template> <main v-title="labels.title"> <section class="ui vertical stripe segment"> @@ -8,7 +130,7 @@ </h2> <form :class="['ui', {'loading': isLoading}, 'form']" - @submit.prevent="updatePage();updateQueryString();fetchData()" + @submit.prevent="search" > <div class="fields"> <div class="field"> @@ -78,14 +200,12 @@ v-model="paginateBy" class="ui dropdown" > - <option :value="parseInt(12)"> - 12 - </option> - <option :value="parseInt(30)"> - 30 - </option> - <option :value="parseInt(50)"> - 50 + <option + v-for="opt in paginateOptions" + :key="opt" + :value="opt" + > + {{ opt }} </option> </select> </div> @@ -144,16 +264,15 @@ <div class="ui center aligned basic segment"> <pagination v-if="result && result.count > paginateBy" - :current="page" + v-model:current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> </div> </section> - <modal + <semantic-modal + v-model:show="showSubscribeModal" class="tiny" - :show.sync="showSubscribeModal" :fullscreen="false" > <h2 class="header"> @@ -190,137 +309,6 @@ </translate> </button> </div> - </modal> + </semantic-modal> </main> </template> - -<script> -import qs from 'qs' -import axios from 'axios' -import $ from 'jquery' - -import logger from '@/logging.js' - -import OrderingMixin from '@/components/mixins/Ordering.vue' -import PaginationMixin from '@/components/mixins/Pagination.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import ArtistCard from '@/components/audio/artist/Card.vue' -import Pagination from '@/components/Pagination.vue' -import TagsSelector from '@/components/library/TagsSelector.vue' -import Modal from '@/components/semantic/Modal.vue' -import RemoteSearchForm from '@/components/RemoteSearchForm.vue' - -const FETCH_URL = 'artists/' - -export default { - components: { - ArtistCard, - Pagination, - TagsSelector, - RemoteSearchForm, - Modal - }, - mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], - props: { - defaultQuery: { type: String, required: false, default: '' }, - defaultTags: { type: Array, required: false, default: () => { return [] } }, - scope: { type: String, required: false, default: 'all' } - }, - data () { - return { - isLoading: true, - result: null, - page: parseInt(this.defaultPage), - query: this.defaultQuery, - tags: (this.defaultTags || []).filter((t) => { return t.length > 0 }), - orderingOptions: [['creation_date', 'creation_date'], ['name', 'name']], - showSubscribeModal: false - } - }, - computed: { - labels () { - const searchPlaceholder = this.$pgettext('Content/Search/Input.Placeholder', 'Search…') - const title = this.$pgettext('*/*/*/Noun', 'Podcasts') - return { - searchPlaceholder, - title - } - } - }, - watch: { - page () { - this.updateQueryString() - this.fetchData() - }, - '$store.state.moderation.lastUpdate': function () { - this.fetchData() - }, - excludeCompilation () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - mounted () { - $('.ui.dropdown').dropdown() - }, - methods: { - updateQueryString: function () { - history.pushState( - {}, - null, - this.$route.path + '?' + new URLSearchParams( - { - query: this.query, - page: this.page, - tag: this.tags, - paginateBy: this.paginateBy, - ordering: this.getOrderingAsString(), - include_channels: true, - content_category: 'podcast' - }).toString() - ) - }, - fetchData: function () { - const self = this - this.isLoading = true - const url = FETCH_URL - const params = { - scope: this.scope, - page: this.page, - page_size: this.paginateBy, - has_albums: this.excludeCompilation, - q: this.query, - ordering: this.getOrderingAsString(), - playable: 'true', - tag: this.tags, - include_channels: 'true', - content_category: 'podcast' - } - logger.default.debug('Fetching artists') - axios.get( - url, - { - params: params, - paramsSerializer: function (params) { - return qs.stringify(params, { indices: false }) - } - } - ).then(response => { - self.result = response.data - self.isLoading = false - }, () => { - self.result = null - self.isLoading = false - }) - }, - selectPage: function (page) { - this.page = page - }, - updatePage () { - this.page = this.defaultPage - } - } -} -</script> diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue index 89998b0f7d1b7703f4a1a600df23d88c3dcdbfe6..ca57b7c513ab302d57de1f46afb9f9bc79ac15f5 100644 --- a/front/src/components/library/Radios.vue +++ b/front/src/components/library/Radios.vue @@ -1,3 +1,113 @@ +<script setup lang="ts"> +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { Radio, BackendResponse } from '~/types' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { computed, onMounted, ref, watch } from 'vue' +import { useRouteQuery } from '@vueuse/router' +import { useGettext } from 'vue3-gettext' +import { syncRef } from '@vueuse/core' +import { sortedUniq } from 'lodash-es' +import { useStore } from '~/store' + +import axios from 'axios' +import $ from 'jquery' + +import Pagination from '~/components/vui/Pagination.vue' +import RadioCard from '~/components/radios/Card.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' +import useLogger from '~/composables/useLogger' + +interface Props extends OrderingProps { + scope?: 'me' | 'all' + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName +} + +const props = withDefaults(defineProps<Props>(), { + scope: 'all', + orderingConfigName: undefined +}) + +const page = usePage() + +const q = useRouteQuery('query', '') +const query = ref(q.value) +syncRef(q, query, { direction: 'ltr' }) + +const result = ref<BackendResponse<Radio>>() + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['name', 'name'] +] + +const logger = useLogger() +const sharedLabels = useSharedLabels() + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + scope: props.scope, + page: page.value, + page_size: paginateBy.value, + name__icontains: query.value, + ordering: orderingString.value + } + + logger.time('Fetching radios') + try { + const response = await axios.get('radios/radios/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = undefined + } finally { + logger.timeEnd('Fetching radios') + isLoading.value = false + } +} + +const store = useStore() +const isAuthenticated = computed(() => store.state.auth.authenticated) +const hasFavorites = computed(() => store.state.favorites.count > 0) + +watch([page, q], fetchData) +fetchData() + +const search = () => { + page.value = 1 + q.value = query.value +} + +onOrderingUpdate(() => { + page.value = 1 + fetchData() +}) + +onMounted(() => $('.ui.dropdown').dropdown()) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Enter a radio name…'), + title: $pgettext('*/*/*', 'Radios') +})) + +const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value].sort((a, b) => a - b))) +</script> + <template> <main v-title="labels.title"> <section class="ui vertical stripe segment"> @@ -42,7 +152,6 @@ v-if="isAuthenticated" class="ui success button" to="/library/radios/build" - exact > <translate translate-context="Content/Radio/Button.Label/Verb"> Create your own radio @@ -51,7 +160,7 @@ <div class="ui hidden divider" /> <form :class="['ui', {'loading': isLoading}, 'form']" - @submit.prevent="updateQueryString();fetchData()" + @submit.prevent="search" > <div class="fields"> <div class="field"> @@ -115,14 +224,12 @@ v-model="paginateBy" class="ui dropdown" > - <option :value="parseInt(12)"> - 12 - </option> - <option :value="parseInt(25)"> - 25 - </option> - <option :value="parseInt(50)"> - 50 + <option + v-for="opt in paginateOptions" + :key="opt" + :value="opt" + > + {{ opt }} </option> </select> </div> @@ -130,7 +237,7 @@ </form> <div class="ui hidden divider" /> <div - v-if="result && !result.results.length > 0" + v-if="result && result.results.length === 0" class="ui placeholder segment" > <div class="ui icon header"> @@ -164,111 +271,11 @@ <div class="ui center aligned basic segment"> <pagination v-if="result && result.count > paginateBy" - :current="page" + v-model:current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> </div> </section> </main> </template> - -<script> -import axios from 'axios' -import $ from 'jquery' - -import logger from '@/logging.js' - -import OrderingMixin from '@/components/mixins/Ordering.vue' -import PaginationMixin from '@/components/mixins/Pagination.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import RadioCard from '@/components/radios/Card.vue' -import Pagination from '@/components/Pagination.vue' - -const FETCH_URL = 'radios/radios/' - -export default { - components: { - RadioCard, - Pagination - }, - mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], - props: { - defaultQuery: { type: String, required: false, default: '' }, - scope: { type: String, required: false, default: 'all' } - }, - data () { - return { - isLoading: true, - result: null, - page: parseInt(this.defaultPage), - query: this.defaultQuery, - orderingOptions: [['creation_date', 'creation_date'], ['name', 'name']] - } - }, - computed: { - labels () { - const searchPlaceholder = this.$pgettext('Content/Search/Input.Placeholder', 'Enter a radio name…') - const title = this.$pgettext('*/*/*', 'Radios') - return { - searchPlaceholder, - title - } - }, - isAuthenticated () { - return this.$store.state.auth.authenticated - }, - hasFavorites () { - return this.$store.state.favorites.count > 0 - } - }, - watch: { - page () { - this.updateQueryString() - this.fetchData() - } - }, - created () { - this.fetchData() - }, - mounted () { - $('.ui.dropdown').dropdown() - }, - methods: { - updateQueryString: function () { - history.pushState( - {}, - null, - this.$route.path + '?' + new URLSearchParams( - { - query: this.query, - page: this.page, - paginateBy: this.paginateBy, - ordering: this.getOrderingAsString() - }).toString() - ) - }, - fetchData: function () { - const self = this - this.isLoading = true - const url = FETCH_URL - const params = { - scope: this.scope, - page: this.page, - page_size: this.paginateBy, - name__icontains: this.query, - ordering: this.getOrderingAsString() - } - logger.default.debug('Fetching radios') - axios.get(url, { params: params }).then(response => { - self.result = response.data - self.isLoading = false - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/library/TagDetail.vue b/front/src/components/library/TagDetail.vue index 0506b989dbb97bd8bcb71e6cc7be45e8ae0e61c5..67bf69abaa2674b6c3032e6c3d5efc27489786d2 100644 --- a/front/src/components/library/TagDetail.vue +++ b/front/src/components/library/TagDetail.vue @@ -1,3 +1,23 @@ +<script setup lang="ts"> +import { computed } from 'vue' + +import ChannelsWidget from '~/components/audio/ChannelsWidget.vue' +import TrackWidget from '~/components/audio/track/Widget.vue' +import AlbumWidget from '~/components/audio/album/Widget.vue' +import ArtistWidget from '~/components/audio/artist/Widget.vue' +import RadioButton from '~/components/radios/Button.vue' + +interface Props { + id: string +} + +const props = defineProps<Props>() + +const labels = computed(() => ({ + title: `#${props.id}` +})) +</script> + <template> <main v-title="labels.title"> <section class="ui vertical stripe segment"> @@ -28,7 +48,7 @@ :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id, include_channels: 'false'}" > - <template slot="title"> + <template #title> <router-link :to="{name: 'library.artists.browse', query: {tag: id}}"> <translate translate-context="*/*/*/Noun"> Artists @@ -57,7 +77,7 @@ :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}" > - <template slot="title"> + <template #title> <router-link :to="{name: 'library.albums.browse', query: {tag: id}}"> <translate translate-context="*/*/*"> Albums @@ -76,7 +96,7 @@ :is-activity="false" :filters="{playable: true, ordering: '-creation_date', tag: id}" > - <template slot="title"> + <template #title> <translate translate-context="*/*/*"> Tracks </translate> @@ -87,38 +107,3 @@ </section> </main> </template> - -<script> -import ChannelsWidget from '@/components/audio/ChannelsWidget.vue' -import TrackWidget from '@/components/audio/track/Widget.vue' -import AlbumWidget from '@/components/audio/album/Widget.vue' -import ArtistWidget from '@/components/audio/artist/Widget.vue' -import RadioButton from '@/components/radios/Button.vue' - -export default { - components: { - ArtistWidget, - AlbumWidget, - TrackWidget, - RadioButton, - ChannelsWidget - }, - props: { - id: { type: String, required: true } - }, - computed: { - labels () { - const title = `#${this.id}` - return { - title - } - }, - isAuthenticated () { - return this.$store.state.auth.authenticated - }, - hasFavorites () { - return this.$store.state.favorites.count > 0 - } - } -} -</script> diff --git a/front/src/components/library/TagsSelector.vue b/front/src/components/library/TagsSelector.vue index 27e3d7143012de66bb8d2104e10d518eff3e4341..e0d90e4ffc7d7282125cc974a4b00a07fe093e55 100644 --- a/front/src/components/library/TagsSelector.vue +++ b/front/src/components/library/TagsSelector.vue @@ -1,3 +1,91 @@ +<script setup lang="ts"> +import type { Tag } from '~/types' + +import { ref, watch, onMounted, nextTick } from 'vue' +import { isEqual } from 'lodash-es' +import { useStore } from '~/store' + +import $ from 'jquery' + +interface Events { + (e: 'update:modelValue', tags: string[]): void +} + +interface Props { + modelValue: string[] +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const store = useStore() + +const dropdown = ref() +watch(() => props.modelValue, (value) => { + const current = $(dropdown.value).dropdown('get value').split(',').sort() + + if (!isEqual([...value].sort(), current)) { + $(dropdown.value).dropdown('set exactly', value) + } +}) + +const handleUpdate = () => { + const value = $(dropdown.value).dropdown('get value').split(',') + emit('update:modelValue', value) + return value +} + +onMounted(async () => { + await nextTick() + + $(dropdown.value).dropdown({ + keys: { delimiter: 32 }, + forceSelection: false, + saveRemoteData: false, + filterRemoteData: true, + preserveHTML: false, + apiSettings: { + url: store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'), + beforeXHR: function (xhrObject) { + if (store.state.auth.oauth.accessToken) { + xhrObject.setRequestHeader('Authorization', store.getters['auth/header']) + } + return xhrObject + }, + onResponse (response) { + response = { results: [], ...response } + + // @ts-expect-error Semantic UI + const currentSearch: string = $(dropdown.value).dropdown('get query') + + if (currentSearch) { + const existingTag = response.results.find((result: Tag) => result.name === currentSearch) + + if (existingTag) { + if (response.results.indexOf(existingTag) !== 0) { + response.results = [existingTag, ...response.results] + response.results.splice(response.results.indexOf(existingTag) + 1, 1) + } + } else { + response.results = [{ name: currentSearch }, ...response.results] + } + } + return response + } + }, + fields: { remoteValues: 'results', value: 'name' }, + allowAdditions: true, + minCharacters: 1, + onAdd: handleUpdate, + onRemove: handleUpdate, + onLabelRemove: handleUpdate, + onChange: handleUpdate + }) + + $(dropdown.value).dropdown('set exactly', props.modelValue) +}) +</script> + <template> <div ref="dropdown" @@ -17,86 +105,3 @@ </div> </div> </template> -<script> -import $ from 'jquery' - -import lodash from 'lodash' -export default { - props: { value: { type: Array, required: true } }, - watch: { - value: { - handler (v) { - const current = $(this.$refs.dropdown).dropdown('get value').split(',').sort() - if (!lodash.isEqual([...v].sort(), current)) { - $(this.$refs.dropdown).dropdown('set exactly', v) - } - }, - deep: true - } - }, - mounted () { - this.$nextTick(() => { - this.initDropdown() - }) - }, - methods: { - initDropdown () { - const self = this - const handleUpdate = () => { - const value = $(self.$refs.dropdown).dropdown('get value').split(',') - self.$emit('input', value) - return value - } - const settings = { - keys: { - delimiter: 32 - }, - forceSelection: false, - saveRemoteData: false, - filterRemoteData: true, - preserveHTML: false, - apiSettings: { - url: this.$store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'), - beforeXHR: function (xhrObject) { - if (self.$store.state.auth.oauth.accessToken) { - xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) - } - return xhrObject - }, - onResponse (response) { - const currentSearch = $(self.$refs.dropdown).dropdown('get query') - response = { - results: [], - ...response - } - if (currentSearch) { - const existingTag = response.results.find((result) => result.name === currentSearch) - if (existingTag) { - if (response.results.indexOf(existingTag) !== 0) { - response.results = [existingTag, ...response.results] - response.results.splice(response.results.indexOf(existingTag) + 1, 1) - } - } else { - response.results = [{ name: currentSearch }, ...response.results] - } - } - return response - } - }, - fields: { - remoteValues: 'results', - value: 'name' - }, - allowAdditions: true, - minCharacters: 1, - onAdd: handleUpdate, - onRemove: handleUpdate, - onLabelRemove: handleUpdate, - onChange: handleUpdate - } - $(this.$refs.dropdown).dropdown(settings) - $(this.$refs.dropdown).dropdown('set exactly', this.value) - } - } -} -</script> diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index 452632e500272bf2826eab82335c90627cba4494..96a99b7889c8644c5d45fe867e9ec7b0831ee236 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -1,3 +1,132 @@ +<script setup lang="ts"> +import type { Track, Artist, Library } from '~/types' + +import { momentFormat } from '~/utils/filters' +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { getDomain } from '~/utils' +import { useStore } from '~/store' + +import axios from 'axios' + +import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' +import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue' +import EmbedWizard from '~/components/audio/EmbedWizard.vue' +import SemanticModal from '~/components/semantic/Modal.vue' +import PlayButton from '~/components/audio/PlayButton.vue' + +import updateQueryString from '~/composables/updateQueryString' +import useErrorHandler from '~/composables/useErrorHandler' +import useReport from '~/composables/moderation/useReport' +import useLogger from '~/composables/useLogger' + +interface Events { + (e: 'deleted'): void +} + +interface Props { + id: number +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const { report, getReportableObjects } = useReport() + +const track = ref<Track | null>(null) +const artist = ref<Artist | null>(null) +const showEmbedModal = ref(false) +const libraries = ref([] as Library[]) + +const logger = useLogger() +const router = useRouter() +const store = useStore() + +const domain = computed(() => getDomain(track.value?.fid ?? '')) +const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? []) +const isEmbedable = computed(() => artist.value?.channel?.actor || publicLibraries.value.length) +const upload = computed(() => track.value?.uploads?.[0] ?? null) +const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(`${track.value?.title ?? ''} ${track.value?.artist?.name ?? ''}`)}`) +const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(track.value?.album?.title ?? '')}&artist=${encodeURI(track.value?.artist?.name ?? '')}&title=${encodeURI(track.value?.title ?? '')}`) +const downloadUrl = computed(() => { + const url = store.getters['instance/absoluteUrl'](upload.value?.listen_url ?? '') + return store.state.auth.authenticated + ? updateQueryString(url, 'token', encodeURI(store.state.auth.scopedTokens.listen ?? '')) + : url +}) + +const attributedToUrl = computed(() => router.resolve({ + name: 'profile.full.overview', + params: { + username: track.value?.attributed_to.preferred_username, + domain: track.value?.attributed_to.domain + } +})?.href) + +const escapeHtml = (unsafe: string) => document.createTextNode(unsafe).textContent ?? '' +const subtitle = computed(() => { + if (track.value?.attributed_to) { + return $pgettext( + 'Content/Track/Paragraph', + 'Uploaded by <a class="internal" href="%{ uploaderUrl }">%{ uploader }</a> on <time title="%{ date }" datetime="%{ date }">%{ prettyDate }</time>', + { + uploaderUrl: attributedToUrl.value, + uploader: escapeHtml(`@${track.value.attributed_to.full_username}`), + date: escapeHtml(track.value.creation_date), + prettyDate: escapeHtml(momentFormat(new Date(track.value.creation_date), 'LL')) + } + ) + } + + return $pgettext( + 'Content/Track/Paragraph', + 'Uploaded on <time title="%{ date }" datetime="%{ date }">%{ prettyDate }</time>', + { + date: escapeHtml(track.value?.creation_date ?? ''), + prettyDate: escapeHtml(momentFormat(new Date(track.value?.creation_date ?? '1970-01-01'), 'LL')) + } + ) +}) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('*/*/*/Noun', 'Track'), + download: $pgettext('Content/Track/Link/Verb', 'Download'), + more: $pgettext('*/*/Button.Label/Noun', 'More…') +})) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + logger.debug(`Fetching track "${props.id}"`) + try { + const trackResponse = await axios.get(`tracks/${props.id}/`, { params: { refresh: 'true' } }) + track.value = trackResponse.data + const artistResponse = await axios.get(`artists/${trackResponse.data.artist.id}/`) + artist.value = artistResponse.data + } catch (error) { + useErrorHandler(error as Error) + } + isLoading.value = false +} + +watch(() => props.id, fetchData, { immediate: true }) + +const remove = async () => { + isLoading.value = true + try { + await axios.delete(`tracks/${track.value?.id}`) + emit('deleted') + router.push({ name: 'library.artists.detail', params: { id: artist.value?.id } }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} +</script> + <template> <main> <div @@ -17,9 +146,9 @@ <div class="eight wide left aligned column"> <h1 class="ui header"> {{ track.title }} - <div + <sanitized-html class="sub header" - v-html="subtitle" + :html="subtitle" /> </h1> </div> @@ -55,9 +184,9 @@ > <i class="download icon" /> </a> - <modal + <semantic-modal v-if="isEmbedable" - :show.sync="showEmbedModal" + v-model:show="showEmbedModal" > <h4 class="header"> <translate translate-context="Popup/Track/Title"> @@ -79,7 +208,7 @@ </translate> </button> </div> - </modal> + </semantic-modal> <button v-dropdown="{direction: 'downward'}" class="ui floating dropdown circular icon basic button" @@ -151,31 +280,37 @@ <translate translate-context="*/*/*/Verb"> Delete… </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Channel/Title"> - Delete this track? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> + <p> + <translate translate-context="Popup/Channel/Title"> + Delete this track? + </translate> + </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The track will be deleted, as well as any related files and data. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The track will be deleted, as well as any related files and data. This action is irreversible. + <translate translate-context="*/*/*/Verb"> + Delete </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> </dangerous-button> <div class="divider" /> <div - v-for="obj in getReportableObjs({track})" + v-for="obj in getReportableObjects({track})" :key="obj.target.type + obj.target.id" role="button" class="basic item" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <i class="share icon" /> {{ obj.label }} </div> @@ -217,200 +352,3 @@ </template> </main> </template> - -<script> -import time from '@/utils/time.js' -import axios from 'axios' -import url from '@/utils/url.js' -import { getDomain } from '@/utils.js' -import logger from '@/logging.js' -import PlayButton from '@/components/audio/PlayButton.vue' -import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue' -import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon.vue' -import Modal from '@/components/semantic/Modal.vue' -import EmbedWizard from '@/components/audio/EmbedWizard.vue' -import ReportMixin from '@/components/mixins/Report.vue' -import { momentFormat } from '@/filters' - -const FETCH_URL = 'tracks/' - -function escapeHtml (unsafe) { - return unsafe - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -export default { - components: { - PlayButton, - TrackPlaylistIcon, - TrackFavoriteIcon, - Modal, - EmbedWizard - }, - mixins: [ReportMixin], - props: { id: { type: [String, Number], required: true } }, - data () { - return { - time, - isLoading: true, - track: null, - artist: null, - showEmbedModal: false, - libraries: [] - } - }, - computed: { - domain () { - if (this.track) { - return getDomain(this.track.fid) - } - return null - }, - publicLibraries () { - return this.libraries.filter(l => { - return l.privacy_level === 'everyone' - }) - }, - isEmbedable () { - const self = this - return (self.artist && self.artist.channel && self.artist.channel.actor) || this.publicLibraries.length > 0 - }, - upload () { - if (this.track.uploads) { - return this.track.uploads[0] - } - return null - }, - labels () { - return { - title: this.$pgettext('*/*/*/Noun', 'Track'), - download: this.$pgettext('Content/Track/Link/Verb', 'Download'), - more: this.$pgettext('*/*/Button.Label/Noun', 'More…') - } - }, - wikipediaUrl () { - return ( - 'https://en.wikipedia.org/w/index.php?search=' + - encodeURI(this.track.title + ' ' + this.track.artist.name) - ) - }, - discogsUrl () { - if (this.track.album) { - return ( - 'https://discogs.com/search/?type=release&title=' + - encodeURI(this.track.album.title) + '&artist=' + - encodeURI(this.track.artist.name) + '&track=' + - encodeURI(this.track.title) - ) - } - return null - }, - downloadUrl () { - let u = this.$store.getters['instance/absoluteUrl']( - this.upload.listen_url - ) - if (this.$store.state.auth.authenticated) { - let param = 'jwt' - let value = this.$store.state.auth.token - if (this.$store.state.auth.scopedTokens && this.$store.state.auth.scopedTokens.listen) { - // used scoped tokens instead of JWT to reduce the attack surface if the token - // is leaked - param = 'token' - value = this.$store.state.auth.scopedTokens.listen - } - u = url.updateQueryString( - u, - param, - encodeURI(value) - ) - } - return u - }, - attributedToUrl () { - const route = this.$router.resolve({ - name: 'profile.full.overview', - params: { - username: this.track.attributed_to.preferred_username, - domain: this.track.attributed_to.domain - } - }) - return route.href - }, - albumUrl () { - const route = this.$router.resolve({ name: 'library.albums.detail', params: { id: this.track.album.id } }) - return route.href - }, - artistUrl () { - const route = this.$router.resolve({ name: 'library.artists.detail', params: { id: this.track.artist.id } }) - return route.href - }, - headerStyle () { - if (!this.cover || !this.cover.urls.original) { - return '' - } - return ( - 'background-image: url(' + - this.$store.getters['instance/absoluteUrl'](this.cover.urls.original) + - ')' - ) - }, - subtitle () { - let msg - if (this.track.attributed_to) { - msg = this.$pgettext('Content/Track/Paragraph', 'Uploaded by <a class="internal" href="%{ uploaderUrl }">%{ uploader }</a> on <time title="%{ date }" datetime="%{ date }">%{ prettyDate }</time>') - return this.$gettextInterpolate(msg, { - uploaderUrl: this.attributedToUrl, - uploader: escapeHtml(`@${this.track.attributed_to.full_username}`), - date: escapeHtml(this.track.creation_date), - prettyDate: escapeHtml(momentFormat(this.track.creation_date, 'LL')) - }) - } else { - msg = this.$pgettext('Content/Track/Paragraph', 'Uploaded on <time title="%{ date }" datetime="%{ date }">%{ prettyDate }</time>') - return this.$gettextInterpolate(msg, { - date: escapeHtml(this.track.creation_date), - prettyDate: escapeHtml(momentFormat(this.track.creation_date, 'LL')) - }) - } - } - }, - watch: { - id () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = FETCH_URL + this.id + '/' - logger.default.debug('Fetching track "' + this.id + '"') - axios.get(url, { params: { refresh: 'true' } }).then(response => { - self.track = response.data - axios.get(`artists/${response.data.artist.id}/`).then(response => { - self.artist = response.data - }) - self.isLoading = false - }) - }, - remove () { - const self = this - self.isLoading = true - axios.delete(`tracks/${this.track.id}`).then((response) => { - self.isLoading = false - self.$emit('deleted') - self.$router.push({ name: 'library.artists.detail', params: { id: this.artist.id } }) - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/library/TrackDetail.vue b/front/src/components/library/TrackDetail.vue index 0c1d2a0e1a5ad7ef8633b757c399eeea9231870e..4641cdcd3e88626eace8adcdcbcd51d021159abe 100644 --- a/front/src/components/library/TrackDetail.vue +++ b/front/src/components/library/TrackDetail.vue @@ -1,3 +1,56 @@ +<script setup lang="ts"> +import type { Track, Library } from '~/types' + +import { humanSize, momentFormat, truncate } from '~/utils/filters' +import { computed, ref, watchEffect } from 'vue' + +import time from '~/utils/time' +import axios from 'axios' + +import LibraryWidget from '~/components/federation/LibraryWidget.vue' +import PlaylistWidget from '~/components/playlists/Widget.vue' +import TagsList from '~/components/tags/List.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'libraries-loaded', libraries: Library[]): void +} + +interface Props { + track: Track +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const musicbrainzUrl = computed(() => props.track.mbid + ? `https://musicbrainz.org/recording/${props.track.mbid}` + : null +) + +const upload = computed(() => props.track.uploads?.[0] ?? null) + +const license = ref() +const fetchLicense = async (licenseId: string) => { + license.value = undefined + + try { + const response = await axios.get(`licenses/${licenseId}`) + license.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } +} + +watchEffect(() => { + if (props.track.license) { + // @ts-expect-error For some reason, track.license is id instead of License here + fetchLicense(props.track.license) + } +}) +</script> + <template> <div v-if="track"> <section class="ui vertical stripe segment"> @@ -24,15 +77,13 @@ > <h3 class="ui header"> <translate - v-if="track.artist.content_category === 'music'" - key="1" + v-if="track.artist?.content_category === 'music'" translate-context="Content/*/*" > Track Details </translate> <translate v-else - key="2" translate-context="Content/*/*" > Episode Details @@ -48,7 +99,7 @@ </td> <td class="right aligned"> <template v-if="upload.duration"> - {{ upload.duration | duration }} + {{ time.parse(upload.duration) }} </template> <translate v-else @@ -66,7 +117,7 @@ </td> <td class="right aligned"> <template v-if="upload.size"> - {{ upload.size | humanSize }} + {{ humanSize(upload.size) }} </template> <translate v-else @@ -102,7 +153,7 @@ </td> <td class="right aligned"> <template v-if="upload.bitrate"> - {{ upload.bitrate | humanSize }}/s + {{ humanSize(upload.bitrate) }}/s </template> <translate v-else @@ -150,8 +201,8 @@ </translate> </td> <td class="right aligned"> - <router-link :to="{name: 'library.artists.detail', params: {id: track.artist.id}}"> - {{ track.artist.name }} + <router-link :to="{name: 'library.artists.detail', params: {id: track.artist?.id}}"> + {{ track.artist?.name }} </router-link> </td> </tr> @@ -159,14 +210,12 @@ <td> <translate v-if="track.album.artist.content_category === 'music'" - key="1" translate-context="*/*/*/Noun" > Album </translate> <translate v-else - key="2" translate-context="*/*/*" > Serie @@ -186,7 +235,7 @@ </td> <td class="right aligned"> <template v-if="track.album && track.album.release_date"> - {{ track.album.release_date | moment('Y') }} + {{ momentFormat(new Date(track.album.release_date), 'Y') }} </template> <template v-else> <translate translate-context="*/*/*"> @@ -205,7 +254,7 @@ <span v-if="track.copyright" :title="track.copyright" - >{{ track.copyright|truncate(50) }}</span> + >{{ truncate(track.copyright, 50) }}</span> <template v-else> <translate translate-context="*/*/*"> N/A @@ -246,7 +295,7 @@ target="_blank" rel="noopener noreferrer" > - {{ track.fid|truncate(65) }} + {{ truncate(track.fid, 65) }} </a> </td> </tr> @@ -277,13 +326,10 @@ </translate> </h2> <library-widget - :url="'tracks/' + id + '/libraries/'" - @loaded="$emit('libraries-loaded', $event)" + :url="`tracks/${track.id}/libraries/`" + @loaded="emit('libraries-loaded', $event)" > - <translate - slot="subtitle" - translate-context="Content/Track/Paragraph" - > + <translate translate-context="Content/Track/Paragraph"> This track is present in the following libraries: </translate> </library-widget> @@ -292,83 +338,3 @@ </section> </div> </template> - -<script> -import axios from 'axios' -import LibraryWidget from '@/components/federation/LibraryWidget.vue' -import TagsList from '@/components/tags/List.vue' -import PlaylistWidget from '@/components/playlists/Widget.vue' - -export default { - components: { - LibraryWidget, - TagsList, - PlaylistWidget - }, - props: { - track: { type: Object, required: true }, - libraries: { type: Array, default: null } - }, - data () { - return { - id: this.track.id, - licenseData: null - } - }, - computed: { - labels () { - return { - title: this.$pgettext('*/*/*/Noun', 'Track') - } - }, - musicbrainzUrl () { - if (this.track.mbid) { - return 'https://musicbrainz.org/recording/' + this.track.mbid - } - return null - }, - upload () { - if (this.track.uploads) { - return this.track.uploads[0] - } - return null - }, - license () { - if (!this.track || !this.track.license) { - return null - } - return this.licenseData - }, - cover () { - if (this.track.cover && this.track.cover.urls.original) { - return this.track.cover - } - if (this.track.album && this.track.album.cover) { - return this.track.album.cover - } - return null - } - }, - watch: { - track (v) { - if (v && v.license) { - this.fetchLicenseData(v.license) - } - } - }, - created () { - if (this.track && this.track.license) { - this.fetchLicenseData(this.track.license) - } - }, - methods: { - fetchLicenseData (licenseId) { - const self = this - const url = `licenses/${licenseId}` - axios.get(url).then(response => { - self.licenseData = response.data - }) - } - } -} -</script> diff --git a/front/src/components/library/TrackEdit.vue b/front/src/components/library/TrackEdit.vue index 6b28755d18880525a2ec919b7ec9748380ae3afe..2fb26b2ba2e7756c404e2c9f62238a7dd5c11133 100644 --- a/front/src/components/library/TrackEdit.vue +++ b/front/src/components/library/TrackEdit.vue @@ -1,16 +1,56 @@ +<script setup lang="ts"> +import type { EditObject, EditObjectType } from '~/composables/moderation/useEditConfigs' +import type { Library } from '~/types' + +import { ref } from 'vue' + +import store from '~/store' +import axios from 'axios' + +import useErrorHandler from '~/composables/useErrorHandler' +import EditForm from '~/components/library/EditForm.vue' + +interface Props { + objectType: EditObjectType + object: EditObject + libraries: Library[] | null +} + +withDefaults(defineProps<Props>(), { + libraries: null +}) + +const canEdit = store.state.auth.availablePermissions.library + +const isLoadingLicenses = ref(false) +const licenses = ref([]) +const fetchLicenses = async () => { + isLoadingLicenses.value = true + + try { + const response = await axios.get('licenses/') + licenses.value = response.data.results + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingLicenses.value = false +} + +fetchLicenses() +</script> + <template> <section class="ui vertical stripe segment"> <div class="ui text container"> <h2> <translate v-if="canEdit" - key="1" translate-context="Content/*/Title" > Edit this track </translate> <translate - v-else key="2" translate-context="Content/*/Title" > @@ -41,44 +81,3 @@ </div> </section> </template> - -<script> -import axios from 'axios' - -import EditForm from '@/components/library/EditForm.vue' -export default { - components: { - EditForm - }, - props: { - objectType: { type: String, required: true }, - object: { type: Object, required: true }, - libraries: { type: Array, default: null } - }, - data () { - return { - id: this.object.id, - isLoadingLicenses: false, - licenses: [] - } - }, - computed: { - canEdit () { - return true - } - }, - created () { - this.fetchLicenses() - }, - methods: { - fetchLicenses () { - const self = this - self.isLoadingLicenses = true - axios.get('licenses/').then((response) => { - self.isLoadingLicenses = false - self.licenses = response.data.results - }) - } - } -} -</script> diff --git a/front/src/components/library/UploadDetail.vue b/front/src/components/library/UploadDetail.vue index 89d4a4cce0be674a5a5e0b09f6a8b69b61cafc2e..be64b8724c179cffee3559e307154749ea325e3d 100644 --- a/front/src/components/library/UploadDetail.vue +++ b/front/src/components/library/UploadDetail.vue @@ -1,3 +1,45 @@ +<script setup lang="ts"> +import { useRouter } from 'vue-router' +import { ref } from 'vue' + +import axios from 'axios' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const router = useRouter() + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`uploads/${props.id}/`, { + params: { + refresh: 'true', + include_channels: 'true' + } + }) + + router.replace({ + name: 'library.tracks.detail', + params: { id: response.data.track.id } + }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() +</script> + <template> <main> <div @@ -8,23 +50,3 @@ </div> </main> </template> - -<script> -import axios from 'axios' - -export default { - props: { id: { type: Number, required: true } }, - async created () { - const upload = await this.fetchData() - this.$router.replace({ name: 'library.tracks.detail', params: { id: upload.track.id } }) - }, - methods: { - async fetchData () { - this.isLoading = true - const response = await axios.get(`uploads/${this.id}/`, { params: { refresh: 'true', include_channels: 'true' } }) - this.isLoading = false - return response.data - } - } -} -</script> diff --git a/front/src/components/library/radios/Builder.vue b/front/src/components/library/radios/Builder.vue index 1758453c6e5ab58973e0843d126b2ca71391cda6..8ef30499b415c9e14056f34b49c671186b4e5e94 100644 --- a/front/src/components/library/radios/Builder.vue +++ b/front/src/components/library/radios/Builder.vue @@ -1,3 +1,200 @@ +<script setup lang="ts"> +import { computed, ref, reactive, watch, watchEffect, onMounted } from 'vue' +import { useRouter } from 'vue-router' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' +import $ from 'jquery' + +import useErrorHandler from '~/composables/useErrorHandler' + +import TrackTable from '~/components/audio/track/Table.vue' +import RadioButton from '~/components/radios/Button.vue' +import BuilderFilter from './Filter.vue' + +export interface BuilderFilter { + type: string + label: string + help_text: string + fields: FilterField[] +} + +export interface FilterField { + name: string + placeholder: string + type: 'list' + subtype: 'number' + autocomplete?: string + autocomplete_qs: string + autocomplete_fields: { + remoteValues?: unknown + } +} + +export interface FilterConfig extends Record<string, unknown> { + type: string + not: boolean + names: string[] +} + +interface Filter { + hash: number + config: FilterConfig + filter: BuilderFilter +} + +interface Props { + id?: number +} + +const props = withDefaults(defineProps<Props>(), { + id: 0 +}) + +const { $pgettext } = useGettext() +const router = useRouter() + +const labels = computed(() => ({ + title: $pgettext('Head/Radio/Title', 'Radio Builder'), + placeholder: { + description: $pgettext('Content/Radio/Input.Placeholder', 'My awesome description'), + name: $pgettext('Content/Radio/Input.Placeholder', 'My awesome radio') + } +})) + +const filters = reactive([] as Filter[]) +const checkResult = ref() +const fetchCandidates = async () => { + // TODO (wvffle): Add loader + + try { + const response = await axios.post('radios/radios/validate/', { + filters: [{ + type: 'group', + filters: filters.map(filter => ({ + ...filter.config, + type: filter.filter.type + })) + }] + }) + + checkResult.value = response.data.filters[0] + } catch (error) { + useErrorHandler(error as Error) + } +} + +watch(filters, fetchCandidates) +const checkErrors = computed(() => checkResult.value?.errors ?? []) + +const isPublic = ref(true) +const radioName = ref('') +const radioDesc = ref('') +const canSave = computed(() => radioName.value.length > 0 && checkErrors.value.length === 0) + +const currentFilterType = ref() +const availableFilters = reactive([] as BuilderFilter[]) +const currentFilter = computed(() => availableFilters.find(filter => filter.type === currentFilterType.value)) + +const fetchFilters = async () => { + // TODO (wvffle): Add loader + try { + const response = await axios.get('radios/radios/filters/') + availableFilters.length = 0 + availableFilters.push(...response.data) + } catch (error) { + useErrorHandler(error as Error) + } +} + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`radios/radios/${props.id}/`) + filters.length = 0 + filters.push(...response.data.config.map((filter: FilterConfig) => ({ + config: filter, + filter: availableFilters.find(available => available.type === filter.type), + hash: +new Date() + }))) + + radioName.value = response.data.name + radioDesc.value = response.data.description + isPublic.value = response.data.is_public + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchFilters().then(() => watchEffect(fetchData)) + +const add = async () => { + if (currentFilter.value) { + filters.push({ + config: {} as FilterConfig, + filter: currentFilter.value, + hash: +new Date() + }) + } + + return fetchCandidates() +} + +const updateConfig = async (index: number, field: keyof FilterConfig, value: unknown) => { + filters[index].config[field] = value + return fetchCandidates() +} + +const deleteFilter = async (index: number) => { + filters.splice(index, 1) + return fetchCandidates() +} + +const success = ref(false) +const save = async () => { + success.value = false + isLoading.value = true + + try { + const data = { + name: radioName.value, + description: radioDesc.value, + is_public: isPublic.value, + config: filters.map(filter => ({ + ...filter.config, + type: filter.filter.type + })) + } + + const response = props.id + ? await axios.put(`radios/radios/${props.id}/`, data) + : await axios.post('radios/radios/', data) + + success.value = true + if (!props.id) { + router.push({ + name: 'library.radios.detail', + params: { + id: response.data.id + } + }) + } + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +onMounted(() => { + $('.ui.dropdown').dropdown() +}) +</script> + <template> <div v-title="labels.title" @@ -96,8 +293,8 @@ </translate> </option> <option - v-for="(f, key) in availableFilters" - :key="key" + v-for="f in availableFilters" + :key="f.label" :value="f.type" > {{ f.label }} @@ -151,7 +348,7 @@ <tbody> <builder-filter v-for="(f, index) in filters" - :key="(f, index, f.hash)" + :key="f.hash" :index="index" :config="f.config" :filter="f.filter" @@ -183,179 +380,3 @@ </div> </div> </template> -<script> -import axios from 'axios' -import $ from 'jquery' -import _ from 'lodash' -import BuilderFilter from './Filter.vue' -import TrackTable from '@/components/audio/track/Table.vue' -import RadioButton from '@/components/radios/Button.vue' - -export default { - components: { - BuilderFilter, - TrackTable, - RadioButton - }, - props: { - id: { type: Number, required: false, default: 0 } - }, - data: function () { - return { - isLoading: false, - success: false, - availableFilters: [], - currentFilterType: null, - filters: [], - checkResult: null, - radioName: '', - radioDesc: '', - isPublic: true - } - }, - computed: { - labels () { - const title = this.$pgettext('Head/Radio/Title', 'Radio Builder') - const placeholder = { - name: this.$pgettext('Content/Radio/Input.Placeholder', 'My awesome radio'), - description: this.$pgettext('Content/Radio/Input.Placeholder', 'My awesome description') - } - return { - title, - placeholder - } - }, - canSave: function () { - return this.radioName.length > 0 && this.checkErrors.length === 0 - }, - checkErrors: function () { - if (!this.checkResult) { - return [] - } - const errors = this.checkResult.errors - return errors - }, - currentFilter: function () { - const self = this - return this.availableFilters.filter(e => { - return e.type === self.currentFilterType - })[0] - } - }, - watch: { - filters: { - handler: function () { - this.fetchCandidates() - }, - deep: true - } - }, - created: function () { - const self = this - this.fetchFilters().then(() => { - if (self.id) { - self.fetch() - } - }) - }, - mounted () { - $('.ui.dropdown').dropdown() - }, - methods: { - fetchFilters: function () { - const self = this - const url = 'radios/radios/filters/' - return axios.get(url).then(response => { - self.availableFilters = response.data - }) - }, - add () { - this.filters.push({ - config: {}, - filter: this.currentFilter, - hash: +new Date() - }) - this.fetchCandidates() - }, - updateConfig (index, field, value) { - this.filters[index].config[field] = value - this.fetchCandidates() - }, - deleteFilter (index) { - this.filters.splice(index, 1) - this.fetchCandidates() - }, - fetch: function () { - const self = this - self.isLoading = true - const url = 'radios/radios/' + this.id + '/' - axios.get(url).then(response => { - self.filters = response.data.config.map(f => { - return { - config: f, - filter: this.availableFilters.filter(e => { - return e.type === f.type - })[0], - hash: +new Date() - } - }) - self.radioName = response.data.name - self.radioDesc = response.data.description - self.isPublic = response.data.is_public - self.isLoading = false - }) - }, - fetchCandidates: function () { - const self = this - const url = 'radios/radios/validate/' - let final = this.filters.map(f => { - const c = _.clone(f.config) - c.type = f.filter.type - return c - }) - final = { - filters: [{ type: 'group', filters: final }] - } - axios.post(url, final).then(response => { - self.checkResult = response.data.filters[0] - }) - }, - save: function () { - const self = this - self.success = false - self.isLoading = true - - let final = this.filters.map(f => { - const c = _.clone(f.config) - c.type = f.filter.type - return c - }) - final = { - name: this.radioName, - description: this.radioDesc, - is_public: this.isPublic, - config: final - } - if (this.id) { - const url = 'radios/radios/' + this.id + '/' - axios.put(url, final).then(response => { - self.isLoading = false - self.success = true - }) - } else { - const url = 'radios/radios/' - axios.post(url, final).then(response => { - self.success = true - self.isLoading = false - self.$router.push({ - name: 'library.radios.detail', - params: { - id: response.data.id - } - }) - }) - } - } - } -} -</script> diff --git a/front/src/components/library/radios/Filter.vue b/front/src/components/library/radios/Filter.vue index 8ee0202bf5d6657a074d1b06fc0f1ab279b81800..f70e47e8549b33abf4c62af0c3bb6ce6a6f03569 100644 --- a/front/src/components/library/radios/Filter.vue +++ b/front/src/components/library/radios/Filter.vue @@ -1,3 +1,113 @@ +<script setup lang="ts"> +// TODO (wvffle): SORT IMPORTS LIKE SO EVERYWHERE +import type { BuilderFilter, FilterConfig } from './Builder.vue' +import type { Track } from '~/types' + +import axios from 'axios' +import $ from 'jquery' + +import { useCurrentElement } from '@vueuse/core' +import { ref, onMounted, watch } from 'vue' +import { useStore } from '~/store' +import { clone } from 'lodash-es' + +import SemanticModal from '~/components/semantic/Modal.vue' +import TrackTable from '~/components/audio/track/Table.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +type Filter = { candidates: { count: number, sample: Track[] } } +type ResponseType = { filters: Array<Filter> } + +interface Events { + (e: 'update-config', index: number, name: string, value: number[] | boolean): void + (e: 'delete', index: number): void +} + +interface Props { + index: number + + filter: BuilderFilter + config: FilterConfig +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const store = useStore() + +const checkResult = ref<Filter | null>(null) +const showCandidadesModal = ref(false) +const exclude = ref(props.config.not) + +const el = useCurrentElement() +onMounted(() => { + for (const field of props.filter.fields) { + const selector = ['.dropdown'] + + if (field.type === 'list') { + selector.push('.multiple') + } + + const settings: SemanticUI.DropdownSettings = { + onChange (value) { + value = $(this).dropdown('get value').split(',') + + if (field.type === 'list' && field.subtype === 'number') { + value = value.map((number: string) => parseInt(number)) + } + + value.value = value + emit('update-config', props.index, field.name, value) + fetchCandidates() + } + } + + if (field.autocomplete) { + selector.push('.autocomplete') + // @ts-expect-error custom field? + settings.fields = field.autocomplete_fields + settings.minCharacters = 1 + settings.apiSettings = { + url: store.getters['instance/absoluteUrl'](`${field.autocomplete}?${field.autocomplete_qs}`), + beforeXHR (xhrObject) { + if (store.state.auth.oauth.accessToken) { + xhrObject.setRequestHeader('Authorization', store.getters['auth/header']) + } + + return xhrObject + }, + onResponse (initialResponse) { + return !settings.fields?.remoteValues + ? { results: initialResponse.results } + : initialResponse + } + } + } + + $(el.value).find(selector.join('')).dropdown(settings) + } +}) + +const fetchCandidates = async () => { + const params = { + filters: [{ + ...clone(props.config), + type: props.filter.type + }] + } + + try { + const response = await axios.post('radios/radios/validate/', params) + checkResult.value = (response.data as ResponseType).filters[0] + } catch (error) { + useErrorHandler(error as Error) + } +} + +watch(exclude, fetchCandidates) +</script> + <template> <tr> <td>{{ filter.label }}</td> @@ -22,7 +132,6 @@ <div v-for="f in filter.fields" :key="f.name" - :ref="f.name" class="ui field" > <div :class="['ui', 'search', 'selection', 'dropdown', {'autocomplete': f.autocomplete}, {'multiple': f.type === 'list'}]"> @@ -31,18 +140,18 @@ {{ f.placeholder }} </div> <input - v-if="f.type === 'list' && config[f.name]" + v-if="f.type === 'list' && config[f.name as keyof FilterConfig]" :id="f.name" - :value="config[f.name].join(',')" + :value="(config[f.name as keyof FilterConfig] as string[]).join(',')" type="hidden" > <div - v-if="config[f.name]" + v-if="typeof config[f.name as keyof FilterConfig] === 'object'" class="ui menu" > <div - v-for="(v, i) in config[f.name]" - :key="v" + v-for="(v, i) in config[f.name as keyof FilterConfig] as object" + :key="i" class="ui item" :data-value="v" > @@ -66,9 +175,9 @@ > {{ checkResult.candidates.count }} tracks matching filter </a> - <modal + <semantic-modal v-if="checkResult" - :show.sync="showCandidadesModal" + v-model:show="showCandidadesModal" > <h4 class="header"> <translate translate-context="Popup/Radio/Title/Noun"> @@ -90,7 +199,7 @@ </translate> </button> </div> - </modal> + </semantic-modal> </td> <td> <button @@ -104,90 +213,3 @@ </td> </tr> </template> -<script> -import axios from 'axios' -import $ from 'jquery' -import _ from 'lodash' - -import Modal from '@/components/semantic/Modal.vue' -import TrackTable from '@/components/audio/track/Table.vue' - -export default { - components: { - TrackTable, - Modal - }, - props: { - filter: { type: Object, required: true }, - config: { type: Object, required: true }, - index: { type: Number, required: true } - }, - data: function () { - return { - checkResult: null, - showCandidadesModal: false, - exclude: this.config.not - } - }, - watch: { - exclude: function () { - this.fetchCandidates() - } - }, - mounted: function () { - const self = this - this.filter.fields.forEach(f => { - const selector = ['.dropdown'] - const settings = { - onChange: function (value, text, $choice) { - value = $(this).dropdown('get value').split(',') - if (f.type === 'list' && f.subtype === 'number') { - value = value.map(e => { - return parseInt(e) - }) - } - self.value = value - self.$emit('update-config', self.index, f.name, value) - self.fetchCandidates() - } - } - if (f.type === 'list') { - selector.push('.multiple') - } - if (f.autocomplete) { - selector.push('.autocomplete') - settings.fields = f.autocomplete_fields - settings.minCharacters = 1 - settings.apiSettings = { - url: self.$store.getters['instance/absoluteUrl'](f.autocomplete + '?' + f.autocomplete_qs), - beforeXHR: function (xhrObject) { - if (self.$store.state.auth.oauth.accessToken) { - xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) - } - return xhrObject - }, - onResponse: function (initialResponse) { - if (settings.fields.remoteValues) { - return initialResponse - } - return { results: initialResponse.results } - } - } - } - $(self.$el).find(selector.join('')).dropdown(settings) - }) - }, - methods: { - fetchCandidates: function () { - const self = this - const url = 'radios/radios/validate/' - let final = _.clone(this.config) - final.type = this.filter.type - final = { filters: [final] } - axios.post(url, final).then((response) => { - self.checkResult = response.data.filters[0] - }) - } - } -} -</script> diff --git a/front/src/components/manage/ChannelsTable.vue b/front/src/components/manage/ChannelsTable.vue index cd1df49a3b8cf3f71d0aba441a357610ae87f7b4..77e2c199a7d03d144d2c686c8c234895bbe45d73 100644 --- a/front/src/components/manage/ChannelsTable.vue +++ b/front/src/components/manage/ChannelsTable.vue @@ -1,16 +1,105 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + orderingConfigName: undefined +}) + +const page = usePage() +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) +const search = ref() + +const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['name', 'name'] +] + +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/channels/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const { $pgettext } = useGettext() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by domain, name, account…'), + openModeration: $pgettext('Content/Moderation/Verb', 'Open in moderation interface') +})) +</script> + <template> <div> <div class="ui inline form"> <div class="fields"> <div class="ui six wide field"> <label for="channel-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="channel-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -21,7 +110,7 @@ id="channel-category" class="ui dropdown" :value="getTokenValue('category', '')" - @change="addSearchToken('category', $event.target.value)" + @change="addSearchToken('category', ($event.target as HTMLSelectElement).value)" > <option value=""> <translate translate-context="Content/*/Dropdown"> @@ -86,12 +175,12 @@ <action-table v-if="result" :objects-data="result" - :actions="actions" + :actions="[]" action-url="manage/library/artists/action/" :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Name @@ -124,8 +213,7 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <td> <router-link :to="{name: 'manage.channels.detail', params: {id: scope.obj.actor.full_username }}"> @@ -180,11 +268,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -198,115 +285,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import { normalizeQuery, parseTokens } from '@/search' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' - -export default { - components: { - Pagination, - ActionTable - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: () => { return {} } } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['name', 'name'] - ] - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, name, account…'), - openModeration: this.$pgettext('Content/Moderation/Verb', 'Open in moderation interface') - } - }, - actionFilters () { - const currentFilters = { - q: this.search.query - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - // let deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') - // let confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible.') - return [ - // { - // name: 'delete', - // label: deleteLabel, - // confirmationMessage: confirmationMessage, - // isDangerous: true, - // allowAll: false, - // confirmColor: 'danger', - // }, - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/channels/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/manage/library/AlbumsTable.vue b/front/src/components/manage/library/AlbumsTable.vue index 6ea4b863d30efb6fb738e50505ebaf83e9b75141..c2b4e568fef5c876fe1cc272c05a0edef5bc3b34 100644 --- a/front/src/components/manage/library/AlbumsTable.vue +++ b/front/src/components/manage/library/AlbumsTable.vue @@ -1,16 +1,117 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) + +const { onSearch, query, addSearchToken } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['release_date', 'release_date'], + ['name', 'name'] +] + +const { $pgettext } = useGettext() +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'delete', + label: $pgettext('*/*/*/Verb', 'Delete'), + confirmationMessage: $pgettext('Popup/*/Paragraph', 'The selected albums will be removed, as well as associated tracks, uploads, favorites and listening history. This action is irreversible.'), + isDangerous: true, + allowAll: false, + confirmColor: 'danger' + } +]) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/library/albums/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by domain, title, artist, MusicBrainz ID…'), + openModeration: $pgettext('Content/Moderation/Verb', 'Open in moderation interface') +})) +</script> + <template> <div> <div class="ui inline form"> <div class="fields"> <div class="ui six wide field"> <label for="albums-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="albums-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -66,7 +167,7 @@ :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Title @@ -99,8 +200,7 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <td> <router-link :to="{name: 'manage.library.albums.detail', params: {id: scope.obj.id }}"> @@ -165,11 +265,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -183,116 +282,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import { normalizeQuery, parseTokens } from '@/search' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' - -export default { - components: { - Pagination, - ActionTable - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: () => { return {} } } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['release_date', 'release_date'], - ['name', 'name'] - ] - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, title, artist, MusicBrainz ID…'), - openModeration: this.$pgettext('Content/Moderation/Verb', 'Open in moderation interface') - } - }, - actionFilters () { - const currentFilters = { - q: this.search.query - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - const deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') - const confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected albums will be removed, as well as associated tracks, uploads, favorites and listening history. This action is irreversible.') - return [ - { - name: 'delete', - label: deleteLabel, - confirmationMessage: confirmationMessage, - isDangerous: true, - allowAll: false, - confirmColor: 'danger' - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/library/albums/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/manage/library/ArtistsTable.vue b/front/src/components/manage/library/ArtistsTable.vue index deeef42a8f19a4c27080afc8b075a8ffca49149f..5a7efbd88df924dd89b58af60b5f1a049aef382a 100644 --- a/front/src/components/manage/library/ArtistsTable.vue +++ b/front/src/components/manage/library/ArtistsTable.vue @@ -1,16 +1,121 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { ref, computed, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) + +const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['name', 'name'] +] + +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'delete', + label: $pgettext('*/*/*/Verb', 'Delete'), + confirmationMessage: $pgettext('Popup/*/Paragraph', 'The selected artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible.'), + isDangerous: true, + allowAll: false, + confirmColor: 'danger' + } +]) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/library/artists/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const { $pgettext } = useGettext() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by domain, name, MusicBrainz ID…') +})) + +const getUrl = (artist: { channel?: number; id: number }) => { + return artist.channel + ? { name: 'manage.channels.detail', params: { id: artist.channel } } + : { name: 'manage.library.artists.detail', params: { id: artist.id } } +} +</script> + <template> <div> <div class="ui inline form"> <div class="fields"> <div class="ui six wide field"> <label for="artists-serarch"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="artists-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -21,7 +126,7 @@ id="artists-category" class="ui dropdown" :value="getTokenValue('category', '')" - @change="addSearchToken('category', $event.target.value)" + @change="addSearchToken('category', ($event.target as HTMLSelectElement).value)" > <option value=""> <translate translate-context="Content/*/Dropdown"> @@ -91,7 +196,7 @@ :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Name @@ -119,8 +224,7 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <td> <router-link :to="getUrl(scope.obj)"> @@ -164,11 +268,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -182,120 +285,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import { normalizeQuery, parseTokens } from '@/search' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' - -export default { - components: { - Pagination, - ActionTable - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: () => { return {} } } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['name', 'name'] - ] - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, name, MusicBrainz ID…') - } - }, - actionFilters () { - const currentFilters = { - q: this.search.query - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - const deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') - const confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible.') - return [ - { - name: 'delete', - label: deleteLabel, - confirmationMessage: confirmationMessage, - isDangerous: true, - allowAll: false, - confirmColor: 'danger' - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - getUrl (artist) { - if (artist.channel) { - return { name: 'manage.channels.detail', params: { id: artist.channel } } - } - return { name: 'manage.library.artists.detail', params: { id: artist.id } } - }, - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/library/artists/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/manage/library/EditsCardList.vue b/front/src/components/manage/library/EditsCardList.vue index ed6785811c87a31848b2d367508935dd311935b9..b8b5035aab6b198785ac67663e84a9902c7ef21d 100644 --- a/front/src/components/manage/library/EditsCardList.vue +++ b/front/src/components/manage/library/EditsCardList.vue @@ -1,3 +1,164 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { EditObjectType } from '~/composables/moderation/useEditConfigs' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { ReviewState, Review } from '~/types' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { ref, reactive, watch, computed } from 'vue' +import { useGettext } from 'vue3-gettext' +import { uniq } from 'lodash-es' + +import axios from 'axios' + +import Pagination from '~/components/vui/Pagination.vue' +import EditCard from '~/components/library/EditCard.vue' + +import useEditConfigs from '~/composables/moderation/useEditConfigs' +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + orderingConfigName: undefined +}) + +const configs = useEditConfigs() +const search = ref() + +const page = usePage() + +type StateTarget = Review['target'] +type ResponseType = { count: number, results: Review[] } +const result = ref<null | ResponseType>(null) + +const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['applied_date', 'applied_date'] +] + +interface TargetType { + payload: Review + currentState: Record<EditObjectType, { value: unknown }> +} + +type Targets = Exclude<StateTarget, undefined>['type'] +const targets = reactive({ + track: {} +}) as Record<Targets, Record<string, TargetType>> + +const fetchTargets = async () => { + // we request target data via the API so we can display previous state + // additionnal data next to the edit card + type Config = { url: string, ids: number[] } + const typesAndIds: Record<Targets, Config> = { + artist: { url: 'artists/', ids: [] }, + track: { url: 'tracks/', ids: [] }, + album: { url: 'albums/', ids: [] } + } + + for (const res of result.value?.results ?? []) { + if (!res.target) continue + const typeAndId = typesAndIds[res.target.type as keyof typeof typesAndIds] + typeAndId?.ids.push(res.target.id) + } + + for (const [key, config] of Object.entries(typesAndIds)) { + if (config.ids.length === 0) { + continue + } + + const response = await axios.get(config.url, { + params: { + id: uniq(config.ids), + hidden: 'null' + } + }).catch((error) => { + useErrorHandler(error as Error) + }) + + for (const payload of response?.data?.results ?? []) { + targets[key as keyof typeof targets][payload.id] = { + payload, + currentState: configs[key as keyof typeof targets].fields.reduce((state, field) => { + // TODO (wvffle): This cast may result in an `undefined` key added, make sure to test that. + state[field.id as EditObjectType] = { value: field.getValue(payload) } + return state + }, {} as Record<EditObjectType, { value: unknown }>) + } + } + } +} + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('mutations/', { + params + }) + + result.value = response.data + fetchTargets() + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const { $pgettext } = useGettext() +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by account, summary, domain…') +})) + +const handle = (type: 'delete' | 'approved', id: string, value: boolean) => { + for (const entry of result.value?.results ?? []) { + if (entry.uuid === id) { + entry.is_approved = value + } + } +} + +const getCurrentState = (target?: StateTarget): ReviewState => { + if (!target || !(target.type in targets)) return {} + return targets[target.type]?.[target.id]?.currentState ?? {} +} +</script> + <template> <div class="ui text container"> <slot /> @@ -5,13 +166,13 @@ <div class="fields"> <div class="ui field"> <label for="search-edits"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="search-edits" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -22,7 +183,7 @@ id="edit-status" class="ui dropdown" :value="getTokenValue('is_approved', '')" - @change="addSearchToken('is_approved', $event.target.value)" + @change="addSearchToken('is_approved', ($event.target as HTMLSelectElement).value)" > <option value=""> <translate translate-context="Content/*/Dropdown"> @@ -90,13 +251,13 @@ > <div class="ui loader" /> </div> - <div v-else-if="result && result.count > 0"> + <div v-else-if="(result?.count ?? 0) > 0"> <edit-card - v-for="obj in result.results" + v-for="obj in result?.results ?? []" :key="obj.uuid" :obj="obj" :current-state="getCurrentState(obj.target)" - @deleted="handle('delete', obj.uuid, null)" + @deleted="handle('delete', obj.uuid, false)" @approved="handle('approved', obj.uuid, $event)" /> </div> @@ -110,11 +271,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -128,149 +288,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import Pagination from '@/components/Pagination.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import EditCard from '@/components/library/EditCard.vue' -import { normalizeQuery, parseTokens } from '@/search' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' - -import edits from '@/edits' - -export default { - components: { - Pagination, - EditCard - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: () => { return {} } } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['applied_date', 'applied_date'] - ], - targets: { - track: {} - } - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by account, summary, domain…') - } - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - this.result = null - axios.get('mutations/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - self.fetchTargets() - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - fetchTargets () { - // we request target data via the API so we can display previous state - // additionnal data next to the edit card - const self = this - const typesAndIds = { - track: { - url: 'tracks/', - ids: [] - } - } - this.result.results.forEach((m) => { - if (!m.target || !typesAndIds[m.target.type]) { - return - } - typesAndIds[m.target.type].ids.push(m.target.id) - }) - Object.keys(typesAndIds).forEach((k) => { - const config = typesAndIds[k] - if (config.ids.length === 0) { - return - } - axios.get(config.url, { params: { id: _.uniq(config.ids), hidden: 'null' } }).then((response) => { - response.data.results.forEach((e) => { - self.$set(self.targets[k], e.id, { - payload: e, - currentState: edits.getCurrentStateForObj(e, edits.getConfigs.bind(self)()[k]) - }) - }) - }, error => { - self.errors = error.backendErrors - }) - }) - }, - selectPage: function (page) { - this.page = page - }, - handle (type, id, value) { - if (type === 'delete') { - this.exclude.push(id) - } - - this.result.results.forEach((e) => { - if (e.uuid === id) { - e.is_approved = value - } - }) - }, - getCurrentState (target) { - if (!target) { - return {} - } - if (this.targets[target.type] && this.targets[target.type][String(target.id)]) { - return this.targets[target.type][String(target.id)].currentState - } - return {} - } - } -} -</script> diff --git a/front/src/components/manage/library/LibrariesTable.vue b/front/src/components/manage/library/LibrariesTable.vue index da9848ebe15dc78e63d337b386a301658382b522..dfab3cdd0c970b7d709caa77f0f1e048845f0e0c 100644 --- a/front/src/components/manage/library/LibrariesTable.vue +++ b/front/src/components/manage/library/LibrariesTable.vue @@ -1,16 +1,120 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' +import type { PrivacyLevel } from '~/types' + +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) + +const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['followers_count', 'followers'], + ['uploads_count', 'uploads'] +] + +const { $pgettext } = useGettext() +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'delete', + label: $pgettext('*/*/*/Verb', 'Delete'), + confirmationMessage: $pgettext('Popup/*/Paragraph', 'The selected library will be removed, as well as associated uploads and follows. This action is irreversible.'), + isDangerous: true, + allowAll: false, + confirmColor: 'danger' + } +]) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/library/libraries/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by domain, actor, name, description…') +})) + +const getPrivacyLevelChoice = (privacyLevel: PrivacyLevel) => { + return sharedLabels.fields.privacy_level.shortChoices[privacyLevel] +} +</script> + <template> <div> <div class="ui inline form"> <div class="fields"> <div class="ui six wide field"> <label for="libraries-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="libraries-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -21,7 +125,7 @@ id="libraries-visibility" class="ui dropdown" :value="getTokenValue('privacy_level', '')" - @change="addSearchToken('privacy_level', $event.target.value)" + @change="addSearchToken('privacy_level', ($event.target as HTMLSelectElement).value)" > <option value=""> <translate translate-context="Content/*/Dropdown"> @@ -91,7 +195,7 @@ :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Name @@ -128,10 +232,7 @@ </translate> </th> </template> - <template - slot="row-cells" - slot-scope="scope" - > + <template #row-cells="scope"> <td> <router-link :to="{name: 'manage.library.libraries.detail', params: {id: scope.obj.uuid }}"> {{ scope.obj.name }} @@ -174,10 +275,10 @@ <a href="" class="discrete link" - :title="sharedLabels.fields.privacy_level.shortChoices[scope.obj.privacy_level]" + :title="getPrivacyLevelChoice(scope.obj.privacy_level)" @click.prevent="addSearchToken('privacy_level', scope.obj.privacy_level)" > - {{ sharedLabels.fields.privacy_level.shortChoices[scope.obj.privacy_level] }} + {{ getPrivacyLevelChoice(scope.obj.privacy_level) }} </a> </td> <td> @@ -195,11 +296,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -213,115 +313,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import { normalizeQuery, parseTokens } from '@/search' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' - -export default { - components: { - Pagination, - ActionTable - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: () => { return {} } } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['followers_count', 'followers'], - ['uploads_count', 'uploads'] - ] - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, actor, name, description…') - } - }, - actionFilters () { - const currentFilters = { - q: this.search.query - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - const deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') - const confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected library will be removed, as well as associated uploads and follows. This action is irreversible.') - return [ - { - name: 'delete', - label: deleteLabel, - confirmationMessage: confirmationMessage, - isDangerous: true, - allowAll: false, - confirmColor: 'danger' - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/library/libraries/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/manage/library/TagsTable.vue b/front/src/components/manage/library/TagsTable.vue index 36266f29ff54751c753bfee075200ab20b857405..437b6649a4b04a80d9d6d31b7886eb704deca862 100644 --- a/front/src/components/manage/library/TagsTable.vue +++ b/front/src/components/manage/library/TagsTable.vue @@ -1,16 +1,122 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { computed, ref, watch } from 'vue' +import { truncate } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +import ImportStatusModal from '~/components/library/ImportStatusModal.vue' +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) + +const { onSearch, query } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['name', 'name'], + ['length', 'length'], + ['items_count', 'items_count'] +] + +const { $pgettext } = useGettext() +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'delete', + label: $pgettext('*/*/*/Verb', 'Delete'), + confirmationMessage: $pgettext('Popup/*/Paragraph', 'The selected tag will be removed and unlinked with existing content, if any. This action is irreversible.'), + isDangerous: true, + allowAll: false, + confirmColor: 'danger' + } +]) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/tags/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by name') +})) + +const detailedUpload = ref() +const showUploadDetailModal = ref(false) +</script> + <template> <div> <div class="ui inline form"> <div class="fields"> <div class="ui six wide field"> <label for="tags-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="tags-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -53,8 +159,9 @@ </div> </div> <import-status-modal + v-if="detailedUpload" + v-model:show="showUploadDetailModal" :upload="detailedUpload" - :show.sync="showUploadDetailModal" /> <div class="dimmable"> <div @@ -72,7 +179,7 @@ :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Name @@ -100,12 +207,11 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <td> <router-link :to="{name: 'manage.library.tags.detail', params: {id: scope.obj.name }}"> - {{ scope.obj.name|truncate(30, "…", true) }} + {{ truncate(scope.obj.name, 30, undefined, true) }} </router-link> </td> <td> @@ -126,11 +232,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -144,120 +249,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import { normalizeQuery, parseTokens } from '@/search' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' -import ImportStatusModal from '@/components/library/ImportStatusModal.vue' - -export default { - components: { - Pagination, - ActionTable, - ImportStatusModal - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: () => { return {} } } - }, - data () { - return { - detailedUpload: {}, - showUploadDetailModal: false, - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['name', 'name'], - ['length', 'length'], - ['items_count', 'items_count'] - ] - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by name') - } - }, - actionFilters () { - const currentFilters = { - q: this.search.query - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - const deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') - const confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected tag will be removed and unlinked with existing content, if any. This action is irreversible.') - return [ - { - name: 'delete', - label: deleteLabel, - confirmationMessage: confirmationMessage, - isDangerous: true, - allowAll: false, - confirmColor: 'danger' - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/tags/', { params: params }).then((response) => { - self.isLoading = false - self.result = response.data - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/manage/library/TracksTable.vue b/front/src/components/manage/library/TracksTable.vue index bec213a4b4478fbd44abc9fff8c7a0165cabc307..26bdd35810e86d4e9a896b6c5ea2a864f264f6e7 100644 --- a/front/src/components/manage/library/TracksTable.vue +++ b/front/src/components/manage/library/TracksTable.vue @@ -1,16 +1,114 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { ref, computed, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) + +const { onSearch, query, addSearchToken } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'] +] + +const { $pgettext } = useGettext() +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'delete', + label: $pgettext('*/*/*/Verb', 'Delete'), + confirmationMessage: $pgettext('Popup/*/Paragraph', 'The selected tracks will be removed, as well as associated uploads, favorites and listening history. This action is irreversible.'), + isDangerous: true, + allowAll: false, + confirmColor: 'danger' + } +]) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/library/tracks/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by domain, title, artist, album, MusicBrainz ID…') +})) +</script> + <template> <div> <div class="ui inline form"> <div class="fields"> <div class="ui six wide field"> <label for="tracks-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="tracks-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -67,7 +165,7 @@ :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Title @@ -100,8 +198,7 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <td> <router-link :to="{name: 'manage.library.tracks.detail', params: {id: scope.obj.id }}"> @@ -178,11 +275,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -196,113 +292,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import { normalizeQuery, parseTokens } from '@/search' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' - -export default { - components: { - Pagination, - ActionTable - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: () => { return {} } } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'] - ] - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, title, artist, album, MusicBrainz ID…') - } - }, - actionFilters () { - const currentFilters = { - q: this.search.query - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - const deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') - const confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected tracks will be removed, as well as associated uploads, favorites and listening history. This action is irreversible.') - return [ - { - name: 'delete', - label: deleteLabel, - confirmationMessage: confirmationMessage, - isDangerous: true, - allowAll: false, - confirmColor: 'danger' - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/library/tracks/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/manage/library/UploadsTable.vue b/front/src/components/manage/library/UploadsTable.vue index 581c1087fbde3403a326762871f320fcdd3da12f..cacff66074b2c1f210890f4611e6ddb8809a5699 100644 --- a/front/src/components/manage/library/UploadsTable.vue +++ b/front/src/components/manage/library/UploadsTable.vue @@ -1,16 +1,136 @@ +<script setup lang="ts"> +import type { ImportStatus, PrivacyLevel, Upload, BackendResponse } from '~/types' +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { humanSize, truncate } from '~/utils/filters' +import { ref, computed, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +import ImportStatusModal from '~/components/library/ImportStatusModal.vue' +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +const result = ref<BackendResponse<Upload>>() + +const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['modification_date', 'modification_date'], + ['accessed_date', 'accessed_date'], + ['size', 'size'], + ['bitrate', 'bitrate'], + ['duration', 'duration'] +] + +const { $pgettext } = useGettext() +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'delete', + label: $pgettext('*/*/*/Verb', 'Delete'), + confirmationMessage: $pgettext('Popup/*/Paragraph', 'The selected upload will be removed. This action is irreversible.'), + isDangerous: true, + allowAll: false, + confirmColor: 'danger' + } +]) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/library/uploads/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = undefined + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by domain, actor, name, reference, source…') +})) + +const displayName = (upload: Upload): string => { + return upload.filename ?? upload.source ?? upload.uuid +} + +const detailedUpload = ref<Upload>() +const showUploadDetailModal = ref(false) + +const getImportStatusChoice = (importStatus: ImportStatus) => { + return sharedLabels.fields.import_status.choices[importStatus] +} + +const getPrivacyLevelChoice = (privacyLevel: PrivacyLevel) => { + return sharedLabels.fields.privacy_level.shortChoices[privacyLevel] +} +</script> + <template> <div> <div class="ui inline form"> <div class="fields"> <div class="ui six wide field"> <label for="uploads-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="uploads-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -21,7 +141,7 @@ id="uploads-visibility" class="ui dropdown" :value="getTokenValue('privacy_level', '')" - @change="addSearchToken('privacy_level', $event.target.value)" + @change="addSearchToken('privacy_level', ($event.target as HTMLSelectElement).value)" > <option value=""> <translate translate-context="Content/*/Dropdown"> @@ -45,7 +165,7 @@ id="uploads-status" class="ui dropdown" :value="getTokenValue('status', '')" - @change="addSearchToken('status', $event.target.value)" + @change="addSearchToken('status', ($event.target as HTMLSelectElement).value)" > <option value=""> <translate translate-context="Content/*/Dropdown"> @@ -111,9 +231,11 @@ </div> </div> </div> + <!-- TODO (wvffle): Check if :upload shouldn't be v-model:upload --> <import-status-modal + v-if="detailedUpload" + v-model:show="showUploadDetailModal" :upload="detailedUpload" - :show.sync="showUploadDetailModal" /> <div class="dimmable"> <div @@ -130,7 +252,7 @@ :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Name @@ -177,13 +299,10 @@ </translate> </th> </template> - <template - slot="row-cells" - slot-scope="scope" - > + <template #row-cells="scope"> <td> <router-link :to="{name: 'manage.library.uploads.detail', params: {id: scope.obj.uuid }}"> - {{ displayName(scope.obj)|truncate(30, "…", true) }} + {{ truncate(displayName(scope.obj), 30, undefined, true) }} </router-link> </td> <td> @@ -196,7 +315,7 @@ :title="scope.obj.library.name" @click.prevent="addSearchToken('library_id', scope.obj.library.id)" > - {{ scope.obj.library.name | truncate(20) }} + {{ truncate(scope.obj.library.name, 20) }} </a> </td> <td> @@ -234,31 +353,31 @@ <a href="" class="discrete link" - :title="sharedLabels.fields.privacy_level.shortChoices[scope.obj.library.privacy_level]" + :title="getPrivacyLevelChoice(scope.obj.library.privacy_level)" @click.prevent="addSearchToken('privacy_level', scope.obj.library.privacy_level)" > - {{ sharedLabels.fields.privacy_level.shortChoices[scope.obj.library.privacy_level] }} + {{ getPrivacyLevelChoice(scope.obj.library.privacy_level) }} </a> </td> <td> <a href="" class="discrete link" - :title="sharedLabels.fields.import_status.choices[scope.obj.import_status].help" + :title="getImportStatusChoice(scope.obj.import_status).help" @click.prevent="addSearchToken('status', scope.obj.import_status)" > - {{ sharedLabels.fields.import_status.choices[scope.obj.import_status].label }} + {{ getImportStatusChoice(scope.obj.import_status).label }} </a> <button class="ui tiny basic icon button" - :title="sharedLabels.fields.import_status.detailTitle" + :title="sharedLabels.fields.import_status.label" @click="detailedUpload = scope.obj; showUploadDetailModal = true" > <i class="question circle outline icon" /> </button> </td> <td> - <span v-if="scope.obj.size">{{ scope.obj.size | humanSize }}</span> + <span v-if="scope.obj.size">{{ humanSize(scope.obj.size) }}</span> <translate v-else translate-context="*/*/*" @@ -287,11 +406,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -305,131 +423,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import { normalizeQuery, parseTokens } from '@/search' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' -import ImportStatusModal from '@/components/library/ImportStatusModal.vue' - -export default { - components: { - Pagination, - ActionTable, - ImportStatusModal - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: function () { return {} } } - }, - data () { - return { - detailedUpload: {}, - showUploadDetailModal: false, - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['modification_date', 'modification_date'], - ['accessed_date', 'accessed_date'], - ['size', 'size'], - ['bitrate', 'bitrate'], - ['duration', 'duration'] - ] - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, actor, name, reference, source…') - } - }, - actionFilters () { - const currentFilters = { - q: this.search.query - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - const deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') - const confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected upload will be removed. This action is irreversible.') - return [ - { - name: 'delete', - label: deleteLabel, - confirmationMessage: confirmationMessage, - isDangerous: true, - allowAll: false, - confirmColor: 'danger' - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/library/uploads/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - }, - displayName (upload) { - if (upload.filename) { - return upload.filename - } - if (upload.source) { - return upload.source - } - return upload.uuid - } - } -} -</script> diff --git a/front/src/components/manage/moderation/AccountsTable.vue b/front/src/components/manage/moderation/AccountsTable.vue index 72a4e1f3fd66e359b81517791b3a223220942de4..68a1b23027e0c7530763c8352dd6fca034e085b4 100644 --- a/front/src/components/manage/moderation/AccountsTable.vue +++ b/front/src/components/manage/moderation/AccountsTable.vue @@ -1,16 +1,115 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { ref, computed, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) + +const { onSearch, query, addSearchToken } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'first_seen'], + ['last_fetch_date', 'last_seen'], + ['preferred_username', 'username'], + ['domain', 'domain'], + ['uploads_count', 'uploads'] +] + +const { $pgettext } = useGettext() +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'purge', + label: $pgettext('*/*/*/Verb', 'Purge'), + isDangerous: true + } +]) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/accounts/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by domain, username, bio…') +})) +</script> + <template> <div> <div class="ui inline form"> <div class="fields"> <div class="ui six wide field"> <label for="accounts-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="accounts-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -67,7 +166,7 @@ :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Name @@ -100,8 +199,7 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <td> <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.full_username }}"> @@ -151,11 +249,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -169,112 +266,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import { normalizeQuery, parseTokens } from '@/search' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' - -export default { - components: { - Pagination, - ActionTable - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: function () { return {} } } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'first_seen'], - ['last_fetch_date', 'last_seen'], - ['preferred_username', 'username'], - ['domain', 'domain'], - ['uploads_count', 'uploads'] - ] - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, username, bio…') - } - }, - actionFilters () { - const currentFilters = { - q: this.search.query - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - return [ - { - name: 'purge', - label: this.$pgettext('*/*/*/Verb', 'Purge'), - isDangerous: true - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/accounts/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/manage/moderation/DomainsTable.vue b/front/src/components/manage/moderation/DomainsTable.vue index 7cb3c4e553fd490057f5cfd701ad5d91d79d3f33..49d6db93e5f368ec43eed1aeaf5ce53ce360606c 100644 --- a/front/src/components/manage/moderation/DomainsTable.vue +++ b/front/src/components/manage/moderation/DomainsTable.vue @@ -1,3 +1,118 @@ +<script setup lang="ts"> +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { watchDebounced } from '@vueuse/core' +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends OrderingProps { + filters?: object + allowListEnabled?: boolean + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName +} + +const props = withDefaults(defineProps<Props>(), { + filters: () => ({}), + allowListEnabled: false, + orderingConfigName: undefined +}) + +const page = usePage() +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['name', 'name'], + ['creation_date', 'first_seen'], + ['actors_count', 'users'], + ['outbox_activities_count', 'received_messages'] +] + +const { $pgettext } = useGettext() +const query = ref('') +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'purge', + label: $pgettext('*/*/*/Verb', 'Purge'), + isDangerous: true + }, + { + name: 'allow_list_add', + label: $pgettext('Content/Moderation/Action/Verb', 'Add to allow-list'), + filterCheckable: (obj: { allowed: boolean }) => { + return !obj.allowed + } + }, + { + name: 'allow_list_remove', + label: $pgettext('Content/Moderation/Action/Verb', 'Remove from allow-list'), + filterCheckable: (obj: { allowed: boolean }) => { + return obj.allowed + } + } +]) + +const allowed = ref(null) +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + allowed: allowed.value, + ...props.filters + } as Record<string, unknown> + + if (params.allowed === null) { + delete params.allowed + } + + try { + const response = await axios.get('/manage/federation/domains/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +watchDebounced(query, () => (page.value = 1), { debounce: 300 }) +watch(page, fetchData) +watch(allowed, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by name…'), + allowListTitle: $pgettext('Content/Moderation/Popup', 'This domain is present in your allow-list') +})) +</script> + <template> <div> <div class="ui inline form"> @@ -6,7 +121,7 @@ <label for="domains-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> <input id="domains-search" - v-model="search" + v-model="query" name="search" type="text" :placeholder="labels.searchPlaceholder" @@ -92,7 +207,7 @@ :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Name @@ -120,8 +235,7 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <td> <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.name }}"> @@ -162,11 +276,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -180,131 +293,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' - -export default { - components: { - Pagination, - ActionTable - }, - mixins: [OrderingMixin, TranslationsMixin], - props: { - filters: { type: Object, required: false, default: function () { return {} } }, - allowListEnabled: { type: Boolean, default: false } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: '', - allowed: null, - orderingOptions: [ - ['name', 'name'], - ['creation_date', 'first_seen'], - ['actors_count', 'users'], - ['outbox_activities_count', 'received_messages'] - ] - - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by name…'), - allowListTitle: this.$pgettext('Content/Moderation/Popup', 'This domain is present in your allow-list') - } - }, - actionFilters () { - const currentFilters = { - q: this.search - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - return [ - { - name: 'purge', - label: this.$pgettext('*/*/*/Verb', 'Purge'), - isDangerous: true - }, - { - name: 'allow_list_add', - label: this.$pgettext('Content/Moderation/Action/Verb', 'Add to allow-list'), - filterCheckable: (obj) => { - return !obj.allowed - } - }, - { - name: 'allow_list_remove', - label: this.$pgettext('Content/Moderation/Action/Verb', 'Remove from allow-list'), - filterCheckable: (obj) => { - return obj.allowed - } - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - allowed () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const baseFilters = { - page: this.page, - page_size: this.paginateBy, - q: this.search, - ordering: this.getOrderingAsString() - } - if (this.allowed !== null) { - baseFilters.allowed = this.allowed - } - const params = _.merge(baseFilters, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/federation/domains/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/manage/moderation/InstancePolicyCard.vue b/front/src/components/manage/moderation/InstancePolicyCard.vue index 3267e42a0d36648c19f4087c14b5db54381ccf06..56a3a6f6f3f7a61b3a410f9c5e6cb611f7db3e0c 100644 --- a/front/src/components/manage/moderation/InstancePolicyCard.vue +++ b/front/src/components/manage/moderation/InstancePolicyCard.vue @@ -1,3 +1,22 @@ +<script setup lang="ts"> +import type { InstancePolicy } from '~/types' + +import useMarkdown from '~/composables/useMarkdown' + +interface Events { + (e: 'update'): void +} + +interface Props { + object: InstancePolicy +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const summary = useMarkdown(() => props.object.summary) +</script> + <template> <div> <slot /> @@ -64,15 +83,15 @@ </div> </div> </div> - <div v-if="markdown && object.summary"> + <div v-if="summary"> <div class="ui hidden divider" /> <p><strong><translate translate-context="Content/Moderation/*/Noun">Reason</translate></strong></p> - <div v-html="markdown.makeHtml(object.summary)" /> + <sanitized-html :html="summary" /> </div> <div class="ui hidden divider" /> <button class="ui right floated labeled icon button" - @click="$emit('update')" + @click="emit('update')" > <i class="edit icon" /> <translate translate-context="Content/*/Button.Label/Verb"> @@ -81,21 +100,3 @@ </button> </div> </template> - -<script> -import showdown from 'showdown' - -export default { - props: { - object: { type: Object, default: null } - }, - data () { - return { - markdown: null - } - }, - created () { - this.markdown = showdown.Converter({ simplifiedAutoLink: true, openLinksInNewWindow: true }) - } -} -</script> diff --git a/front/src/components/manage/moderation/InstancePolicyForm.vue b/front/src/components/manage/moderation/InstancePolicyForm.vue index f76484d4dfac6ed28d87cb1e58992aec9123effb..0a1515d1f3518578851da22a6185d2f661df3bc8 100644 --- a/front/src/components/manage/moderation/InstancePolicyForm.vue +++ b/front/src/components/manage/moderation/InstancePolicyForm.vue @@ -1,3 +1,121 @@ +<script setup lang="ts"> +import type { BackendError, InstancePolicy } from '~/types' + +import { computed, ref, reactive } from 'vue' +import { whenever } from '@vueuse/core' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +interface Events { + (e: 'save', data: InstancePolicy): void + (e: 'delete'): void + (e: 'cancel'): void +} + +interface Props { + type: string + target: string + object?: InstancePolicy | null +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + object: null +}) + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + summaryHelp: $pgettext('Content/Moderation/Help text', "Explain why you're applying this policy: this will help you remember why you added this rule. Depending on your pod configuration, this may be displayed publicly to help users understand the moderation rules in place."), + isActiveHelp: $pgettext('Content/Moderation/Help text', 'Use this setting to temporarily enable/disable the policy without completely removing it.'), + blockAllHelp: $pgettext('Content/Moderation/Help text', 'Block everything from this account or domain. This will prevent any interaction with the entity, and purge related content (uploads, libraries, follows, etc.)'), + silenceActivity: { + help: $pgettext('Content/Moderation/Help text', 'Hide account or domain content, except from followers.'), + label: $pgettext('Content/Moderation/*/Verb', 'Mute activity') + }, + silenceNotifications: { + help: $pgettext('Content/Moderation/Help text', 'Prevent account or domain from triggering notifications, except from followers.'), + label: $pgettext('Content/Moderation/*/Verb', 'Mute notifications') + }, + rejectMedia: { + help: $pgettext('Content/Moderation/Help text', 'Do not download any media file (audio, album cover, account avatar…) from this account or domain. This will purge existing content as well.'), + label: $pgettext('Content/Moderation/*/Verb', 'Reject media') + } +})) + +const current = reactive({ + summary: props.object?.summary ?? '', + isActive: props.object?.is_active ?? true, + blockAll: props.object?.block_all ?? true, + silenceActivity: props.object?.silence_activity ?? false, + silenceNotifications: props.object?.silence_notifications ?? false, + rejectMedia: props.object?.reject_media ?? false +}) + +const fieldConfig = [ + // TODO: We hide those until we actually have the related features implemented :) + // { id: 'silenceActivity', icon: 'feed' }, + // { id: 'silenceNotifications', icon: 'bell' }, + { id: 'rejectMedia', icon: 'file' } +] as const + +whenever(() => current.silenceNotifications, () => (current.blockAll = false)) +whenever(() => current.silenceActivity, () => (current.blockAll = false)) +whenever(() => current.rejectMedia, () => (current.blockAll = false)) +whenever(() => current.blockAll, () => { + for (const config of fieldConfig) { + current[config.id] = false + } +}) + +const isLoading = ref(false) +const errors = ref([] as string[]) +const createOrUpdate = async () => { + isLoading.value = true + errors.value = [] + + try { + const data = { + summary: current.summary, + is_active: current.isActive, + block_all: current.blockAll, + silence_activity: current.silenceActivity, + silence_notifications: current.silenceNotifications, + reject_media: current.rejectMedia, + target: { + type: props.type, + id: props.target + } + } + + const response = props.object + ? await axios.patch(`manage/moderation/instance-policies/${props.object.id}/`, data) + : await axios.post('manage/moderation/instance-policies/', data) + + emit('save', response.data) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const remove = async () => { + isLoading.value = true + errors.value = [] + + try { + await axios.delete(`manage/moderation/instance-policies/${props.object?.id}/`) + emit('delete') + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> <form class="ui form" @@ -6,14 +124,12 @@ <h3 class="ui header"> <translate v-if="object" - key="1" translate-context="Content/Moderation/Card.Title/Verb" > Edit moderation rule </translate> <translate v-else - key="2" translate-context="Content/Moderation/Card.Button.Label/Verb" > Add a new moderation rule @@ -52,12 +168,10 @@ <label for="policy-is-active"> <translate v-if="current.isActive" - key="1" translate-context="*/*/*/State of feature" >Enabled</translate> <translate v-else - key="2" translate-context="*/*/*/State of feature" >Disabled</translate> <tooltip :content="labels.isActiveHelp" /> @@ -115,7 +229,7 @@ <div class="ui hidden divider" /> <button class="ui basic left floated button" - @click.prevent="$emit('cancel')" + @click.prevent="emit('cancel')" > <translate translate-context="*/*/Button.Label/Verb"> Cancel @@ -127,14 +241,12 @@ > <translate v-if="object" - key="1" translate-context="Content/Moderation/Card.Button.Label/Verb" > Update </translate> <translate v-else - key="2" translate-context="Content/Moderation/Card.Button.Label/Verb" > Create @@ -148,149 +260,27 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Moderation/Title"> - Delete this moderation rule? - </translate> - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Moderation/Paragraph"> - This action is irreversible. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="Popup/Moderation/Button.Label/Verb"> - Delete moderation rule - </translate> - </div> + <template #modal-header> + <p> + <translate translate-context="Popup/Moderation/Title"> + Delete this moderation rule? + </translate> + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Moderation/Paragraph"> + This action is irreversible. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="Popup/Moderation/Button.Label/Verb"> + Delete moderation rule + </translate> + </div> + </template> </dangerous-button> </form> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' - -export default { - props: { - type: { type: String, required: true }, - object: { type: Object, default: null }, - target: { type: String, required: true } - }, - data () { - const current = this.object || {} - return { - isLoading: false, - errors: [], - current: { - summary: _.get(current, 'summary', ''), - isActive: _.get(current, 'is_active', true), - blockAll: _.get(current, 'block_all', true), - silenceActivity: _.get(current, 'silence_activity', false), - silenceNotifications: _.get(current, 'silence_notifications', false), - rejectMedia: _.get(current, 'reject_media', false) - }, - fieldConfig: [ - // we hide those until we actually have the related features implemented :) - // {id: "silenceActivity", icon: "feed"}, - // {id: "silenceNotifications", icon: "bell"}, - { id: 'rejectMedia', icon: 'file' } - ] - } - }, - computed: { - labels () { - return { - summaryHelp: this.$pgettext('Content/Moderation/Help text', "Explain why you're applying this policy: this will help you remember why you added this rule. Depending on your pod configuration, this may be displayed publicly to help users understand the moderation rules in place."), - isActiveHelp: this.$pgettext('Content/Moderation/Help text', 'Use this setting to temporarily enable/disable the policy without completely removing it.'), - blockAllHelp: this.$pgettext('Content/Moderation/Help text', 'Block everything from this account or domain. This will prevent any interaction with the entity, and purge related content (uploads, libraries, follows, etc.)'), - silenceActivity: { - help: this.$pgettext('Content/Moderation/Help text', 'Hide account or domain content, except from followers.'), - label: this.$pgettext('Content/Moderation/*/Verb', 'Mute activity') - }, - silenceNotifications: { - help: this.$pgettext('Content/Moderation/Help text', 'Prevent account or domain from triggering notifications, except from followers.'), - label: this.$pgettext('Content/Moderation/*/Verb', 'Mute notifications') - }, - rejectMedia: { - help: this.$pgettext('Content/Moderation/Help text', 'Do not download any media file (audio, album cover, account avatar…) from this account or domain. This will purge existing content as well.'), - label: this.$pgettext('Content/Moderation/*/Verb', 'Reject media') - } - } - } - }, - watch: { - 'current.silenceActivity': function (v) { - if (v) { - this.current.blockAll = false - } - }, - 'current.silenceNotifications': function (v) { - if (v) { - this.current.blockAll = false - } - }, - 'current.rejectMedia': function (v) { - if (v) { - this.current.blockAll = false - } - }, - 'current.blockAll': function (v) { - if (v) { - const self = this - this.fieldConfig.forEach((f) => { - self.current[f.id] = false - }) - } - } - }, - methods: { - createOrUpdate () { - const self = this - this.isLoading = true - this.errors = [] - let url, method - const data = { - summary: this.current.summary, - is_active: this.current.isActive, - block_all: this.current.blockAll, - silence_activity: this.current.silenceActivity, - silence_notifications: this.current.silenceNotifications, - reject_media: this.current.rejectMedia, - target: { - type: this.type, - id: this.target - } - } - if (this.object) { - url = `manage/moderation/instance-policies/${this.object.id}/` - method = 'patch' - } else { - url = 'manage/moderation/instance-policies/' - method = 'post' - } - axios[method](url, data).then((response) => { - this.isLoading = false - self.$emit('save', response.data) - }, (error) => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - remove () { - const self = this - this.isLoading = true - this.errors = [] - - const url = `manage/moderation/instance-policies/${this.object.id}/` - axios.delete(url).then((response) => { - this.isLoading = false - self.$emit('delete') - }, (error) => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/manage/moderation/InstancePolicyModal.vue b/front/src/components/manage/moderation/InstancePolicyModal.vue index fb10b91431cd3f4719c47c543438d6b351547203..265971ef082d30c2ca6ad91b0af2604d756642c3 100644 --- a/front/src/components/manage/moderation/InstancePolicyModal.vue +++ b/front/src/components/manage/moderation/InstancePolicyModal.vue @@ -1,3 +1,60 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import axios from 'axios' + +import { ref, computed } from 'vue' + +import InstancePolicyForm from '~/components/manage/moderation/InstancePolicyForm.vue' +import InstancePolicyCard from '~/components/manage/moderation/InstancePolicyCard.vue' +import SemanticModal from '~/components/semantic/Modal.vue' + +interface Props { + target: string + type: 'domain' | 'actor' +} + +const props = defineProps<Props>() + +const show = ref(false) +const showForm = ref(false) + +const errors = ref([] as string[]) +const result = ref() + +const obj = computed(() => result.value?.results[0] ?? null) + +const isLoading = ref(false) +const fetchData = async () => { + const [username, domain] = props.target.split('@') + + const params = { + target_domain: props.type === 'domain' + ? props.target + : undefined, + + target_account_username: props.type === 'actor' + ? username + : undefined, + + target_account_domain: props.type === 'actor' + ? domain + : undefined + } + + isLoading.value = true + + try { + const response = await axios.get('/manage/moderation/instance-policies/', { params }) + result.value = response.data + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> <button class="ui button" @@ -9,8 +66,8 @@ Moderation rules… </translate> </slot> - <modal - :show.sync="show" + <semantic-modal + v-model:show="show" @show="fetchData" > <h4 class="header"> @@ -46,8 +103,8 @@ :type="type" :target="target" @cancel="showForm = false" - @save="showForm = false; result = {count: 1, results: [$event]}" - @delete="result = {count: 0, results: []}; showForm = false" + @save="(event: unknown) => { showForm = false; result = {count: 1, results: [event]} }" + @delete="() => { result = {count: 0, results: []}; showForm = false }" /> </div> <div class="ui hidden divider" /> @@ -60,64 +117,6 @@ </translate> </button> </div> - </modal> + </semantic-modal> </button> </template> - -<script> -import axios from 'axios' -import InstancePolicyForm from '@/components/manage/moderation/InstancePolicyForm.vue' -import InstancePolicyCard from '@/components/manage/moderation/InstancePolicyCard.vue' -import Modal from '@/components/semantic/Modal.vue' - -export default { - components: { - InstancePolicyForm, - InstancePolicyCard, - Modal - }, - props: { - target: { type: String, required: true }, - type: { type: String, required: true } - }, - data () { - return { - show: false, - isLoading: false, - errors: [], - showForm: false, - result: null - } - }, - computed: { - obj () { - if (!this.result) { - return null - } - return this.result.results[0] - } - }, - methods: { - fetchData () { - const params = {} - if (this.type === 'domain') { - params.target_domain = this.target - } - if (this.type === 'actor') { - const parts = this.target.split('@') - params.target_account_username = parts[0] - params.target_account_domain = parts[1] - } - const self = this - self.isLoading = true - axios.get('/manage/moderation/instance-policies/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/manage/moderation/NoteForm.vue b/front/src/components/manage/moderation/NoteForm.vue index 1b767675d714e79804e9148a3de64ab3ab014196..cfd36ec31a85dfc7bf946d19e1ae4911135b77a2 100644 --- a/front/src/components/manage/moderation/NoteForm.vue +++ b/front/src/components/manage/moderation/NoteForm.vue @@ -1,3 +1,50 @@ +<script setup lang="ts"> +import type { Note, BackendError } from '~/types' + +import axios from 'axios' +import { ref, computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +interface Events { + (e: 'created', note: Note): void +} + +interface Props { + target: Note +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + summaryPlaceholder: $pgettext('Content/Moderation/Placeholder', 'Describe what actions have been taken, or any other related updates…') +})) + +const summary = ref('') + +const isLoading = ref(false) +const errors = ref([] as string[]) +const submit = async () => { + isLoading.value = true + errors.value = [] + + try { + const response = await axios.post('manage/moderation/notes/', { + target: props.target, + summary: summary.value + }) + + emit('created', response.data) + summary.value = '' + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> <form class="ui form" @@ -42,48 +89,3 @@ </button> </form> </template> - -<script> -import axios from 'axios' -import showdown from 'showdown' - -export default { - props: { - target: { type: Object, required: true } - }, - data () { - return { - markdown: new showdown.Converter(), - isLoading: false, - summary: '', - errors: [] - } - }, - computed: { - labels () { - return { - summaryPlaceholder: this.$pgettext('Content/Moderation/Placeholder', 'Describe what actions have been taken, or any other related updates…') - } - } - }, - methods: { - submit () { - const self = this - this.isLoading = true - const payload = { - target: this.target, - summary: this.summary - } - this.errors = [] - axios.post('manage/moderation/notes/', payload).then((response) => { - self.$emit('created', response.data) - self.summary = '' - self.isLoading = false - }, error => { - self.errors = error.backendErrors - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/components/manage/moderation/NotesThread.vue b/front/src/components/manage/moderation/NotesThread.vue index 12acc07eb9538894daebb08190bc71461c708e60..d864c49436f056886d890078607612eb2dfdfddf 100644 --- a/front/src/components/manage/moderation/NotesThread.vue +++ b/front/src/components/manage/moderation/NotesThread.vue @@ -1,3 +1,38 @@ +<script setup lang="ts"> +import type { Note } from '~/types' + +import { useMarkdownRaw } from '~/composables/useMarkdown' +import { ref } from 'vue' + +import axios from 'axios' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'deleted', uuid: string): void +} +interface Props { + notes: Note[] +} + +const emit = defineEmits<Events>() +defineProps<Props>() + +const isLoading = ref(false) +const remove = async (note: Note) => { + isLoading.value = true + + try { + await axios.delete(`manage/moderation/notes/${note.uuid}/`) + emit('deleted', note.uuid) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} +</script> + <template> <div class="ui feed"> <div @@ -20,7 +55,7 @@ </div> <div class="extra text"> <expandable-div :content="note.summary"> - <div v-html="markdown.makeHtml(note.summary)" /> + <sanitized-html :html="useMarkdownRaw(note.summary ?? '')" /> </expandable-div> </div> <div class="meta"> @@ -32,55 +67,32 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Moderation/Title"> - Delete this note? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> + <p> + <translate translate-context="Popup/Moderation/Title"> + Delete this note? + </translate> + </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The note will be removed. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The note will be removed. This action is irreversible. + <translate translate-context="*/*/*/Verb"> + Delete </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> </dangerous-button> </div> </div> </div> </div> </template> - -<script> -import axios from 'axios' -import showdown from 'showdown' - -export default { - props: { - notes: { type: Array, required: true } - }, - data () { - return { - markdown: new showdown.Converter(), - isLoading: false - } - }, - methods: { - remove (obj) { - const self = this - this.isLoading = true - axios.delete(`manage/moderation/notes/${obj.uuid}/`).then((response) => { - self.$emit('deleted', obj.uuid) - self.isLoading = false - }, () => { - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/components/manage/moderation/ReportCard.vue b/front/src/components/manage/moderation/ReportCard.vue index d90bf4682430bd748cf5a065e3f6a386ff726344..42bfe108ab167b743b647926b0aac548fe6f1b8c 100644 --- a/front/src/components/manage/moderation/ReportCard.vue +++ b/front/src/components/manage/moderation/ReportCard.vue @@ -1,3 +1,141 @@ +<script setup lang="ts"> +import type { Report } from '~/types' + +import { ref, computed, reactive } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' + +import InstancePolicyModal from '~/components/manage/moderation/InstancePolicyModal.vue' +import ReportCategoryDropdown from '~/components/moderation/ReportCategoryDropdown.vue' +import NotesThread from '~/components/manage/moderation/NotesThread.vue' +import NoteForm from '~/components/manage/moderation/NoteForm.vue' + +import useReportConfigs from '~/composables/moderation/useReportConfigs' +import useErrorHandler from '~/composables/useErrorHandler' +import useMarkdown from '~/composables/useMarkdown' + +interface Events { + (e: 'updated', updating: { type: string }): void + (e: 'handled', isHandled: boolean): void +} + +interface Props { + initObj: Report +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const configs = useReportConfigs() + +const obj = ref(props.initObj) +const summary = useMarkdown(() => obj.value.summary ?? '') + +const target = computed(() => obj.value.target + ? obj.value.target + : obj.value.target_state._target +) + +const targetFields = computed(() => { + if (!target.value) { + return [] + } + + const payload = obj.value.target_state + const fields = configs[target.value.type].moderatedFields + return fields.map((fieldConfig) => { + const getValueRepr = fieldConfig.getValueRepr ?? (i => i) + return { + id: fieldConfig.id, + label: fieldConfig.label, + value: payload[fieldConfig.id], + repr: getValueRepr(payload[fieldConfig.id]) ?? '' + } + }) +}) + +const { $pgettext } = useGettext() +const actions = computed(() => { + if (!target.value) { + return [] + } + + const typeConfig = configs[target.value.type] + const deleteUrl = typeConfig.getDeleteUrl?.(target.value) + return deleteUrl + ? [{ + label: $pgettext('Content/Moderation/Button/Verb', 'Delete reported object'), + modalHeader: $pgettext('Content/Moderation/Popup/Header', 'Delete reported object?'), + modalContent: $pgettext('Content/Moderation/Popup,Paragraph', 'This will delete the object associated with this report and mark the report as resolved. The deletion is irreversible.'), + modalConfirmLabel: $pgettext('*/*/*/Verb', 'Delete'), + icon: 'x', + iconColor: 'danger', + show: (obj: Report) => { return !!obj.target }, + dangerous: true, + handler: async () => { + try { + await axios.delete(deleteUrl) + console.log('Target deleted') + obj.value.target = undefined + resolveReport(true) + } catch (error) { + console.log('Error while deleting target', error) + useErrorHandler(error as Error) + } + } + }] + : [] +}) + +const isLoading = ref(false) +const updating = reactive({ type: false }) +const update = async (type: string) => { + isLoading.value = true + updating.type = true + + try { + await axios.patch(`manage/moderation/reports/${obj.value.uuid}/`, { type }) + emit('updated', { type }) + } catch (error) { + useErrorHandler(error as Error) + } + + updating.type = false + isLoading.value = false +} + +const store = useStore() +const isCollapsed = ref(false) +const resolveReport = async (isHandled: boolean) => { + isLoading.value = true + + try { + await axios.patch(`manage/moderation/reports/${obj.value.uuid}/`, { is_handled: isHandled }) + emit('handled', isHandled) + obj.value.is_handled = isHandled + + if (isHandled) { + isCollapsed.value = true + } + + store.commit('ui/incrementNotifications', { + type: 'pendingReviewReports', + count: isHandled ? -1 : 1 + }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const handleRemovedNote = (uuid: string) => { + obj.value.notes = obj.value.notes.filter((note) => note.uuid !== uuid) +} +</script> + <template> <div class="ui fluid report card"> <div class="content"> @@ -47,8 +185,8 @@ </td> <td> <report-category-dropdown - :value="obj.type" - @input="update({type: $event})" + v-model="obj.type" + @update:model-value="update($event)" >   <action-feedback :is-loading="updating.type" /> @@ -163,11 +301,11 @@ </translate> </h3> <expandable-div - v-if="obj.summary" + v-if="summary" class="summary" :content="obj.summary" > - <div v-html="markdown.makeHtml(obj.summary)" /> + <sanitized-html :html="summary" /> </expandable-div> </div> <aside class="column"> @@ -188,7 +326,7 @@ <router-link v-if="target && configs[target.type].urls.getDetail" class="ui basic button" - :to="configs[target.type].urls.getDetail(obj.target_state)" + :to="configs[target.type].urls.getDetail?.(obj.target_state) ?? '/'" > <i class="eye icon" /> <translate translate-context="Content/Moderation/Link"> @@ -198,7 +336,7 @@ <router-link v-if="target && configs[target.type].urls.getAdminDetail" class="ui basic button" - :to="configs[target.type].urls.getAdminDetail(obj.target_state)" + :to="configs[target.type].urls.getAdminDetail?.(obj.target_state) ?? '/'" > <i class="wrench icon" /> <translate translate-context="Content/Moderation/Link"> @@ -253,10 +391,10 @@ </td> <td> <instance-policy-modal - v-if="!obj.target_owner.is_local" + v-if="!obj.target_owner?.is_local" class="right floated mini basic" type="actor" - :target="obj.target_owner.full_username" + :target="obj.target_owner?.full_username ?? ''" /> </td> </tr> @@ -275,7 +413,7 @@ </tr> <tr v-else-if="obj.target_state.domain"> <td> - <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: obj.target_state.domain }}"> + <router-link :to="{name: 'manage.moderation.domains.detail', params: { id: obj.target_state.domain }}"> <translate translate-context="Content/Moderation/*/Noun"> Domain </translate> @@ -342,7 +480,7 @@ <button v-if="obj.is_handled === false" :class="['ui', {loading: isLoading}, 'button']" - @click="resolve(true)" + @click="resolveReport(true)" > <i class="success check icon" /> <translate translate-context="Content/*/Button.Label/Verb"> @@ -352,7 +490,7 @@ <button v-if="obj.is_handled === true" :class="['ui', {loading: isLoading}, 'button']" - @click="resolve(false)" + @click="resolveReport(false)" > <i class="warning redo icon" /> <translate translate-context="Content/*/Button.Label"> @@ -360,25 +498,27 @@ </translate> </button> <template - v-for="(action, key) in actions" + v-for="action in actions" + :key="action.label" > <dangerous-button v-if="action.dangerous && action.show(obj)" - :key="key" :class="['ui', {loading: isLoading}, 'button']" :action="action.handler" > <i :class="[action.iconColor, action.icon, 'icon']" /> {{ action.label }} - <p slot="modal-header"> - {{ action.modalHeader }} - </p> - <div slot="modal-content"> - <p>{{ action.modalContent }}</p> - </div> - <p slot="modal-confirm"> - {{ action.modalConfirmLabel }} - </p> + <template #modal-header> + <p>{{ action.modalHeader }}</p> + </template> + <template #modal-content> + <div> + <p>{{ action.modalContent }}</p> + </div> + </template> + <template #modal-confirm> + <p>{{ action.modalConfirmLabel }}</p> + </template> </dangerous-button> </template> </div> @@ -387,174 +527,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import NoteForm from '@/components/manage/moderation/NoteForm.vue' -import NotesThread from '@/components/manage/moderation/NotesThread.vue' -import ReportCategoryDropdown from '@/components/moderation/ReportCategoryDropdown.vue' -import InstancePolicyModal from '@/components/manage/moderation/InstancePolicyModal.vue' -import entities from '@/entities' -import { setUpdate } from '@/utils' -import showdown from 'showdown' - -function castValue (value) { - if (value === null || value === undefined) { - return '' - } - return String(value) -} - -export default { - components: { - NoteForm, - NotesThread, - ReportCategoryDropdown, - InstancePolicyModal - }, - props: { - initObj: { type: Object, required: true }, - currentState: { type: String, required: false, default: '' } - }, - data () { - return { - obj: this.initObj, - markdown: new showdown.Converter(), - isLoading: false, - isCollapsed: false, - updating: { - type: false - } - } - }, - computed: { - configs: entities.getConfigs, - previousState () { - if (this.obj.is_applied) { - // mutation was applied, we use the previous state that is stored - // on the mutation itself - return this.obj.previous_state - } - // mutation is not applied yet, so we use the current state that was - // passed to the component, if any - return this.currentState - }, - detailUrl () { - if (!this.target) { - return '' - } - let namespace - const id = this.target.id - if (this.target.type === 'track') { - namespace = 'library.tracks.edit.detail' - } - if (this.target.type === 'album') { - namespace = 'library.albums.edit.detail' - } - if (this.target.type === 'artist') { - namespace = 'library.artists.edit.detail' - } - return this.$router.resolve({ name: namespace, params: { id, editId: this.obj.uuid } }).href - }, - - targetFields () { - if (!this.target) { - return [] - } - const payload = this.obj.target_state - const fields = this.configs[this.target.type].moderatedFields - return fields.map((fieldConfig) => { - const dummyRepr = (v) => { return v } - const getValueRepr = fieldConfig.getValueRepr || dummyRepr - const d = { - id: fieldConfig.id, - label: fieldConfig.label, - value: payload[fieldConfig.id], - repr: castValue(getValueRepr(payload[fieldConfig.id])) - } - return d - }) - }, - target () { - if (this.obj.target) { - return this.obj.target - } else { - return this.obj.target_state._target - } - }, - actions () { - if (!this.target) { - return [] - } - const self = this - const actions = [] - const typeConfig = this.configs[this.target.type] - if (typeConfig.getDeleteUrl) { - const deleteUrl = typeConfig.getDeleteUrl(this.target) - actions.push({ - label: this.$pgettext('Content/Moderation/Button/Verb', 'Delete reported object'), - modalHeader: this.$pgettext('Content/Moderation/Popup/Header', 'Delete reported object?'), - modalContent: this.$pgettext('Content/Moderation/Popup,Paragraph', 'This will delete the object associated with this report and mark the report as resolved. The deletion is irreversible.'), - modalConfirmLabel: this.$pgettext('*/*/*/Verb', 'Delete'), - icon: 'x', - iconColor: 'danger', - show: (obj) => { return !!obj.target }, - dangerous: true, - handler: () => { - axios.delete(deleteUrl).then((response) => { - console.log('Target deleted') - self.obj.target = null - self.resolve(true) - }, () => { - console.log('Error while deleting target') - }) - } - }) - } - return actions - } - }, - methods: { - update (payload) { - const url = `manage/moderation/reports/${this.obj.uuid}/` - const self = this - this.isLoading = true - setUpdate(payload, this.updating, true) - axios.patch(url, payload).then((response) => { - self.$emit('updated', payload) - Object.assign(self.obj, payload) - self.isLoading = false - setUpdate(payload, self.updating, false) - }, () => { - self.isLoading = false - setUpdate(payload, self.updating, false) - }) - }, - resolve (v) { - const url = `manage/moderation/reports/${this.obj.uuid}/` - const self = this - this.isLoading = true - axios.patch(url, { is_handled: v }).then((response) => { - self.$emit('handled', v) - self.isLoading = false - self.obj.is_handled = v - let increment - if (v) { - self.isCollapsed = true - increment = -1 - } else { - increment = 1 - } - self.$store.commit('ui/incrementNotifications', { count: increment, type: 'pendingReviewReports' }) - }, () => { - self.isLoading = false - }) - }, - handleRemovedNote (uuid) { - this.obj.notes = this.obj.notes.filter((note) => { - return note.uuid !== uuid - }) - } - } -} -</script> diff --git a/front/src/components/manage/moderation/UserRequestCard.vue b/front/src/components/manage/moderation/UserRequestCard.vue index 8439d35547113760545d0faab177818e97c1f417..ad253ccc48d3a53da34d2d286b756db16445c7f2 100644 --- a/front/src/components/manage/moderation/UserRequestCard.vue +++ b/front/src/components/manage/moderation/UserRequestCard.vue @@ -1,3 +1,67 @@ +<script setup lang="ts"> +import type { UserRequest, UserRequestStatus } from '~/types' + +import { useStore } from '~/store' +import { ref } from 'vue' + +import axios from 'axios' + +import NotesThread from '~/components/manage/moderation/NotesThread.vue' +import NoteForm from '~/components/manage/moderation/NoteForm.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'handled', status: UserRequestStatus): void +} + +interface Props { + initObj: UserRequest +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const store = useStore() + +const obj = ref(props.initObj) + +const isCollapsed = ref(false) +const isLoading = ref(false) +const approve = async (isApproved: boolean) => { + isLoading.value = true + + try { + const status = isApproved + ? 'approved' + : 'refused' + + await axios.patch(`manage/moderation/requests/${obj.value.uuid}/`, { + status + }) + + emit('handled', status) + + if (isApproved) { + isCollapsed.value = true + } + + store.commit('ui/incrementNotifications', { + type: 'pendingReviewRequests', + count: -1 + }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const handleRemovedNote = (uuid: string) => { + obj.value.notes = obj.value.notes.filter((note) => note.uuid !== uuid) +} +</script> + <template> <div class="ui fluid user-request card"> <div class="content"> @@ -157,12 +221,12 @@ <template v-if="obj.metadata"> <div class="ui hidden divider" /> <div - v-for="k in Object.keys(obj.metadata)" - :key="k" + v-for="(value, key) in obj.metadata" + :key="key" > - <h4>{{ k }}</h4> - <p v-if="obj.metadata[k] && obj.metadata[k].length"> - {{ obj.metadata[k] }} + <h4>{{ key }}</h4> + <p v-if="value"> + {{ value }} </p> <translate v-else @@ -222,52 +286,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import NoteForm from '@/components/manage/moderation/NoteForm.vue' -import NotesThread from '@/components/manage/moderation/NotesThread.vue' -import showdown from 'showdown' - -export default { - components: { - NoteForm, - NotesThread - }, - props: { - initObj: { type: Object, required: true } - }, - data () { - return { - markdown: new showdown.Converter(), - isLoading: false, - isCollapsed: false, - obj: this.initObj - } - }, - methods: { - approve (v) { - const url = `manage/moderation/requests/${this.obj.uuid}/` - const self = this - const newStatus = v ? 'approved' : 'refused' - this.isLoading = true - axios.patch(url, { status: newStatus }).then((response) => { - self.$emit('handled', newStatus) - self.isLoading = false - self.obj.status = newStatus - if (v) { - self.isCollapsed = true - } - self.$store.commit('ui/incrementNotifications', { count: -1, type: 'pendingReviewRequests' }) - }, () => { - self.isLoading = false - }) - }, - handleRemovedNote (uuid) { - this.obj.notes = this.obj.notes.filter((note) => { - return note.uuid !== uuid - }) - } - } -} -</script> diff --git a/front/src/components/manage/users/InvitationForm.vue b/front/src/components/manage/users/InvitationForm.vue index 72011a6aa6f419c415d41bad1d69da1cac44d7ab..9871e23103b794c74ec3c7652b853373a302810e 100644 --- a/front/src/components/manage/users/InvitationForm.vue +++ b/front/src/components/manage/users/InvitationForm.vue @@ -1,3 +1,49 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { computed, ref, reactive } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { useStore } from '~/store' + +import axios from 'axios' + +interface Invitation { + code: string +} + +const { $pgettext } = useGettext() +const router = useRouter() +const store = useStore() + +const labels = computed(() => ({ + placeholder: $pgettext('Content/Admin/Input.Placeholder', 'Leave empty for a random code') +})) + +const invitations = reactive([] as Invitation[]) +const code = ref('') +const isLoading = ref(false) +const errors = ref([] as string[]) +const submit = async () => { + isLoading.value = true + errors.value = [] + + try { + const response = await axios.post('manage/users/invitations/', { code: code.value }) + invitations.unshift(response.data) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const getUrl = (code: string) => store.getters['instance/absoluteUrl'](router.resolve({ + name: 'signup', + query: { invitation: code.toUpperCase() } +}).href) +</script> + <template> <div> <form @@ -90,46 +136,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' - -export default { - data () { - return { - isLoading: false, - code: null, - invitations: [], - errors: [] - } - }, - computed: { - labels () { - return { - placeholder: this.$pgettext('Content/Admin/Input.Placeholder', 'Leave empty for a random code') - } - } - }, - methods: { - submit () { - const self = this - this.isLoading = true - this.errors = [] - const url = 'manage/users/invitations/' - const payload = { - code: this.code - } - axios.post(url, payload).then((response) => { - self.isLoading = false - self.invitations.unshift(response.data) - }, (error) => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - getUrl (code) { - return this.$store.getters['instance/absoluteUrl'](this.$router.resolve({ name: 'signup', query: { invitation: code.toUpperCase() } }).href) - } - } -} -</script> diff --git a/front/src/components/manage/users/InvitationsTable.vue b/front/src/components/manage/users/InvitationsTable.vue index 3efd53cf217ce3451c90375d1ac7cc47b39bcc30..8c7098c1b72a52eaac0145544c702c0cb6f47c5c 100644 --- a/front/src/components/manage/users/InvitationsTable.vue +++ b/front/src/components/manage/users/InvitationsTable.vue @@ -1,3 +1,98 @@ +<script setup lang="ts"> +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { watchDebounced } from '@vueuse/core' +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import moment from 'moment' +import axios from 'axios' + +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName +} + +const props = withDefaults(defineProps<Props>(), { + filters: () => ({}), + orderingConfigName: undefined +}) + +const page = usePage() +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) + +const { onOrderingUpdate, orderingString, paginateBy, ordering } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['expiration_date', 'expiration_date'], + ['creation_date', 'creation_date'] +] + +const query = ref('') +const isOpen = ref(false) +const { $pgettext } = useGettext() +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'delete', + label: $pgettext('*/*/*/Verb', 'Delete'), + filterCheckable: (obj: { users: unknown[], expiration_date: Date }) => { + return obj.users.length === 0 && moment().isBefore(obj.expiration_date) + } + } +]) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + is_open: isOpen.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/users/invitations/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +watchDebounced(query, () => (page.value = 1), { debounce: 300 }) +watch(isOpen, () => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Admin/Input.Placeholder/Verb', 'Search by username, e-mail address, code…') +})) +</script> + <template> <div> <div class="ui inline form"> @@ -6,7 +101,7 @@ <label for="invitations-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> <input id="invitations-search" - v-model="search" + v-model="query" name="search" type="text" :placeholder="labels.searchPlaceholder" @@ -69,7 +164,7 @@ :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*"> Owner @@ -97,8 +192,7 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <td> <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}"> @@ -134,11 +228,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -154,116 +247,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import moment from 'moment' -import _ from 'lodash' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' - -export default { - components: { - Pagination, - ActionTable - }, - mixins: [OrderingMixin, TranslationsMixin], - props: { - filters: { type: Object, required: false, default: function () { return {} } } - }, - data () { - return { - moment, - isLoading: false, - result: null, - page: 1, - search: '', - isOpen: null, - orderingOptions: [ - ['expiration_date', 'expiration_date'], - ['creation_date', 'creation_date'] - ] - - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Admin/Input.Placeholder/Verb', 'Search by username, e-mail address, code…') - } - }, - actionFilters () { - const currentFilters = { - q: this.search - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - const deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') - return [ - { - name: 'delete', - label: deleteLabel, - filterCheckable: (obj) => { - return obj.users.length === 0 && moment().isBefore(obj.expiration_date) - } - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.page = 1 - this.fetchData() - }, - isOpen () { - this.page = 1 - this.fetchData() - }, - orderingDirection () { - this.page = 1 - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search, - is_open: this.isOpen, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/users/invitations/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue index 9d4245c68514ef4170429d5af6488fc4ef1c4439..7aad1c76d1b6e5d64141a8ed0ffb020e033b5988 100644 --- a/front/src/components/manage/users/UsersTable.vue +++ b/front/src/components/manage/users/UsersTable.vue @@ -1,3 +1,100 @@ +<script setup lang="ts"> +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { watchDebounced } from '@vueuse/core' +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useErrorHandler from '~/composables/useErrorHandler' +import useOrdering from '~/composables/navigation/useOrdering' + +interface Props extends OrderingProps { + filters?: object + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName +} + +const props = withDefaults(defineProps<Props>(), { + filters: () => ({}), + orderingConfigName: undefined +}) + +const page = ref(1) +const query = ref('') +type ResponseType = { count: number, results: any[] } +const result = ref<null | ResponseType>(null) + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['date_joined', 'date_joined'], + ['last_activity', 'last_activity'], + ['username', 'username'] +] + +const permissions = computed(() => [ + { + code: 'library', + label: $pgettext('*/*/*/Noun', 'Library') + }, + { + code: 'moderation', + label: $pgettext('*/Moderation/*', 'Moderation') + }, + { + code: 'settings', + label: $pgettext('*/*/*/Noun', 'Settings') + } +]) + +const { $pgettext } = useGettext() +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + ...props.filters + } + + try { + const response = await axios.get('/manage/users/users/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = null + } finally { + isLoading.value = false + } +} + +watchDebounced(query, () => (page.value = 1), { debounce: 300 }) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by username, e-mail address, name…') +})) +</script> + <template> <div> <div class="ui inline form"> @@ -6,7 +103,7 @@ <label for="users-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> <input id="users-search" - v-model="search" + v-model="query" name="search" type="text" :placeholder="labels.searchPlaceholder" @@ -59,12 +156,12 @@ <action-table v-if="result" :objects-data="result" - :actions="actions" + :actions="[]" :action-url="'manage/library/uploads/action/'" :filters="actionFilters" @action-launched="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="Content/*/*"> Username @@ -102,8 +199,7 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <td> <router-link @@ -148,11 +244,11 @@ </td> <td> <template - v-for="(p, key) in permissions" + v-for="p in permissions" + :key="p.code" > <span v-if="scope.obj.permissions[p.code]" - :key="key" class="ui basic tiny label" >{{ p.label }}</span> </template> @@ -177,11 +273,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> <span v-if="result && result.results.length > 0"> @@ -197,125 +292,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' - -export default { - components: { - Pagination, - ActionTable - }, - mixins: [OrderingMixin, TranslationsMixin], - props: { - filters: { type: Object, required: false, default: function () { return {} } } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: '', - orderingOptions: [ - ['date_joined', 'date_joined'], - ['last_activity', 'last_activity'], - ['username', 'username'] - ] - - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by username, e-mail address, name…') - } - }, - privacyLevels () { - return {} - }, - permissions () { - return [ - { - code: 'library', - label: this.$pgettext('*/*/*/Noun', 'Library') - }, - { - code: 'moderation', - label: this.$pgettext('*/Moderation/*', 'Moderation') - }, - { - code: 'settings', - label: this.$pgettext('*/*/*/Noun', 'Settings') - } - ] - }, - actionFilters () { - const currentFilters = { - q: this.search - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - return [ - // { - // name: 'delete', - // label: this.$pgettext('Content/Admin/Button.Label/Verb', 'Delete'), - // isDangerous: true - // } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/users/users/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/components/mixins/Ordering.vue b/front/src/components/mixins/Ordering.vue deleted file mode 100644 index 787519e91df4383b91d93d41db55aa86f7e0c78f..0000000000000000000000000000000000000000 --- a/front/src/components/mixins/Ordering.vue +++ /dev/null @@ -1,69 +0,0 @@ -<script> -export default { - props: { - defaultOrdering: { type: String, required: false, default: '' }, - orderingConfigName: { type: String, required: false, default: '' } - }, - computed: { - orderingConfig () { - return this.$store.state.ui.routePreferences[this.orderingConfigName || this.$route.name] - }, - paginateBy: { - set (paginateBy) { - this.$store.commit('ui/paginateBy', { - route: this.$route.name, - value: paginateBy - }) - }, - get () { - return this.orderingConfig.paginateBy - } - }, - ordering: { - set (ordering) { - this.$store.commit('ui/ordering', { - route: this.$route.name, - value: ordering - }) - }, - get () { - return this.orderingConfig.ordering - } - }, - orderingDirection: { - set (orderingDirection) { - this.$store.commit('ui/orderingDirection', { - route: this.$route.name, - value: orderingDirection - }) - }, - get () { - return this.orderingConfig.orderingDirection - } - } - }, - methods: { - getOrderingFromString (s) { - const parts = s.split('-') - if (parts.length > 1) { - return { - direction: '-', - field: parts.slice(1).join('-') - } - } else { - return { - direction: '+', - field: s - } - } - }, - getOrderingAsString () { - let direction = this.orderingDirection - if (direction === '+') { - direction = '' - } - return [direction, this.ordering].join('') - } - } -} -</script> diff --git a/front/src/components/mixins/Pagination.vue b/front/src/components/mixins/Pagination.vue deleted file mode 100644 index 1bc7a05b9e8386ad9ab3438a0be3379c954aff3b..0000000000000000000000000000000000000000 --- a/front/src/components/mixins/Pagination.vue +++ /dev/null @@ -1,8 +0,0 @@ -<script> -export default { - props: { - defaultPage: { type: Number, required: false, default: 1 }, - defaultPaginateBy: { type: Number, required: false, default: 1 } - } -} -</script> diff --git a/front/src/components/mixins/PlayOptions.vue b/front/src/components/mixins/PlayOptions.vue deleted file mode 100644 index 42e2dab5dd6ec2614437c6d1552e957c53e3ec28..0000000000000000000000000000000000000000 --- a/front/src/components/mixins/PlayOptions.vue +++ /dev/null @@ -1,184 +0,0 @@ -<script> -import axios from 'axios' -import jQuery from 'jquery' - -export default { - computed: { - playable () { - if (this.isPlayable) { - return true - } - if (this.track) { - return this.track.uploads && this.track.uploads.length > 0 - } else if (this.artist && this.artist.tracks_count) { - return this.artist.tracks_count > 0 - } else if (this.artist && this.artist.albums) { - return this.artist.albums.filter((a) => { - return a.is_playable === true - }).length > 0 - } else if (this.tracks) { - return this.tracks.filter((t) => { - return t.uploads && t.uploads.length > 0 - }).length > 0 - } - return false - }, - filterableArtist () { - if (this.track) { - return this.track.artist - } - if (this.album) { - return this.album.artist - } - if (this.artist) { - return this.artist - } - return null - } - }, - methods: { - filterArtist () { - this.$store.dispatch('moderation/hide', { type: 'artist', target: this.filterableArtist }) - }, - activateTrack (track, index) { - if ( - this.currentTrack && - this.isPlaying && - track.id === this.currentTrack.id - ) { - this.pausePlayback() - } else if ( - this.currentTrack && - !this.isPlaying && - track.id === this.currentTrack.id - ) { - this.resumePlayback() - } else { - this.replacePlay(this.tracks, index) - } - }, - getTracksPage (page, params, resolve, tracks) { - if (page > 10) { - // it's 10 * 100 tracks already, let's stop here - resolve(tracks) - } - // when fetching artists/or album tracks, sometimes, we may have to fetch - // multiple pages - const self = this - params.page_size = 100 - params.page = page - params.hidden = '' - params.playable = 'true' - tracks = tracks || [] - axios.get('tracks/', { params: params }).then((response) => { - response.data.results.forEach(t => { - tracks.push(t) - }) - if (response.data.next) { - self.getTracksPage(page + 1, params, resolve, tracks) - } else { - resolve(tracks) - } - }) - }, - getPlayableTracks () { - const self = this - this.isLoading = true - const getTracks = new Promise((resolve, reject) => { - if (self.tracks) { - resolve(self.tracks) - } else if (self.track) { - if (!self.track.uploads || self.track.uploads.length === 0) { - // fetch uploads from api - axios.get(`tracks/${self.track.id}/`).then((response) => { - resolve([response.data]) - }) - } else { - resolve([self.track]) - } - } else if (self.playlist) { - const url = 'playlists/' + self.playlist.id + '/' - axios.get(url + 'tracks/').then((response) => { - const artistIds = self.$store.getters['moderation/artistFilters']().map((f) => { - return f.target.id - }) - let tracks = response.data.results.map(plt => { - return plt.track - }) - if (artistIds.length > 0) { - // skip tracks from hidden artists - tracks = tracks.filter((t) => { - const matchArtist = artistIds.indexOf(t.artist.id) > -1 - return !((matchArtist || t.album) && artistIds.indexOf(t.album.artist.id) > -1) - }) - } - - resolve(tracks) - }) - } else if (self.artist) { - const params = { artist: self.artist.id, include_channels: 'true', ordering: 'album__release_date,disc_number,position' } - self.getTracksPage(1, params, resolve) - } else if (self.album) { - const params = { album: self.album.id, include_channels: 'true', ordering: 'disc_number,position' } - self.getTracksPage(1, params, resolve) - } else if (self.library) { - const params = { library: self.library.uuid, ordering: '-creation_date' } - self.getTracksPage(1, params, resolve) - } - }) - return getTracks.then((tracks) => { - setTimeout(e => { - self.isLoading = false - }, 250) - return tracks.filter(e => { - return e.uploads && e.uploads.length > 0 - }) - }) - }, - add () { - const self = this - this.getPlayableTracks().then((tracks) => { - self.$store.dispatch('queue/appendMany', { tracks: tracks }).then(() => self.addMessage(tracks)) - }) - jQuery(self.$el).find('.ui.dropdown').dropdown('hide') - }, - replacePlay () { - const self = this - self.$store.dispatch('queue/clean') - this.getPlayableTracks().then((tracks) => { - self.$store.dispatch('queue/appendMany', { tracks: tracks }).then(() => { - if (self.track) { - // set queue position to selected track - const trackIndex = self.tracks.findIndex(track => track.id === self.track.id) - self.$store.dispatch('queue/currentIndex', trackIndex) - } - self.addMessage(tracks) - }) - }) - jQuery(self.$el).find('.ui.dropdown').dropdown('hide') - }, - addNext (next) { - const self = this - const wasEmpty = this.$store.state.queue.tracks.length === 0 - this.getPlayableTracks().then((tracks) => { - self.$store.dispatch('queue/appendMany', { tracks: tracks, index: self.$store.state.queue.currentIndex + 1 }).then(() => self.addMessage(tracks)) - const goNext = next && !wasEmpty - if (goNext) { - self.$store.dispatch('queue/next') - } - }) - jQuery(self.$el).find('.ui.dropdown').dropdown('hide') - }, - addMessage (tracks) { - if (tracks.length < 1) { - return - } - const msg = this.$npgettext('*/Queue/Message', '%{ count } track was added to your queue', '%{ count } tracks were added to your queue', tracks.length) - this.$store.commit('ui/addMessage', { - content: this.$gettextInterpolate(msg, { count: tracks.length }), - date: new Date() - }) - } - } -} -</script> diff --git a/front/src/components/mixins/Report.vue b/front/src/components/mixins/Report.vue deleted file mode 100644 index 66a49540b9aedca436cab4aa206d0dd16c6b7e89..0000000000000000000000000000000000000000 --- a/front/src/components/mixins/Report.vue +++ /dev/null @@ -1,104 +0,0 @@ -<script> -export default { - methods: { - getReportableObjs ({ track, album, artist, playlist, account, library, channel }) { - const reportableObjs = [] - if (account) { - const accountLabel = this.$pgettext('*/Moderation/*/Verb', 'Report @%{ username }…') - reportableObjs.push({ - label: this.$gettextInterpolate(accountLabel, { username: account.preferred_username }), - target: { - type: 'account', - _obj: account, - full_username: account.full_username, - label: account.full_username, - typeLabel: this.$pgettext('*/*/*/Noun', 'Account') - } - }) - if (track) { - album = track.album - artist = track.artist - } - } - if (track) { - reportableObjs.push({ - label: this.$pgettext('*/Moderation/*/Verb', 'Report this track…'), - target: { - type: 'track', - id: track.id, - _obj: track, - label: track.title, - typeLabel: this.$pgettext('*/*/*/Noun', 'Track') - } - }) - album = track.album - artist = track.artist - } - if (album) { - reportableObjs.push({ - label: this.$pgettext('*/Moderation/*/Verb', 'Report this album…'), - target: { - type: 'album', - id: album.id, - label: album.title, - _obj: album, - typeLabel: this.$pgettext('*/*/*', 'Album') - } - }) - if (!artist) { - artist = album.artist - } - } - - if (channel) { - reportableObjs.push({ - label: this.$pgettext('*/Moderation/*/Verb', 'Report this channel…'), - target: { - type: 'channel', - uuid: channel.uuid, - label: channel.artist.name, - _obj: channel, - typeLabel: this.$pgettext('*/*/*', 'Channel') - } - }) - } else if (artist) { - reportableObjs.push({ - label: this.$pgettext('*/Moderation/*/Verb', 'Report this artist…'), - target: { - type: 'artist', - id: artist.id, - label: artist.name, - _obj: artist, - typeLabel: this.$pgettext('*/*/*/Noun', 'Artist') - } - }) - } - if (playlist) { - reportableObjs.push({ - label: this.$pgettext('*/Moderation/*/Verb', 'Report this playlist…'), - target: { - type: 'playlist', - id: playlist.id, - label: playlist.name, - _obj: playlist, - typeLabel: this.$pgettext('*/*/*', 'Playlist') - } - }) - } - if (library) { - reportableObjs.push({ - label: this.$pgettext('*/Moderation/*/Verb', 'Report this library…'), - target: { - type: 'library', - uuid: library.uuid, - label: library.name, - _obj: library, - typeLabel: this.$pgettext('*/*/*/Noun', 'Library') - } - }) - } - return reportableObjs - } - } -} -</script> diff --git a/front/src/components/mixins/SmartSearch.vue b/front/src/components/mixins/SmartSearch.vue deleted file mode 100644 index f662f8a303cebe2ed3f0fe4cb6c7a79c22f4c497..0000000000000000000000000000000000000000 --- a/front/src/components/mixins/SmartSearch.vue +++ /dev/null @@ -1,68 +0,0 @@ -<script> - -import { normalizeQuery, parseTokens, compileTokens } from '@/search' - -export default { - props: { - defaultQuery: { type: String, required: false, default: '' }, - updateUrl: { type: Boolean, required: false, default: false } - }, - watch: { - 'search.query' (newValue) { - this.search.tokens = parseTokens(normalizeQuery(newValue)) - }, - 'search.tokens': { - handler (newValue) { - const newQuery = compileTokens(newValue) - if (this.updateUrl) { - const params = {} - if (newQuery) { - params.q = newQuery - } - this.$router.replace({ - query: params - }) - } else { - this.search.query = newQuery - this.page = 1 - this.fetchData() - } - }, - deep: true - } - }, - methods: { - getTokenValue (key, fallback) { - const matching = this.search.tokens.filter(t => { - return t.field === key - }) - if (matching.length > 0) { - return matching[0].value - } - return fallback - }, - addSearchToken (key, value) { - value = String(value) - if (!value) { - // we remove existing matching tokens, if any - this.search.tokens = this.search.tokens.filter(t => { - return t.field !== key - }) - } else { - const existing = this.search.tokens.filter(t => { - return t.field === key - }) - if (existing.length > 0) { - // we replace the value in existing tokens, if any - existing.forEach(t => { - t.value = value - }) - } else { - // we add a new token - this.search.tokens.push({ field: key, value }) - } - } - } - } -} -</script> diff --git a/front/src/components/mixins/Themes.vue b/front/src/components/mixins/Themes.vue deleted file mode 100644 index fe7858c3b05f2a25f75e40a19504b0d6d9321af7..0000000000000000000000000000000000000000 --- a/front/src/components/mixins/Themes.vue +++ /dev/null @@ -1,25 +0,0 @@ -<script> -export default { - computed: { - themes () { - return [ - { - icon: 'palette icon', - name: this.$pgettext('*/Settings/Dropdown.Label/Theme name', 'Browser default'), - key: 'system' - }, - { - icon: 'sun icon', - name: this.$pgettext('*/Settings/Dropdown.Label/Theme name', 'Light'), - key: 'light' - }, - { - icon: 'moon icon', - name: this.$pgettext('*/Settings/Dropdown.Label/Theme name', 'Dark'), - key: 'dark' - } - ] - } - } -} -</script> diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue deleted file mode 100644 index 4380ae6eecc1b55449cd883740e47e3fb42fe3e0..0000000000000000000000000000000000000000 --- a/front/src/components/mixins/Translations.vue +++ /dev/null @@ -1,148 +0,0 @@ -<script> -export default { - computed: { - sharedLabels () { - return { - fields: { - privacy_level: { - label: this.$pgettext('Content/Settings/Dropdown.Label/Noun', 'Activity visibility'), - help: this.$pgettext('Content/Settings/Dropdown.Help text', 'Determine the visibility level of your activity'), - choices: { - me: this.$pgettext('Content/Settings/Dropdown', 'Nobody except me'), - instance: this.$pgettext('Content/Settings/Dropdown', 'Everyone on this instance'), - everyone: this.$pgettext('Content/Settings/Dropdown', 'Everyone, across all instances') - }, - shortChoices: { - me: this.$pgettext('Content/Settings/Dropdown/Short', 'Private'), - instance: this.$pgettext('Content/Settings/Dropdown/Short', 'Instance'), - everyone: this.$pgettext('Content/Settings/Dropdown/Short', 'Everyone') - } - }, - import_status: { - detailTitle: this.$pgettext('Content/Library/Link.Title', 'Click to display more information about the import process for this upload'), - choices: { - skipped: { - label: this.$pgettext('Content/Library/*', 'Skipped'), - help: this.$pgettext('Content/Library/Help text', 'This track is already present in one of your libraries') - }, - draft: { - label: this.$pgettext('Content/Library/*/Short', 'Draft'), - help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been scheduled for processing yet') - }, - pending: { - label: this.$pgettext('Content/Library/*/Short', 'Pending'), - help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been processed by the server yet') - }, - errored: { - label: this.$pgettext('Content/Library/Table/Short', 'Errored'), - help: this.$pgettext('Content/Library/Help text', 'This track could not be processed, please make sure it is tagged correctly') - }, - finished: { - label: this.$pgettext('Content/Library/*', 'Finished'), - help: this.$pgettext('Content/Library/Help text', 'Imported') - } - } - }, - report_type: { - label: this.$pgettext('*/*/*', 'Category'), - choices: { - takedown_request: this.$pgettext('Content/Moderation/Dropdown', 'Takedown request'), - invalid_metadata: this.$pgettext('Popup/Import/Error.Label', 'Invalid metadata'), - illegal_content: this.$pgettext('Content/Moderation/Dropdown', 'Illegal content'), - offensive_content: this.$pgettext('Content/Moderation/Dropdown', 'Offensive content'), - other: this.$pgettext('Content/Moderation/Dropdown', 'Other') - } - }, - summary: { - label: this.$pgettext('Content/Account/*', 'Bio') - }, - content_category: { - label: this.$pgettext('Content/*/Dropdown.Label/Noun', 'Content category'), - choices: { - podcast: this.$pgettext('Content/*/Dropdown', 'Podcast'), - music: this.$pgettext('*/*/*', 'Music'), - other: this.$pgettext('*/*/*', 'Other') - } - } - }, - filters: { - creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'), - release_date: this.$pgettext('Content/*/*/Noun', 'Release date'), - accessed_date: this.$pgettext('Content/*/*/Noun', 'Accessed date'), - first_seen: this.$pgettext('Content/Moderation/Dropdown/Noun', 'First seen date'), - last_seen: this.$pgettext('Content/Moderation/Dropdown/Noun', 'Last seen date'), - modification_date: this.$pgettext('Content/Playlist/Dropdown/Noun', 'Modification date'), - expiration_date: this.$pgettext('Content/Admin/Table.Label/Noun', 'Expiration date'), - track_title: this.$pgettext('Content/*/Dropdown/Noun', 'Track name'), - album_title: this.$pgettext('Content/*/Dropdown/Noun', 'Album name'), - artist_name: this.$pgettext('Content/*/Dropdown/Noun', 'Artist name'), - name: this.$pgettext('*/*/*/Noun', 'Name'), - length: this.$pgettext('*/*/*/Noun', 'Duration'), - items_count: this.$pgettext('*/*/*/Noun', 'Items'), - size: this.$pgettext('Content/*/*/Noun', 'Size'), - bitrate: this.$pgettext('Content/Track/*/Noun', 'Bitrate'), - duration: this.$pgettext('Content/*/*', 'Duration'), - date_joined: this.$pgettext('Content/Admin/Table.Label/Noun', 'Sign-up date'), - last_activity: this.$pgettext('Content/Profile/Table.Label/Short, Noun (Value is a date)', 'Last activity'), - username: this.$pgettext('Content/*/*', 'Username'), - domain: this.$pgettext('Content/Moderation/*/Noun', 'Domain'), - users: this.$pgettext('*/*/*/Noun', 'Users'), - received_messages: this.$pgettext('Content/Moderation/*/Noun', 'Received messages'), - uploads: this.$pgettext('*/*/*', 'Uploads'), - followers: this.$pgettext('Content/Federation/*/Noun', 'Followers') - }, - scopes: { - profile: { - label: this.$pgettext('Content/OAuth Scopes/Label', 'Profile'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to e-mail, username, and profile information') - }, - libraries: { - label: this.$pgettext('Content/OAuth Scopes/Label', 'Libraries and uploads'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to audio files, libraries, artists, albums and tracks') - }, - favorites: { - label: this.$pgettext('Sidebar/Favorites/List item.Link/Noun', 'Favorites'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to favorites') - }, - listenings: { - label: this.$pgettext('*/*/*/Noun', 'Listenings'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to listening history') - }, - follows: { - label: this.$pgettext('Content/OAuth Scopes/Label', 'Follows'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to follows') - }, - playlists: { - label: this.$pgettext('*/*/*', 'Playlists'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to playlists') - }, - radios: { - label: this.$pgettext('*/*/*', 'Radios'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to radios') - }, - filters: { - label: this.$pgettext('Content/Settings/Title/Noun', 'Content filters'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to content filters') - }, - notifications: { - label: this.$pgettext('*/Notifications/*', 'Notifications'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to notifications') - }, - edits: { - label: this.$pgettext('*/Admin/*/Noun', 'Edits'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to edits') - }, - security: { - label: this.$pgettext('*/Admin/*/Noun', 'Security'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to security settings such as password and authorization') - }, - reports: { - label: this.$pgettext('*/Moderation/*/Noun', 'Reports'), - description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to moderation reports') - } - } - } - } - } -} -</script> diff --git a/front/src/components/moderation/FilterModal.vue b/front/src/components/moderation/FilterModal.vue index e5e57dc896cc0e2605fd5f58e3d1e93f8beffa08..6e326d21e2f91132cab62bce823c1033c2f4bb00 100644 --- a/front/src/components/moderation/FilterModal.vue +++ b/front/src/components/moderation/FilterModal.vue @@ -1,14 +1,69 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import axios from 'axios' + +import { computed, ref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import SemanticModal from '~/components/semantic/Modal.vue' +import useLogger from '~/composables/useLogger' + +const logger = useLogger() +const { $pgettext } = useGettext() + +const store = useStore() +const show = computed({ + get: () => store.state.moderation.showFilterModal, + set: (value) => { + store.commit('moderation/showFilterModal', value) + errors.value = [] + } +}) + +const type = computed(() => store.state.moderation.filterModalTarget.type) +const target = computed(() => store.state.moderation.filterModalTarget.target) + +const errors = ref([] as string[]) +const isLoading = ref(false) + +const hide = async () => { + isLoading.value = true + + const payload = { + target: { + type: type.value, + id: target.value?.id + } + } + + try { + const response = await axios.post('moderation/content-filters/', payload) + logger.info(`Successfully hidden ${type.value} ${target.value?.id}`) + show.value = false + store.state.moderation.lastUpdate = new Date() + store.commit('moderation/contentFilter', response.data) + store.commit('ui/addMessage', { + content: $pgettext('*/Moderation/Message', 'Content filter successfully added'), + date: new Date() + }) + } catch (error) { + logger.error(`Error while hiding ${type.value} ${target.value?.id}`) + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> - <modal - :show="$store.state.moderation.showFilterModal" - @update:show="update" - > + <semantic-modal v-model:show="show"> <h4 class="header"> <translate v-if="type === 'artist'" - key="1" translate-context="Popup/Moderation/Title/Verb" - :translate-params="{name: target.name}" + :translate-params="{name: target?.name}" > Do you want to hide content from artist "%{ name }"? </translate> @@ -85,64 +140,5 @@ </translate> </button> </div> - </modal> + </semantic-modal> </template> - -<script> -import axios from 'axios' -import { mapState } from 'vuex' - -import logger from '@/logging' -import Modal from '@/components/semantic/Modal.vue' - -export default { - components: { - Modal - }, - data () { - return { - formKey: String(new Date()), - errors: [], - isLoading: false - } - }, - computed: { - ...mapState({ - type: state => state.moderation.filterModalTarget.type, - target: state => state.moderation.filterModalTarget.target - }) - }, - methods: { - update (v) { - this.$store.commit('moderation/showFilterModal', v) - this.errors = [] - }, - hide () { - const self = this - self.isLoading = true - const payload = { - target: { - type: this.type, - id: this.target.id - } - } - return axios.post('moderation/content-filters/', payload).then(response => { - logger.default.info('Successfully added track to playlist') - self.update(false) - self.$store.commit('moderation/lastUpdate', new Date()) - self.isLoading = false - const msg = this.$pgettext('*/Moderation/Message', 'Content filter successfully added') - self.$store.commit('moderation/contentFilter', response.data) - self.$store.commit('ui/addMessage', { - content: msg, - date: new Date() - }) - }, error => { - logger.default.error(`Error while hiding ${self.type} ${self.target.id}`) - self.errors = error.backendErrors - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/components/moderation/ReportCategoryDropdown.vue b/front/src/components/moderation/ReportCategoryDropdown.vue index 4521f5b1c8a330f7de974e3f673cd37ba03a60f1..0cbb6ba7ace3349d2030869a1a57f3115380709f 100644 --- a/front/src/components/moderation/ReportCategoryDropdown.vue +++ b/front/src/components/moderation/ReportCategoryDropdown.vue @@ -1,11 +1,69 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' +import useSharedLabels from '~/composables/locale/useSharedLabels' +import { useVModel } from '@vueuse/core' + +interface Events { + (e: 'update:modelValue', value: string): void +} + +interface Props { + modelValue: string + all?: boolean + label?: boolean + empty?: boolean + required?: boolean + restrictTo?: string[] // TODO (wvffle): Make sure its string list +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + all: false, + label: false, + empty: false, + required: false, + restrictTo: () => [] +}) + +const value = useVModel(props, 'modelValue', emit) + +const { $pgettext } = useGettext() +const sharedLabels = useSharedLabels() +const sharedChoices = sharedLabels.fields.report_type.choices + +const allCategories = computed(() => { + const res = [] + if (props.all) { + res.push({ value: '', label: $pgettext('Content/*/Dropdown', 'All') }) + } + + const choices = props.restrictTo.length === 0 + ? Object.keys(sharedChoices) + : props.restrictTo + + for (const value of choices.sort()) { + res.push({ + value, + label: value in sharedChoices + // NOTE: M$ simply locked the conversation instead of fixing type inferring + // https://github.com/microsoft/TypeScript/issues/35859 + ? sharedChoices[value as keyof typeof sharedChoices] + : value + }) + } + + return res +}) +</script> + <template> <div> <label v-if="label"><translate translate-context="*/*/*">Category</translate></label> <select + v-model="value" class="ui dropdown" - :value="value" - :required="required" - @change="$emit('input', $event.target.value)" + :required="required || undefined" > <option v-if="empty" @@ -13,8 +71,8 @@ value="" /> <option - v-for="(option, key) in allCategories" - :key="key" + v-for="option in allCategories" + :key="option.label" :value="option.value" > {{ option.label }} @@ -23,46 +81,3 @@ <slot /> </div> </template> - -<script> -import TranslationsMixin from '@/components/mixins/Translations.vue' -import lodash from 'lodash' -export default { - mixins: [TranslationsMixin], - props: { - value: { type: String, default: null }, - all: { type: Boolean, default: null }, - label: { type: Boolean }, - empty: { type: Boolean }, - required: { type: Boolean }, - restrictTo: { type: Array, default: () => { return [] } } - }, - computed: { - allCategories () { - const c = [] - if (this.all) { - c.push( - { - value: '', - label: this.$pgettext('Content/*/Dropdown', 'All') - } - ) - } - let choices - if (this.restrictTo.length > 0) { - choices = this.restrictTo - } else { - choices = lodash.keys(this.sharedLabels.fields.report_type.choices) - } - return c.concat( - choices.sort().map((v) => { - return { - value: v, - label: this.sharedLabels.fields.report_type.choices[v] || v - } - }) - ) - } - } -} -</script> diff --git a/front/src/components/moderation/ReportModal.vue b/front/src/components/moderation/ReportModal.vue index 244f30a7c0697a68591615180ca57c73256a832e..d4ef9a8da83b689b86616663dd2ff8018609a4db 100644 --- a/front/src/components/moderation/ReportModal.vue +++ b/front/src/components/moderation/ReportModal.vue @@ -1,8 +1,126 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import axios from 'axios' +import ReportCategoryDropdown from '~/components/moderation/ReportCategoryDropdown.vue' +import SemanticModal from '~/components/semantic/Modal.vue' +import { computed, ref, watchEffect } from 'vue' +import { useStore } from '~/store' +import { useGettext } from 'vue3-gettext' + +interface ReportType { + anonymous: boolean + type: string +} + +const store = useStore() +const target = computed(() => store.state.moderation.reportModalTarget) + +const forward = ref(false) +const summary = ref('') +const category = ref('') +const submitterEmail = ref('') + +const reportTypes = ref([] as ReportType[]) +const allowedCategories = computed(() => { + if (store.state.auth.authenticated) { + return [] + } + + return reportTypes.value + .filter((type) => type.anonymous === true) + .map((type) => type.type) +}) + +const canSubmit = computed(() => store.state.auth.authenticated || allowedCategories.value.length > 0) + +const targetDomain = computed(() => { + if (!target.value._obj) { + return + } + + const fid = target.value.type === 'channel' && target.value._obj.actor + ? target.value._obj.actor.fid + : target.value._obj.fid + + return !fid + ? store.getters['instance/domain'] + : new URL(fid).hostname +}) + +const isLocal = computed(() => store.getters['instance/domain'] === targetDomain.value) + +const errors = ref([] as string[]) + +const show = computed({ + get: () => store.state.moderation.showReportModal, + set: (value: boolean) => { + store.commit('moderation/showReportModal', value) + errors.value = [] + } +}) + +const isLoading = ref(false) + +// TODO (wvffle): MOVE ALL use*() METHODS SOMEWHERE TO THE TOP +const { $pgettext } = useGettext() + +const submit = async () => { + isLoading.value = true + + const payload = { + target: { ...target.value, _obj: undefined }, + summary: summary.value, + type: category.value, + forward: forward.value, + submitter_email: !store.state.auth.authenticated + ? submitterEmail.value + : undefined + } + + try { + const response = await axios.post('moderation/reports/', payload) + show.value = false + + store.commit('moderation/contentFilter', response.data) + store.commit('ui/addMessage', { + content: $pgettext('*/Moderation/Message', 'Report successfully submitted, thank you'), + date: new Date() + }) + + summary.value = '' + category.value = '' + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const isLoadingReportTypes = ref(false) +watchEffect(async () => { + if (!store.state.moderation.showReportModal || store.state.auth.authenticated) { + return + } + + isLoadingReportTypes.value = true + + try { + const response = await axios.get('instance/nodeinfo/2.0/') + reportTypes.value = response.data.metadata.reportTypes ?? [] + } catch (error) { + store.commit('ui/addMessage', { + content: $pgettext('*/Moderation/Message', 'Cannot fetch Node Info: %{ error }', { error: `${error}` }), + date: new Date() + }) + } + + isLoadingReportTypes.value = false +}) +</script> + <template> - <modal - :show="$store.state.moderation.showReportModal" - @update:show="update" - > + <semantic-modal v-model:show="show"> <h2 v-if="target" class="ui header" @@ -150,130 +268,5 @@ </translate> </button> </div> - </modal> + </semantic-modal> </template> - -<script> -import axios from 'axios' -import { mapState } from 'vuex' -import ReportCategoryDropdown from '@/components/moderation/ReportCategoryDropdown.vue' -import Modal from '@/components/semantic/Modal.vue' - -function urlDomain (data) { - const a = document.createElement('a') - a.href = data - return a.hostname -} - -export default { - components: { - ReportCategoryDropdown, - Modal - }, - data () { - return { - formKey: String(new Date()), - errors: [], - isLoading: false, - isLoadingReportTypes: false, - summary: '', - submitterEmail: '', - category: null, - reportTypes: [], - forward: false - } - }, - computed: { - ...mapState({ - target: state => state.moderation.reportModalTarget - }), - allowedCategories () { - if (this.$store.state.auth.authenticated) { - return [] - } - return this.reportTypes.filter((t) => { - return t.anonymous === true - }).map((c) => { - return c.type - }) - }, - canSubmit () { - if (this.$store.state.auth.authenticated) { - return true - } - - return this.allowedCategories.length > 0 - }, - targetDomain () { - if (!this.target._obj) { - return - } - let fid = this.target._obj.fid - if (this.target.type === 'channel' && this.target._obj.actor) { - fid = this.target._obj.actor.fid - } - if (!fid) { - return this.$store.getters['instance/domain'] - } - return urlDomain(fid) - }, - isLocal () { - return this.$store.getters['instance/domain'] === this.targetDomain - } - }, - watch: { - '$store.state.moderation.showReportModal': function (v) { - if (!v || this.$store.state.auth.authenticated) { - return - } - - const self = this - self.isLoadingReportTypes = true - axios.get('instance/nodeinfo/2.0/').then(response => { - self.isLoadingReportTypes = false - self.reportTypes = response.data.metadata.reportTypes || [] - }, error => { - self.isLoadingReportTypes = false - self.$store.commit('ui/addMessage', { - content: 'Cannot fetch Node Info: ' + error, - date: new Date() - }) - }) - } - }, - methods: { - update (v) { - this.$store.commit('moderation/showReportModal', v) - this.errors = [] - }, - submit () { - const self = this - self.isLoading = true - const payload = { - target: { ...this.target, _obj: null }, - summary: this.summary, - type: this.category, - forward: this.forward - } - if (!this.$store.state.auth.authenticated) { - payload.submitter_email = this.submitterEmail - } - return axios.post('moderation/reports/', payload).then(response => { - self.update(false) - self.isLoading = false - const msg = this.$pgettext('*/Moderation/Message', 'Report successfully submitted, thank you') - self.$store.commit('moderation/contentFilter', response.data) - self.$store.commit('ui/addMessage', { - content: msg, - date: new Date() - }) - self.summary = '' - self.category = '' - }, error => { - self.errors = error.backendErrors - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/components/notifications/NotificationRow.vue b/front/src/components/notifications/NotificationRow.vue index 9668d5351a545ed36f8135f699556526203d31c3..0f20596057c5f0470ebf37544b83a07daa4c71e5 100644 --- a/front/src/components/notifications/NotificationRow.vue +++ b/front/src/components/notifications/NotificationRow.vue @@ -1,3 +1,109 @@ +<script setup lang="ts"> +import type { Notification, LibraryFollow } from '~/types' + +import { computed, ref, watchEffect } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' + +interface Props { + initialItem: Notification +} + +const props = defineProps<Props>() + +const { $pgettext, $gettext } = useGettext() +const store = useStore() + +const labels = computed(() => ({ + libraryFollowMessage: $pgettext('Content/Notifications/Paragraph', '%{ username } followed your library "%{ library }"'), + libraryAcceptFollowMessage: $pgettext('Content/Notifications/Paragraph', '%{ username } accepted your follow on library "%{ library }"'), + libraryRejectMessage: $pgettext('Content/Notifications/Paragraph', 'You rejected %{ username }'s request to follow "%{ library }"'), + libraryPendingFollowMessage: $pgettext('Content/Notifications/Paragraph', '%{ username } wants to follow your library "%{ library }"'), + markRead: $pgettext('Content/Notifications/Button.Tooltip/Verb', 'Mark as read'), + markUnread: $pgettext('Content/Notifications/Button.Tooltip/Verb', 'Mark as unread') +})) + +const item = ref(props.initialItem) +watchEffect(() => (item.value = props.initialItem)) + +const username = computed(() => props.initialItem.activity.actor.preferred_username) +const notificationData = computed(() => { + const activity = props.initialItem.activity + + if (activity.type === 'Follow') { + if (activity.object && activity.object.type === 'music.Library') { + const detailUrl = { name: 'content.libraries.detail', params: { id: activity.object.uuid } } + + if (activity.related_object?.approved === null) { + return { + detailUrl, + message: $gettext(labels.value.libraryPendingFollowMessage, { username: username.value, library: activity.object.name }), + acceptFollow: { + buttonClass: 'success', + icon: 'check', + label: $pgettext('Content/*/Button.Label/Verb', 'Approve'), + handler: () => approveLibraryFollow(activity.related_object) + }, + rejectFollow: { + buttonClass: 'danger', + icon: 'x', + label: $pgettext('Content/*/Button.Label/Verb', 'Reject'), + handler: () => rejectLibraryFollow(activity.related_object) + } + } + } else if (activity.related_object?.approved) { + return { + detailUrl, + message: $gettext(labels.value.libraryFollowMessage, { username: username.value, library: activity.object.name }) + } + } + + return { + detailUrl, + message: $gettext(labels.value.libraryRejectMessage, { username: username.value, library: activity.object.name }) + } + } + } + + if (activity.type === 'Accept') { + if (activity.object?.type === 'federation.LibraryFollow') { + return { + detailUrl: { name: 'content.remote.index' }, + message: $gettext(labels.value.libraryAcceptFollowMessage, { username: username.value, library: activity.related_object.name }) + } + } + } + + return {} +}) + +const read = ref(false) +watchEffect(async () => { + await axios.patch(`federation/inbox/${item.value.id}/`, { is_read: read.value }) + + item.value.is_read = read.value + store.commit('ui/incrementNotifications', { type: 'inbox', count: read.value ? -1 : 1 }) +}) + +const handleAction = (handler?: () => void) => { + // call handler then mark notification as read + handler?.() + read.value = true +} + +const approveLibraryFollow = async (follow: LibraryFollow) => { + await axios.post(`federation/follows/library/${follow.uuid}/accept/`) + follow.approved = true +} + +const rejectLibraryFollow = async (follow: LibraryFollow) => { + await axios.post(`federation/follows/library/${follow.uuid}/reject/`) + follow.approved = false +} +</script> + <template> <tr :class="[{'disabled-row': item.is_read}]"> <td> @@ -9,20 +115,27 @@ <td> <router-link v-if="notificationData.detailUrl" - tag="span" - class="link" + v-slot="{ navigate }" + custom :to="notificationData.detailUrl" - v-html="notificationData.message" - /> - <template + > + <sanitized-html + tag="span" + class="link" + :html="notificationData.message" + @click="navigate" + @keypress.enter="navigate" + /> + </router-link> + <sanitized-html v-else - v-html="notificationData.message" + :html="notificationData.message" /> <template v-if="notificationData.acceptFollow"> <button :class="['ui', 'basic', 'tiny', notificationData.acceptFollow.buttonClass || '', 'button']" - @click="handleAction(notificationData.acceptFollow.handler)" + @click="handleAction(notificationData.acceptFollow?.handler)" > <i v-if="notificationData.acceptFollow.icon" @@ -32,7 +145,7 @@ </button> <button :class="['ui', 'basic', 'tiny', notificationData.rejectFollow.buttonClass || '', 'button']" - @click="handleAction(notificationData.rejectFollow.handler)" + @click="handleAction(notificationData.rejectFollow?.handler)" > <i v-if="notificationData.rejectFollow.icon" @@ -50,7 +163,7 @@ :aria-label="labels.markUnread" class="discrete link" :title="labels.markUnread" - @click.prevent="markRead(false)" + @click.prevent="read = false" > <i class="redo icon" /> </a> @@ -60,128 +173,10 @@ :aria-label="labels.markRead" class="discrete link" :title="labels.markRead" - @click.prevent="markRead(true)" + @click.prevent="read = true" > <i class="check icon" /> </a> </td> </tr> </template> -<script> -import axios from 'axios' - -export default { - props: { initialItem: { type: Object, required: true } }, - data: function () { - return { - item: this.initialItem - } - }, - computed: { - message () { - return 'plop' - }, - labels () { - const libraryFollowMessage = this.$pgettext('Content/Notifications/Paragraph', '%{ username } followed your library "%{ library }"') - const libraryAcceptFollowMessage = this.$pgettext('Content/Notifications/Paragraph', '%{ username } accepted your follow on library "%{ library }"') - const libraryRejectMessage = this.$pgettext('Content/Notifications/Paragraph', 'You rejected %{ username }'s request to follow "%{ library }"') - const libraryPendingFollowMessage = this.$pgettext('Content/Notifications/Paragraph', '%{ username } wants to follow your library "%{ library }"') - return { - libraryFollowMessage, - libraryAcceptFollowMessage, - libraryRejectMessage, - libraryPendingFollowMessage, - markRead: this.$pgettext('Content/Notifications/Button.Tooltip/Verb', 'Mark as read'), - markUnread: this.$pgettext('Content/Notifications/Button.Tooltip/Verb', 'Mark as unread') - - } - }, - username () { - return this.item.activity.actor.preferred_username - }, - notificationData () { - const self = this - const a = this.item.activity - if (a.type === 'Follow') { - if (a.object && a.object.type === 'music.Library') { - let acceptFollow = null - let rejectFollow = null - let message = null - if (a.related_object && a.related_object.approved === null) { - message = this.labels.libraryPendingFollowMessage - acceptFollow = { - buttonClass: 'success', - icon: 'check', - label: this.$pgettext('Content/*/Button.Label/Verb', 'Approve'), - handler: () => { self.approveLibraryFollow(a.related_object) } - } - rejectFollow = { - buttonClass: 'danger', - icon: 'x', - label: this.$pgettext('Content/*/Button.Label/Verb', 'Reject'), - handler: () => { self.rejectLibraryFollow(a.related_object) } - } - } else if (a.related_object && a.related_object.approved) { - message = this.labels.libraryFollowMessage - } else { - message = this.labels.libraryRejectMessage - } - return { - acceptFollow, - rejectFollow, - detailUrl: { name: 'content.libraries.detail', params: { id: a.object.uuid } }, - message: this.$gettextInterpolate( - message, - { username: this.username, library: a.object.name } - ) - } - } - } - if (a.type === 'Accept') { - if (a.object && a.object.type === 'federation.LibraryFollow') { - return { - detailUrl: { name: 'content.remote.index' }, - message: this.$gettextInterpolate( - this.labels.libraryAcceptFollowMessage, - { username: this.username, library: a.related_object.name } - ) - } - } - } - return {} - } - }, - methods: { - handleAction (handler) { - // call handler then mark notification as read - handler() - this.markRead(true) - }, - approveLibraryFollow (follow) { - const action = 'accept' - axios.post(`federation/follows/library/${follow.uuid}/${action}/`).then((response) => { - follow.isLoading = false - follow.approved = true - }) - }, - rejectLibraryFollow (follow) { - const action = 'reject' - axios.post(`federation/follows/library/${follow.uuid}/${action}/`).then((response) => { - follow.isLoading = false - follow.approved = false - }) - }, - markRead (value) { - const self = this - axios.patch(`federation/inbox/${this.item.id}/`, { is_read: value }).then((response) => { - self.item.is_read = value - if (value) { - self.$store.commit('ui/incrementNotifications', { type: 'inbox', count: -1 }) - } else { - self.$store.commit('ui/incrementNotifications', { type: 'inbox', count: 1 }) - } - }) - } - } -} -</script> diff --git a/front/src/components/playlists/Card.vue b/front/src/components/playlists/Card.vue index 7335e0d6e78fd5b02b9b347b125c6f3de3b635c9..4007fc767ff37a15352cbbb14010650c3fca8b27 100644 --- a/front/src/components/playlists/Card.vue +++ b/front/src/components/playlists/Card.vue @@ -1,3 +1,29 @@ +<script setup lang="ts"> +import type { Playlist } from '~/types' + +import PlayButton from '~/components/audio/PlayButton.vue' +import defaultCover from '~/assets/audio/default-cover.png' +import { computed } from 'vue' +import { useStore } from '~/store' + +interface Props { + playlist: Playlist +} + +const props = defineProps<Props>() +const store = useStore() + +const images = computed(() => { + const urls = props.playlist.album_covers.slice(0, 4).map(url => store.getters['instance/absoluteUrl'](url)) + + while (urls.length < 4) { + urls.push(defaultCover) + } + + return urls +}) +</script> + <template> <div class="ui app-card card"> <div @@ -53,27 +79,3 @@ </div> </div> </template> - -<script> -import PlayButton from '@/components/audio/PlayButton.vue' -import defaultCover from '@/assets/audio/default-cover.png' - -export default { - components: { - PlayButton - }, - props: { playlist: { type: Object, required: true } }, - computed: { - images () { - const self = this - const urls = this.playlist.album_covers.map((url) => { - return self.$store.getters['instance/absoluteUrl'](url) - }).slice(0, 4) - while (urls.length < 4) { - urls.push(defaultCover) - } - return urls - } - } -} -</script> diff --git a/front/src/components/playlists/CardList.vue b/front/src/components/playlists/CardList.vue index f88cf5f74e96583e2629c014e27966823293ce78..7426642bdc7954e85d2d10735173a11858618338 100644 --- a/front/src/components/playlists/CardList.vue +++ b/front/src/components/playlists/CardList.vue @@ -1,3 +1,15 @@ +<script setup lang="ts"> +import type { Playlist } from '~/types' + +import PlaylistCard from '~/components/playlists/Card.vue' + +interface Props { + playlists: Playlist[] +} + +defineProps<Props>() +</script> + <template> <div v-if="playlists.length > 0"> <div class="ui app-cards cards"> @@ -9,15 +21,3 @@ </div> </div> </template> - -<script> - -import PlaylistCard from '@/components/playlists/Card.vue' - -export default { - components: { - PlaylistCard - }, - props: { playlists: { type: Array, required: true } } -} -</script> diff --git a/front/src/components/playlists/Editor.vue b/front/src/components/playlists/Editor.vue index e6e304089723a26d1e24d1416e21a454b37e9839..ea4f6b8c387691aab47d3f1e8c1b7b5297056b75 100644 --- a/front/src/components/playlists/Editor.vue +++ b/front/src/components/playlists/Editor.vue @@ -1,9 +1,170 @@ +<script setup lang="ts"> +import type { Playlist, Track, PlaylistTrack, BackendError, APIErrorResponse } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { useVModels } from '@vueuse/core' +import { computed, ref } from 'vue' +import { useStore } from '~/store' + +import draggable from 'vuedraggable' +import axios from 'axios' + +import PlaylistForm from '~/components/playlists/Form.vue' + +import useQueue from '~/composables/audio/useQueue' + +interface Events { + (e: 'update:playlistTracks', value: PlaylistTrack[]): void + (e: 'update:playlist', value: Playlist): void +} + +interface Props { + playlist: Playlist | null + playlistTracks: PlaylistTrack[] +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const { playlistTracks, playlist } = useVModels(props, emit) + +const errors = ref([] as string[]) +const duplicateTrackAddInfo = ref<{ tracks: string[] }>() +const showDuplicateTrackAddConfirmation = ref(false) + +const { tracks: queueTracks } = useQueue() + +interface ModifiedPlaylistTrack extends PlaylistTrack { + _id?: string +} + +const tracks = computed({ + get: () => playlistTracks.value.map((playlistTrack, index) => ({ ...playlistTrack, _id: `${index}-${playlistTrack.track.id}` } as ModifiedPlaylistTrack)), + set: (playlist) => { + playlistTracks.value = playlist.map((modifiedPlaylistTrack, index) => { + const res = { ...modifiedPlaylistTrack, index } as ModifiedPlaylistTrack + delete res._id + return res as PlaylistTrack + }) + } +}) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + copyTitle: $pgettext('Content/Playlist/Button.Tooltip/Verb', 'Copy the current queue to this playlist') +})) + +const isLoading = ref(false) +const status = computed(() => isLoading.value + ? 'loading' + : showDuplicateTrackAddConfirmation.value + ? 'confirmDuplicateAdd' + : errors.value.length > 0 + ? 'errored' + : 'saved' +) + +const responseHandlers = { + success () { + errors.value = [] + showDuplicateTrackAddConfirmation.value = false + }, + errored (error: BackendError): void { + showDuplicateTrackAddConfirmation.value = false + + const { backendErrors, rawPayload = {} } = error + if (backendErrors.length === 1 && backendErrors[0] === 'Tracks Already Exist In Playlist') { + duplicateTrackAddInfo.value = ((rawPayload.playlist as APIErrorResponse).non_field_errors as APIErrorResponse)[0] as { tracks: string[] } + showDuplicateTrackAddConfirmation.value = true + return + } + + errors.value = backendErrors + } +} + +const fetchTracks = async () => { + // NOTE: This is handled by other functions and never used directly + const response = await axios.get(`playlists/${playlist.value?.id}/tracks/`) + playlistTracks.value = response.data.results +} + +const store = useStore() +const reorder = async ({ oldIndex: from, newIndex: to }: { oldIndex: number, newIndex: number }) => { + isLoading.value = true + + try { + await axios.post(`playlists/${playlist.value?.id}/move/`, { from, to }) + await store.dispatch('playlists/fetchOwn') + responseHandlers.success() + } catch (error) { + responseHandlers.errored(error as BackendError) + } + + isLoading.value = false +} + +const removePlaylistTrack = async (index: number) => { + isLoading.value = true + + try { + tracks.value.splice(index, 1) + await axios.post(`playlists/${playlist.value?.id}/remove/`, { index }) + await Promise.all([ + store.dispatch('playlists/fetchOwn'), + fetchTracks() + ]) + responseHandlers.success() + } catch (error) { + responseHandlers.errored(error as BackendError) + } + + isLoading.value = false +} + +const clearPlaylist = async () => { + isLoading.value = true + + try { + tracks.value = [] + await axios.delete(`playlists/${playlist.value?.id}/clear/`) + await store.dispatch('playlists/fetchOwn') + responseHandlers.success() + } catch (error) { + responseHandlers.errored(error as BackendError) + } + + isLoading.value = false +} + +const insertMany = async (insertedTracks: Track[], allowDuplicates: boolean) => { + isLoading.value = true + + try { + const response = await axios.post(`playlists/${playlist.value?.id}/add/`, { + allow_duplicates: allowDuplicates, + tracks: insertedTracks.map(track => track.id) + }) + + tracks.value.push(...response.data.results) + await Promise.all([ + store.dispatch('playlists/fetchOwn'), + fetchTracks() + ]) + responseHandlers.success() + } catch (error) { + responseHandlers.errored(error as BackendError) + } + + isLoading.value = false +} +</script> + <template> <div class="ui text container component-playlist-editor"> <playlist-form + v-model:playlist="playlist" :title="false" - :playlist="playlist" - @updated="$emit('playlist-updated', $event)" /> <h3 class="ui top attached header"> <translate translate-context="Content/Playlist/Title"> @@ -29,8 +190,8 @@ > <ul class="list"> <li - v-for="(error, key) in errors" - :key="key" + v-for="error in errors" + :key="error" > {{ error }} </li> @@ -43,15 +204,15 @@ class="ui warning message" > <p - v-translate="{playlist: playlist.name}" + v-translate="{playlist: playlist?.name}" translate-context="Content/Playlist/Paragraph" > Some tracks in your queue are already in this playlist: </p> <ul class="ui relaxed divided list duplicate-tracks-list"> <li - v-for="(track, key) in duplicateTrackAddInfo.tracks" - :key="key" + v-for="track in duplicateTrackAddInfo?.tracks ?? []" + :key="track" class="ui item" > {{ track }} @@ -91,34 +252,39 @@ </button> <dangerous-button - :disabled="plts.length === 0" + :disabled="tracks.length === 0" class="ui labeled right floated danger icon button" :action="clearPlaylist" > <i class="eraser icon" /> <translate translate-context="*/Playlist/Button.Label/Verb"> Clear playlist </translate> - <p - slot="modal-header" - v-translate="{playlist: playlist.name}" - translate-context="Popup/Playlist/Title" - :translate-params="{playlist: playlist.name}" - > - Do you want to clear the playlist "%{ playlist }"? - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Playlist/Paragraph"> - This will remove all tracks from this playlist and cannot be undone. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="*/Playlist/Button.Label/Verb"> - Clear playlist - </translate> - </div> + <template #modal-header> + <p + v-translate="{playlist: playlist?.name}" + translate-context="Popup/Playlist/Title" + :translate-params="{playlist: playlist?.name}" + > + Do you want to clear the playlist "%{ playlist }"? + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Playlist/Paragraph"> + This will remove all tracks from this playlist and cannot be undone. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="*/Playlist/Button.Label/Verb"> + Clear playlist + </translate> + </div> + </template> </dangerous-button> <div class="ui hidden divider" /> - <template v-if="plts.length > 0"> + <template v-if="tracks.length > 0"> <p> <translate translate-context="Content/Playlist/Paragraph/Call to action"> Drag and drop rows to reorder tracks in the playlist @@ -127,46 +293,46 @@ <div class="table-wrapper"> <table class="ui compact very basic unstackable table"> <draggable - v-model="plts" + v-model="tracks" tag="tbody" + item-key="_id" @update="reorder" > - <tr - v-for="(plt, index) in plts" - :key="`${index}-${plt.track.id}`" - > - <td class="left aligned"> - {{ plt.index + 1 }} - </td> - <td class="center aligned"> - <img - v-if="plt.track.album && plt.track.album.cover && plt.track.album.cover.urls.original" - v-lazy="$store.getters['instance/absoluteUrl'](plt.track.album.cover.urls.medium_square_crop)" - alt="" - class="ui mini image" - > - <img - v-else - alt="" - class="ui mini image" - src="../../assets/audio/default-cover.png" - > - </td> - <td colspan="4"> - <strong>{{ plt.track.title }}</strong><br> - {{ plt.track.artist.name }} - </td> - <td class="right aligned"> - <button - class="ui circular danger basic icon button" - @click.stop="removePlt(index)" - > - <i - class="trash icon" - /> - </button> - </td> - </tr> + <template #item="{ element: plt, index }"> + <tr> + <td class="left aligned"> + {{ plt.index + 1 }} + </td> + <td class="center aligned"> + <img + v-if="plt.track.album && plt.track.album.cover && plt.track.album.cover.urls.original" + v-lazy="$store.getters['instance/absoluteUrl'](plt.track.album.cover.urls.medium_square_crop)" + alt="" + class="ui mini image" + > + <img + v-else + alt="" + class="ui mini image" + src="../../assets/audio/default-cover.png" + > + </td> + <td colspan="4"> + <strong>{{ plt.track.title }}</strong><br> + {{ plt.track.artist.name }} + </td> + <td class="right aligned"> + <button + class="ui circular danger basic icon button" + @click.stop="removePlaylistTrack(index)" + > + <i + class="trash icon" + /> + </button> + </td> + </tr> + </template> </draggable> </table> </div> @@ -174,141 +340,3 @@ </div> </div> </template> - -<script> -import { mapState } from 'vuex' -import axios from 'axios' -import PlaylistForm from '@/components/playlists/Form.vue' - -import draggable from 'vuedraggable' - -export default { - components: { - draggable, - PlaylistForm - }, - props: { - playlist: { type: Object, required: true }, - playlistTracks: { type: Array, required: true } - }, - data () { - return { - plts: this.playlistTracks, - isLoading: false, - errors: [], - duplicateTrackAddInfo: {}, - showDuplicateTrackAddConfirmation: false - } - }, - computed: { - ...mapState({ - queueTracks: state => state.queue.tracks - }), - labels () { - return { - copyTitle: this.$pgettext('Content/Playlist/Button.Tooltip/Verb', 'Copy the current queue to this playlist') - } - }, - status () { - if (this.isLoading) { - return 'loading' - } - if (this.errors.length > 0) { - return 'errored' - } - if (this.showDuplicateTrackAddConfirmation) { - return 'confirmDuplicateAdd' - } - return 'saved' - } - }, - watch: { - plts: { - handler (newValue) { - newValue.forEach((e, i) => { - e.index = i - }) - this.$emit('tracks-updated', newValue) - }, - deep: true - } - }, - methods: { - success () { - this.isLoading = false - this.errors = [] - this.showDuplicateTrackAddConfirmation = false - }, - errored (errors) { - this.isLoading = false - if (errors.length === 1 && errors[0].code === 'tracks_already_exist_in_playlist') { - this.duplicateTrackAddInfo = errors[0] - this.showDuplicateTrackAddConfirmation = true - } else { - this.errors = errors - } - }, - reorder ({ oldIndex, newIndex }) { - const self = this - self.isLoading = true - const url = `playlists/${this.playlist.id}/move` - axios.post(url, { from: oldIndex, to: newIndex }).then((response) => { - self.success() - }, error => { - self.errored(error.backendErrors) - }) - }, - removePlt (index) { - this.plts.splice(index, 1) - const self = this - self.isLoading = true - const url = `playlists/${this.playlist.id}/remove` - axios.post(url, { index }).then((response) => { - self.success() - self.$store.dispatch('playlists/fetchOwn') - }, error => { - self.errored(error.backendErrors) - }) - }, - clearPlaylist () { - this.plts = [] - const self = this - self.isLoading = true - const url = 'playlists/' + this.playlist.id + '/clear' - axios.delete(url).then((response) => { - self.success() - self.$store.dispatch('playlists/fetchOwn') - }, error => { - self.errored(error.backendErrors) - }) - }, - insertMany (tracks, allowDuplicates) { - const self = this - const ids = tracks.map(t => { - return t.id - }) - const payload = { - tracks: ids, - allow_duplicates: allowDuplicates - } - self.isLoading = true - const url = 'playlists/' + this.playlist.id + '/add/' - axios.post(url, payload).then((response) => { - response.data.results.forEach(r => { - self.plts.push(r) - }) - self.success() - self.$store.dispatch('playlists/fetchOwn') - }, error => { - // if backendErrors isn't populated (e.g. duplicate track exceptions raised by - // the playlist model), read directly from the response - if (error.rawPayload.playlist) { - self.errored(error.rawPayload.playlist) - } else { - self.errored(error.backendErrors) - } - }) - } - } -} -</script> diff --git a/front/src/components/playlists/Form.vue b/front/src/components/playlists/Form.vue index 1703bce91e9f6bcd1cd897572b463516166c3fda..9635b0e4d591e4be47ca183b7d72a3ad591dc498 100644 --- a/front/src/components/playlists/Form.vue +++ b/front/src/components/playlists/Form.vue @@ -1,3 +1,107 @@ +<script setup lang="ts"> +import type { Playlist, PrivacyLevel, BackendError } from '~/types' + +import { useVModels, useCurrentElement } from '@vueuse/core' +import { ref, computed, onMounted, nextTick } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' +import $ from 'jquery' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useLogger from '~/composables/useLogger' + +interface Events { + (e: 'update:playlist', value: Playlist): void +} + +interface Props { + title?: boolean + create?: boolean + playlist?: Playlist | null +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + title: true, + create: false, + playlist: null +}) + +const { playlist } = useVModels(props, emit) + +const logger = useLogger() + +const errors = ref([] as string[]) +const success = ref(false) + +const store = useStore() +const name = ref(playlist.value?.name ?? '') +const privacyLevel = ref(playlist.value?.privacy_level ?? store.state.auth.profile?.privacy_level ?? 'me') + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + placeholder: $pgettext('Content/Playlist/Input.Placeholder', 'My awesome playlist') +})) + +const sharedLabels = useSharedLabels() +const privacyLevelChoices = computed(() => [ + { + value: 'me', + label: sharedLabels.fields.privacy_level.choices.me + }, + { + value: 'instance', + label: sharedLabels.fields.privacy_level.choices.instance + }, + { + value: 'everyone', + label: sharedLabels.fields.privacy_level.choices.everyone + } +] as { value: PrivacyLevel, label: string }[]) + +const el = useCurrentElement() +onMounted(async () => { + await nextTick() + $(el.value).find('.dropdown').dropdown() +}) + +const isLoading = ref(false) +const submit = async () => { + isLoading.value = true + success.value = false + errors.value = [] + + try { + const url = props.create ? 'playlists/' : `playlists/${playlist.value?.id}/` + const method = props.create ? 'post' : 'patch' + + const data = { + name: name.value, + privacy_level: privacyLevel.value + } + + const response = await axios.request({ method, url, data }) + success.value = true + + if (props.create) { + name.value = '' + } else { + playlist.value = response.data + } + + store.dispatch('playlists/fetchOwn') + } catch (error) { + logger.error('Error while creating playlist') + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +</script> + <template> <form class="ui form" @@ -96,95 +200,3 @@ </div> </form> </template> - -<script> -import $ from 'jquery' -import axios from 'axios' -import TranslationsMixin from '@/components/mixins/Translations.vue' - -import logger from '@/logging' - -export default { - mixins: [TranslationsMixin], - props: { - title: { type: Boolean, default: true }, - playlist: { type: Object, default: null } - }, - data () { - const d = { - errors: [], - success: false, - isLoading: false - } - if (this.playlist) { - d.name = this.playlist.name - d.privacyLevel = this.playlist.privacy_level - } else { - d.privacyLevel = this.$store.state.auth.profile.privacy_level - d.name = '' - } - return d - }, - computed: { - labels () { - return { - placeholder: this.$pgettext('Content/Playlist/Input.Placeholder', 'My awesome playlist') - } - }, - privacyLevelChoices: function () { - return [ - { - value: 'me', - label: this.sharedLabels.fields.privacy_level.choices.me - }, - { - value: 'instance', - label: this.sharedLabels.fields.privacy_level.choices.instance - }, - { - value: 'everyone', - label: this.sharedLabels.fields.privacy_level.choices.everyone - } - ] - } - }, - mounted () { - $(this.$el).find('.dropdown').dropdown() - }, - methods: { - submit () { - this.isLoading = true - this.success = false - this.errors = [] - const self = this - const payload = { - name: this.name, - privacy_level: this.privacyLevel - } - - let promise - let url - if (this.playlist) { - url = `playlists/${this.playlist.id}/` - promise = axios.patch(url, payload) - } else { - url = 'playlists/' - promise = axios.post(url, payload) - } - return promise.then(response => { - self.success = true - self.isLoading = false - if (!self.playlist) { - self.name = '' - } - self.$emit('updated', response.data) - self.$store.dispatch('playlists/fetchOwn') - }, error => { - logger.default.error('Error while creating playlist') - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/components/playlists/PlaylistModal.vue b/front/src/components/playlists/PlaylistModal.vue index a316a1629677d8ff46f30df78cfaacfa3e330297..538b29d7c36a1ee5ac6d6aab710b44fda0912c87 100644 --- a/front/src/components/playlists/PlaylistModal.vue +++ b/front/src/components/playlists/PlaylistModal.vue @@ -1,7 +1,86 @@ +<script setup lang="ts"> +import type { BackendError, Playlist, APIErrorResponse } from '~/types' + +import { filter, sortBy, flow } from 'lodash-es' +import axios from 'axios' +import { useGettext } from 'vue3-gettext' +import SemanticModal from '~/components/semantic/Modal.vue' +import PlaylistForm from '~/components/playlists/Form.vue' +import useLogger from '~/composables/useLogger' +import { useStore } from '~/store' +import { ref, computed, watch } from 'vue' +import { useRouter } from 'vue-router' + +const logger = useLogger() +const store = useStore() + +const showDuplicateTrackAddConfirmation = ref(false) + +const router = useRouter() +router.beforeEach(() => { + store.commit('playlists/showModal', false) + showDuplicateTrackAddConfirmation.value = false +}) + +const playlists = computed(() => store.state.playlists.playlists) +const track = computed(() => store.state.playlists.modalTrack) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + addToPlaylist: $pgettext('Popup/Playlist/Table.Button.Tooltip/Verb', 'Add to this playlist'), + filterPlaylistField: $pgettext('Popup/Playlist/Form/Placeholder', 'Enter playlist name') +})) + +const playlistNameFilter = ref('') + +const sortedPlaylists = computed(() => flow( + filter((playlist: Playlist) => playlist.name.match(new RegExp(playlistNameFilter.value, 'i')) !== null), + sortBy((playlist: Playlist) => { return playlist.modification_date }) +)(playlists.value).reverse()) + +const formKey = ref(new Date().toString()) +watch(() => store.state.playlists.showModal, () => { + formKey.value = new Date().toString() + showDuplicateTrackAddConfirmation.value = false +}) + +const lastSelectedPlaylist = ref(-1) +const errors = ref([] as string[]) +const duplicateTrackAddInfo = ref({} as { playlist_name?: string }) + +const addToPlaylist = async (playlistId: number, allowDuplicates: boolean) => { + lastSelectedPlaylist.value = playlistId + + try { + await axios.post(`playlists/${playlistId}/add/`, { + tracks: [track.value?.id].filter(i => i), + allow_duplicates: allowDuplicates + }) + + logger.info('Successfully added track to playlist') + store.state.playlists.showModal = false + store.dispatch('playlists/fetchOwn') + } catch (error) { + if (error as BackendError) { + const { backendErrors, rawPayload = {} } = error as BackendError + + if (backendErrors.length === 1 && backendErrors[0] === 'Tracks Already Exist In Playlist') { + duplicateTrackAddInfo.value = ((rawPayload.playlist as APIErrorResponse).non_field_errors as APIErrorResponse)[0] as object + showDuplicateTrackAddConfirmation.value = true + } else { + errors.value = backendErrors + showDuplicateTrackAddConfirmation.value = false + } + } + } +} + +store.dispatch('playlists/fetchOwn') +</script> + <template> - <modal - :show="$store.state.playlists.showModal" - @update:show="update" + <semantic-modal + v-model:show="$store.state.playlists.showModal" > <h4 class="header"> <template v-if="track"> @@ -10,10 +89,10 @@ Add to playlist </translate> <div - v-translate="{artist: track.artist.name, title: track.title}" + v-translate="{artist: track.artist?.name, title: track.title}" class="ui sub header" translate-context="Popup/Playlist/Paragraph" - :translate-params="{artist: track.artist.name, title: track.title}" + :translate-params="{artist: track.artist?.name, title: track.title}" > "%{ title }", by %{ artist } </div> @@ -27,7 +106,10 @@ </translate> </h4> <div class="scrolling content"> - <playlist-form :key="formKey" /> + <playlist-form + :key="formKey" + :create="true" + /> <div class="ui divider" /> <div v-if="playlists.length > 0"> <div @@ -36,15 +118,15 @@ class="ui warning message" > <p - v-translate="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}" + v-translate="{track: track?.title, playlist: duplicateTrackAddInfo.playlist_name}" translate-context="Popup/Playlist/Paragraph" - :translate-params="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}" + :translate-params="{track: track?.title, playlist: duplicateTrackAddInfo.playlist_name}" > <strong>%{ track }</strong> is already in <strong>%{ playlist }</strong>. </p> <button class="ui small basic cancel button" - @click="duplicateTrackAddConfirm(false)" + @click="showDuplicateTrackAddConfirmation = false" > <translate translate-context="*/*/Button.Label/Verb"> Cancel @@ -138,7 +220,7 @@ <td> <router-link :to="{name: 'library.playlists.detail', params: {id: playlist.id }}" - @click.native="update(false)" + @click="$store.state.playlists.showModal = false" > {{ playlist.name }} </router-link> @@ -170,16 +252,17 @@ </div> </template> </div> - <template v-else> - <div class="ui placeholder segment"> - <div class="ui icon header"> - <i class="list icon" /> - <translate translate-context="Content/Home/Placeholder"> - No playlists have been created yet - </translate> - </div> + <div + v-else + class="ui placeholder segment" + > + <div class="ui icon header"> + <i class="list icon" /> + <translate translate-context="Content/Home/Placeholder"> + No playlists have been created yet + </translate> </div> - </template> + </div> </div> <div class="actions"> <button class="ui basic cancel button"> @@ -188,97 +271,5 @@ </translate> </button> </div> - </modal> + </semantic-modal> </template> - -<script> -import filter from 'lodash/fp/filter' -import sortBy from 'lodash/fp/sortBy' -import flow from 'lodash/fp/flow' - -import axios from 'axios' -import { mapState } from 'vuex' - -import logger from '@/logging' -import Modal from '@/components/semantic/Modal.vue' -import PlaylistForm from '@/components/playlists/Form.vue' - -export default { - components: { - Modal, - PlaylistForm - }, - data () { - return { - formKey: String(new Date()), - errors: [], - playlistNameFilter: '', - duplicateTrackAddInfo: {}, - showDuplicateTrackAddConfirmation: false, - lastSelectedPlaylist: -1 - } - }, - computed: { - ...mapState({ - playlists: state => state.playlists.playlists, - track: state => state.playlists.modalTrack - }), - labels () { - return { - addToPlaylist: this.$pgettext('Popup/Playlist/Table.Button.Tooltip/Verb', 'Add to this playlist'), - filterPlaylistField: this.$pgettext('Popup/Playlist/Form/Placeholder', 'Enter playlist name') - } - }, - sortedPlaylists () { - const regexp = new RegExp(this.playlistNameFilter, 'i') - const p = flow( - filter((e) => e.name.match(regexp) !== null), - sortBy((e) => { return e.modification_date }) - )(this.playlists) - p.reverse() - return p - } - }, - watch: { - '$store.state.route.path' () { - this.$store.commit('playlists/showModal', false) - this.showDuplicateTrackAddConfirmation = false - }, - '$store.state.playlists.showModal' () { - this.formKey = String(new Date()) - this.showDuplicateTrackAddConfirmation = false - } - }, - methods: { - update (v) { - this.$store.commit('playlists/showModal', v) - }, - addToPlaylist (playlistId, allowDuplicate) { - const self = this - const payload = { - tracks: [this.track.id], - allow_duplicates: allowDuplicate - } - - self.lastSelectedPlaylist = playlistId - - return axios.post(`playlists/${playlistId}/add`, payload).then(response => { - logger.default.info('Successfully added track to playlist') - self.update(false) - self.$store.dispatch('playlists/fetchOwn') - }, error => { - if (error.backendErrors.length === 1 && error.backendErrors[0].code === 'tracks_already_exist_in_playlist') { - self.duplicateTrackAddInfo = error.backendErrors[0] - self.showDuplicateTrackAddConfirmation = true - } else { - self.errors = error.backendErrors - self.showDuplicateTrackAddConfirmation = false - } - }) - }, - duplicateTrackAddConfirm (v) { - this.showDuplicateTrackAddConfirmation = v - } - } -} -</script> diff --git a/front/src/components/playlists/TrackPlaylistIcon.vue b/front/src/components/playlists/TrackPlaylistIcon.vue index fbae04d2904983dd166853b5923c7926b8d78b4c..8ef8b7e2ef700528e92fb2d96871590586dc472c 100644 --- a/front/src/components/playlists/TrackPlaylistIcon.vue +++ b/front/src/components/playlists/TrackPlaylistIcon.vue @@ -1,3 +1,28 @@ +<script setup lang="ts"> +import type { Track } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { computed } from 'vue' + +interface Props { + track?: Track | null + button?: boolean + border?: boolean +} + +withDefaults(defineProps<Props>(), { + track: null, + button: false, + border: false +}) + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + addToPlaylist: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…') +})) +</script> + <template> <button v-if="button" @@ -19,26 +44,3 @@ <i :class="['list', 'basic', 'icon']" /> </button> </template> - -<script> - -export default { - props: { - track: { type: Object, default: function () { return {} } }, - button: { type: Boolean, default: false }, - border: { type: Boolean, default: false } - }, - data () { - return { - showModal: false - } - }, - computed: { - labels () { - return { - addToPlaylist: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…') - } - } - } -} -</script> diff --git a/front/src/components/playlists/Widget.vue b/front/src/components/playlists/Widget.vue index 437b787c926df569823980b0a020128468b3bf97..c8382cda61b0592e7c8ce211967b6384e3d4d679 100644 --- a/front/src/components/playlists/Widget.vue +++ b/front/src/components/playlists/Widget.vue @@ -1,3 +1,53 @@ +<script setup lang="ts"> +import type { Playlist } from '~/types' + +import { ref, reactive, watch } from 'vue' +import { useStore } from '~/store' + +import axios from 'axios' + +import useErrorHandler from '~/composables/useErrorHandler' + +import PlaylistCard from '~/components/playlists/Card.vue' + +interface Props { + filters: Record<string, unknown> + url: string +} + +const props = defineProps<Props>() + +const store = useStore() + +const objects = reactive([] as Playlist[]) +const isLoading = ref(false) +const nextPage = ref('') +const fetchData = async (url = props.url) => { + isLoading.value = true + + try { + const params = { + ...props.filters, + page_size: props.filters.limit ?? 3 + } + + const response = await axios.get(url, { params }) + nextPage.value = response.data.next + objects.push(...response.data.results) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +watch( + () => store.state.moderation.lastUpdate, + () => fetchData(), + { immediate: true } +) +</script> + <template> <div> <h3 @@ -13,7 +63,7 @@ <div class="ui loader" /> </div> <div - v-if="playlistsExist" + v-if="objects.length > 0" class="ui cards app-cards" > <playlist-card @@ -57,73 +107,3 @@ </template> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import PlaylistCard from '@/components/playlists/Card.vue' - -export default { - components: { - PlaylistCard - }, - props: { - filters: { type: Object, required: true }, - url: { type: String, required: true } - }, - data () { - return { - objects: [], - limit: this.filters.limit || 3, - isLoading: false, - errors: null, - previousPage: null, - nextPage: null - } - }, - computed: { - playlistsExist: function () { - return this.objects.length > 0 - } - }, - watch: { - offset () { - this.fetchData() - }, - '$store.state.moderation.lastUpdate': function () { - this.fetchData(this.url) - } - }, - created () { - this.fetchData(this.url) - }, - methods: { - fetchData (url) { - if (!url) { - return - } - this.isLoading = true - const self = this - const params = _.clone(this.filters) - params.page_size = this.limit - params.offset = this.offset - axios.get(url, { params: params }).then((response) => { - self.previousPage = response.data.previous - self.nextPage = response.data.next - self.isLoading = false - self.objects = [...self.objects, ...response.data.results] - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - updateOffset (increment) { - if (increment) { - this.offset += this.limit - } else { - this.offset = Math.max(this.offset - this.limit, 0) - } - } - } -} -</script> diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue index 1d63b552865d676a73d6307bfcf58cf45cd10851..69960170c25089dffce3905ed246f5eed5fa73dc 100644 --- a/front/src/components/radios/Button.vue +++ b/front/src/components/radios/Button.vue @@ -1,3 +1,76 @@ +<script setup lang="ts"> +import type { ObjectId, RadioConfig } from '~/store/radios' + +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' +import { computed } from 'vue' + +interface Props { + customRadioId?: number | null + type?: string + clientOnly?: boolean + objectId?: ObjectId | number | string | null + radioConfig?: RadioConfig | null +} + +const props = withDefaults(defineProps<Props>(), { + customRadioId: null, + type: '', + clientOnly: false, + objectId: null, + radioConfig: null +}) + +const store = useStore() +const running = computed(() => { + if (!store.state.radios.running) { + return false + } + + return store.state.radios.current?.type === props.type + && store.state.radios.current?.customRadioId === props.customRadioId + && ( + typeof props.objectId === 'string' + || ( + typeof props.objectId !== 'number' + && store.state.radios.current?.objectId?.fullUsername === props.objectId?.fullUsername + ) + ) +}) + +const { $pgettext } = useGettext() +const buttonLabel = computed(() => { + switch (props.radioConfig?.type) { + case 'tag': + return running.value + ? $pgettext('*/Player/Button.Label/Short, Verb', 'Stop tags radio') + : $pgettext('*/Player/Button.Label/Short, Verb', 'Start tags radio') + case 'artist': + return running.value + ? $pgettext('*/Player/Button.Label/Short, Verb', 'Stop artists radio') + : $pgettext('*/Player/Button.Label/Short, Verb', 'Start artists radio') + default: + return running.value + ? $pgettext('*/Player/Button.Label/Short, Verb', 'Stop radio') + : $pgettext('*/Queue/Button.Label/Short, Verb', 'Play radio') + } +}) + +const toggleRadio = () => { + if (running.value) { + return store.dispatch('radios/stop') + } + + return store.dispatch('radios/start', { + type: props.type, + objectId: props.objectId, + customRadioId: props.customRadioId, + clientOnly: props.clientOnly, + config: props.radioConfig + }) +} +</script> + <template> <button :class="['ui', 'primary', {'inverted': running}, 'icon', 'labeled', 'button']" @@ -10,62 +83,3 @@ {{ buttonLabel }} </button> </template> - -<script> - -import lodash from 'lodash' -export default { - props: { - customRadioId: { type: Number, required: false, default: null }, - type: { type: String, required: false, default: '' }, - clientOnly: { type: Boolean, default: false }, - objectId: { type: [String, Number, Object], default: null }, - config: { type: [Array, Object], required: false, default: null } - }, - computed: { - running () { - const state = this.$store.state.radios - const current = state.current - if (!state.running) { - return false - } else { - return current.type === this.type && lodash.isEqual(current.objectId, this.objectId) && current.customRadioId === this.customRadioId - } - }, - label () { - return this.config?.[0]?.type ?? null - }, - buttonLabel () { - switch (this.label) { - case 'tag': - return this.running - ? this.$pgettext('*/Player/Button.Label/Short, Verb', 'Stop tags radio') - : this.$pgettext('*/Player/Button.Label/Short, Verb', 'Start tags radio') - case 'artist': - return this.running - ? this.$pgettext('*/Player/Button.Label/Short, Verb', 'Stop artists radio') - : this.$pgettext('*/Player/Button.Label/Short, Verb', 'Start artists radio') - default: - return this.running - ? this.$pgettext('*/Player/Button.Label/Short, Verb', 'Stop radio') - : this.$pgettext('*/Queue/Button.Label/Short, Verb', 'Play radio') - } - } - }, - methods: { - toggleRadio () { - if (this.running) { - this.$store.dispatch('radios/stop') - } else { - this.$store.dispatch('radios/start', { - type: this.type, - objectId: this.objectId, - customRadioId: this.customRadioId, - clientOnly: this.clientOnly, - config: this.config - }) - } - } - } -} -</script> diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue index 5ec3515455142eb9e14aaf9eb4c09a12d374d370..c02063e16532822cc67800cc9951090f8375df81 100644 --- a/front/src/components/radios/Card.vue +++ b/front/src/components/radios/Card.vue @@ -1,3 +1,34 @@ +<script setup lang="ts"> +import type { Radio } from '~/types' + +import { ref, computed } from 'vue' +import { useStore } from '~/store' + +import RadioButton from './Button.vue' + +interface Props { + type: string + customRadio?: Radio | null + objectId?: string | null +} + +const props = withDefaults(defineProps<Props>(), { + customRadio: null, + objectId: null +}) + +const store = useStore() + +const isDescriptionExpanded = ref(false) + +const radio = computed(() => props.customRadio + ? props.customRadio + : store.getters['radios/types'][props.type] +) + +const customRadioId = computed(() => props.customRadio?.id ?? null) +</script> + <template> <div class="ui card"> <div class="content"> @@ -35,7 +66,7 @@ :object-id="objectId" /> <router-link - v-if="$store.state.auth.authenticated && type === 'custom' && radio.user.id === $store.state.auth.profile.id" + v-if="$store.state.auth.authenticated && type === 'custom' && radio.user.id === $store.state.auth.profile?.id" class="ui success button right floated" :to="{name: 'library.radios.edit', params: {id: customRadioId }}" > @@ -46,37 +77,3 @@ </div> </div> </template> - -<script> -import RadioButton from './Button.vue' - -export default { - components: { - RadioButton - }, - props: { - type: { type: String, required: true, default: '' }, - customRadio: { type: Object, required: false, default: () => { return {} } }, - objectId: { type: String, required: false, default: null } - }, - data () { - return { - isDescriptionExpanded: false - } - }, - computed: { - radio () { - if (Object.keys(this.customRadio).length > 0) { - return this.customRadio - } - return this.$store.getters['radios/types'][this.type] - }, - customRadioId: function () { - if (this.customRadio) { - return this.customRadio.id - } - return null - } - } -} -</script> diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue index b1837b7590d2af7beb6b7a420b154cbb240b8055..c6c90dd24351ffa6779baf44b341bcfa23a0d4fe 100644 --- a/front/src/components/semantic/Modal.vue +++ b/front/src/components/semantic/Modal.vue @@ -1,5 +1,91 @@ +<script setup lang="ts"> +import $ from 'jquery' +import { useFocusTrap } from '@vueuse/integrations/useFocusTrap' +import { computed, onBeforeUnmount, ref, watchEffect } from 'vue' +import { useVModel } from '@vueuse/core' +import { useStore } from '~/store' + +interface Events { + (e: 'update:show', show: boolean): void + (e: 'approved'): void + (e: 'deny'): void + (e: 'show'): void + (e: 'hide'): void +} + +interface Props { + show: boolean + fullscreen?: boolean + scrolling?: boolean + additionalClasses?: string[] +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + fullscreen: true, + scrolling: false, + additionalClasses: () => [] +}) + +const modal = ref() +const { activate, deactivate, pause, unpause } = useFocusTrap(modal, { + allowOutsideClick: true +}) + +const show = useVModel(props, 'show', emit) + +const control = ref<JQuery | undefined>() +const initModal = () => { + control.value = $(modal.value).modal({ + duration: 100, + onApprove: () => emit('approved'), + onDeny: () => emit('deny'), + onHidden: () => (show.value = false) + }) +} + +watchEffect(() => { + if (show.value) { + initModal() + emit('show') + control.value?.modal('show') + activate() + unpause() + document.body.classList.add('scrolling') + return + } + + if (control.value) { + emit('hide') + control.value.modal('hide') + control.value.remove() + deactivate() + pause() + document.body.classList.remove('scrolling') + } +}) + +onBeforeUnmount(() => { + control.value?.modal('hide') +}) + +const store = useStore() +const classes = computed(() => [ + ...props.additionalClasses, + 'ui', 'modal', + { + active: show.value, + scrolling: props.scrolling, + 'overlay fullscreen': props.fullscreen && ['phone', 'tablet'].includes(store.getters['ui/windowSize']) + } +]) +</script> + <template> - <div :class="additionalClasses.concat(['ui', {'active': show}, {'scrolling': scrolling} ,{'overlay fullscreen': fullscreen && ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal'])"> + <div + ref="modal" + :class="classes" + > <i tabindex="0" class="close inside icon" @@ -7,83 +93,3 @@ <slot v-if="show" /> </div> </template> - -<script> -import $ from 'jquery' -import { createFocusTrap } from 'focus-trap' - -export default { - props: { - show: { type: Boolean, required: true }, - fullscreen: { type: Boolean, default: true }, - scrolling: { type: Boolean, required: false, default: false }, - additionalClasses: { type: Array, required: false, default: () => [] } - }, - data () { - return { - control: null, - focusTrap: null - } - }, - watch: { - show: { - handler (newValue) { - if (newValue) { - this.initModal() - this.$emit('show') - this.control.modal('show') - this.focusTrap.activate() - this.focusTrap.unpause() - document.body.classList.add('scrolling') - } else { - if (this.control) { - this.$emit('hide') - this.control.modal('hide') - this.control.remove() - this.focusTrap.deactivate() - this.focusTrap.pause() - document.body.classList.remove('scrolling') - } - } - } - }, - $route (to, from) { - this.closeModal() - } - }, - mounted () { - this.focusTrap = createFocusTrap(this.$el) - }, - beforeDestroy () { - if (this.control) { - $(this.$el).modal('hide') - } - this.focusTrap.deactivate() - $(this.$el).remove() - }, - methods: { - initModal () { - this.control = $(this.$el).modal({ - duration: 100, - onApprove: function () { - this.$emit('approved') - }.bind(this), - onDeny: function () { - this.$emit('deny') - }.bind(this), - onHidden: function () { - this.$emit('update:show', false) - }.bind(this), - onVisible: function () { - this.focusTrap.activate() - this.focusTrap.unpause() - }.bind(this) - }) - }, - closeModal () { - this.$emit('update:show', false) - } - } - -} -</script> diff --git a/front/src/components/tags/List.vue b/front/src/components/tags/List.vue index e9f695fcac7c43227ae68e0edd131bcc42bd215c..b02d54675561b4bdd317b60a2c6375b9afd425dd 100644 --- a/front/src/components/tags/List.vue +++ b/front/src/components/tags/List.vue @@ -1,23 +1,55 @@ +<script setup lang="ts"> +import { truncate } from '~/utils/filters' +import { computed, ref } from 'vue' + +interface Props { + tags: string[] + showMore?: boolean + truncateSize?: number + limit?: number + labelClasses?: string + detailRoute?: string +} + +const props = withDefaults(defineProps<Props>(), { + showMore: true, + truncateSize: 25, + limit: 5, + labelClasses: '', + detailRoute: 'library.tags.detail' +}) + +const honorLimit = ref(true) + +const tags = computed(() => { + if (!honorLimit.value) { + return props.tags + } + + return props.tags.slice(0, props.limit) +}) +</script> + <template> <div class="component-tags-list"> <router-link - v-for="tag in toDisplay" + v-for="tag in tags" :key="tag" - :to="{name: detailRoute, params: {id: tag}}" - :class="['ui', 'circular', 'hashtag', 'label', labelClasses]" + :to="{name: props.detailRoute, params: { id: tag } }" + :class="['ui', 'circular', 'hashtag', 'label', props.labelClasses]" > - #{{ tag|truncate(truncateSize) }} + #{{ truncate(tag, props.truncateSize) }} </router-link> <div - v-if="showMore && toDisplay.length < tags.length" + v-if="props.showMore && tags.length < props.tags.length" role="button" class="ui circular inverted accent label" @click.prevent="honorLimit = false" > <translate translate-context="Content/*/Button/Label/Verb" - :translate-params="{count: tags.length - toDisplay.length}" - :translate-n="tags.length - toDisplay.length" + :translate-params="{ count: props.tags.length - tags.length }" + :translate-n="props.tags.length - tags.length" translate-plural="Show %{ count } more tags" > Show 1 more tag @@ -25,28 +57,3 @@ </div> </div> </template> -<script> -export default { - props: { - tags: { type: Array, required: true }, - showMore: { type: Boolean, default: true }, - truncateSize: { type: Number, default: 25 }, - limit: { type: Number, default: 5 }, - labelClasses: { type: String, default: '' }, - detailRoute: { type: String, default: 'library.tags.detail' } - }, - data () { - return { - honorLimit: true - } - }, - computed: { - toDisplay () { - if (!this.honorLimit) { - return this.tags - } - return (this.tags || []).slice(0, this.limit) - } - } -} -</script> diff --git a/front/src/components/utils/global-events.vue b/front/src/components/utils/global-events.vue deleted file mode 100644 index 6cc7f8472c90b72b3ed87c6d3e3268673ef6752c..0000000000000000000000000000000000000000 --- a/front/src/components/utils/global-events.vue +++ /dev/null @@ -1,52 +0,0 @@ -<script> -import $ from 'jquery' - -const modifiersRE = /^[~!&]*/ -const nonEventNameCharsRE = /\W+/ -const names = { - '!': 'capture', - '~': 'once', - '&': 'passive' -} - -function extractEventOptions (eventDescriptor) { - const [modifiers] = eventDescriptor.match(modifiersRE) - return modifiers.split('').reduce((options, modifier) => { - options[names[modifier]] = true - return options - }, {}) -} - -export default { - - mounted () { - this._listeners = Object.create(null) - Object.keys(this.$listeners).forEach(event => { - const handler = this.$listeners[event] - const wrapper = function (event) { - // we check here the event is not triggered from an input - // to avoid collisions - if (!$(event.target).is('.field, :input, [contenteditable]')) { - handler(event) - } - } - document.addEventListener( - event.replace(nonEventNameCharsRE, ''), - wrapper, - extractEventOptions(event) - ) - this._listeners[event] = handler - }) - }, - - beforeDestroy () { - for (const event in this._listeners) { - document.removeEventListener( - event.replace(nonEventNameCharsRE, ''), - this._listeners[event] - ) - } - }, - render: h => h() -} -</script> diff --git a/front/src/components/vui/Pagination.vue b/front/src/components/vui/Pagination.vue new file mode 100644 index 0000000000000000000000000000000000000000..179eaf6937a48a42471485e41bb4f0f8691b3a1f --- /dev/null +++ b/front/src/components/vui/Pagination.vue @@ -0,0 +1,124 @@ +<script setup lang="ts"> +import { useVModel } from '@vueuse/core' +import { range, clamp } from 'lodash-es' +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +const RANGE = 2 + +interface Events { + (e: 'update:current', page: number): void +} + +interface Props { + current?: number + paginateBy?: number + total: number, + compact?: boolean +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + current: 1, + paginateBy: 25, + compact: false +}) + +const current = useVModel(props, 'current', emit) + +const pages = computed(() => { + const start = range(1, 1 + RANGE) + const end = range(maxPage.value - RANGE + 1, maxPage.value + 1) + const middle = range( + clamp(props.current - RANGE + 1, 1, maxPage.value), + clamp(props.current + RANGE, 1, maxPage.value) + ).filter(i => !start.includes(i) && !end.includes(i)) + + if (end[0] - 1 <= start[RANGE - 1]) { + return [ + ...start, + ...end.filter(i => i > start[RANGE - 1]) + ] + } + + return [ + ...start, + middle.length === 0 && 'skip', + middle.length !== 0 && start[start.length - 1] + 1 !== middle[0] && 'skip', + ...middle, + middle.length !== 0 && middle[middle.length - 1] + 1 !== end[0] && 'skip', + ...end + ].filter(i => i !== false) as Array<'skip' | number> +}) + +const maxPage = computed(() => Math.ceil(props.total / props.paginateBy)) + +const setPage = (page: number) => { + if (page > maxPage.value || page < 1) { + return + } + + current.value = page +} + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + pagination: $pgettext('Content/*/Hidden text/Noun', 'Pagination'), + previousPage: $pgettext('Content/*/Link', 'Previous Page'), + nextPage: $pgettext('Content/*/Link', 'Next Page') +})) +</script> + +<template> + <div + v-if="maxPage > 1" + class="ui pagination menu component-pagination" + role="navigation" + :aria-label="labels.pagination" + > + <a + href="#" + :disabled="current - 1 < 1 || null" + role="button" + :aria-label="labels.previousPage" + :class="[{ 'disabled': current - 1 < 1 }, 'item']" + @click.prevent.stop="setPage(current - 1)" + > + <i class="angle left icon" /> + </a> + + <template v-if="!compact"> + <template + v-for="page in pages" + :key="page" + > + <a + v-if="page === 'skip'" + href="#" + class="item disabled" + > + <span>…</span> + </a> + <a + v-else + href="#" + :class="[{ active: page === current }, 'item']" + @click.prevent.stop="setPage(page as number)" + > + <span>{{ page }}</span> + </a> + </template> + </template> + + <a + href="#" + :disabled="current + 1 > maxPage || null" + role="button" + :aria-label="labels.nextPage" + :class="[{ disabled: current + 1 > maxPage }, 'item']" + @click.prevent.stop="setPage(current + 1)" + > + <i class="angle right icon" /> + </a> + </div> +</template> diff --git a/front/src/components/vui/list/VirtualList.vue b/front/src/components/vui/list/VirtualList.vue new file mode 100644 index 0000000000000000000000000000000000000000..17d55a8651b0cee0a8e24506d4feedb2e9fc1077 --- /dev/null +++ b/front/src/components/vui/list/VirtualList.vue @@ -0,0 +1,239 @@ +<script setup lang="ts"> +import { useMouse, useCurrentElement, useRafFn, useElementByPoint } from '@vueuse/core' +import { ref, watchEffect, reactive } from 'vue' + +// @ts-expect-error no typings +// import VirtualList from 'vue3-virtual-scroll-list' +import { RecycleScroller } from 'vue-virtual-scroller' +import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' + +interface Events { + (e: 'reorder', from: number, to: number): void + (e: 'visible'): void + (e: 'hidden'): void +} + +interface Props { + list: object[] + size: number +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const ghostContainer = ref() +const hoveredIndex = ref() +const draggedItem = ref() +const position = ref('after') + +const getIndex = (element: HTMLElement) => +(element?.getAttribute('data-index') ?? 0) + +const isTouch = ref(false) +const onMousedown = (event: MouseEvent | TouchEvent) => { + const element = event.target as HTMLElement + const dragItem = element.closest('.drag-item') as HTMLElement + if (!dragItem || !element.classList.contains('handle')) return + + // Touch devices stop emitting touch events while container is scrolled + // NOTE: FF does not support TouchEvent constructor + isTouch.value = window.TouchEvent + ? event instanceof TouchEvent + : !(event instanceof MouseEvent) + + const ghost = dragItem.cloneNode(true) as HTMLElement + ghost.classList.add('drag-ghost') + ghostContainer.value.appendChild(ghost) + + const index = getIndex(dragItem) + document.body.classList.add('dragging') + hoveredIndex.value = index + draggedItem.value = { + item: props.list[index], + ghost, + index + } + + resume() +} + +// Touch and mobile devices support +const onTouchmove = (event: TouchEvent) => { + if (draggedItem.value) { + event.preventDefault() + } +} + +document.addEventListener('touchcancel', (event: TouchEvent) => { + cleanup() +}) + +const reorder = (event: MouseEvent | TouchEvent) => { + if (draggedItem.value) { + const from = draggedItem.value.index + let to = hoveredIndex.value + + if (from === to) return cleanup() + to -= +(position.value === 'before') + to += +(from > to) + + if (from === to) return cleanup() + emit('reorder', from, to) + } + + cleanup() +} + +document.addEventListener('mouseup', reorder) +document.addEventListener('touchend', reorder) + +const cleanup = () => { + pause() + document.body.classList.remove('dragging') + draggedItem.value?.ghost?.remove() + draggedItem.value = undefined + hoveredIndex.value = undefined + scrollDirection.value = undefined +} + +const scrollDirection = ref() +const containerSize = reactive({ bottom: 0, top: 0 }) +const { x, y: screenY } = useMouse({ type: 'client' }) +const { element: hoveredElement } = useElementByPoint({ x, y: screenY }) + +// Find current index and position on both desktop and mobile devices +watchEffect(() => { + if (draggedItem.value) { + const dragItem = (hoveredElement.value as HTMLElement)?.closest('.drag-item') as HTMLElement + if (!dragItem) return + + hoveredIndex.value = getIndex(dragItem) + const { y } = dragItem.getBoundingClientRect() + position.value = screenY.value - y < props.size / 2 ? 'before' : 'after' + } +}) + +// Automatically scroll when on the edge +watchEffect(() => { + const { top, bottom } = containerSize + const y = Math.min(bottom, Math.max(top, screenY.value)) + + if (draggedItem.value) { + ghostContainer.value.style.top = `${y}px` + + scrollDirection.value = y === top + ? 'up' + : y === bottom + ? 'down' + : undefined + + return + } + + scrollDirection.value = undefined +}) + +const el = useCurrentElement() +const resize = () => { + const element = el.value as HTMLElement + containerSize.top = element.offsetTop + containerSize.bottom = element.offsetHeight + containerSize.top +} + +let lastDate = +new Date() +const { resume, pause } = useRafFn(() => { + const now = +new Date() + const delta = now - lastDate + const direction = scrollDirection.value + + if (direction && el.value?.children[0] && !isTouch.value) { + el.value.children[0].scrollTop += 200 / delta * (direction === 'up' ? -1 : 1) + } + + lastDate = now +}, { immediate: false }) + +const virtualList = ref() +defineExpose({ + scrollToIndex: (index: number) => virtualList.value?.scrollToItem(index), + scroller: virtualList, + cleanup +}) +</script> + +<template> + <div> + <recycle-scroller + ref="virtualList" + v-slot="{ item, index }" + class="virtual-list drag-container" + :items="list" + :item-size="size" + @mousedown="onMousedown" + @touchstart="onMousedown" + @touchmove="onTouchmove" + @resize="resize" + @visible="emit('visible')" + @hidden="emit('hidden')" + > + <slot + :class-list="[draggedItem && hoveredIndex === index && `drop-${position}`, 'drag-item']" + :item="item" + :index="index" + /> + </recycle-scroller> + + <div + ref="ghostContainer" + class="ghost-container" + /> + </div> +</template> + +<style lang="scss"> +.drag-container { + position: relative; +} + +.dragging { + user-select: none; + cursor: grab !important; +} + +.drop-before { + box-shadow: 0 -1px 0 var(--vibrant-color), + inset 0 1px 0 var(--vibrant-color); + + &:last-child { + box-shadow: inset 0 2px 0 var(--vibrant-color); + } +} + +.drop-after { + box-shadow: 0 1px 0 var(--vibrant-color), + inset 0 -1px 0 var(--vibrant-color); + + &:last-child { + box-shadow: inset 0 -2px 0 var(--vibrant-color); + } +} + +.drag-ghost { + background: transparent !important; +} + +.ghost-container { + position: absolute; + pointer-events: none; + z-index: 1002; + width: 100%; + transform: translateY(-50%); + left: 0; + top: 0; + opacity: 0.8; + background: rgba(255, 255, 255, 0.1); +} + +.theme-light .ghost-container { + background: rgba(0, 0, 0, 0.1); +} +</style> diff --git a/front/src/composables/audio/toLinearVolumeScale.ts b/front/src/composables/audio/toLinearVolumeScale.ts new file mode 100644 index 0000000000000000000000000000000000000000..eacf6180eb507d117f88a7352cfce7d6429a5bde --- /dev/null +++ b/front/src/composables/audio/toLinearVolumeScale.ts @@ -0,0 +1,11 @@ +export const DYNAMIC_RANGE = 40 // dB + +export default (volume: number) => { + if (volume <= 0.0) { + return 0.0 + } + + // (1.0; 0.0) -> (0; -DYNAMIC_RANGE) dB + const dB = (volume - 1) * DYNAMIC_RANGE + return Math.pow(10, dB / 20) +} diff --git a/front/src/composables/audio/usePlayOptions.ts b/front/src/composables/audio/usePlayOptions.ts new file mode 100644 index 0000000000000000000000000000000000000000..136b9608ba0329f6c008d0e4580dec990d256091 --- /dev/null +++ b/front/src/composables/audio/usePlayOptions.ts @@ -0,0 +1,200 @@ +import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' +import type { ContentFilter } from '~/store/moderation' + +import { useStore } from '~/store' +import { useGettext } from 'vue3-gettext' +import { computed, markRaw, ref } from 'vue' +import axios from 'axios' +import usePlayer from '~/composables/audio/usePlayer' +import useQueue from '~/composables/audio/useQueue' +import { useCurrentElement } from '@vueuse/core' +import jQuery from 'jquery' + +export interface PlayOptionsProps { + isPlayable?: boolean + tracks?: Track[] + track?: Track | null + artist?: Artist | null + album?: Album | null + playlist?: Playlist | null + library?: Library | null + channel?: Channel | null + account?: Actor | null +} + +export default (props: PlayOptionsProps) => { + const store = useStore() + const { resume, pause, playing } = usePlayer() + const { currentTrack } = useQueue() + + const playable = computed(() => { + if (props.isPlayable) { + return true + } + + if (props.track) { + return props.track.uploads?.length > 0 + } else if (props.artist) { + return props.artist.tracks_count > 0 + || props.artist?.albums?.some((album) => album.is_playable === true) + } else if (props.tracks) { + return props.tracks?.some((track) => (track.uploads?.length ?? 0) > 0) + } + + return false + }) + + const filterableArtist = computed(() => props.track?.artist ?? props.album?.artist ?? props.artist) + const filterArtist = () => store.dispatch('moderation/hide', { type: 'artist', target: filterableArtist.value }) + + const { $npgettext } = useGettext() + const addMessage = (tracks: Track[]) => { + if (!tracks.length) { + return + } + + store.commit('ui/addMessage', { + content: $npgettext('*/Queue/Message', '%{ count } track was added to your queue', '%{ count } tracks were added to your queue', tracks.length, { + count: tracks.length.toString() + }), + date: new Date() + }) + } + + const getTracksPage = async (params: object, page = 1, tracks: Track[] = []): Promise<Track[]> => { + if (page > 11) { + // it's 10 * 100 tracks already, let's stop here + return tracks + } + + // when fetching artists/or album tracks, sometimes, we may have to fetch + // multiple pages + const response = await axios.get('tracks/', { + params: { + ...params, + page_size: 100, + page, + hidden: '', + playable: true + } + }) + + tracks.push(...response.data.results) + if (response.data.next) { + return getTracksPage(params, page + 1, tracks) + } + + return tracks + } + + const isLoading = ref(false) + const getPlayableTracks = async () => { + isLoading.value = true + + const tracks: Track[] = [] + + // TODO (wvffle): Why is there no channel? + if (props.tracks?.length) { + tracks.push(...props.tracks) + } else if (props.track) { + if (props.track.uploads?.length) { + tracks.push(props.track) + } else { + // fetch uploads from api + const response = await axios.get(`tracks/${props.track.id}/`) + tracks.push(response.data as Track) + } + } else if (props.playlist) { + const response = await axios.get(`playlists/${props.playlist.id}/tracks/`) + const playlistTracks = (response.data.results as Array<{ track: Track }>).map(({ track }) => track as Track) + + const artistIds = store.getters['moderation/artistFilters']().map((filter: ContentFilter) => filter.target.id) + if (artistIds.length) { + tracks.push(...playlistTracks.filter((track) => { + return !((artistIds.includes(track.artist?.id) || track.album) && artistIds.includes(track.album?.artist.id)) + })) + } else { + tracks.push(...playlistTracks) + } + } else if (props.artist) { + tracks.push(...await getTracksPage({ artist: props.artist.id, include_channels: 'true', ordering: 'album__release_date,disc_number,position' })) + } else if (props.album) { + tracks.push(...await getTracksPage({ album: props.album.id, include_channels: 'true', ordering: 'disc_number,position' })) + } else if (props.library) { + tracks.push(...await getTracksPage({ library: props.library.uuid, ordering: '-creation_date' })) + } + + isLoading.value = false + + return tracks.filter(track => track.uploads?.length).map(markRaw) + } + + const el = useCurrentElement() + const enqueue = async () => { + jQuery(el.value).find('.ui.dropdown').dropdown('hide') + + const tracks = await getPlayableTracks() + await store.dispatch('queue/appendMany', { tracks }) + addMessage(tracks) + } + + const enqueueNext = async (next = false) => { + jQuery(el.value).find('.ui.dropdown').dropdown('hide') + + const tracks = await getPlayableTracks() + + const wasEmpty = store.state.queue.tracks.length === 0 + await store.dispatch('queue/appendMany', { tracks, index: store.state.queue.currentIndex + 1 }) + + if (next && !wasEmpty) { + await store.dispatch('queue/next') + resume() + } + + addMessage(tracks) + } + + const replacePlay = async () => { + store.dispatch('queue/clean') + + jQuery(el.value).find('.ui.dropdown').dropdown('hide') + + const tracks = await getPlayableTracks() + await store.dispatch('queue/appendMany', { tracks }) + + if (props.track && props.tracks?.length) { + // set queue position to selected track + const trackIndex = props.tracks.findIndex(track => track.id === props.track?.id && track.position === props.track?.position) + store.dispatch('queue/currentIndex', trackIndex) + } else { + store.dispatch('queue/currentIndex', 0) + } + + resume() + addMessage(tracks) + } + + const activateTrack = (track: Track, index: number) => { + // TODO (wvffle): Check if position checking did not break anything + if (track.id === currentTrack.value?.id && track.position === currentTrack.value?.position) { + if (playing.value) { + return pause() + } + + return resume() + } + + replacePlay() + } + + return { + playable, + filterableArtist, + filterArtist, + enqueue, + enqueueNext, + replacePlay, + activateTrack, + isLoading + } +} diff --git a/front/src/composables/audio/usePlayer.ts b/front/src/composables/audio/usePlayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..313e2d0cbd9d9852ca9d217ce508a5ecfcdb76e3 --- /dev/null +++ b/front/src/composables/audio/usePlayer.ts @@ -0,0 +1,265 @@ +import type { Track } from '~/types' + +import { computed, watchEffect, ref, watch } from 'vue' +import { Howler } from 'howler' +import { useRafFn, useTimeoutFn } from '@vueuse/core' +import useQueue from '~/composables/audio/useQueue' +import useSound from '~/composables/audio/useSound' +import toLinearVolumeScale from '~/composables/audio/toLinearVolumeScale' +import store from '~/store' +import axios from 'axios' + +const PRELOAD_DELAY = 15 + +const { currentSound, loadSound, onSoundProgress } = useSound() +const { isShuffling, currentTrack, currentIndex } = useQueue() + +const looping = computed(() => store.state.player.looping) +const playing = computed(() => store.state.player.playing) +const loading = computed(() => store.state.player.isLoadingAudio) +const errored = computed(() => store.state.player.errored) +const focused = computed(() => store.state.ui.queueFocused === 'player') + +// Cache sound if we have currentTrack available +if (currentTrack.value) { + loadSound(currentTrack.value) +} + +// Playing +const playTrack = async (track: Track, oldTrack?: Track) => { + const oldSound = currentSound.value + + // TODO (wvffle): Move oldTrack to watcher + if (oldSound && track !== oldTrack) { + oldSound.stop() + } + + if (!track) { + return + } + + if (!isShuffling.value) { + if (!track.uploads.length) { + // we don't have any information for this track, we need to fetch it + track = await axios.get(`tracks/${track.id}/`) + .then(response => response.data, () => null) + } + + if (track === null) { + store.commit('player/isLoadingAudio', false) + store.dispatch('player/trackErrored') + return + } + + currentSound.value = loadSound(track) + + if (playing.value) { + currentSound.value.play() + store.commit('player/playing', true) + } else { + store.commit('player/isLoadingAudio', false) + } + + store.commit('player/errored', false) + store.dispatch('player/updateProgress', 0) + } +} + +const { start: loadTrack, stop: cancelLoading } = useTimeoutFn((track, oldTrack) => { + playTrack(track as Track, oldTrack as Track) +}, 100, { immediate: false }) as { + start: (a: Track, b: Track) => void + stop: () => void +} + +watch(currentTrack, (track, oldTrack) => { + cancelLoading() + currentSound.value?.pause() + store.commit('player/isLoadingAudio', true) + loadTrack(track, oldTrack) +}) + +// Volume +const volume = computed({ + get: () => store.state.player.volume, + set: (value) => store.commit('player/volume', value) +}) + +watchEffect(() => Howler.volume(toLinearVolumeScale(volume.value))) + +const mute = () => store.dispatch('player/mute') +const unmute = () => store.dispatch('player/unmute') +const toggleMute = () => store.dispatch('player/toggleMute') + +// Time and duration +const duration = computed(() => store.state.player.duration) +const currentTime = computed({ + get: () => store.state.player.currentTime, + set: (time) => { + if (time < 0 || time > duration.value) { + return + } + + if (!currentSound.value?.getSource() || time === currentSound.value.seek()) { + return + } + + currentSound.value.seek(time) + + // Update progress immediately to ensure updated UI + progress.value = time + } +}) + +const durationFormatted = computed(() => store.getters['player/durationFormatted']) +const currentTimeFormatted = computed(() => store.getters['player/currentTimeFormatted']) + +// Progress +const progress = computed({ + get: () => store.getters['player/progress'], + set: (time) => { + if (currentSound.value?.state() === 'loaded') { + store.state.player.currentTime = time + + const duration = currentSound.value.duration() + currentSound.value.triggerSoundProgress(time, duration) + } + } +}) + +const bufferProgress = computed(() => store.state.player.bufferProgress) +onSoundProgress(({ node, time, duration }) => { + const toPreload = store.state.queue.tracks[currentIndex.value + 1] + if (!nextTrackPreloaded.value && toPreload && (time > PRELOAD_DELAY || duration - time < 30)) { + loadSound(toPreload) + nextTrackPreloaded.value = true + } + + if (time > duration / 2) { + if (!isListeningSubmitted.value) { + store.dispatch('player/trackListened', currentTrack.value) + isListeningSubmitted.value = true + } + } + + // from https://github.com/goldfire/howler.js/issues/752#issuecomment-372083163 + + const { buffered, currentTime } = node + + let range = 0 + try { + while (buffered.start(range) >= currentTime || currentTime >= buffered.end(range)) { + range += 1 + } + } catch (IndexSizeError) { + return + } + + let loadPercentage + + const start = buffered.start(range) + const end = buffered.end(range) + + if (range === 0) { + // easy case, no user-seek + const loadStartPercentage = start / node.duration + const loadEndPercentage = end / node.duration + loadPercentage = loadEndPercentage - loadStartPercentage + } else { + const loaded = end - start + const remainingToLoad = node.duration - start + // user seeked a specific position in the audio, our progress must be + // computed based on the remaining portion of the track + loadPercentage = loaded / remainingToLoad + } + + if (loadPercentage * 100 === bufferProgress.value) { + return + } + + store.commit('player/bufferProgress', loadPercentage * 100) +}) + +const observeProgress = ref(false) +useRafFn(() => { + if (observeProgress.value && currentSound.value?.state() === 'loaded') { + progress.value = currentSound.value.seek() + } +}) + +watch(playing, async (isPlaying) => { + if (currentSound.value) { + if (isPlaying) { + currentSound.value.play() + } else { + currentSound.value.pause() + } + } else { + await playTrack(currentTrack.value) + } + + observeProgress.value = isPlaying +}) + +const isListeningSubmitted = ref(false) +const nextTrackPreloaded = ref(false) +watch(currentTrack, () => (nextTrackPreloaded.value = false)) + +// Controls +const pause = () => store.dispatch('player/pausePlayback') +const resume = () => store.dispatch('player/resumePlayback') + +const { next } = useQueue() +const seek = (step: number) => { + // seek right + if (step > 0) { + if (currentTime.value + step < duration.value) { + store.dispatch('player/updateProgress', (currentTime.value + step)) + } else { + next() + } + + return + } + + // seek left + const position = Math.max(currentTime.value + step, 0) + store.dispatch('player/updateProgress', position) +} + +const togglePlayback = () => { + if (playing.value) return pause() + return resume() +} + +export default () => { + return { + looping, + playing, + loading, + errored, + focused, + isListeningSubmitted, + + playTrack, + + volume, + mute, + unmute, + toggleMute, + + duration, + currentTime, + + durationFormatted, + currentTimeFormatted, + + progress, + bufferProgress, + + pause, + resume, + seek, + togglePlayback + } +} diff --git a/front/src/composables/audio/useQueue.ts b/front/src/composables/audio/useQueue.ts new file mode 100644 index 0000000000000000000000000000000000000000..8eceb1e44f407d2cd5fd14df966b0b67ac684433 --- /dev/null +++ b/front/src/composables/audio/useQueue.ts @@ -0,0 +1,106 @@ +import type { Track } from '~/types' + +import { useTimeoutFn, useThrottleFn, useTimeAgo, useNow, whenever } from '@vueuse/core' +import { Howler } from 'howler' +import { gettext } from '~/init/locale' +import { ref, computed } from 'vue' +import { sum } from 'lodash-es' +import store from '~/store' + +const { $pgettext } = gettext + +const currentTrack = computed(() => store.getters['queue/currentTrack']) +const currentIndex = computed(() => store.state.queue.currentIndex) +const hasNext = computed(() => store.getters['queue/hasNext']) +const hasPrevious = computed(() => store.getters['queue/hasPrevious']) + +const isEmpty = computed(() => store.getters['queue/isEmpty']) +whenever(isEmpty, () => Howler.unload()) + +const removeTrack = (index: number) => store.dispatch('queue/cleanTrack', index) +const clear = () => store.dispatch('queue/clean') + +const next = () => store.dispatch('queue/next') +const previous = () => store.dispatch('queue/previous') + +const focused = computed(() => store.state.ui.queueFocused === 'queue') + +// +// Track list +// +const tracks = computed<Track[]>(() => store.state.queue.tracks) + +const reorder = (oldIndex: number, newIndex: number) => { + store.commit('queue/reorder', { + oldIndex, + newIndex + }) +} + +// +// Shuffle +// +const isShuffling = ref(false) + +const forceShuffle = useThrottleFn(() => { + isShuffling.value = true + + useTimeoutFn(async () => { + await store.dispatch('queue/shuffle') + store.commit('ui/addMessage', { + content: $pgettext('Content/Queue/Message', 'Queue shuffled!'), + date: new Date() + }) + + isShuffling.value = false + }, 100) +}) + +const shuffle = useThrottleFn(() => { + if (isShuffling.value || isEmpty.value) { + return + } + + return forceShuffle() +}, 101, false) + +// +// Time left +// +const now = useNow() +const endsIn = useTimeAgo(computed(() => { + const seconds = sum( + tracks.value + .slice(currentIndex.value) + .map((track) => track.uploads?.[0]?.duration ?? 0) + ) + + const date = new Date(now.value) + date.setSeconds(date.getSeconds() + seconds) + return date +})) + +export default () => { + return { + currentTrack, + currentIndex, + hasNext, + hasPrevious, + isEmpty, + isShuffling, + + removeTrack, + clear, + next, + previous, + + tracks, + reorder, + + shuffle, + forceShuffle, + + endsIn, + focused + } +} diff --git a/front/src/composables/audio/useSound.ts b/front/src/composables/audio/useSound.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfab65633e590826b30d8469b74a2154b960b8fd --- /dev/null +++ b/front/src/composables/audio/useSound.ts @@ -0,0 +1,156 @@ +import type { Track } from '~/types' + +import { ref, computed } from 'vue' +import { Howl } from 'howler' +import useTrackSources from '~/composables/audio/useTrackSources' +import useSoundCache from '~/composables/audio/useSoundCache' +import usePlayer from '~/composables/audio/usePlayer' +import store from '~/store' +import { createEventHook, useThrottleFn } from '@vueuse/core' + +interface Sound { + id?: number + howl: Howl + stop: () => void + play: () => void + pause: () => void + state: () => 'unloaded' | 'loading' | 'loaded' + seek: (time?: number) => number + duration: () => number + getSource: () => boolean + triggerSoundProgress: (time: number, duration: number) => void +} + +const soundCache = useSoundCache() +const currentTrack = computed(() => store.getters['queue/currentTrack']) +const looping = computed(() => store.state.player.looping) + +const currentSound = ref<Sound>() +const soundId = ref() + +const soundProgress = createEventHook<{ node: HTMLAudioElement, time: number, duration: number }>() + +const createSound = (howl: Howl): Sound => ({ + howl, + play () { + this.id = howl.play(this.id) + }, + stop () { + howl.stop(this.id) + this.id = undefined + }, + pause () { + howl.pause(this.id) + }, + state: () => howl.state(), + seek: (time?: number) => howl.seek(time), + duration: () => howl.duration(), + getSource: () => (howl as any)._sounds[0], + triggerSoundProgress: useThrottleFn((time: number, duration: number) => { + const node = (howl as any)._sounds[0]?._node + if (node) { + soundProgress.trigger({ node, time, duration }) + } + }, 1000) +}) + +const loadSound = (track: Track): Sound => { + const cached = soundCache.get(track.id) + if (cached) { + return createSound(cached.howl) + } + + const sources = useTrackSources(track) + + const howl = new Howl({ + src: sources.map((source) => source.url), + format: sources.map((source) => source.type), + autoplay: false, + loop: false, + html5: true, + preload: true, + + onend () { + const onlyTrack = store.state.queue.tracks.length === 1 + + // NOTE: We need to send trackListened when we've finished track playback and we are not focused on the tab + const { isListeningSubmitted } = usePlayer() + if (!isListeningSubmitted.value) { + store.dispatch('player/trackListened', currentTrack.value) + isListeningSubmitted.value = true + } + + if (looping.value === 1 || (onlyTrack && looping.value === 2)) { + currentSound.value?.seek(0) + store.dispatch('player/updateProgress', 0) + soundId.value = currentSound.value?.play() + } else { + store.dispatch('player/trackEnded', currentTrack.value) + } + }, + + onunlock () { + if (store.state.player.playing && currentSound.value) { + soundId.value = currentSound.value.play() + } + }, + + onplay () { + const [otherId] = (this as any)._getSoundIds() + const [currentId] = (currentSound.value?.howl as any)?._getSoundIds() ?? [] + + if (otherId !== currentId) { + return (this as any).stop() + } + + const time = currentSound.value?.seek() ?? 0 + const duration = currentSound.value?.duration() ?? 0 + if (time <= duration / 2) { + const { isListeningSubmitted } = usePlayer() + isListeningSubmitted.value = false + } + + store.commit('player/isLoadingAudio', false) + store.commit('player/resetErrorCount') + store.commit('player/errored', false) + store.commit('player/duration', howl.duration()) + }, + + onplayerror (soundId, error) { + console.error('play error', soundId, error) + }, + + onloaderror (soundId, error) { + soundCache.delete(track.id) + howl.unload() + + const [otherId] = (this as any)._getSoundIds() + const [currentId] = (currentSound.value?.howl as any)._getSoundIds() ?? [] + + if (otherId !== currentId) { + console.error('load error', soundId, error) + return + } + + console.error('Error while playing:', soundId, error) + store.commit('player/isLoadingAudio', false) + store.dispatch('player/trackErrored') + } + }) + + soundCache.set(track.id, { + id: track.id, + date: new Date(), + howl + }) + + return createSound(howl) +} + +export default () => { + return { + loadSound, + currentSound, + onSoundProgress: soundProgress.on + } +} diff --git a/front/src/composables/audio/useSoundCache.ts b/front/src/composables/audio/useSoundCache.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f2a46eac1caa7724eb67f5ff8a2b4beb12391a3 --- /dev/null +++ b/front/src/composables/audio/useSoundCache.ts @@ -0,0 +1,38 @@ +import type { Howl } from 'howler' + +import { sortBy } from 'lodash-es' +import { reactive, watchEffect, ref } from 'vue' + +const MAX_PRELOADED = 3 + +export interface CachedSound { + id: number + date: Date + howl: Howl +} + +const soundCache = reactive(new Map<number, CachedSound>()) +const cleaningCache = ref(false) + +watchEffect(() => { + const toRemove = soundCache.size - MAX_PRELOADED + + if (toRemove > 0 && !cleaningCache.value) { + cleaningCache.value = true + + const excess = sortBy([...soundCache.values()], [(cached: CachedSound) => cached.date]) + .slice(0, toRemove) + + for (const cached of excess) { + console.log('Removing cached element:', cached) + soundCache.delete(cached.id) + cached.howl.unload() + } + + cleaningCache.value = false + } +}) + +export default () => { + return soundCache +} diff --git a/front/src/composables/audio/useTrackSources.ts b/front/src/composables/audio/useTrackSources.ts new file mode 100644 index 0000000000000000000000000000000000000000..500cc8606284c60286e068f4d7fb03528f7f599b --- /dev/null +++ b/front/src/composables/audio/useTrackSources.ts @@ -0,0 +1,51 @@ +import type { Track } from '~/types' + +import store from '~/store' +import updateQueryString from '~/composables/updateQueryString' + +export interface TrackSource { + url: string + type: string +} + +export default (trackData: Track): TrackSource[] => { + const audio = document.createElement('audio') + + const allowed = ['probably', 'maybe'] + + const sources = trackData.uploads + .filter(upload => { + const canPlay = audio.canPlayType(upload.mimetype) + return allowed.indexOf(canPlay) > -1 + }) + .map(upload => ({ + type: upload.extension, + url: store.getters['instance/absoluteUrl'](upload.listen_url) + })) + + // We always add a transcoded MP3 src at the end + // because transcoding is expensive, but we want browsers that do + // not support other codecs to be able to play it :) + sources.push({ + type: 'mp3', + url: updateQueryString( + store.getters['instance/absoluteUrl'](trackData.listen_url), + 'to', + 'mp3' + ) + }) + + const token = store.state.auth.scopedTokens.listen + if (store.state.auth.authenticated && token !== null) { + // we need to send the token directly in url + // so authentication can be checked by the backend + // because for audio files we cannot use the regular Authentication + // header + return sources.map(source => ({ + ...source, + url: updateQueryString(source.url, 'token', token) + })) + } + + return sources +} diff --git a/front/src/composables/auth/useScopes.ts b/front/src/composables/auth/useScopes.ts new file mode 100644 index 0000000000000000000000000000000000000000..704011b4f18fd000747c951b3710ef15ce7d8b59 --- /dev/null +++ b/front/src/composables/auth/useScopes.ts @@ -0,0 +1,17 @@ +export type ScopeId = 'profile' | 'libraries' | 'favorites' | 'listenings' | 'follows' + | 'playlists' | 'radios' | 'filters' | 'notifications' | 'edits' | 'security' | 'reports' + +export default () => [ + { id: 'profile', icon: 'user' }, + { id: 'libraries', icon: 'book' }, + { id: 'favorites', icon: 'heart' }, + { id: 'listenings', icon: 'music' }, + { id: 'follows', icon: 'users' }, + { id: 'playlists', icon: 'list' }, + { id: 'radios', icon: 'rss' }, + { id: 'filters', icon: 'eye slash' }, + { id: 'notifications', icon: 'bell' }, + { id: 'edits', icon: 'pencil alternate' }, + { id: 'security', icon: 'lock' }, + { id: 'reports', icon: 'warning sign' } +] as { id: ScopeId, icon: string }[] diff --git a/front/src/composables/locale/useSharedLabels.ts b/front/src/composables/locale/useSharedLabels.ts new file mode 100644 index 0000000000000000000000000000000000000000..418691a1298c88f2ff068c446bc7e10e3f72db75 --- /dev/null +++ b/front/src/composables/locale/useSharedLabels.ts @@ -0,0 +1,150 @@ +import type { PrivacyLevel, ImportStatus } from '~/types' +import type { ScopeId } from '~/composables/auth/useScopes' + +import { gettext } from '~/init/locale' + +const { $pgettext } = gettext + +export default () => ({ + fields: { + privacy_level: { + label: $pgettext('Content/Settings/Dropdown.Label/Noun', 'Activity visibility'), + help: $pgettext('Content/Settings/Dropdown.Help text', 'Determine the visibility level of your activity'), + choices: { + me: $pgettext('Content/Settings/Dropdown', 'Nobody except me'), + instance: $pgettext('Content/Settings/Dropdown', 'Everyone on this instance'), + everyone: $pgettext('Content/Settings/Dropdown', 'Everyone, across all instances') + } as Record<PrivacyLevel, string>, + shortChoices: { + me: $pgettext('Content/Settings/Dropdown/Short', 'Private'), + instance: $pgettext('Content/Settings/Dropdown/Short', 'Instance'), + everyone: $pgettext('Content/Settings/Dropdown/Short', 'Everyone') + } as Record<PrivacyLevel, string> + }, + import_status: { + label: $pgettext('Content/Library/Link.Title', 'Click to display more information about the import process for this upload'), + choices: { + skipped: { + label: $pgettext('Content/Library/*', 'Skipped'), + help: $pgettext('Content/Library/Help text', 'This track is already present in one of your libraries') + }, + draft: { + label: $pgettext('Content/Library/*/Short', 'Draft'), + help: $pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been scheduled for processing yet') + }, + pending: { + label: $pgettext('Content/Library/*/Short', 'Pending'), + help: $pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been processed by the server yet') + }, + errored: { + label: $pgettext('Content/Library/Table/Short', 'Errored'), + help: $pgettext('Content/Library/Help text', 'This track could not be processed, please make sure it is tagged correctly') + }, + finished: { + label: $pgettext('Content/Library/*', 'Finished'), + help: $pgettext('Content/Library/Help text', 'Imported') + } + } as Record<ImportStatus, { label: string, help: string }> + }, + report_type: { + label: $pgettext('*/*/*', 'Category'), + choices: { + takedown_request: $pgettext('Content/Moderation/Dropdown', 'Takedown request'), + invalid_metadata: $pgettext('Popup/Import/Error.Label', 'Invalid metadata'), + illegal_content: $pgettext('Content/Moderation/Dropdown', 'Illegal content'), + offensive_content: $pgettext('Content/Moderation/Dropdown', 'Offensive content'), + other: $pgettext('Content/Moderation/Dropdown', 'Other') + } + }, + summary: { + label: $pgettext('Content/Account/*', 'Bio'), + help: undefined + }, + content_category: { + label: $pgettext('Content/*/Dropdown.Label/Noun', 'Content category'), + choices: { + podcast: $pgettext('Content/*/Dropdown', 'Podcast'), + music: $pgettext('*/*/*', 'Music'), + other: $pgettext('*/*/*', 'Other') + } + } + }, + filters: { + creation_date: $pgettext('Content/*/*/Noun', 'Creation date'), + release_date: $pgettext('Content/*/*/Noun', 'Release date'), + accessed_date: $pgettext('Content/*/*/Noun', 'Accessed date'), + applied_date: $pgettext('Content/*/*/Noun', 'Applied date'), + handled_date: $pgettext('Content/*/*/Noun', 'Handled date'), + first_seen: $pgettext('Content/Moderation/Dropdown/Noun', 'First seen date'), + last_seen: $pgettext('Content/Moderation/Dropdown/Noun', 'Last seen date'), + modification_date: $pgettext('Content/Playlist/Dropdown/Noun', 'Modification date'), + expiration_date: $pgettext('Content/Admin/Table.Label/Noun', 'Expiration date'), + track_title: $pgettext('Content/*/Dropdown/Noun', 'Track name'), + album_title: $pgettext('Content/*/Dropdown/Noun', 'Album name'), + artist_name: $pgettext('Content/*/Dropdown/Noun', 'Artist name'), + name: $pgettext('*/*/*/Noun', 'Name'), + length: $pgettext('*/*/*/Noun', 'Duration'), + items_count: $pgettext('*/*/*/Noun', 'Items'), + size: $pgettext('Content/*/*/Noun', 'Size'), + bitrate: $pgettext('Content/Track/*/Noun', 'Bitrate'), + duration: $pgettext('Content/*/*', 'Duration'), + date_joined: $pgettext('Content/Admin/Table.Label/Noun', 'Sign-up date'), + last_activity: $pgettext('Content/Profile/Table.Label/Short, Noun (Value is a date)', 'Last activity'), + username: $pgettext('Content/*/*', 'Username'), + domain: $pgettext('Content/Moderation/*/Noun', 'Domain'), + users: $pgettext('*/*/*/Noun', 'Users'), + received_messages: $pgettext('Content/Moderation/*/Noun', 'Received messages'), + uploads: $pgettext('*/*/*', 'Uploads'), + followers: $pgettext('Content/Federation/*/Noun', 'Followers') + }, + scopes: { + profile: { + label: $pgettext('Content/OAuth Scopes/Label', 'Profile'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to e-mail, username, and profile information') + }, + libraries: { + label: $pgettext('Content/OAuth Scopes/Label', 'Libraries and uploads'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to audio files, libraries, artists, albums and tracks') + }, + favorites: { + label: $pgettext('Sidebar/Favorites/List item.Link/Noun', 'Favorites'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to favorites') + }, + listenings: { + label: $pgettext('*/*/*/Noun', 'Listenings'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to listening history') + }, + follows: { + label: $pgettext('Content/OAuth Scopes/Label', 'Follows'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to follows') + }, + playlists: { + label: $pgettext('*/*/*', 'Playlists'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to playlists') + }, + radios: { + label: $pgettext('*/*/*', 'Radios'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to radios') + }, + filters: { + label: $pgettext('Content/Settings/Title/Noun', 'Content filters'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to content filters') + }, + notifications: { + label: $pgettext('*/Notifications/*', 'Notifications'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to notifications') + }, + edits: { + label: $pgettext('*/Admin/*/Noun', 'Edits'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to edits') + }, + security: { + label: $pgettext('*/Admin/*/Noun', 'Security'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to security settings such as password and authorization') + }, + reports: { + label: $pgettext('*/Moderation/*/Noun', 'Reports'), + description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to moderation reports') + } + } as Record<ScopeId, { label: string, description: string }> +}) diff --git a/front/src/composables/moderation/useEditConfigs.ts b/front/src/composables/moderation/useEditConfigs.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9958d5a7b22d20289b5f6b2906a7a811e6d3ee9 --- /dev/null +++ b/front/src/composables/moderation/useEditConfigs.ts @@ -0,0 +1,125 @@ +import type { Album, Artist, Content, Track, Actor } from '~/types' + +import { gettext } from '~/init/locale' + +export interface ConfigField { + id: string + label: string + type: 'content' | 'attachment' | 'tags' | 'text' | 'license' + inputType?: 'text' | 'number' + required: boolean + getValue: (obj: EditObject) => unknown + getValueRepr?: (obj: any) => string +} + +export interface EditableConfigField extends ConfigField { + id: EditObjectType +} + +export type EditObject = (Partial<Artist> | Partial<Album> | Partial<Track>) & { attributed_to: Actor } +export type EditObjectType = 'artist' | 'album' | 'track' +type Configs = Record<EditObjectType, { fields: (EditableConfigField|ConfigField)[] }> + +const { $pgettext } = gettext +const getContentValueRepr = (val: Content) => val.text + +const description: ConfigField = { + id: 'description', + type: 'content', + required: true, + label: $pgettext('*/*/*/Noun', 'Description'), + getValue: (obj) => obj.description ?? { text: '', content_type: 'text/markdown' }, + getValueRepr: getContentValueRepr +} + +const cover: ConfigField = { + id: 'cover', + type: 'attachment', + required: false, + label: $pgettext('Content/*/*/Noun', 'Cover'), + getValue: (obj) => obj.cover?.uuid ?? null +} + +const tags: ConfigField = { + id: 'tags', + type: 'tags', + required: true, + label: $pgettext('*/*/*/Noun', 'Tags'), + getValue: (obj) => { return obj.tags }, + getValueRepr: (tags: string[]) => tags.slice().sort().join('\n') +} + +// TODO: Get params from typescript type somehow? +export default (): Configs => ({ + artist: { + fields: [ + { + id: 'name', + type: 'text', + required: true, + label: $pgettext('*/*/*/Noun', 'Name'), + getValue: (artist) => (artist as Artist).name + }, + description, + cover, + tags + ] + }, + album: { + fields: [ + { + id: 'title', + type: 'text', + required: true, + label: $pgettext('*/*/*/Noun', 'Title'), + getValue: (album) => (album as Album).title + }, + description, + { + id: 'release_date', + type: 'text', + required: false, + label: $pgettext('Content/*/*/Noun', 'Release date'), + getValue: (album) => (album as Album).release_date + }, + cover, + tags + ] + }, + track: { + fields: [ + { + id: 'title', + type: 'text', + required: true, + label: $pgettext('*/*/*/Noun', 'Title'), + getValue: (track) => (track as Track).title + }, + description, + cover, + { + id: 'position', + type: 'text', + inputType: 'number', + required: false, + label: $pgettext('*/*/*/Short, Noun', 'Position'), + getValue: (track) => (track as Track).position + }, + { + id: 'copyright', + type: 'text', + required: false, + label: $pgettext('Content/Track/*/Noun', 'Copyright'), + getValue: (track) => (track as Track).copyright + }, + { + id: 'license', + type: 'license', + required: false, + label: $pgettext('Content/*/*/Noun', 'License'), + getValue: (track) => (track as Track).license + }, + tags + ] + } +}) diff --git a/front/src/composables/moderation/useReport.ts b/front/src/composables/moderation/useReport.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0e90f4b8a11a2df776aa81cf6e58bbbc0b780cf --- /dev/null +++ b/front/src/composables/moderation/useReport.ts @@ -0,0 +1,140 @@ +import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' + +import { gettext } from '~/init/locale' +import store from '~/store' +const { $pgettext } = gettext + +interface Objects { + track?: Track | null + album?: Album | null + artist?: Artist | null + playlist?: Playlist | null + account?: Actor | null + library?: Library | null + channel?: Channel | null +} + +interface ReportableObject { + label: string, + target: { + type: keyof Objects + label: string + typeLabel: string + _obj: Objects[keyof Objects] + + full_username?: string + id?: number + uuid?: string + } +} + +const getReportableObjects = ({ track, album, artist, playlist, account, library, channel }: Objects) => { + const reportableObjs: ReportableObject[] = [] + + if (account) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report @%{ username }…', { username: account.preferred_username }), + target: { + type: 'account', + _obj: account, + full_username: account.full_username, + label: account.full_username, + typeLabel: $pgettext('*/*/*/Noun', 'Account') + } + }) + } + + if (track) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this track…'), + target: { + type: 'track', + id: track.id, + _obj: track, + label: track.title, + typeLabel: $pgettext('*/*/*/Noun', 'Track') + } + }) + + album = track.album + artist = track.artist + } + + if (album) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this album…'), + target: { + type: 'album', + id: album.id, + label: album.title, + _obj: album, + typeLabel: $pgettext('*/*/*', 'Album') + } + }) + + if (!artist) { + artist = album.artist + } + } + + if (channel) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this channel…'), + target: { + type: 'channel', + uuid: channel.uuid, + label: channel.artist?.name ?? $pgettext('*/*/*', 'Unknown artist'), + _obj: channel, + typeLabel: $pgettext('*/*/*', 'Channel') + } + }) + } else if (artist) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this artist…'), + target: { + type: 'artist', + id: artist.id, + label: artist.name, + _obj: artist, + typeLabel: $pgettext('*/*/*/Noun', 'Artist') + } + }) + } + + if (playlist) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this playlist…'), + target: { + type: 'playlist', + id: playlist.id, + label: playlist.name, + _obj: playlist, + typeLabel: $pgettext('*/*/*', 'Playlist') + } + }) + } + + if (library) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this library…'), + target: { + type: 'library', + uuid: library.uuid, + label: library.name, + _obj: library, + typeLabel: $pgettext('*/*/*/Noun', 'Library') + } + }) + } + + return reportableObjs +} + +const report = (obj: ReportableObject) => { + store.dispatch('moderation/report', obj.target) +} + +export default () => ({ + getReportableObjects, + report +}) diff --git a/front/src/composables/moderation/useReportConfigs.ts b/front/src/composables/moderation/useReportConfigs.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc472e2e94df141d949e827dc17e7f542d2d1a59 --- /dev/null +++ b/front/src/composables/moderation/useReportConfigs.ts @@ -0,0 +1,184 @@ +import type { EntityObjectType } from '~/types' +import type { RouteLocationRaw } from 'vue-router' + +import { gettext } from '~/init/locale' + +interface ModeratedField { + id: string + label: string + getValueRepr?: (obj: any) => string +} + +export interface Entity { + label: string + icon: string + getDeleteUrl?: (object: any) => string + urls: { + getDetail?: (object: any) => RouteLocationRaw + getAdminDetail?: (object: any) => RouteLocationRaw + } + moderatedFields: ModeratedField[] +} + +type Configs = Record<EntityObjectType, Entity> + +const { $pgettext } = gettext + +const tags: ModeratedField = { + id: 'tags', + label: $pgettext('*/*/*/Noun', 'Tags'), + getValueRepr: (tags: string[]) => tags.slice().sort().join('\n') +} + +const name: ModeratedField = { + id: 'name', + label: $pgettext('*/*/*/Noun', 'Name') +} + +const creationDate: ModeratedField = { + id: 'creation_date', + label: $pgettext('Content/*/*/Noun', 'Creation date') +} + +const musicBrainzId: ModeratedField = { + id: 'mbid', + label: $pgettext('*/*/*/Noun', 'MusicBrainz ID') +} + +const visibility: ModeratedField = { + id: 'privacy_level', + label: $pgettext('*/*/*', 'Visibility') +} + +export default (): Configs => ({ + artist: { + label: $pgettext('*/*/*/Noun', 'Artist'), + icon: 'users', + getDeleteUrl: (obj) => { + return `manage/library/artists/${obj.id}/` + }, + urls: { + getDetail: (obj) => ({ name: 'library.artists.detail', params: { id: obj.id } }), + getAdminDetail: (obj) => ({ name: 'manage.library.artists.detail', params: { id: obj.id } }) + }, + moderatedFields: [ + name, + creationDate, + tags, + musicBrainzId + ] + }, + album: { + label: $pgettext('*/*/*', 'Album'), + icon: 'play', + getDeleteUrl: (obj) => { + return `manage/library/albums/${obj.id}/` + }, + urls: { + getDetail: (obj) => ({ name: 'library.albums.detail', params: { id: obj.id } }), + getAdminDetail: (obj) => ({ name: 'manage.library.albums.detail', params: { id: obj.id } }) + }, + moderatedFields: [ + { + id: 'title', + label: $pgettext('*/*/*/Noun', 'Title') + }, + creationDate, + { + id: 'release_date', + label: $pgettext('Content/*/*/Noun', 'Release date') + }, + tags, + musicBrainzId + ] + }, + track: { + label: $pgettext('*/*/*/Noun', 'Track'), + icon: 'music', + getDeleteUrl: (obj) => { + return `manage/library/tracks/${obj.id}/` + }, + urls: { + getDetail: (obj) => ({ name: 'library.tracks.detail', params: { id: obj.id } }), + getAdminDetail: (obj) => ({ name: 'manage.library.tracks.detail', params: { id: obj.id } }) + }, + moderatedFields: [ + { + id: 'title', + label: $pgettext('*/*/*/Noun', 'Title') + }, + { + id: 'position', + label: $pgettext('*/*/*/Short, Noun', 'Position') + }, + { + id: 'copyright', + label: $pgettext('Content/Track/*/Noun', 'Copyright') + }, + { + id: 'license', + label: $pgettext('Content/*/*/Noun', 'License') + }, + tags, + musicBrainzId + ] + }, + library: { + label: $pgettext('*/*/*/Noun', 'Library'), + icon: 'book', + getDeleteUrl: (obj) => { + return `manage/library/libraries/${obj.uuid}/` + }, + urls: { + getAdminDetail: (obj) => ({ name: 'manage.library.libraries.detail', params: { id: obj.uuid } }) + }, + moderatedFields: [ + name, + { + id: 'description', + label: $pgettext('*/*/*/Noun', 'Description') + }, + visibility + ] + }, + playlist: { + label: $pgettext('*/*/*', 'Playlist'), + icon: 'list', + urls: { + getDetail: (obj) => ({ name: 'library.playlists.detail', params: { id: obj.id } }) + // getAdminDetail: (obj) => ({name: 'manage.playlists.detail', params: {id: obj.id}}} + }, + moderatedFields: [ + name, + visibility + ] + }, + account: { + label: $pgettext('*/*/*/Noun', 'Account'), + icon: 'user', + urls: { + getDetail: (obj) => ({ name: 'profile.full.overview', params: { username: obj.preferred_username, domain: obj.domain } }), + getAdminDetail: (obj) => ({ name: 'manage.moderation.accounts.detail', params: { id: `${obj.preferred_username}@${obj.domain}` } }) + }, + moderatedFields: [ + name, + { + id: 'summary', + label: $pgettext('*/*/*/Noun', 'Bio') + } + ] + }, + channel: { + label: $pgettext('*/*/*', 'Channel'), + icon: 'stream', + urls: { + getDetail: (obj) => ({ name: 'channels.detail', params: { id: obj.uuid } }), + getAdminDetail: (obj) => ({ name: 'manage.channels.detail', params: { id: obj.uuid } }) + }, + moderatedFields: [ + name, + creationDate, + tags + ] + } +}) diff --git a/front/src/composables/navigation/useOrdering.ts b/front/src/composables/navigation/useOrdering.ts new file mode 100644 index 0000000000000000000000000000000000000000..edb02cff199a5562e66e9e1bb2c8b82d6a16dce5 --- /dev/null +++ b/front/src/composables/navigation/useOrdering.ts @@ -0,0 +1,73 @@ +import type { RouteRecordName } from 'vue-router' + +import { toRefs, useStorage, syncRef } from '@vueuse/core' +import { useRouteQuery } from '@vueuse/router' +import { useRoute } from 'vue-router' +import { ref, watch } from 'vue' + +export interface OrderingProps { + orderingConfigName?: RouteRecordName +} + +export default (props: OrderingProps) => { + const route = useRoute() + + const preferences = useStorage(`route-preferences:${props.orderingConfigName?.toString() ?? route.name?.toString() ?? '*'}`, { + orderingDirection: route.meta.orderingDirection ?? '-', + ordering: route.meta.ordering ?? 'creation_date', + paginateBy: route.meta.paginateBy ?? 50 + }) + + const { + orderingDirection: perfOrderingDirection, + paginateBy: perfPaginateBy, + ordering: perfOrdering + } = toRefs(preferences) + + const queryPaginateBy = useRouteQuery<string>('paginateBy', perfPaginateBy.value.toString()) + const paginateBy = ref() + syncRef(queryPaginateBy, paginateBy, { + transform: { + ltr: (left) => +left, + rtl: (right) => right.toString() + } + }) + + const queryOrdering = useRouteQuery('ordering', perfOrderingDirection.value + perfOrdering.value) + console.log(queryOrdering.value) + + watch(queryOrdering, (ordering) => { + perfOrderingDirection.value = ordering[0] === '-' ? '-' : '+' + perfOrdering.value = ordering[0] === '-' || ordering[0] === '+' + ? ordering.slice(1) + : ordering + }) + + watch(perfOrderingDirection, (direction) => { + if (direction === '-') { + queryOrdering.value = direction + perfOrdering.value + return + } + + queryOrdering.value = perfOrdering.value + }) + + watch(perfOrdering, (field) => { + const direction = perfOrderingDirection.value + queryOrdering.value = (direction === '-' ? '-' : '') + field + }) + + watch(queryPaginateBy, (paginateBy) => { + perfPaginateBy.value = +paginateBy + }) + + const onOrderingUpdate = (fn: () => void) => watch(preferences, fn) + + return { + paginateBy, + ordering: perfOrdering, + orderingDirection: perfOrderingDirection, + orderingString: queryOrdering, + onOrderingUpdate + } +} diff --git a/front/src/composables/navigation/usePage.ts b/front/src/composables/navigation/usePage.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf16aa9ebe5b7ae235f64cbd3c58111489c19f8e --- /dev/null +++ b/front/src/composables/navigation/usePage.ts @@ -0,0 +1,16 @@ +import { useRouteQuery } from '@vueuse/router' +import { syncRef } from '@vueuse/core' +import { ref } from 'vue' + +export default () => { + const pageQuery = useRouteQuery<string>('page', '1') + const page = ref() + syncRef(pageQuery, page, { + transform: { + ltr: (left) => +left, + rtl: (right) => right.toString() + } + }) + + return page +} diff --git a/front/src/composables/navigation/useSmartSearch.ts b/front/src/composables/navigation/useSmartSearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd4b57f5c88358133f3b2ea548b984302a4d5c36 --- /dev/null +++ b/front/src/composables/navigation/useSmartSearch.ts @@ -0,0 +1,83 @@ +import type { Token } from '~/utils/search' + +import { compileTokens, normalizeQuery, parseTokens } from '~/utils/search' +import { refWithControl } from '@vueuse/core' +import { computed, ref, watch } from 'vue' +import { useRouter } from 'vue-router' + +export interface SmartSearchProps { + defaultQuery?: string + updateUrl?: boolean +} + +export default (props: SmartSearchProps) => { + const query = refWithControl(props.defaultQuery ?? '') + const tokens = ref([] as Token[]) + + watch(query, (value) => { + tokens.value = parseTokens(normalizeQuery(value)) + }, { immediate: true }) + + const updateHandlers = new Set<() => void>() + const onSearch = (fn: () => void) => { + updateHandlers.add(fn) + return () => updateHandlers.delete(fn) + } + + const router = useRouter() + watch(tokens, (value) => { + const newQuery = compileTokens(value) + if (props.updateUrl) { + return router.replace({ query: { q: newQuery } }) + } + + // TODO (wvffle): updateUrl = false only in FilesTable.vue + query.set(newQuery, false) + for (const handler of updateHandlers) { + handler() + } + // this.page = 1 + // this.fetchData() + }, { deep: true }) + + const getTokenValue = (key: string, fallback: string) => { + const matching = tokens.value.find(token => { + return token.field === key + }) + + return matching?.value ?? fallback + } + + const addSearchToken = (key: string, value: string) => { + if (value === '') { + tokens.value = tokens.value.filter(token => { + return token.field !== key + }) + + return + } + + const existing = tokens.value.filter(token => { + return token.field === key + }) + + if (!existing.length) { + tokens.value.push({ field: key, value }) + return + } + + for (const token of existing) { + token.value = value + } + } + + return { + getTokenValue, + addSearchToken, + onSearch, + query: computed({ + get: () => compileTokens(tokens.value), + set: (value: string) => query.set(value, true) + }) + } +} diff --git a/front/src/composables/onKeyboardShortcut.ts b/front/src/composables/onKeyboardShortcut.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f066775e76244e8fa764247ba5653ba74382aec --- /dev/null +++ b/front/src/composables/onKeyboardShortcut.ts @@ -0,0 +1,76 @@ +import { DefaultMagicKeysAliasMap, tryOnScopeDispose, useEventListener } from '@vueuse/core' +import { isEqual, isMatch } from 'lodash-es' +import { reactive } from 'vue' + +type KeyFilter = string | string[] + +interface Entry { + handler: () => unknown, + prevent: boolean, + __location?: string +} + +const combinations = reactive(new Map()) + +const current = new Set() +useEventListener(window, 'keydown', (event) => { + if (!event.key) return + + const target = event.target as HTMLElement + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return + + current.add(event.key.toLowerCase()) + + const currentArray = [...current] + for (const [requiredKeys, { handler, prevent }] of combinations.entries()) { + if (isEqual(currentArray, requiredKeys)) { + if (prevent) event.preventDefault() + handler() + } + } +}) + +useEventListener(window, 'keyup', (event) => { + if (event.key) { + current.delete(event.key.toLowerCase()) + } +}) + +export default (key: KeyFilter, handler: () => unknown, prevent = false) => { + const combination = (Array.isArray(key) ? key : [key as string]).map(key => { + return DefaultMagicKeysAliasMap[key] ?? key + }) + + const entry: Entry = { prevent, handler } + + if (import.meta.env.DEV) { + entry.__location = new Error().stack?.split('\n', 2).pop() + // TODO: Get correct line number somehow? + // Currently $3 is a line number that should work in .ts files, + // though in .vue files we need to get the line of the script plus + // the position of <script> tag in SFC + ?.replace(/^(.+?)@.+\/(.+\..+?)(?:\?.+?|):(\d+):.+$/, 'Method $1 in $2') + + // NOTE: Inform about possible combination collision + for (const [keys, { __location }] of combinations.entries()) { + const collisions = [] + if (isMatch(keys, combination) || isMatch(combination, keys)) { + collisions.push(`${__location}: ${keys.join(' + ')}`) + } + + if (collisions.length) { + console.warn([ + 'onKeyboardShortcut detected a possible collision in:', + `${entry.__location}: ${combination.join(' + ')}`, + ...collisions + ].join('\n')) + } + } + } + + combinations.set(combination, entry) + + const stop = () => combinations.delete(combination) + tryOnScopeDispose(stop) + return stop +} diff --git a/front/src/composables/updateQueryString.ts b/front/src/composables/updateQueryString.ts new file mode 100644 index 0000000000000000000000000000000000000000..84873d0673ee1eeda459dc28e5d1045ae79e9a57 --- /dev/null +++ b/front/src/composables/updateQueryString.ts @@ -0,0 +1,5 @@ +export default (uri: string, key: string, value: string) => { + const url = new URL(uri) + url.searchParams.set(key, value) + return url.href +} diff --git a/front/src/composables/useErrorHandler.ts b/front/src/composables/useErrorHandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a3a833a862f7270c775bbc287d230990ef7d0fb --- /dev/null +++ b/front/src/composables/useErrorHandler.ts @@ -0,0 +1,71 @@ +import type { BackendError } from '~/types' + +import { gettext } from '~/init/locale' +import { COOKIE } from '~/init/sentry' + +import useLogger from '~/composables/useLogger' +import store from '~/store' + +const { $pgettext } = gettext +const logger = useLogger() + +async function useErrorHandler (error: Error | BackendError): Promise<void> +async function useErrorHandler (error: Error | BackendError, eventId?: string): Promise<void> +async function useErrorHandler (error: Error | BackendError, eventId?: string): Promise<void> { + const title = 'backendErrors' in error + ? 'Unexpected API error' + : 'Unexpected error' + + let content = $pgettext('App/Message/Paragraph', 'An unexpected error occured.') + + if ('backendErrors' in error) { + logger.error(title, error, error.backendErrors) + } else { + logger.error(title, error) + } + + const date = new Date() + const actions = [] + + if (import.meta.env.FUNKWHALE_SENTRY_DSN) { + const [Sentry, { useCookies }] = await Promise.all([ + import('@sentry/vue'), + import('@vueuse/integrations/useCookies') + ]) + + const { get } = useCookies() + if (get(COOKIE) === 'yes') { + content = $pgettext('App/Message/Paragraph', 'An unexpected error occured. <br><sub>To help us understand why it happened, please attach a detailed description of what you did that has triggered the error.</sub>') + const user = store.state.auth.authenticated + ? { + name: store.state.auth.username, + email: store.state.auth.profile?.email + } + : undefined + + actions.push({ + text: $pgettext('App/Message/Paragraph', 'Leave feedback'), + class: 'basic red', + click: () => Sentry.showReportDialog({ + eventId: eventId ?? Sentry.captureException(error), + user + }) + }) + } + } + + if ('isHandled' in error && error.isHandled) { + return + } + + store.commit('ui/addMessage', { + content, + date, + class: 'error', + key: `error-${date}`, + classActions: 'bottom attached opaque', + actions + }) +} + +export default useErrorHandler diff --git a/front/src/composables/useFormData.ts b/front/src/composables/useFormData.ts new file mode 100644 index 0000000000000000000000000000000000000000..590918b84864662101c08bc3d21c22110a7c3f84 --- /dev/null +++ b/front/src/composables/useFormData.ts @@ -0,0 +1,17 @@ +export default (object?: Record<string, string | File | object | null>) => { + const data = new FormData() + + if (object) { + for (const [key, value] of Object.entries(object)) { + if (typeof value === 'string') { + data.set(key, value) + } else if (value instanceof File) { + data.set(key, value, value.name) + } else { + data.set(key, JSON.stringify(value)) + } + } + } + + return data +} diff --git a/front/src/composables/useLogger.ts b/front/src/composables/useLogger.ts new file mode 100644 index 0000000000000000000000000000000000000000..660c79385f644f2c362202de2b0000f8ddb20b3d --- /dev/null +++ b/front/src/composables/useLogger.ts @@ -0,0 +1,11 @@ +import Logger from 'js-logger' + +Logger.useDefaults({ + defaultLevel: import.meta.env.DEV + ? Logger.DEBUG + : Logger.WARN +}) + +export default (logger?: string) => logger + ? Logger.get(logger) + : Logger diff --git a/front/src/composables/useMarkdown.ts b/front/src/composables/useMarkdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4e05a616ad68f8a5d12313be14f13ee2f21a6d8 --- /dev/null +++ b/front/src/composables/useMarkdown.ts @@ -0,0 +1,55 @@ +import type { MaybeComputedRef } from '@vueuse/core' + +import { resolveUnref } from '@vueuse/core' +import { computed } from 'vue' +import showdown from 'showdown' + +showdown.extension('openExternalInNewTab', { + type: 'output', + regex: /<a.+?href.+">/g, + replace (text: string) { + const matches = text.match(/href="(.+)">/) ?? [] + const url = matches[1] ?? './' + + if ((!url.startsWith('http://') && !url.startsWith('https://')) || url.startsWith('mailto:')) { + return text + } + + const { hostname } = new URL(url) + return hostname !== location.hostname + ? text.replace(matches[0], `href="${url}" target="_blank" rel="noopener noreferrer">`) + : text + } +}) + +showdown.extension('linkifyTags', { + type: 'language', + regex: /#[^\W]+/g, + replace (text: string) { + return `<a href="/library/tags/${text.slice(1)}">${text}</a>` + } +}) + +const markdown = new showdown.Converter({ + extensions: ['openExternalInNewTab', 'linkifyTags'], + ghMentions: true, + ghMentionsLink: '/@{u}', + simplifiedAutoLink: true, + openLinksInNewWindow: false, + simpleLineBreaks: true, + strikethrough: true, + tables: true, + tasklists: true, + underline: true, + noHeaderId: true, + headerLevelStart: 3, + literalMidWordUnderscores: true, + excludeTrailingPunctuationFromURLs: true, + encodeEmails: true, + emoji: true +}) + +export const useMarkdownRaw = (md: string) => markdown.makeHtml(md) +export const useMarkdownComputed = (md: MaybeComputedRef<string>) => computed(() => useMarkdownRaw(resolveUnref(md))) + +export default useMarkdownComputed diff --git a/front/src/composables/useTheme.ts b/front/src/composables/useTheme.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab8cdd1c79e963e62a5604bfc9e4fd445af31db7 --- /dev/null +++ b/front/src/composables/useTheme.ts @@ -0,0 +1,12 @@ +import { useColorMode } from '@vueuse/core' + +const theme = useColorMode({ + selector: 'body', + modes: { + auto: '', + light: 'theme-light', + dark: 'theme-dark' + } +}) + +export default () => theme diff --git a/front/src/composables/useThemeList.ts b/front/src/composables/useThemeList.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb2c25c017943eacdb7cf30405f221e1bf9f1063 --- /dev/null +++ b/front/src/composables/useThemeList.ts @@ -0,0 +1,25 @@ +import type { ThemeEntry } from '~/types' + +import { gettext } from '~/init/locale' + +const { $pgettext } = gettext + +const themeList: ThemeEntry[] = [ + { + icon: 'palette icon', + name: $pgettext('*/Settings/Dropdown.Label/Theme name', 'Browser default'), + key: 'auto' + }, + { + icon: 'sun icon', + name: $pgettext('*/Settings/Dropdown.Label/Theme name', 'Light'), + key: 'light' + }, + { + icon: 'moon icon', + name: $pgettext('*/Settings/Dropdown.Label/Theme name', 'Dark'), + key: 'dark' + } +] + +export default () => themeList diff --git a/front/src/composables/useWebSocketHandler.ts b/front/src/composables/useWebSocketHandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3e0733787da4269712e36eaec83c6b2dd3879f7 --- /dev/null +++ b/front/src/composables/useWebSocketHandler.ts @@ -0,0 +1,67 @@ +import type { Notification } from '~/types' + +import store from '~/store' +import { tryOnScopeDispose } from '@vueuse/core' + +export interface ImportStatusWS { + old_status: 'pending' | 'skipped' | 'finished' | 'errored' + new_status: 'pending' | 'skipped' | 'finished' | 'errored' + upload: { + import_reference: string + uuid: string + } +} + +export interface ListenWsEventObject { + local_id: string +} + +export interface ListenWS { + actor: ListenWsEventObject + object: ListenWsEventObject +} + +// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534) +// export interface ListenWSEvent extends Listening { +// type: 'Listen' +// } + +export interface PendingReviewEdits { + pending_review_count: number +} + +export interface PendingReviewReports { + unresolved_count: number +} + +export interface PendingReviewRequests { + pending_count: number +} + +export interface InboxItemAdded { + item: Notification +} + +type stopFn = () => void + +function useWebSocketHandler (eventName: 'inbox.item_added', handler: (event: InboxItemAdded) => void): stopFn +function useWebSocketHandler (eventName: 'report.created', handler: (event: PendingReviewReports) => void): stopFn +function useWebSocketHandler (eventName: 'mutation.created', handler: (event: PendingReviewEdits) => void): stopFn +function useWebSocketHandler (eventName: 'mutation.updated', handler: (event: PendingReviewEdits) => void): stopFn +function useWebSocketHandler (eventName: 'import.status_updated', handler: (event: ImportStatusWS) => void): stopFn +function useWebSocketHandler (eventName: 'user_request.created', handler: (event: PendingReviewRequests) => void): stopFn +function useWebSocketHandler (eventName: 'Listen', handler: (event: ListenWS) => void): stopFn + +function useWebSocketHandler (eventName: string, handler: (event: any) => void): stopFn { + const id = `${+new Date() + Math.random()}` + store.commit('ui/addWebsocketEventHandler', { eventName, handler, id }) + + const stop = () => { + store.commit('ui/removeWebsocketEventHandler', { eventName, id }) + } + + tryOnScopeDispose(stop) + return stop +} + +export default useWebSocketHandler diff --git a/front/src/edits.js b/front/src/edits.js deleted file mode 100644 index 637f28e46ef9a10a2b4215c02be559b1ad4f81cd..0000000000000000000000000000000000000000 --- a/front/src/edits.js +++ /dev/null @@ -1,191 +0,0 @@ -function getTagsValueRepr (val) { - if (!val) { - return '' - } - return val.slice().sort().join('\n') -} - -function getContentValueRepr (val) { - return val.text -} - -export default { - getConfigs () { - const description = { - id: 'description', - type: 'content', - required: true, - label: this.$pgettext('*/*/*/Noun', 'Description'), - getValue: (obj) => { return obj.description || { text: null, content_type: 'text/markdown' } }, - getValueRepr: getContentValueRepr - } - const cover = { - id: 'cover', - type: 'attachment', - required: false, - label: this.$pgettext('Content/*/*/Noun', 'Cover'), - getValue: (obj) => { - if (obj.cover) { - return obj.cover.uuid - } else { - return null - } - } - } - return { - artist: { - fields: [ - { - id: 'name', - type: 'text', - required: true, - label: this.$pgettext('*/*/*/Noun', 'Name'), - getValue: (obj) => { return obj.name } - }, - description, - cover, - { - id: 'tags', - type: 'tags', - required: true, - label: this.$pgettext('*/*/*/Noun', 'Tags'), - getValue: (obj) => { return obj.tags }, - getValueRepr: getTagsValueRepr - } - ] - }, - album: { - fields: [ - { - id: 'title', - type: 'text', - required: true, - label: this.$pgettext('*/*/*/Noun', 'Title'), - getValue: (obj) => { return obj.title } - }, - description, - { - id: 'release_date', - type: 'text', - required: false, - label: this.$pgettext('Content/*/*/Noun', 'Release date'), - getValue: (obj) => { return obj.release_date } - }, - cover, - { - id: 'tags', - type: 'tags', - required: true, - label: this.$pgettext('*/*/*/Noun', 'Tags'), - getValue: (obj) => { return obj.tags }, - getValueRepr: getTagsValueRepr - } - ] - }, - track: { - fields: [ - { - id: 'title', - type: 'text', - required: true, - label: this.$pgettext('*/*/*/Noun', 'Title'), - getValue: (obj) => { return obj.title } - }, - description, - cover, - { - id: 'position', - type: 'text', - inputType: 'number', - required: false, - label: this.$pgettext('*/*/*/Short, Noun', 'Position'), - getValue: (obj) => { return obj.position } - }, - { - id: 'copyright', - type: 'text', - required: false, - label: this.$pgettext('Content/Track/*/Noun', 'Copyright'), - getValue: (obj) => { return obj.copyright } - }, - { - id: 'license', - type: 'license', - required: false, - label: this.$pgettext('Content/*/*/Noun', 'License'), - getValue: (obj) => { return obj.license } - }, - { - id: 'tags', - type: 'tags', - required: true, - label: this.$pgettext('*/*/*/Noun', 'Tags'), - getValue: (obj) => { return obj.tags }, - getValueRepr: getTagsValueRepr - } - ] - } - } - }, - - getConfig () { - return this.configs[this.objectType] - }, - getFieldConfig (configs, type, fieldId) { - const c = configs[type] - return c.fields.filter((f) => { - return f.id === fieldId - })[0] - }, - getCurrentState () { - const self = this - const s = {} - this.config.fields.forEach(f => { - s[f.id] = { value: f.getValue(self.object) } - }) - return s - }, - getCurrentStateForObj (obj, config) { - const s = {} - config.fields.forEach(f => { - s[f.id] = { value: f.getValue(obj) } - }) - return s - }, - - getCanDelete () { - if (this.obj.is_applied || this.obj.is_approved) { - return false - } - if (!this.$store.state.auth.authenticated) { - return false - } - return ( - this.obj.created_by.full_username === this.$store.state.auth.fullUsername || - this.$store.state.auth.availablePermissions.library - ) - }, - getCanApprove () { - if (this.obj.is_applied) { - return false - } - if (!this.$store.state.auth.authenticated) { - return false - } - return this.$store.state.auth.availablePermissions.library - }, - getCanEdit () { - if (!this.$store.state.auth.authenticated) { - return false - } - - const libraryPermission = this.$store.state.auth.availablePermissions.library - const objData = this.object || {} - let isOwner = false - if (objData.attributed_to) { - isOwner = this.$store.state.auth.fullUsername === objData.attributed_to.full_username - } - return libraryPermission || isOwner - } - -} diff --git a/front/src/embed/EmbedFrame.vue b/front/src/embed/EmbedFrame.vue deleted file mode 100644 index 6da9f99f6b9419d4cceb3e3f8056309d838fcc1b..0000000000000000000000000000000000000000 --- a/front/src/embed/EmbedFrame.vue +++ /dev/null @@ -1,796 +0,0 @@ - -<template> - <main :class="[theme]"> - <!-- SVG from https://cdn.plyr.io/3.4.7/plyr.svg --> - <svg - aria-hidden="true" - style="display: none" - xmlns="http://www.w3.org/2000/svg" - > - <symbol id="plyr-download"><path d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3zM2 15h14v2H2z" /></symbol> - <symbol id="plyr-enter-fullscreen"><path d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z" /></symbol> - <symbol id="plyr-exit-fullscreen"><path d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z" /></symbol> - <symbol id="plyr-fast-forward"><path d="M7.875 7.171L0 1v16l7.875-6.171V17L18 9 7.875 1z" /></symbol> - <symbol id="plyr-muted"><path d="M12.4 12.5l2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z" /></symbol> - <symbol id="plyr-pause"><path d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zM12 1c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1h-3z" /></symbol> - <symbol id="plyr-pip"><path d="M13.293 3.293L7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z" /><path d="M13 15H3V5h5V3H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6h-2v5z" /></symbol> - <symbol id="plyr-play"><path d="M15.562 8.1L3.87.225C3.052-.337 2 .225 2 1.125v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z" /></symbol> - <symbol id="plyr-restart"><path d="M9.7 1.2l.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5-1.3 0-2.6-.5-3.5-1.5-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9l-6.3-.7z" /></symbol> - <symbol id="plyr-rewind"><path d="M10.125 1L0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z" /></symbol> - <symbol id="plyr-settings"><path d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997zM9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6z" /></symbol> - <symbol id="plyr-volume"><path d="M15.6 3.3c-.4-.4-1-.4-1.4 0-.4.4-.4 1 0 1.4C15.4 5.9 16 7.4 16 9c0 1.6-.6 3.1-1.8 4.3-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7z" /><path d="M11.282 5.282a.909.909 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.909.909 0 0 0 0 1.316c.145.145.636.262 1.018.156a.725.725 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9c0-.17-.002-.34-.011-.51-.053-.992-.319-2.005-1.522-3.208a.909.909 0 0 0-1.316 0zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z" /></symbol> - <!-- those ones are from fork-awesome --> - <symbol id="plyr-step-backward"><path d="M979 141c25-25 45-16 45 19v1472c0 35-20 44-45 19L269 941c-6-6-10-12-13-19v678c0 35-29 64-64 64H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h128c35 0 64 29 64 64v678c3-7 7-13 13-19z" /></symbol> - <symbol id="plyr-step-forward"><path d="M45 1651c-25 25-45 16-45-19V160c0-35 20-44 45-19l710 710c6 6 10 12 13 19V192c0-35 29-64 64-64h128c35 0 64 29 64 64v1408c0 35-29 64-64 64H832c-35 0-64-29-64-64V922c-3 7-7 13-13 19z" /></symbol> - </svg> - <article> - <aside - v-if="currentTrack" - class="cover main" - > - <img - v-if="currentTrack.cover" - height="120" - :src="currentTrack.cover" - alt="Cover" - > - <img - v-else-if="artistCover" - height="120" - :src="artistCover" - alt="Cover" - > - <img - v-else - height="120" - src="@/assets/embed/default-cover.jpeg" - alt="Cover" - > - </aside> - <div - class="content" - aria-label="Track information" - > - <header v-if="currentTrack"> - <h3> - <a - :href="fullUrl('/library/tracks/' + currentTrack.id)" - target="_blank" - rel="noopener noreferrer" - >{{ currentTrack.title }}</a> - </h3> - <a - :href="fullUrl('/library/artists/' + currentTrack.artist.id)" - target="_blank" - rel="noopener noreferrer" - >{{ currentTrack.artist.name }}</a> - </header> - <section - v-if="!isLoading" - class="controls" - aria-label="Audio player" - > - <template v-if="currentTrack && currentTrack.sources.length > 0"> - <div - v-if="tracks.length > 1" - class="queue-controls plyr--audio" - > - <div class="plyr__controls"> - <button - type="button" - class="plyr__control" - aria-label="Play previous track" - @focus="setControlFocus($event, true)" - @blur="setControlFocus($event, false)" - @click="previous()" - > - <svg - class="icon--not-pressed" - role="presentation" - focusable="false" - viewBox="0 0 1100 1650" - width="80" - height="80" - > - <use xlink:href="#plyr-step-backward" /> - </svg> - </button> - <button - type="button" - class="plyr__control" - aria-label="Play next track" - @click="next()" - @focus="setControlFocus($event, true)" - @blur="setControlFocus($event, false)" - > - <svg - class="icon--not-pressed" - role="presentation" - focusable="false" - viewBox="0 0 1100 1650" - width="80" - height="80" - > - <use xlink:href="#plyr-step-forward" /> - </svg> - </button> - </div> - </div> - - <vue-plyr - :key="currentIndex" - ref="player" - class="player" - :options="{loadSprite: false, controls: controls, duration: currentTrack.sources[0].duration, autoplay}" - > - <audio preload="none"> - <source - v-for="(source, key) in currentTrack.sources" - :key="key" - :src="source.src" - :type="source.type" - > - </audio> - </vue-plyr> - </template> - <div - v-else - class="player" - > - <span - v-if="error === 'invalid_type'" - class="error" - >Widget improperly configured (bad resource type {{ type }}).</span> - <span - v-else-if="error === 'invalid_id'" - class="error" - >Widget improperly configured (missing resource id).</span> - <span - v-else-if="error === 'server_not_found'" - class="error" - >Track not found.</span> - <span - v-else-if="error === 'server_requires_auth'" - class="error" - >You need to login to access this resource.</span> - <span - v-else-if="error === 'server_error'" - class="error" - >An unknown error occurred while loading track data from server.</span> - <span - v-else-if="currentTrack && currentTrack.sources.length === 0" - class="error" - >This track is unavailable.</span> - <span - v-else - class="error" - >An unknown error occurred while loading track data.</span> - </div> - <a - title="Funkwhale" - href="https://funkwhale.audio" - target="_blank" - rel="noopener noreferrer" - class="logo-wrapper" - > - <logo - :fill="currentTheme.textColor" - class="logo" - /> - </a> - </section> - </div> - </article> - <div - v-if="tracks.length > 1" - id="queue" - class="queue-wrapper" - > - <table class="queue"> - <tbody> - <tr - v-for="(track, index) in tracks" - v-if="track.sources.length > 0" - :id="'queue-item-' + index" - :key="index" - role="button" - :class="[{active: index === currentIndex}]" - @click="play(index)" - @keyup.enter="play(index)" - > - <td - class="position-cell" - width="40" - > - <span class="position"> - {{ index + 1 }} - </span> - </td> - <td - class="title" - :title="track.title" - > - <div - colspan="2" - class="ellipsis" - > - {{ track.title }} - </div> - </td> - <td - class="artist" - :title="track.artist.name" - > - <div class="ellipsis"> - {{ track.artist.name }} - </div> - </td> - <td class="album"> - <div - v-if="track.album" - class="ellipsis" - :title="track.album.title" - > - {{ track.album.title }} - </div> - </td> - <td width="50"> - {{ time.durationFormatted(track.sources[0].duration) }} - </td> - </tr> - </tbody> - </table> - </div> - </main> -</template> - -<script> -import axios from 'axios' -import Logo from '@/components/Logo.vue' -import url from '@/utils/url' -import time from '@/utils/time' - -function getURLParams () { - let match - const pl = /\+/g // Regex for replacing addition symbol with a space - const urlParams = {} - const search = /([^&=]+)=?([^&]*)/g - const decode = function (s) { return decodeURIComponent(s.replace(pl, ' ')) } - const query = window.location.search.substring(1) - - while ((match = search.exec(query)) !== null) { urlParams[decode(match[1])] = decode(match[2]) } - - return urlParams -} -export default { - name: 'App', - components: { Logo }, - data () { - return { - time, - supportedTypes: ['track', 'album', 'artist', 'playlist', 'channel'], - baseUrl: '', - error: null, - type: null, - id: null, - tracks: [], - autoplay: false, - url: null, - isLoading: true, - theme: 'dark', - currentIndex: -1, - artistCover: null, - themes: { - dark: { - textColor: 'white' - } - } - } - }, - computed: { - currentTrack () { - if (this.tracks.length === 0) { - return null - } - return this.tracks[this.currentIndex] - }, - currentTheme () { - return this.themes[this.theme] - }, - controls () { - return [ - 'play', // Play/pause playback - 'progress', // The progress bar and scrubber for playback and buffering - 'current-time', // The current time of playback - 'mute', // Toggle mute - 'volume' // Volume control - ] - }, - hasPrevious () { - return this.currentIndex > 0 - }, - hasNext () { - return this.currentIndex < this.tracks.length - 1 - } - }, - watch: { - currentIndex (v) { - // we bind player events - const self = this - this.$nextTick(() => { - self.bindEvents() - if (self.tracks.length > 0) { - const el = document.getElementById(`queue-item-${v}`) - if (!el) { - return - } - const topPos = el.offsetTop - document.getElementById('queue').scrollTop = topPos - 10 - } - }) - }, - tracks () { - this.currentIndex = 0 - } - }, - created () { - const params = getURLParams() - this.baseUrl = params.b || '' - this.type = params.type - if (this.supportedTypes.indexOf(this.type) === -1) { - this.error = 'invalid_type' - } - this.id = params.id - if (!this.id) { - this.error = 'invalid_id' - } - if (this.error) { - this.isLoading = false - return - } - if (params.instance) { - this.baseUrl = params.instance - } - - this.autoplay = params.autoplay !== undefined || params.auto_play !== undefined - this.fetch(this.type, this.id) - }, - mounted () { - const parser = document.createElement('a') - parser.href = this.baseUrl - this.url = parser - }, - methods: { - next () { - if (this.hasNext) { - this.play(this.currentIndex + 1) - } - }, - previous () { - if (this.hasPrevious) { - this.play(this.currentIndex - 1) - } - }, - setControlFocus (event, enable) { - if (enable) { - event.target.classList.add('plyr__tab-focus') - } else { - event.target.classList.remove('plyr__tab-focus') - } - }, - fetch (type, id) { - if (type === 'track') { - this.fetchTrack(id) - } - if (type === 'album') { - this.fetchTracks({ album: id, playable: true, ordering: 'disc_number,position' }) - } - if (type === 'channel') { - this.fetchTracks({ channel: id, playable: true, include_channels: 'true', ordering: '-creation_date' }) - } - if (type === 'artist') { - this.fetchTracks({ artist: id, playable: true, include_channels: 'true', ordering: '-album__release_date,disc_number,position' }) - this.fetchArtistCover(id) - } - if (type === 'playlist') { - this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`) - } - }, - play (index) { - this.currentIndex = index - const self = this - this.$nextTick(() => { - self.$refs.player.player.play() - }) - }, - fetchTrack (id) { - const self = this - const url = `${this.baseUrl}/api/v1/tracks/${id}/` - axios.get(url).then(response => { - self.tracks = self.parseTracks([response.data]) - self.isLoading = false - }).catch(error => { - if (error.response) { - if (error.response.status === 404) { - self.error = 'server_not_found' - } else if (error.response.status === 403) { - self.error = 'server_requires_auth' - } else if (error.response.status === 500) { - self.error = 'server_error' - } else { - self.error = 'server_unknown_error' - } - } else { - self.error = 'server_unknown_error' - } - self.isLoading = false - }) - }, - fetchTracks (filters, path) { - path = path || '/api/v1/tracks/' - filters.include_channels = 'true' - const self = this - const url = `${this.baseUrl}${path}` - axios.get(url, { params: filters }).then(response => { - self.tracks = self.parseTracks(response.data.results) - self.isLoading = false - }).catch(error => { - if (error.response) { - if (error.response.status === 404) { - self.error = 'server_not_found' - } else if (error.response.status === 403) { - self.error = 'server_requires_auth' - } else if (error.response.status === 500) { - self.error = 'server_error' - } else { - self.error = 'server_unknown_error' - } - } else { - self.error = 'server_unknown_error' - } - self.isLoading = false - }) - }, - parseTracks (tracks) { - const self = this - if (this.type === 'playlist') { - tracks = tracks.map((t) => { - return t.track - }) - } - return tracks.map(t => { - return { - id: t.id, - title: t.title, - artist: t.artist, - album: t.album, - cover: self.getCover((t || t.album).cover), - sources: self.getSources(t.uploads) - } - }) - }, - fetchArtistCover (id) { - const self = this - self.isLoading = true - const url = `${this.baseUrl}/api/v1/artists/${id}/` - axios.get(url).then(response => { - self.isLoading = false - self.artistCover = response.data.cover.urls.medium_square_crop - }) - }, - bindEvents () { - const self = this - this.$refs.player.player.on('ended', () => { - self.next() - }) - }, - fullUrl (path) { - if (path.startsWith('/')) { - return this.baseUrl + path - } - return path - }, - getCover (albumCover) { - if (albumCover) { - return albumCover.urls.medium_square_crop - } - }, - getSources (uploads) { - const self = this - const a = document.createElement('audio') - const allowed = ['probably', 'maybe'] - const sources = uploads.filter(u => { - const canPlay = a.canPlayType(u.mimetype) - return allowed.indexOf(canPlay) > -1 - }).map(u => { - return { - type: u.mimetype, - src: self.fullUrl(u.listen_url), - duration: u.duration - } - }) - a.remove() - if (sources.length > 0) { - // We always add a transcoded MP3 src at the end - // because transcoding is expensive, but we want browsers that do - // not support other codecs to be able to play it :) - sources.push({ - type: 'audio/mpeg', - src: url.updateQueryString( - self.fullUrl(sources[0].src), - 'to', - 'mp3' - ) - }) - } - return sources - } - } -} -</script> - -<style lang="scss"> -@import "plyr/src/sass/plyr.scss"; - -html, -body, -main { - height: 100%; -} -body { - margin: 0; - font-family: sans-serif; -} -main { - display: flex; - flex-direction: column; -} -article { - display: flex; - position: relative; - aside { - padding: 0.5em; - } -} - -a { - text-decoration: none; -} -a:hover { - text-decoration: underline; -} -section.controls { - display: flex; - width: 100%; -} -.cover { - max-width: 120px; - max-height: 120px; -} - -.player { - flex: 1; - align-self: flex-end; -} -.player .plyr { - min-width: inherit; -} -article .content { - flex: 1; - display: flex; - flex-direction: column; - h3 { - margin: 0 0 0.5em; - } - header { - flex: 1; - padding: 1em; - } -} -.player, -.queue-controls { - padding: 0.25em 0; - margin-right: 0.25em; - align-self: center; -} -section .plyr--audio .plyr__controls { - padding: 0; -} - -.error { - font-weight: bold; - display: block; - text-align: center; -} -.logo-wrapper { - height: 2em; - width: 2em; - padding: 0.25em; - margin-left: 0.5em; - display: block; -} -[role="button"] { - cursor: pointer; -} -.ellipsis { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; -} -.queue-wrapper { - flex: 1; - overflow-y: auto; - padding: 0.5em; -} -.queue { - width: 100%; - border-collapse: collapse; - table-layout: fixed; - margin-bottom: 0.5em; - td { - padding: 0.5em; - font-size: 90%; - img { - vertical-align: middle; - margin-right: 1em; - } - } - td:last-child { - text-align: right; - } - .position { - padding: 0.1em 0.3em; - display: inline-block; - } -} -@media screen and (max-width: 640px) { - .queue .album { - display: none; - } - .plyr__controls .plyr__time { - display: none; - } -} -@media screen and (max-width: 460px) { - article, - article .content { - position: relative; - display: block; - } - .content header { - padding-right: 80px; - } - .cover.main { - position: absolute; - right: 0; - top: 0; - img { - height: 60px; - width: 60px; - } - } -} - -@media screen and (max-width: 320px) { - .content header { - font-size: 14px; - } - .content h3 { - font-size: 15px; - } - .logo-wrapper, - .position-cell { - display: none; - } - .plyr__volume { - min-width: 70px; - } - .queue .artist { - display: none; - } -} - -@media screen and (max-width: 200px) { - .content header { - padding-right: 1em; - font-size: 13px; - } - .content h3 { - font-size: 14px; - } - .cover.main { - display: none; - } - .plyr__progress { - display: none; - } - .controls .plyr__control, - .player .plyr__control { - padding: 3px; - } - .queue td:last-child { - display: none; - } -} - -@media screen and (max-width: 170px) { - .plyr__volume { - min-width: inherit; - } -} - -@media screen and (max-height: 180px) { - .queue-wrapper { - display: none; - } - article .content { - display: flex; - align-items: flex-start; - width: 100%; - height: 100vh; - } - article .content header { - flex-grow: 1; - } -} -// themes - -.dark { - $primary-color: rgb(242, 113, 28); - $dark: rgb(27, 28, 29); - $lighter: rgb(47, 48, 48); - $clear: rgb(242, 242, 242); - // $primary-color: rgb(255, 88, 78); - .logo-wrapper { - background-color: $primary-color; - } - .plyr--audio .plyr__control.plyr__tab-focus, - .plyr--audio .plyr__control:hover, - .plyr--audio .plyr__control[aria-expanded="true"] { - background-color: $primary-color; - } - .plyr--audio .plyr__control.plyr__tab-focus, - .plyr--audio .plyr__control:hover, - .plyr--audio .plyr__control[aria-expanded="true"] { - background-color: $primary-color; - } - .plyr--full-ui input[type="range"] { - color: $primary-color; - } - article, - .player, - .plyr--audio .plyr__controls { - background-color: $dark; - } - .queue-wrapper { - background-color: $lighter; - } - article, - article a, - .player, - .queue tr, - .plyr--audio .plyr__controls { - color: white; - } - .plyr__control.plyr__tab-focus { - -webkit-box-shadow: 0 0 0 2px rgba(26, 175, 255, 0.5); - box-shadow: 0 0 0 2px rgba(26, 175, 255, 0.5); - outline: 0; - } - tr:hover, - tr:focus { - background-color: $dark; - } - tr.active { - background-color: $clear; - color: $dark; - } - - tr.active { - .position { - background-color: $primary-color; - color: $clear; - } - } -} -</style> diff --git a/front/src/embed/embed.js b/front/src/embed/embed.js deleted file mode 100644 index d448b2e12d9afb508523f4c89c5dc810a634f360..0000000000000000000000000000000000000000 --- a/front/src/embed/embed.js +++ /dev/null @@ -1,16 +0,0 @@ - -import Vue from 'vue' -import EmbedFrame from './EmbedFrame.vue' -import VuePlyr from 'vue-plyr' - -Vue.use(VuePlyr) - -Vue.config.productionTip = false -/* eslint-disable no-new */ -new Vue({ - el: '#app', - components: { EmbedFrame }, - render (h) { - return h('EmbedFrame') - } -}) diff --git a/front/src/entities.js b/front/src/entities.js deleted file mode 100644 index 3bafa357bc5ef231045066aba69159a9587b31a6..0000000000000000000000000000000000000000 --- a/front/src/entities.js +++ /dev/null @@ -1,245 +0,0 @@ -function getTagsValueRepr (val) { - if (!val) { - return '' - } - return val.slice().sort().join('\n') -} - -export default { - getConfigs () { - return { - artist: { - label: this.$pgettext('*/*/*/Noun', 'Artist'), - icon: 'users', - getDeleteUrl: (obj) => { - return `manage/library/artists/${obj.id}/` - }, - urls: { - getDetail: (obj) => { return { name: 'library.artists.detail', params: { id: obj.id } } }, - getAdminDetail: (obj) => { return { name: 'manage.library.artists.detail', params: { id: obj.id } } } - }, - moderatedFields: [ - { - id: 'name', - label: this.$pgettext('*/*/*/Noun', 'Name'), - getValue: (obj) => { return obj.name } - }, - { - id: 'creation_date', - label: this.$pgettext('Content/*/*/Noun', 'Creation date'), - getValue: (obj) => { return obj.creation_date } - }, - { - id: 'tags', - type: 'tags', - label: this.$pgettext('*/*/*/Noun', 'Tags'), - getValue: (obj) => { return obj.tags }, - getValueRepr: getTagsValueRepr - }, - { - id: 'mbid', - label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'), - getValue: (obj) => { return obj.mbid } - } - ] - }, - album: { - label: this.$pgettext('*/*/*', 'Album'), - icon: 'play', - getDeleteUrl: (obj) => { - return `manage/library/albums/${obj.id}/` - }, - urls: { - getDetail: (obj) => { return { name: 'library.albums.detail', params: { id: obj.id } } }, - getAdminDetail: (obj) => { return { name: 'manage.library.albums.detail', params: { id: obj.id } } } - }, - moderatedFields: [ - { - id: 'title', - label: this.$pgettext('*/*/*/Noun', 'Title'), - getValue: (obj) => { return obj.title } - }, - { - id: 'creation_date', - label: this.$pgettext('Content/*/*/Noun', 'Creation date'), - getValue: (obj) => { return obj.creation_date } - }, - { - id: 'release_date', - label: this.$pgettext('Content/*/*/Noun', 'Release date'), - getValue: (obj) => { return obj.release_date } - }, - { - id: 'tags', - type: 'tags', - required: true, - label: this.$pgettext('*/*/*/Noun', 'Tags'), - getValue: (obj) => { return obj.tags }, - getValueRepr: getTagsValueRepr - }, - { - id: 'mbid', - label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'), - getValue: (obj) => { return obj.mbid } - } - ] - }, - track: { - label: this.$pgettext('*/*/*/Noun', 'Track'), - icon: 'music', - getDeleteUrl: (obj) => { - return `manage/library/tracks/${obj.id}/` - }, - urls: { - getDetail: (obj) => { return { name: 'library.tracks.detail', params: { id: obj.id } } }, - getAdminDetail: (obj) => { return { name: 'manage.library.tracks.detail', params: { id: obj.id } } } - }, - moderatedFields: [ - { - id: 'title', - label: this.$pgettext('*/*/*/Noun', 'Title'), - getValue: (obj) => { return obj.title } - }, - { - id: 'position', - label: this.$pgettext('*/*/*/Short, Noun', 'Position'), - getValue: (obj) => { return obj.position } - }, - { - id: 'copyright', - label: this.$pgettext('Content/Track/*/Noun', 'Copyright'), - getValue: (obj) => { return obj.copyright } - }, - { - id: 'license', - label: this.$pgettext('Content/*/*/Noun', 'License'), - getValue: (obj) => { return obj.license } - }, - { - id: 'tags', - label: this.$pgettext('*/*/*/Noun', 'Tags'), - getValue: (obj) => { return obj.tags }, - getValueRepr: getTagsValueRepr - }, - { - id: 'mbid', - label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'), - getValue: (obj) => { return obj.mbid } - } - ] - }, - library: { - label: this.$pgettext('*/*/*/Noun', 'Library'), - icon: 'book', - getDeleteUrl: (obj) => { - return `manage/library/libraries/${obj.uuid}/` - }, - urls: { - getAdminDetail: (obj) => { return { name: 'manage.library.libraries.detail', params: { id: obj.uuid } } } - }, - moderatedFields: [ - { - id: 'name', - label: this.$pgettext('*/*/*/Noun', 'Name'), - getValue: (obj) => { return obj.name } - }, - { - id: 'description', - label: this.$pgettext('*/*/*/Noun', 'Description'), - getValue: (obj) => { return obj.position } - }, - { - id: 'privacy_level', - label: this.$pgettext('*/*/*', 'Visibility'), - getValue: (obj) => { return obj.privacy_level } - } - ] - }, - playlist: { - label: this.$pgettext('*/*/*', 'Playlist'), - icon: 'list', - urls: { - getDetail: (obj) => { return { name: 'library.playlists.detail', params: { id: obj.id } } } - // getAdminDetail: (obj) => { return {name: 'manage.playlists.detail', params: {id: obj.id}}} - }, - moderatedFields: [ - { - id: 'name', - label: this.$pgettext('*/*/*/Noun', 'Name'), - getValue: (obj) => { return obj.name } - }, - { - id: 'privacy_level', - label: this.$pgettext('*/*/*', 'Visibility'), - getValue: (obj) => { return obj.privacy_level } - } - ] - }, - account: { - label: this.$pgettext('*/*/*/Noun', 'Account'), - icon: 'user', - urls: { - getDetail: (obj) => { return { name: 'profile.full.overview', params: { username: obj.preferred_username, domain: obj.domain } } }, - getAdminDetail: (obj) => { return { name: 'manage.moderation.accounts.detail', params: { id: `${obj.preferred_username}@${obj.domain}` } } } - }, - moderatedFields: [ - { - id: 'name', - label: this.$pgettext('*/*/*/Noun', 'Name'), - getValue: (obj) => { return obj.name } - }, - { - id: 'summary', - label: this.$pgettext('*/*/*/Noun', 'Bio'), - getValue: (obj) => { return obj.summary } - } - ] - }, - channel: { - label: this.$pgettext('*/*/*', 'Channel'), - icon: 'stream', - urls: { - getDetail: (obj) => { return { name: 'channels.detail', params: { id: obj.uuid } } }, - getAdminDetail: (obj) => { return { name: 'manage.channels.detail', params: { id: obj.uuid } } } - }, - moderatedFields: [ - { - id: 'name', - label: this.$pgettext('*/*/*/Noun', 'Name'), - getValue: (obj) => { return obj.name } - }, - { - id: 'creation_date', - label: this.$pgettext('Content/*/*/Noun', 'Creation date'), - getValue: (obj) => { return obj.creation_date } - }, - { - id: 'tags', - type: 'tags', - label: this.$pgettext('*/*/*/Noun', 'Tags'), - getValue: (obj) => { return obj.tags }, - getValueRepr: getTagsValueRepr - } - ] - } - } - }, - - getConfig () { - return this.configs[this.objectType] - }, - getFieldConfig (configs, type, fieldId) { - const c = configs[type] - return c.fields.filter((f) => { - return f.id === fieldId - })[0] - }, - getCurrentStateForObj (obj, config) { - const s = {} - config.fields.forEach(f => { - s[f.id] = { value: f.getValue(obj) } - }) - return s - } - -} diff --git a/front/src/filters.js b/front/src/filters.js deleted file mode 100644 index 370f5cda90361cbfe3a5bc7a22a8c3b607556909..0000000000000000000000000000000000000000 --- a/front/src/filters.js +++ /dev/null @@ -1,147 +0,0 @@ -import Vue from 'vue' - -import time from '@/utils/time' - -import moment from 'moment' - -export function truncate (str, max, ellipsis, middle) { - if (max === 0) { - return - } - max = max || 100 - ellipsis = ellipsis || '…' - if (str.length <= max) { - return str - } - if (middle) { - const sepLen = 1 - const charsToShow = max - sepLen - const frontChars = Math.ceil(charsToShow / 2) - const backChars = Math.floor(charsToShow / 2) - - return str.substr(0, frontChars) + - ellipsis + - str.substr(str.length - backChars) - } else { - return str.slice(0, max) + ellipsis - } -} - -Vue.filter('truncate', truncate) - -export function ago (date, locale) { - locale = locale || 'en' - const m = moment(date) - m.locale(locale) - return m.calendar(null, { - sameDay: 'LT', - nextDay: 'L', - nextWeek: 'L', - lastDay: 'L', - lastWeek: 'L', - sameElse: 'L' - }) -} - -Vue.filter('ago', ago) - -export function fromNow (date, locale) { - locale = 'en' - moment.locale('en', { - relativeTime: { - future: 'in %s', - past: '%s ago', - s: 'seconds', - ss: '%ss', - m: 'a minute', - mm: '%dm', - h: 'an hour', - hh: '%dh', - d: 'a day', - dd: '%dd', - M: 'a month', - MM: '%dM', - y: 'a year', - yy: '%dY' - } - }) - const m = moment(date) - m.locale(locale) - return m.fromNow(true) -} - -Vue.filter('fromNow', fromNow) - -export function secondsToObject (seconds) { - const m = moment.duration(seconds, 'seconds') - return { - seconds: m.seconds(), - minutes: m.minutes(), - hours: m.hours() - } -} - -Vue.filter('secondsToObject', secondsToObject) - -export function padDuration (duration) { - let s = String(duration) - while (s.length < 2) { s = '0' + s } - return s -} - -Vue.filter('padDuration', padDuration) - -export function duration (seconds) { - return time.parse(seconds) -} - -Vue.filter('duration', duration) - -export function momentFormat (date, format) { - format = format || 'lll' - return moment(date).format(format) -} - -Vue.filter('moment', momentFormat) - -export function year (date) { - return moment(date).year() -} - -Vue.filter('year', year) - -export function capitalize (str) { - return str.charAt(0).toUpperCase() + str.slice(1) -} - -Vue.filter('capitalize', capitalize) - -export function humanSize (bytes) { - const si = true - const thresh = si ? 1000 : 1024 - if (Math.abs(bytes) < thresh) { - return bytes + ' B' - } - const units = si - ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] - let u = -1 - do { - bytes /= thresh - ++u - } while (Math.abs(bytes) >= thresh && u < units.length - 1) - return bytes.toFixed(1) + ' ' + units[u] -} - -Vue.filter('humanSize', humanSize) - -// Removes duplicates from a list -export function unique (list, property) { - property = property || 'id' - const unique = [] - list.map(x => unique.filter(a => a[property] === x[property]).length > 0 ? null : unique.push(x)) - return unique -} -Vue.filter('unique', unique) - -export default {} diff --git a/front/src/init/axios.ts b/front/src/init/axios.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3c43e8a2335796d308b9198297a8472290ba8d3 --- /dev/null +++ b/front/src/init/axios.ts @@ -0,0 +1,131 @@ +import type { APIErrorResponse, BackendError, InitModule, RateLimitStatus } from '~/types' + +import createAuthRefreshInterceptor from 'axios-auth-refresh' +import axios, { AxiosError } from 'axios' +import moment from 'moment' +import { parseAPIErrors } from '~/utils' +import useLogger from '~/composables/useLogger' +import { gettext } from '~/init/locale' + +const { $pgettext, $gettext } = gettext +const logger = useLogger() + +export const install: InitModule = ({ store, router }) => { + axios.defaults.xsrfCookieName = 'csrftoken' + axios.defaults.xsrfHeaderName = 'X-CSRFToken' + axios.interceptors.request.use(function (config) { + // Do something before request is sent + if (store.state.auth.oauth.accessToken) { + config.headers ??= {} + config.headers.Authorization = store.getters['auth/header'] + } + 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 + }, async (error: BackendError) => { + error.backendErrors = [] + error.isHandled = false + + if (store.state.auth.authenticated && !store.state.auth.oauth.accessToken && error.response?.status === 401) { + store.commit('auth/authenticated', false) + logger.warn('Received 401 response from API, redirecting to login form', router.currentRoute.value.fullPath) + await router.push({ name: 'login', query: { next: router.currentRoute.value.fullPath } }) + } + + switch (error.response?.status) { + case 404: + error.backendErrors.push('Resource not found') + error.isHandled = true + store.commit('ui/addMessage', { + // @ts-expect-error TS does not know about .data structure + content: error.response?.data?.detail ?? error.response?.data, + class: 'error' + }) + break + + case 403: + error.backendErrors.push('Permission denied') + break + + case 429: { + let message + const rateLimitStatus: RateLimitStatus = { + limit: error.response?.headers['x-ratelimit-limit'], + scope: error.response?.headers['x-ratelimit-scope'], + remaining: error.response?.headers['x-ratelimit-remaining'], + duration: error.response?.headers['x-ratelimit-duration'], + availableSeconds: parseInt(error.response?.headers['retry-after'] ?? 60), + reset: error.response?.headers['x-ratelimit-reset'], + resetSeconds: error.response?.headers['x-ratelimit-resetseconds'] + } + + if (rateLimitStatus.availableSeconds) { + const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true) + message = $pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again in %{ delay }') + message = $gettext(message, { delay: tryAgain }) + } else { + message = $pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again later') + } + + error.backendErrors.push(message) + error.isHandled = true + store.commit('ui/addMessage', { + content: message, + date: new Date(), + class: 'error' + }) + + logger.error('This client is rate-limited!', rateLimitStatus) + break + } + + case 500: + error.backendErrors.push('A server error occurred') + break + + default: + if (error.response?.data as object) { + const data = error.response?.data as Record<string, unknown> + if (data?.detail) { + error.backendErrors.push(data.detail as string) + } else { + error.rawPayload = data as APIErrorResponse + const parsedErrors = parseAPIErrors(data as APIErrorResponse) + error.backendErrors = [...error.backendErrors, ...parsedErrors] + } + } + } + + if (error.backendErrors.length === 0) { + error.backendErrors.push('An unknown error occurred, ensure your are connected to the internet and your funkwhale instance is up and running') + } + + // Do something with response error + return Promise.reject(error) + }) + + const refreshAuth = (failedRequest: AxiosError) => { + if (store.state.auth.oauth.accessToken) { + console.log('Failed request, refreshing auth…') + // maybe the token was expired, let's try to refresh it + return store.dispatch('auth/refreshOauthToken').then(() => { + if (failedRequest.response) { + failedRequest.response.config.headers ??= {} + failedRequest.response.config.headers.Authorization = store.getters['auth/header'] + } + + return Promise.resolve() + }) + } + + return Promise.resolve() + } + + createAuthRefreshInterceptor(axios, refreshAuth) +} diff --git a/front/src/init/directives.ts b/front/src/init/directives.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bdd462c1e4fec2b4b3160544a65cae148710fd8 --- /dev/null +++ b/front/src/init/directives.ts @@ -0,0 +1,11 @@ +import type { InitModule } from '~/types' + +import { setupDropdown } from '~/utils/fomantic' + +export const install: InitModule = ({ app, store }) => { + app.directive('title', function (el, binding) { + store.commit('ui/pageTitle', binding.value) + }) + + app.directive('dropdown', (element) => setupDropdown(element)) +} diff --git a/front/src/init/globalComponents.ts b/front/src/init/globalComponents.ts new file mode 100644 index 0000000000000000000000000000000000000000..68ea6696a9e45c2aed634c8ed307bc25f55edb8d --- /dev/null +++ b/front/src/init/globalComponents.ts @@ -0,0 +1,45 @@ +import type { InitModule } from '~/types' + +import HumanDate from '~/components/common/HumanDate.vue' +import HumanDuration from '~/components/common/HumanDuration.vue' +import Username from '~/components/common/Username.vue' +import UserLink from '~/components/common/UserLink.vue' +import ActorLink from '~/components/common/ActorLink.vue' +import ActorAvatar from '~/components/common/ActorAvatar.vue' +import Duration from '~/components/common/Duration.vue' +import DangerousButton from '~/components/common/DangerousButton.vue' +import Message from '~/components/common/Message.vue' +import CopyInput from '~/components/common/CopyInput.vue' +import AjaxButton from '~/components/common/AjaxButton.vue' +import Tooltip from '~/components/common/Tooltip.vue' +import EmptyState from '~/components/common/EmptyState.vue' +import ExpandableDiv from '~/components/common/ExpandableDiv.vue' +import CollapseLink from '~/components/common/CollapseLink.vue' +import ActionFeedback from '~/components/common/ActionFeedback.vue' +import RenderedDescription from '~/components/common/RenderedDescription.vue' +import ContentForm from '~/components/common/ContentForm.vue' +import InlineSearchBar from '~/components/common/InlineSearchBar.vue' +import SanitizedHtml from '~/components/SanitizedHtml.vue' + +export const install: InitModule = ({ app }) => { + app.component('HumanDate', HumanDate) + app.component('HumanDuration', HumanDuration) + app.component('Username', Username) + app.component('UserLink', UserLink) + app.component('ActorLink', ActorLink) + app.component('ActorAvatar', ActorAvatar) + app.component('Duration', Duration) + app.component('DangerousButton', DangerousButton) + app.component('Message', Message) + app.component('CopyInput', CopyInput) + app.component('AjaxButton', AjaxButton) + app.component('Tooltip', Tooltip) + app.component('EmptyState', EmptyState) + app.component('ExpandableDiv', ExpandableDiv) + app.component('CollapseLink', CollapseLink) + app.component('ActionFeedback', ActionFeedback) + app.component('RenderedDescription', RenderedDescription) + app.component('ContentForm', ContentForm) + app.component('InlineSearchBar', InlineSearchBar) + app.component('SanitizedHtml', SanitizedHtml) +} diff --git a/front/src/init/instance.ts b/front/src/init/instance.ts new file mode 100644 index 0000000000000000000000000000000000000000..2214bc2ca15757431fc61214be8f82a5e28183c8 --- /dev/null +++ b/front/src/init/instance.ts @@ -0,0 +1,37 @@ +import type { InitModule } from '~/types' + +import { watch } from 'vue' +import axios from 'axios' + +export const install: InitModule = async ({ store, router }) => { + await store.dispatch('instance/fetchFrontSettings') + watch(() => store.state.instance.instanceUrl, async () => { + const [{ data }] = await Promise.all([ + axios.get('instance/nodeinfo/2.0/'), + store.dispatch('instance/fetchSettings') + ]) + + store.commit('instance/nodeinfo', data) + }) + + const urlParams = new URLSearchParams(window.location.search) + const serverUrl = urlParams.get('_server') + if (serverUrl) { + store.commit('instance/instanceUrl', serverUrl) + } + + const url = urlParams.get('_url') + if (url) { + router.replace(url) + return + } + + if (!store.state.instance.instanceUrl) { + const defaultInstanceUrl = store.state.instance.frontSettings.defaultServerUrl + store.commit('instance/instanceUrl', defaultInstanceUrl) + } else { + // NOTE: Needed to trigger initialization of axios / service worker / web socket + // TODO (wvffle): Check if it is really needed + store.commit('instance/instanceUrl', store.state.instance.instanceUrl) + } +} diff --git a/front/src/init/internalLinks.ts b/front/src/init/internalLinks.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b2b3092d5f09eb80f7d5a43f3bda5e090680c55 --- /dev/null +++ b/front/src/init/internalLinks.ts @@ -0,0 +1,13 @@ +import type { InitModule } from '~/types' + +// slight hack to allow use to have internal links in <translate> tags +// while preserving router behaviour +export const install: InitModule = ({ router }) => { + document.documentElement.addEventListener('click', async (event) => { + const target = event.target as HTMLAnchorElement + if (!target.matches('a.internal')) return + + event.preventDefault() + return router.push(target.href) + }, false) +} diff --git a/front/src/init/lazyLoad.ts b/front/src/init/lazyLoad.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3b1775c1d7bed3eb89fbda491840503399ba3d5 --- /dev/null +++ b/front/src/init/lazyLoad.ts @@ -0,0 +1,7 @@ +import type { InitModule } from '~/types' + +import VueLazyload from 'vue3-lazyload' + +export const install: InitModule = ({ app }) => { + app.use(VueLazyload) +} diff --git a/front/src/init/locale.ts b/front/src/init/locale.ts new file mode 100644 index 0000000000000000000000000000000000000000..80fbac8603459bdeaa6e23957959932eed36c122 --- /dev/null +++ b/front/src/init/locale.ts @@ -0,0 +1,54 @@ +import type { InitModule } from '~/types' + +import { watch } from 'vue' +import locales from '~/locales.json' +import { usePreferredLanguages } from '@vueuse/core' +import { createGettext } from 'vue3-gettext' +import store from '~/store' + +const defaultLanguage = store.state.ui.currentLanguage ?? 'en_US' +export const availableLanguages = locales.reduce((map: Record<string, string>, locale) => { + map[locale.code] = locale.label + return map +}, {}) + +export const gettext = createGettext({ + availableLanguages, + defaultLanguage, + silent: true +}) + +export const install: InitModule = ({ store, app }) => { + app.use(gettext) + + // Set default language + if (!store.state.ui.selectedLanguage) { + // NOTE: We're selecting the language only once, hence we don't need to make it reactive + const languages = usePreferredLanguages().value.map((code) => { + return code.replace(/-/g, '_') + }) + + let language = Object.keys(availableLanguages).find(code => { + return languages.includes(code) + }) + + if (!language) { + language = Object.keys(availableLanguages).find(code => { + return languages.map(lang => lang.split('_')[0]).includes(code.split('_')[0]) + }) + } + + store.commit('ui/currentLanguage', language ?? defaultLanguage) + } + + // Handle language change + watch(() => store.state.ui.currentLanguage, (locale) => { + const htmlLocale = locale.toLowerCase().replace('_', '-') + document.documentElement.setAttribute('lang', htmlLocale) + + if (locale === 'en_US') { + gettext.current = locale + store.commit('ui/momentLocale', 'en') + } + }, { immediate: true }) +} diff --git a/front/src/init/mediaSession.ts b/front/src/init/mediaSession.ts new file mode 100644 index 0000000000000000000000000000000000000000..67d74be977a5f656c99cc9cec3fe0c33e03d3d96 --- /dev/null +++ b/front/src/init/mediaSession.ts @@ -0,0 +1,46 @@ +import type { InitModule } from '~/types' + +import { whenever } from '@vueuse/core' +import useQueue from '~/composables/audio/useQueue' +import usePlayer from '~/composables/audio/usePlayer' + +export const install: InitModule = ({ app }) => { + const { currentTrack, next, previous } = useQueue() + const { resume, pause, seek } = usePlayer() + + // Add controls for notification drawer + if ('mediaSession' in navigator) { + navigator.mediaSession.setActionHandler('play', resume) + navigator.mediaSession.setActionHandler('pause', pause) + navigator.mediaSession.setActionHandler('seekforward', () => seek(5)) + navigator.mediaSession.setActionHandler('seekbackward', () => seek(-5)) + navigator.mediaSession.setActionHandler('nexttrack', next) + navigator.mediaSession.setActionHandler('previoustrack', previous) + + // TODO (wvffle): set metadata to null when we don't have currentTrack? + // If the session is playing as a PWA, populate the notification + // with details from the track + whenever(currentTrack, () => { + const { title, artist, album } = currentTrack.value + + const metadata: MediaMetadataInit = { + title, + artist: artist.name + } + + if (album?.cover) { + metadata.album = album.title + metadata.artwork = [ + { src: album.cover.urls.original, sizes: '96x96', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '128x128', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '192x192', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '256x256', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '384x384', type: 'image/png' }, + { src: album.cover.urls.original, sizes: '512x512', type: 'image/png' } + ] + } + + navigator.mediaSession.metadata = new window.MediaMetadata(metadata) + }, { immediate: true }) + } +} diff --git a/front/src/semantic.js b/front/src/init/semantic.ts similarity index 100% rename from front/src/semantic.js rename to front/src/init/semantic.ts diff --git a/front/src/init/sentry.ts b/front/src/init/sentry.ts new file mode 100644 index 0000000000000000000000000000000000000000..54b6fd3b34dc912439cd63e7c6a683969768ae86 --- /dev/null +++ b/front/src/init/sentry.ts @@ -0,0 +1,123 @@ +import type { InitModule } from '~/types' +import type { RootState } from '~/store' +import type { Router } from 'vue-router' +import type { Store } from 'vuex' +import { watchEffect, type App } from 'vue' +import useErrorHandler from '~/composables/useErrorHandler' + +export const COOKIE = 'allow-tracing' + +const initSentry = async (app: App, router: Router, store: Store<RootState>) => { + const [{ default: useLogger }, { BrowserTracing }, Sentry] = await Promise.all([ + import('~/composables/useLogger'), + import('@sentry/tracing'), + import('@sentry/vue') + ]) + + const logger = useLogger() + logger.info('Initializing Sentry') + + Sentry.init({ + app, + dsn: import.meta.env.FUNKWHALE_SENTRY_DSN, + logErrors: true, + trackComponents: true, + integrations: [ + new BrowserTracing({ + routingInstrumentation: Sentry.vueRouterInstrumentation(router) + }) + ], + debug: import.meta.env.DEV, + environment: 'front', + beforeSend: (event, hint) => { + if (event.exception?.values?.some(exception => exception.mechanism?.handled === false) && hint.originalException instanceof Error) { + useErrorHandler(hint.originalException, hint.event_id) + } + + return event + }, + tracesSampleRate: import.meta.env.FUNKWHALE_SENTRY_SR, + ignoreErrors: [ + // vue3-lazyload throws an error whenever there is a 404 + 'Image failed to load!' + ] + }) + + Sentry.setTag('mode', import.meta.env.MODE) + + watchEffect(() => { + const url = store.getters['instance/domain'] + Sentry.setTag('instance', url) + }) + + const setUser = (user: { username: string, [key: string]: any } | null) => { + Sentry.setUser(user) + Sentry.setContext('user', user) + } + + watchEffect(() => { + if (store.state.auth.authenticated) { + return setUser({ + username: store.state.auth.username, + canPublish: store.state.auth.availablePermissions.library, + canModerate: store.state.auth.availablePermissions.moderation, + isAdmin: store.state.auth.availablePermissions.settings + }) + } + + setUser(null) + }) +} + +export const install: InitModule = async ({ app, router, store }) => { + if (import.meta.env.FUNKWHALE_SENTRY_DSN) { + const [{ useCookies }, { gettext: { $pgettext } }] = await Promise.all([ + import('@vueuse/integrations/useCookies'), + import('~/init/locale') + ]) + + const { get, set } = useCookies() + + const allowed = get(COOKIE) + + if (allowed === 'yes') { + return initSentry(app, router, store) + } + + if (allowed === undefined) { + const { hostname, origin } = new URL(import.meta.env.FUNKWHALE_SENTRY_DSN) + return store.commit('ui/addMessage', { + content: hostname === 'am.funkwhale.audio' + ? $pgettext( + 'App/Message/Paragraph', + 'To enhance the quality of our services, we would like to collect information about crashes during your session.<br><sub>The stack traces will be shared to <a href="%{origin}">Funkwhale\'s official Glitchtip instance</a> in order to help us understand how and when the errors occur.</sub>', + { hostname, origin } + ) + : $pgettext( + 'App/Message/Paragraph', + 'To enhance the quality of our services, we would like to collect information about crashes during your session.<br><sub>The stack traces will be shared to <a href="%{origin}">%{hostname}</a> in order to help us understand how and when the errors occur.</sub>', + { hostname, origin } + ), + date: new Date(), + key: 'allowSentryTracing', + displayTime: 0, + classActions: 'bottom attached opaque', + actions: [ + { + text: $pgettext('App/Message/Paragraph', 'Allow'), + class: 'primary', + click: () => { + set(COOKIE, 'yes') + return initSentry(app, router, store) + } + }, + { + text: $pgettext('App/Message/Paragraph', 'Deny'), + class: 'basic', + click: () => set(COOKIE, 'no') + } + ] + }) + } + } +} diff --git a/front/src/init/serviceWorker.ts b/front/src/init/serviceWorker.ts new file mode 100644 index 0000000000000000000000000000000000000000..f523a0cb58964769d9f0812c42ae44d34bcc53f8 --- /dev/null +++ b/front/src/init/serviceWorker.ts @@ -0,0 +1,43 @@ +import type { InitModule } from '~/types' + +import { registerSW } from 'virtual:pwa-register' +import useLogger from '~/composables/useLogger' +import { gettext } from '~/init/locale' + +const logger = useLogger() + +const { $pgettext } = gettext + +export const install: InitModule = ({ store }) => { + const updateSW = registerSW({ + onRegisterError () { + logger.error('SW install error') + }, + onOfflineReady () { + logger.info('Funkwhale is being served from cache by a service worker.') + }, + onRegistered () { + logger.info('Service worker has been registered.') + }, + onNeedRefresh () { + store.commit('ui/addMessage', { + content: $pgettext('App/Message/Paragraph', 'A new version of the app is available.'), + date: new Date(), + key: 'refreshApp', + displayTime: 0, + classActions: 'bottom attached opaque', + actions: [ + { + text: $pgettext('App/Message/Paragraph', 'Update'), + class: 'primary', + click: () => updateSW() + }, + { + text: $pgettext('App/Message/Paragraph', 'Later'), + class: 'basic' + } + ] + }) + } + }) +} diff --git a/front/src/init/webSocket.ts b/front/src/init/webSocket.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1ba5b9f14703b73c781611e3b85f04acf9b944f --- /dev/null +++ b/front/src/init/webSocket.ts @@ -0,0 +1,74 @@ +import type { InitModule } from '~/types' + +import { watchEffect, watch } from 'vue' +import { useWebSocket, whenever } from '@vueuse/core' +import useWebSocketHandler from '~/composables/useWebSocketHandler' +import { CLIENT_RADIOS } from '~/utils/clientRadios' + +export const install: InitModule = ({ store }) => { + watch(() => store.state.instance.instanceUrl, () => { + const url = store.getters['instance/absoluteUrl']('api/v1/activity') + .replace(/^http/, 'ws') + + const { data, status, open, close } = useWebSocket(url, { + autoReconnect: import.meta.env.DEV ? { retries: 3 } : true, + immediate: false + }) + + watch(() => store.state.auth.authenticated, (authenticated) => { + if (authenticated) return open() + close() + }, { immediate: true }) + + whenever(data, () => { + return store.dispatch('ui/websocketEvent', JSON.parse(data.value)) + }) + + watchEffect(() => { + console.log('Websocket status:', status.value) + }) + }, { immediate: true }) + + // WebSocket handlers + useWebSocketHandler('inbox.item_added', () => { + store.commit('ui/incrementNotifications', { type: 'inbox', count: 1 }) + }) + + useWebSocketHandler('mutation.created', (event) => { + store.commit('ui/incrementNotifications', { + type: 'pendingReviewEdits', + value: event.pending_review_count + }) + }) + + useWebSocketHandler('mutation.updated', (event) => { + store.commit('ui/incrementNotifications', { + type: 'pendingReviewEdits', + value: event.pending_review_count + }) + }) + + useWebSocketHandler('report.created', (event) => { + store.commit('ui/incrementNotifications', { + type: 'pendingReviewReports', + value: event.unresolved_count + }) + }) + + useWebSocketHandler('user_request.created', (event) => { + store.commit('ui/incrementNotifications', { + type: 'pendingReviewRequests', + value: event.pending_count + }) + }) + + useWebSocketHandler('Listen', (event) => { + if (store.state.radios.current && store.state.radios.running) { + const { current } = store.state.radios + + if (current.clientOnly) { + CLIENT_RADIOS[current.type].handleListen(current, event, store) + } + } + }) +} diff --git a/front/src/init/window.ts b/front/src/init/window.ts new file mode 100644 index 0000000000000000000000000000000000000000..f581dc5374feecca59777e73cee14acb2f295ac9 --- /dev/null +++ b/front/src/init/window.ts @@ -0,0 +1,17 @@ +import type { InitModule } from '~/types' + +import { useWindowSize } from '@vueuse/core' +import { watchEffect } from 'vue' + +export const install: InitModule = ({ store }) => { + // NOTE: Due to Vuex 3, when using store in watchEffect, it results in an infinite loop after committing + const { commit } = store + + const { width, height } = useWindowSize() + watchEffect(() => { + commit('ui/window', { + width: width.value, + height: height.value + }) + }) +} diff --git a/front/src/jquery.js b/front/src/jquery.js deleted file mode 100644 index 84ad6d83cd97062274dcb08fb424d7dbcba082c2..0000000000000000000000000000000000000000 --- a/front/src/jquery.js +++ /dev/null @@ -1,8 +0,0 @@ -import jQuery from 'jquery' - -// NOTE: Workaround for fomantic-ui-css -if (import.meta.env.DEV) { - window.$ = window.jQuery = jQuery -} - -export default jQuery diff --git a/front/src/locales.js b/front/src/locales.js deleted file mode 100644 index 4d114df0ba09d6d83701c35423c0a0f9724b550c..0000000000000000000000000000000000000000 --- a/front/src/locales.js +++ /dev/null @@ -1,129 +0,0 @@ -/* eslint-disable */ -export default { - "locales": [ - { - "code": "ar", - "label": "العربية" - }, - { - "code": "ca", - "label": "Català " - }, - { - "code": "cs", - "label": "ÄŒeÅ¡tina" - }, - { - "code": "de", - "label": "Deutsch" - }, - { - "code": "en_GB", - "label": "English (UK)" - }, - { - "code": "en_US", - "label": "English (United-States)" - }, - { - "code": "eo", - "label": "Esperanto" - }, - { - "code": "es", - "label": "Español" - }, - { - "code": "eu", - "label": "Euskara" - }, - { - "code": "fr_FR", - "label": "Français" - }, - { - "code": "gl", - "label": "Galego" - }, - { - "code": "hu", - "label": "Magyar" - }, - { - "code": "it", - "label": "Italiano" - }, - { - "code": "ja_JP", - "label": "日本語" - }, - { - "code": "kab_DZ", - "label": "Taqbaylit" - }, - { - "code": "ko_KR", - "label": "한êµì–´" - }, - { - "code": "nb_NO", - "label": "BokmÃ¥l" - }, - { - "code": "nn_NO", - "label": "Nynorsk" - }, - { - "code": "nl", - "label": "Nederlands" - }, - { - "code": "oc", - "label": "Occitan" - }, - { - "code": "pl", - "label": "Polski" - }, - { - "code": "pt_BR", - "label": "Português (Brasil)" - }, - { - "code": "pt_PT", - "label": "Português (Portugal)" - }, - { - "code": "ru", - "label": "РуÑÑкий" - }, - { - "code": "sq", - "label": "Shqip" - }, - { - "code": "zh_Hans", - "label": "䏿–‡(简体)" - }, - { - "code": "zh_Hant", - "label": "䏿–‡(ç¹é«”)" - }, - { - "code": "fa_IR", - "label": "ÙØ§Ø±Ø³ÛŒ" - }, - { - "code": "ml", - "label": "മലയാളം" - }, - { - "code": "sv", - "label": "Svenska" - }, - { - "code": "el", - "label": "Ελληνικά" - } - ] -} diff --git a/front/src/locales.json b/front/src/locales.json new file mode 100644 index 0000000000000000000000000000000000000000..7f6cd8e3f039f986b9272e952aa766ed0ee56564 --- /dev/null +++ b/front/src/locales.json @@ -0,0 +1,126 @@ +[ + { + "code": "ar", + "label": "العربية" + }, + { + "code": "ca", + "label": "Català " + }, + { + "code": "cs", + "label": "ÄŒeÅ¡tina" + }, + { + "code": "de", + "label": "Deutsch" + }, + { + "code": "en_GB", + "label": "English (UK)" + }, + { + "code": "en_US", + "label": "English (United-States)" + }, + { + "code": "eo", + "label": "Esperanto" + }, + { + "code": "es", + "label": "Español" + }, + { + "code": "eu", + "label": "Euskara" + }, + { + "code": "fr_FR", + "label": "Français" + }, + { + "code": "gl", + "label": "Galego" + }, + { + "code": "hu", + "label": "Magyar" + }, + { + "code": "it", + "label": "Italiano" + }, + { + "code": "ja_JP", + "label": "日本語" + }, + { + "code": "kab_DZ", + "label": "Taqbaylit" + }, + { + "code": "ko_KR", + "label": "한êµì–´" + }, + { + "code": "nb_NO", + "label": "BokmÃ¥l" + }, + { + "code": "nn_NO", + "label": "Nynorsk" + }, + { + "code": "nl", + "label": "Nederlands" + }, + { + "code": "oc", + "label": "Occitan" + }, + { + "code": "pl", + "label": "Polski" + }, + { + "code": "pt_BR", + "label": "Português (Brasil)" + }, + { + "code": "pt_PT", + "label": "Português (Portugal)" + }, + { + "code": "ru", + "label": "РуÑÑкий" + }, + { + "code": "sq", + "label": "Shqip" + }, + { + "code": "zh_Hans", + "label": "䏿–‡(简体)" + }, + { + "code": "zh_Hant", + "label": "䏿–‡(ç¹é«”)" + }, + { + "code": "fa_IR", + "label": "ÙØ§Ø±Ø³ÛŒ" + }, + { + "code": "ml", + "label": "മലയാളം" + }, + { + "code": "sv", + "label": "Svenska" + }, + { + "code": "el", + "label": "Ελληνικά" + } +] diff --git a/front/src/logging.js b/front/src/logging.js deleted file mode 100644 index ebb98c00577ca13956820f7cc191acbad6546cd2..0000000000000000000000000000000000000000 --- a/front/src/logging.js +++ /dev/null @@ -1,8 +0,0 @@ -import jsLogger from 'js-logger' - -jsLogger.useDefaults() - -export default { - get: jsLogger.get, - default: jsLogger.get('default') -} diff --git a/front/src/main.js b/front/src/main.js deleted file mode 100644 index 23efe39d6abc6c6e9754c871f4b51425dc6576e4..0000000000000000000000000000000000000000 --- a/front/src/main.js +++ /dev/null @@ -1,199 +0,0 @@ -// The Vue build version to load with the `import` command -// (runtime-only or standalone) has been set in webpack.base.conf with an alias. -import logger from '@/logging' -import jQuery from '@/jquery' - -import Vue from 'vue' -import moment from 'moment' -import App from './App.vue' -import router from './router' -import axios from 'axios' -import VueLazyload from 'vue-lazyload' -import store from './store' -import GetTextPlugin from 'vue-gettext' -import { sync } from 'vuex-router-sync' -import locales from '@/locales' -import createAuthRefreshInterceptor from 'axios-auth-refresh' -import VueCompositionAPI from '@vue/composition-api' - -import filters from '@/filters' // eslint-disable-line -import { parseAPIErrors } from '@/utils' -import globals from '@/components/globals' // eslint-disable-line -import './registerServiceWorker' -import '@/semantic' - -logger.default.info('Loading environment:', import.meta.env.MODE) -logger.default.debug('Environment variables:', import.meta.env) - -sync(store, router) - -let APP = null - -const availableLanguages = (function () { - const l = {} - locales.locales.forEach(c => { - l[c.code] = c.label - }) - return l -})() -let defaultLanguage = 'en_US' -if (availableLanguages[store.state.ui.currentLanguage]) { - defaultLanguage = store.state.ui.currentLanguage -} -Vue.use(GetTextPlugin, { - availableLanguages: availableLanguages, - defaultLanguage: defaultLanguage, - // cf https://github.com/Polyconseil/vue-gettext#configuration - // not recommended but this is fixing weird bugs with translation nodes - // not being updated when in v-if/v-else clauses - autoAddKeyAttributes: true, - languageVmMixin: { - computed: { - currentKebabCase: function () { - return this.current.toLowerCase().replace('_', '-') - } - } - }, - translations: {}, - silent: true -}) - -Vue.use(VueCompositionAPI) -Vue.use(VueLazyload) -Vue.directive('title', function (el, binding) { - store.commit('ui/pageTitle', binding.value) -}) -Vue.directive('dropdown', function (el, binding) { - jQuery(el).dropdown({ - selectOnKeydown: false, - action: function (text, value, $el) { - // used to ensure focusing the dropdown and clicking via keyboard - // works as expected - const button = $el[0] - button.click() - jQuery(el).find('.ui.dropdown').dropdown('hide') - }, - ...(binding.value || {}) - }) -}) -axios.defaults.xsrfCookieName = 'csrftoken' -axios.defaults.xsrfHeaderName = 'X-CSRFToken' -axios.interceptors.request.use(function (config) { - // Do something before request is sent - if (store.state.auth.oauth.accessToken) { - config.headers.Authorization = store.getters['auth/header'] - } - 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) { - error.backendErrors = [] - if (store.state.auth.authenticated && !store.state.auth.oauth.accessToken && error.response.status === 401) { - store.commit('auth/authenticated', false) - logger.default.warn('Received 401 response from API, redirecting to login form', router.currentRoute.fullPath) - router.push({ name: 'login', query: { next: router.currentRoute.fullPath } }) - } - if (error.response.status === 404) { - error.backendErrors.push('Resource not found') - const message = error.response.data - store.commit('ui/addMessage', { - content: message, - class: 'error' - }) - } else if (error.response.status === 403) { - error.backendErrors.push('Permission denied') - } else if (error.response.status === 429) { - let message - const rateLimitStatus = { - limit: error.response.headers['x-ratelimit-limit'], - scope: error.response.headers['x-ratelimit-scope'], - remaining: error.response.headers['x-ratelimit-remaining'], - duration: error.response.headers['x-ratelimit-duration'], - availableSeconds: error.response.headers['retry-after'], - reset: error.response.headers['x-ratelimit-reset'], - resetSeconds: error.response.headers['x-ratelimit-resetseconds'] - } - if (rateLimitStatus.availableSeconds) { - rateLimitStatus.availableSeconds = parseInt(rateLimitStatus.availableSeconds) - const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true) - message = APP.$pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again in %{ delay }') - message = APP.$gettextInterpolate(message, { delay: tryAgain }) - } else { - message = APP.$pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again later') - } - error.backendErrors.push(message) - store.commit('ui/addMessage', { - content: message, - date: new Date(), - class: 'error' - }) - logger.default.error('This client is rate-limited!', rateLimitStatus) - } else if (error.response.status === 500) { - error.backendErrors.push('A server error occured') - } else if (error.response.data) { - if (error.response.data.detail) { - error.backendErrors.push(error.response.data.detail) - } else { - error.rawPayload = error.response.data - const parsedErrors = parseAPIErrors(error.response.data) - error.backendErrors = [...error.backendErrors, ...parsedErrors] - } - } - if (error.backendErrors.length === 0) { - error.backendErrors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running') - } - // Do something with response error - return Promise.reject(error) -}) - -const refreshAuth = (failedRequest) => { - if (store.state.auth.oauth.accessToken) { - console.log('Failed request, refreshing auth…') - // maybe the token was expired, let's try to refresh it - return store.dispatch('auth/refreshOauthToken').then(() => { - failedRequest.response.config.headers.Authorization = store.getters['auth/header'] - return Promise.resolve() - }) - } else { - return Promise.resolve() - } -} - -createAuthRefreshInterceptor(axios, refreshAuth) - -store.dispatch('instance/fetchFrontSettings').finally(() => { - /* eslint-disable no-new */ - new Vue({ - el: '#app', - router, - store, - components: { App }, - created () { - APP = this - window.addEventListener('resize', this.handleResize) - this.handleResize() - }, - destroyed () { - window.removeEventListener('resize', this.handleResize) - }, - methods: { - handleResize () { - this.$store.commit('ui/window', { - width: window.innerWidth, - height: window.innerHeight - }) - } - }, - render (h) { - return h('App') - } - }) - - logger.default.info('Everything loaded!') -}) diff --git a/front/src/main.ts b/front/src/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..634cc80886980a4193f5be5b9a85f249890ee9f9 --- /dev/null +++ b/front/src/main.ts @@ -0,0 +1,50 @@ +import type { InitModule } from '~/types' + +import router from '~/router' +import store, { key } from '~/store' +import { createApp, defineAsyncComponent, h } from 'vue' +import useLogger from '~/composables/useLogger' +import useTheme from '~/composables/useTheme' + +// NOTE: Set the theme as fast as possible +useTheme() + +const logger = useLogger() +logger.info('Loading environment:', import.meta.env.MODE) +logger.debug('Environment variables:', import.meta.env) + +const app = createApp({ + name: 'Root', + data: () => ({ ready: false }), + mounted () { + this.ready = true + }, + render () { + if (this.ready) { + return h(defineAsyncComponent(() => import('~/App.vue'))) + } + + return null + } +}) + +app.use(router) +app.use(store, key) + +const modules: Array<void | Promise<void>> = [] +for (const module of Object.values(import.meta.glob('./init/*.ts', { eager: true })) as { install?: InitModule }[]) { + modules.push(module.install?.({ + app, + router, + store + })) +} + +// Wait for all modules to load +Promise.all(modules).finally(() => { + app.mount('#app') + logger.info('Everything loaded!') +}) + +// TODO (wvffle): Rename filters from useSharedLabels to filters from backend +// TODO (wvffle): Migrate EmbedFrame.vue to <script setup lang="ts"> and remove allowJs from tsconfig.json diff --git a/front/src/radios.js b/front/src/radios.js deleted file mode 100644 index 5650881faa3ebf33ebc6cdfdeae0561132f4ecad..0000000000000000000000000000000000000000 --- a/front/src/radios.js +++ /dev/null @@ -1,51 +0,0 @@ -import axios from 'axios' -import logger from '@/logging' - -// import axios from 'axios' - -const RADIOS = { - // some radios are client side only, so we have to implement the populateQueue - // method by hand - account: { - offset: 1, - populateQueue ({ current, dispatch, playNow }) { - const params = { scope: `actor:${current.objectId.fullUsername}`, ordering: '-creation_date', page_size: 1, page: this.offset } - axios.get('history/listenings', { params }).then((response) => { - const latest = response.data.results[0] - if (!latest) { - logger.default.error('No more tracks') - dispatch('stop') - } - this.offset += 1 - const append = dispatch('queue/append', { track: latest.track }, { root: true }) - if (playNow) { - append.then(() => { - dispatch('queue/last', null, { root: true }) - }) - } - }, (error) => { - logger.default.error('Error while fetching listenings', error) - dispatch('stop') - }) - }, - stop () { - this.offset = 1 - }, - handleListen (current, event, store) { - // XXX: handle actors from other pods - if (event.actor.local_id === current.objectId.username) { - axios.get(`tracks/${event.object.local_id}`).then((response) => { - if (response.data.uploads.length > 0) { - store.dispatch('queue/append', { track: response.data }) - this.offset += 1 - } - }, (error) => { - logger.default.error('Cannot retrieve track info', error) - }) - } - } - } -} -export function getClientOnlyRadio ({ type }) { - return RADIOS[type] -} diff --git a/front/src/registerServiceWorker.js b/front/src/registerServiceWorker.js deleted file mode 100644 index 419332ec01452b36541aaa5d8ac82456ce7d0af1..0000000000000000000000000000000000000000 --- a/front/src/registerServiceWorker.js +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable no-console */ - -import { register } from 'register-service-worker' - -import store from './store' - -if (import.meta.env.PROD) { - register(`${import.meta.env.BASE_URL}service-worker.js`, { - registrationOptions: { scope: '/' }, - ready () { - console.log( - 'App is being served from cache by a service worker.' - ) - }, - registered (registration) { - console.log('Service worker has been registered.') - // check for updates every 2 hours - const checkInterval = 1000 * 60 * 60 * 2 - // var checkInterval = 1000 * 5 - setInterval(() => { - console.log('Checking for service worker update…') - registration.update() - }, checkInterval) - store.commit('ui/serviceWorker', { registration: registration }) - if (registration.active) { - registration.active.postMessage({ command: 'serverChosen', serverUrl: store.state.instance.instanceUrl }) - } - }, - cached () { - console.log('Content has been cached for offline use.') - }, - updatefound () { - console.log('New content is downloading.') - }, - updated (registration) { - console.log('New content is available; please refresh!') - store.commit('ui/serviceWorker', { updateAvailable: true, registration: registration }) - }, - offline () { - console.log('No internet connection found. App is running in offline mode.') - }, - error (error) { - console.error('Error during service worker registration:', error) - } - }) -} diff --git a/front/src/router/guards.ts b/front/src/router/guards.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdad7b5255818ce33adbab87a018f9a5b2c47c92 --- /dev/null +++ b/front/src/router/guards.ts @@ -0,0 +1,25 @@ + +import type { NavigationGuardNext, RouteLocationNamedRaw, RouteLocationNormalized } from 'vue-router' +import type { Permission } from '~/store/auth' + +import router from '~/router' +import store from '~/store' + +export const hasPermissions = (permission: Permission) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => { + if (store.state.auth.authenticated && store.state.auth.availablePermissions[permission]) { + return next() + } + + console.log('Not authenticated. Redirecting to library.') + next({ name: 'library.index' }) +} + +export const requireLoggedIn = (fallbackLocation?: RouteLocationNamedRaw) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => { + if (store.state.auth.authenticated) return next() + return next(fallbackLocation ?? { name: 'login', query: { next: router.currentRoute.value.fullPath } }) +} + +export const requireLoggedOut = (fallbackLocation: RouteLocationNamedRaw) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => { + if (!store.state.auth.authenticated) return next() + return next(fallbackLocation) +} diff --git a/front/src/router/index.js b/front/src/router/index.js deleted file mode 100644 index 5e7aeb711242aab9c66dec8ffffc5a6a4e7d795a..0000000000000000000000000000000000000000 --- a/front/src/router/index.js +++ /dev/null @@ -1,1031 +0,0 @@ -import Vue from 'vue' -import Router from 'vue-router' -import store from '@/store' - -Vue.use(Router) - -function adminPermissions (to, from, next) { - if (store.state.auth.authenticated === true && store.state.auth.availablePermissions.settings === true) { - next() - } else { - console.log('Not authenticated. Redirecting to library.') - next({ name: 'library.index' }) - } -} - -function moderatorPermissions (to, from, next) { - if (store.state.auth.authenticated === true && store.state.auth.availablePermissions.moderation === true) { - next() - } else { - console.log('Not authenticated. Redirecting to library.') - next({ name: 'library.index' }) - } -} - -function libraryPermissions (to, from, next) { - if (store.state.auth.authenticated === true && store.state.auth.availablePermissions.library === true) { - next() - } else { - console.log('Not authenticated. Redirecting to library.') - next({ name: 'library.index' }) - } -} - -console.log('PROCESS', import.meta.env) -export default new Router({ - mode: 'history', - linkActiveClass: 'active', - base: import.meta.env.VUE_APP_ROUTER_BASE_URL || '/', - scrollBehavior (to, from, savedPosition) { - if (to.meta.preserveScrollPosition) { - return savedPosition - } - return new Promise(resolve => { - setTimeout(() => { - if (to.hash) { - resolve({ selector: to.hash }) - } - const pos = savedPosition || { x: 0, y: 0 } - resolve(pos) - }, 100) - }) - }, - routes: [ - { - path: '/', - name: 'index', - component: () => - import('@/components/Home.vue') - }, - { - path: '/front', - name: 'front', - redirect: to => { - const { hash, query } = to - return { name: 'index', hash, query } - } - }, - { - path: '/about', - name: 'about', - component: () => - import('@/components/About.vue') - }, - { - path: '/about/pod', - name: 'about-pod', - component: () => - import('@/components/AboutPod.vue') - }, - { - path: '/login', - name: 'login', - component: () => - import('@/views/auth/Login.vue'), - props: route => ({ next: route.query.next || '/library' }) - }, - { - path: '/notifications', - name: 'notifications', - component: () => - import('@/views/Notifications.vue') - }, - { - path: '/auth/password/reset', - name: 'auth.password-reset', - component: () => - import('@/views/auth/PasswordReset.vue'), - props: route => ({ - defaultEmail: route.query.email - }) - }, - { - path: '/auth/callback', - name: 'auth.callback', - component: () => - import('@/views/auth/Callback.vue'), - props: route => ({ - code: route.query.code, - state: route.query.state - }) - }, - { - path: '/auth/email/confirm', - name: 'auth.email-confirm', - component: () => - import('@/views/auth/EmailConfirm.vue'), - props: route => ({ - defaultKey: route.query.key - }) - }, - { - path: '/search', - name: 'search', - component: () => - import('@/views/Search.vue'), - props: route => ({ - initialId: route.query.id, - initialType: route.query.type || 'artists', - initialQuery: route.query.q, - initialPage: parseInt(route.query.page) || 1 - }) - }, - { - path: '/auth/password/reset/confirm', - name: 'auth.password-reset-confirm', - component: () => - import( - '@/views/auth/PasswordResetConfirm.vue' - ), - props: route => ({ - defaultUid: route.query.uid, - defaultToken: route.query.token - }) - }, - { - path: '/authorize', - name: 'authorize', - component: () => - import('@/components/auth/Authorize.vue'), - props: route => ({ - clientId: route.query.client_id, - redirectUri: route.query.redirect_uri, - scope: route.query.scope, - responseType: route.query.response_type, - nonce: route.query.nonce, - state: route.query.state - }) - }, - { - path: '/signup', - name: 'signup', - component: () => - import('@/views/auth/Signup.vue'), - props: route => ({ - defaultInvitation: route.query.invitation - }) - }, - { - path: '/logout', - name: 'logout', - component: () => - import('@/components/auth/Logout.vue') - }, - { - path: '/settings', - name: 'settings', - component: () => - import('@/components/auth/Settings.vue') - }, - { - path: '/settings/applications/new', - name: 'settings.applications.new', - props: route => ({ - scopes: route.query.scopes, - name: route.query.name, - redirect_uris: route.query.redirect_uris - }), - component: () => - import( - '@/components/auth/ApplicationNew.vue' - ) - }, - { - path: '/settings/plugins', - name: 'settings.plugins', - component: () => - import( - '@/views/auth/Plugins.vue' - ) - }, - { - path: '/settings/applications/:id/edit', - name: 'settings.applications.edit', - component: () => - import( - '@/components/auth/ApplicationEdit.vue' - ), - props: true - }, - ...[{ suffix: '.full', path: '/@:username@:domain' }, { suffix: '', path: '/@:username' }].map((route) => { - return { - path: route.path, - name: `profile${route.suffix}`, - component: () => - import('@/views/auth/ProfileBase.vue'), - props: true, - children: [ - { - path: '', - name: `profile${route.suffix}.overview`, - component: () => - import( - '@/views/auth/ProfileOverview.vue' - ) - }, - { - path: 'activity', - name: `profile${route.suffix}.activity`, - component: () => - import( - '@/views/auth/ProfileActivity.vue' - ) - } - ] - } - }), - { - path: '/favorites', - name: 'favorites', - component: () => - import('@/components/favorites/List.vue'), - props: route => ({ - defaultOrdering: route.query.ordering, - defaultPage: route.query.page, - defaultPaginateBy: route.query.paginateBy - }) - }, - { - path: '/content', - component: () => - import('@/views/content/Base.vue'), - children: [ - { - path: '', - name: 'content.index', - component: () => - import('@/views/content/Home.vue') - } - ] - }, - { - path: '/content/libraries/tracks', - component: () => - import('@/views/content/Base.vue'), - children: [ - { - path: '', - name: 'content.libraries.files', - component: () => - import( - '@/views/content/libraries/Files.vue' - ), - props: route => ({ - query: route.query.q - }) - } - ] - }, - { - path: '/content/libraries', - component: () => - import('@/views/content/Base.vue'), - children: [ - { - path: '', - name: 'content.libraries.index', - component: () => - import( - '@/views/content/libraries/Home.vue' - ) - } - ] - }, - { - path: '/content/remote', - component: () => - import('@/views/content/Base.vue'), - children: [ - { - path: '', - name: 'content.remote.index', - component: () => - import('@/views/content/remote/Home.vue') - } - ] - }, - { - path: '/manage/settings', - name: 'manage.settings', - beforeEnter: adminPermissions, - component: () => - import('@/views/admin/Settings.vue') - }, - { - path: '/manage/library', - beforeEnter: libraryPermissions, - component: () => - import('@/views/admin/library/Base.vue'), - children: [ - { - path: 'edits', - name: 'manage.library.edits', - component: () => - import( - '@/views/admin/library/EditsList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q - } - } - }, - { - path: 'artists', - name: 'manage.library.artists', - component: () => - import( - '@/views/admin/library/ArtistsList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q - } - } - }, - { - path: 'artists/:id', - name: 'manage.library.artists.detail', - component: () => - import( - '@/views/admin/library/ArtistDetail.vue' - ), - props: true - }, - { - path: 'channels', - name: 'manage.channels', - component: () => - import( - '@/views/admin/ChannelsList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q - } - } - }, - { - path: 'channels/:id', - name: 'manage.channels.detail', - component: () => - import( - '@/views/admin/ChannelDetail.vue' - ), - props: true - }, - { - path: 'albums', - name: 'manage.library.albums', - component: () => - import( - '@/views/admin/library/AlbumsList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q - } - } - }, - { - path: 'albums/:id', - name: 'manage.library.albums.detail', - component: () => - import( - '@/views/admin/library/AlbumDetail.vue' - ), - props: true - }, - { - path: 'tracks', - name: 'manage.library.tracks', - component: () => - import( - '@/views/admin/library/TracksList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q - } - } - }, - { - path: 'tracks/:id', - name: 'manage.library.tracks.detail', - component: () => - import( - '@/views/admin/library/TrackDetail.vue' - ), - props: true - }, - { - path: 'libraries', - name: 'manage.library.libraries', - component: () => - import( - '@/views/admin/library/LibrariesList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q - } - } - }, - { - path: 'libraries/:id', - name: 'manage.library.libraries.detail', - component: () => - import( - '@/views/admin/library/LibraryDetail.vue' - ), - props: true - }, - { - path: 'uploads', - name: 'manage.library.uploads', - component: () => - import( - '@/views/admin/library/UploadsList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q - } - } - }, - { - path: 'uploads/:id', - name: 'manage.library.uploads.detail', - component: () => - import( - '@/views/admin/library/UploadDetail.vue' - ), - props: true - }, - { - path: 'tags', - name: 'manage.library.tags', - component: () => - import( - '@/views/admin/library/TagsList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q - } - } - }, - { - path: 'tags/:id', - name: 'manage.library.tags.detail', - component: () => - import( - '@/views/admin/library/TagDetail.vue' - ), - props: true - } - ] - }, - { - path: '/manage/users', - beforeEnter: adminPermissions, - component: () => - import('@/views/admin/users/Base.vue'), - children: [ - { - path: 'users', - name: 'manage.users.users.list', - component: () => - import( - '@/views/admin/users/UsersList.vue' - ) - }, - { - path: 'invitations', - name: 'manage.users.invitations.list', - component: () => - import( - '@/views/admin/users/InvitationsList.vue' - ) - } - ] - }, - { - path: '/manage/moderation', - beforeEnter: moderatorPermissions, - component: () => - import('@/views/admin/moderation/Base.vue'), - children: [ - { - path: 'domains', - name: 'manage.moderation.domains.list', - component: () => - import( - '@/views/admin/moderation/DomainsList.vue' - ) - }, - { - path: 'domains/:id', - name: 'manage.moderation.domains.detail', - component: () => - import( - '@/views/admin/moderation/DomainsDetail.vue' - ), - props: true - }, - { - path: 'accounts', - name: 'manage.moderation.accounts.list', - component: () => - import( - '@/views/admin/moderation/AccountsList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q - } - } - }, - { - path: 'accounts/:id', - name: 'manage.moderation.accounts.detail', - component: () => - import( - '@/views/admin/moderation/AccountsDetail.vue' - ), - props: true - }, - { - path: 'reports', - name: 'manage.moderation.reports.list', - component: () => - import( - '@/views/admin/moderation/ReportsList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q, - updateUrl: true - } - } - }, - { - path: 'reports/:id', - name: 'manage.moderation.reports.detail', - component: () => - import( - '@/views/admin/moderation/ReportDetail.vue' - ), - props: true - }, - { - path: 'requests', - name: 'manage.moderation.requests.list', - component: () => - import( - '@/views/admin/moderation/RequestsList.vue' - ), - props: route => { - return { - defaultQuery: route.query.q, - updateUrl: true - } - } - }, - { - path: 'requests/:id', - name: 'manage.moderation.requests.detail', - component: () => - import( - '@/views/admin/moderation/RequestDetail.vue' - ), - props: true - } - ] - }, - { - path: '/library', - component: () => - import('@/components/library/Library.vue'), - children: [ - { - path: '', - component: () => - import('@/components/library/Home.vue'), - name: 'library.index' - }, - { - path: 'me', - component: () => - import('@/components/library/Home.vue'), - name: 'library.me', - props: route => ({ - scope: 'me' - }) - }, - { - path: 'artists/', - name: 'library.artists.browse', - component: () => - import( - '@/components/library/Artists.vue' - ), - props: route => ({ - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultTags: Array.isArray(route.query.tag || []) - ? route.query.tag - : [route.query.tag], - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page - }) - }, - { - path: 'me/artists', - name: 'library.artists.me', - component: () => - import( - '@/components/library/Artists.vue' - ), - props: route => ({ - scope: 'me', - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultTags: Array.isArray(route.query.tag || []) - ? route.query.tag - : [route.query.tag], - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page - }) - }, - { - path: 'albums/', - name: 'library.albums.browse', - component: () => - import( - '@/components/library/Albums.vue' - ), - props: route => ({ - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultTags: Array.isArray(route.query.tag || []) - ? route.query.tag - : [route.query.tag], - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page - }) - }, - { - path: 'podcasts/', - name: 'library.podcasts.browse', - component: () => - import( - '@/components/library/Podcasts.vue' - ), - props: route => ({ - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultTags: Array.isArray(route.query.tag || []) - ? route.query.tag - : [route.query.tag], - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page - }) - }, - { - path: 'me/albums', - name: 'library.albums.me', - component: () => - import( - '@/components/library/Albums.vue' - ), - props: route => ({ - scope: 'me', - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultTags: Array.isArray(route.query.tag || []) - ? route.query.tag - : [route.query.tag], - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page - }) - }, - { - path: 'radios/', - name: 'library.radios.browse', - component: () => - import( - '@/components/library/Radios.vue' - ), - props: route => ({ - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page - }) - }, - { - path: 'me/radios/', - name: 'library.radios.me', - component: () => - import( - '@/components/library/Radios.vue' - ), - props: route => ({ - scope: 'me', - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page - }) - }, - { - path: 'radios/build', - name: 'library.radios.build', - component: () => - import( - '@/components/library/radios/Builder.vue' - ), - props: true - }, - { - path: 'radios/build/:id', - name: 'library.radios.edit', - component: () => - import( - '@/components/library/radios/Builder.vue' - ), - props: true - }, - { - path: 'radios/:id', - name: 'library.radios.detail', - component: () => - import('@/views/radios/Detail.vue'), - props: true - }, - { - path: 'playlists/', - name: 'library.playlists.browse', - component: () => - import('@/views/playlists/List.vue'), - props: route => ({ - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page - }) - }, - { - path: 'me/playlists/', - name: 'library.playlists.me', - component: () => - import('@/views/playlists/List.vue'), - props: route => ({ - scope: 'me', - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page - }) - }, - { - path: 'playlists/:id', - name: 'library.playlists.detail', - component: () => - import('@/views/playlists/Detail.vue'), - props: route => ({ - id: route.params.id, - defaultEdit: route.query.mode === 'edit' - }) - }, - { - path: 'tags/:id', - name: 'library.tags.detail', - component: () => - import( - '@/components/library/TagDetail.vue' - ), - props: true - }, - { - path: 'artists/:id', - component: () => - import( - '@/components/library/ArtistBase.vue' - ), - props: true, - children: [ - { - path: '', - name: 'library.artists.detail', - component: () => - import( - '@/components/library/ArtistDetail.vue' - ) - }, - { - path: 'edit', - name: 'library.artists.edit', - component: () => - import( - '@/components/library/ArtistEdit.vue' - ) - }, - { - path: 'edit/:editId', - name: 'library.artists.edit.detail', - component: () => - import( - '@/components/library/EditDetail.vue' - ), - props: true - } - ] - }, - { - path: 'albums/:id', - component: () => - import( - '@/components/library/AlbumBase.vue' - ), - props: true, - children: [ - { - path: '', - name: 'library.albums.detail', - component: () => - import( - '@/components/library/AlbumDetail.vue' - ) - }, - { - path: 'edit', - name: 'library.albums.edit', - component: () => - import( - '@/components/library/AlbumEdit.vue' - ) - }, - { - path: 'edit/:editId', - name: 'library.albums.edit.detail', - component: () => - import( - '@/components/library/EditDetail.vue' - ), - props: true - } - ] - }, - { - path: 'tracks/:id', - component: () => - import( - '@/components/library/TrackBase.vue' - ), - props: true, - children: [ - { - path: '', - name: 'library.tracks.detail', - component: () => - import( - '@/components/library/TrackDetail.vue' - ) - }, - { - path: 'edit', - name: 'library.tracks.edit', - component: () => - import( - '@/components/library/TrackEdit.vue' - ) - }, - { - path: 'edit/:editId', - name: 'library.tracks.edit.detail', - component: () => - import( - '@/components/library/EditDetail.vue' - ), - props: true - } - ] - }, - { - path: 'uploads/:id', - name: 'library.uploads.detail', - props: true, - component: () => - import( - '@/components/library/UploadDetail.vue' - ) - }, - { - // browse a single library via it's uuid - path: ':id([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})', - props: true, - component: () => - import( - '@/views/library/DetailBase.vue' - ), - children: [ - { - path: '', - name: 'library.detail', - component: () => - import( - '@/views/library/DetailOverview.vue' - ) - }, - { - path: 'albums', - name: 'library.detail.albums', - component: () => - import( - '@/views/library/DetailAlbums.vue' - ) - }, - { - path: 'tracks', - name: 'library.detail.tracks', - component: () => - import( - '@/views/library/DetailTracks.vue' - ) - }, - { - path: 'edit', - name: 'library.detail.edit', - component: () => - import( - '@/views/library/Edit.vue' - ) - }, - { - path: 'upload', - name: 'library.detail.upload', - component: () => - import( - '@/views/library/Upload.vue' - ), - props: route => ({ - defaultImportReference: route.query.import - }) - } - ] - } - ] - }, - { - path: '/channels/:id', - props: true, - component: () => - import( - '@/views/channels/DetailBase.vue' - ), - children: [ - { - path: '', - name: 'channels.detail', - component: () => - import( - '@/views/channels/DetailOverview.vue' - ) - }, - { - path: 'episodes', - name: 'channels.detail.episodes', - component: () => - import( - '@/views/channels/DetailEpisodes.vue' - ) - } - ] - }, - { - path: '/subscriptions', - name: 'subscriptions', - props: route => { - return { - defaultQuery: route.query.q - } - }, - component: () => - import( - '@/views/channels/SubscriptionsList.vue' - ) - }, - { - path: '*/index.html', - redirect: '/' - }, - { - path: '*', - name: '404', - component: () => - import('@/components/PageNotFound.vue') - } - ] -}) diff --git a/front/src/router/index.ts b/front/src/router/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..67927a799ad55b9662a931b3b598b9ada9ed5a0d --- /dev/null +++ b/front/src/router/index.ts @@ -0,0 +1,24 @@ +import { createRouter, createWebHistory } from 'vue-router' +import routes from './routes' + +export default createRouter({ + history: createWebHistory(import.meta.env.VUE_APP_ROUTER_BASE_URL as string ?? '/'), + linkActiveClass: 'active', + routes, + + scrollBehavior (to, from, savedPosition) { + if (to.meta.preserveScrollPosition) { + return savedPosition ?? { left: 0, top: 0 } + } + + return new Promise(resolve => { + setTimeout(() => { + if (to.hash) { + resolve({ el: to.hash, behavior: 'smooth' }) + } + + resolve(savedPosition ?? { left: 0, top: 0 }) + }, 100) + }) + } +}) diff --git a/front/src/router/routes/auth.ts b/front/src/router/routes/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..022534d49c0b6dd24e1a9b73be2c2eee9e31f805 --- /dev/null +++ b/front/src/router/routes/auth.ts @@ -0,0 +1,68 @@ +import type { RouteRecordRaw } from 'vue-router' + +import { requireLoggedOut, requireLoggedIn } from '~/router/guards' + +export default [ + { + path: '/login', + name: 'login', + component: () => import('~/views/auth/Login.vue'), + props: route => ({ next: route.query.next || '/library' }), + beforeEnter: requireLoggedOut({ name: 'library.index' }) + }, + { + path: '/auth/password/reset', + name: 'auth.password-reset', + component: () => import('~/views/auth/PasswordReset.vue'), + props: route => ({ defaultEmail: route.query.email }) + }, + { + path: '/auth/callback', + name: 'auth.callback', + component: () => import('~/views/auth/Callback.vue'), + props: route => ({ + code: route.query.code, + state: route.query.state + }) + }, + { + path: '/auth/email/confirm', + name: 'auth.email-confirm', + component: () => import('~/views/auth/EmailConfirm.vue'), + props: route => ({ defaultKey: route.query.key }) + }, + { + path: '/auth/password/reset/confirm', + name: 'auth.password-reset-confirm', + component: () => import('~/views/auth/PasswordResetConfirm.vue'), + props: route => ({ + defaultUid: route.query.uid, + defaultToken: route.query.token + }) + }, + { + path: '/authorize', + name: 'authorize', + component: () => import('~/components/auth/Authorize.vue'), + props: route => ({ + clientId: route.query.client_id, + redirectUri: route.query.redirect_uri, + scope: route.query.scope, + responseType: route.query.response_type, + nonce: route.query.nonce, + state: route.query.state + }), + beforeEnter: requireLoggedIn() + }, + { + path: '/signup', + name: 'signup', + component: () => import('~/views/auth/Signup.vue'), + props: route => ({ defaultInvitation: route.query.invitation }) + }, + { + path: '/logout', + name: 'logout', + component: () => import('~/components/auth/Logout.vue') + } +] as RouteRecordRaw[] diff --git a/front/src/router/routes/content.ts b/front/src/router/routes/content.ts new file mode 100644 index 0000000000000000000000000000000000000000..57320e3d2b9fccc1b2807205750a09ac8c55fdd0 --- /dev/null +++ b/front/src/router/routes/content.ts @@ -0,0 +1,41 @@ +import type { RouteRecordRaw } from 'vue-router' + +export default [ + { + path: '/content', + component: () => import('~/views/content/Base.vue'), + children: [{ + path: '', + name: 'content.index', + component: () => import('~/views/content/Home.vue') + }] + }, + { + path: '/content/libraries/tracks', + component: () => import('~/views/content/Base.vue'), + children: [{ + path: '', + name: 'content.libraries.files', + component: () => import('~/views/content/libraries/Files.vue'), + props: route => ({ query: route.query.q }) + }] + }, + { + path: '/content/libraries', + component: () => import('~/views/content/Base.vue'), + children: [{ + path: '', + name: 'content.libraries.index', + component: () => import('~/views/content/libraries/Home.vue') + }] + }, + { + path: '/content/remote', + component: () => import('~/views/content/Base.vue'), + children: [{ + path: '', + name: 'content.remote.index', + component: () => import('~/views/content/remote/Home.vue') + }] + } +] as RouteRecordRaw[] diff --git a/front/src/router/routes/index.ts b/front/src/router/routes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7eead6a5369c0e5def143f507f9bbc3f78dc5dc9 --- /dev/null +++ b/front/src/router/routes/index.ts @@ -0,0 +1,103 @@ +import type { RouteRecordRaw } from 'vue-router' + +import settings from './settings' +import library from './library' +import content from './content' +import manage from './manage' +import store from '~/store' +import auth from './auth' +import user from './user' +import { requireLoggedIn } from '~/router/guards' + +export default [ + { + path: '/', + name: 'index', + component: () => import('~/components/Home.vue'), + beforeEnter (to, from, next) { + if (store.state.auth.authenticated) return next('/library') + return next() + } + }, + { + path: '/index.html', + redirect: to => { + const { hash, query } = to + return { name: 'index', hash, query } + } + }, + { + path: '/front', + name: 'front', + redirect: to => { + const { hash, query } = to + return { name: 'index', hash, query } + } + }, + + { + path: '/about', + name: 'about', + component: () => import('~/components/About.vue') + }, + { + // TODO (wvffle): Make it a child of /about to have the active style on the sidebar link + path: '/about/pod', + name: 'about-pod', + component: () => import('~/components/AboutPod.vue') + }, + { + path: '/notifications', + name: 'notifications', + component: () => import('~/views/Notifications.vue') + }, + { + path: '/search', + name: 'search', + component: () => import('~/views/Search.vue') + }, + ...auth, + ...settings, + ...user, + { + path: '/favorites', + name: 'favorites', + component: () => import('~/components/favorites/List.vue'), + props: route => ({ + defaultOrdering: route.query.ordering, + defaultPage: route.query.page ? +route.query.page : undefined + }), + beforeEnter: requireLoggedIn() + }, + ...content, + ...manage, + ...library, + { + path: '/channels/:id', + props: true, + component: () => import('~/views/channels/DetailBase.vue'), + children: [ + { + path: '', + name: 'channels.detail', + component: () => import('~/views/channels/DetailOverview.vue') + }, + { + path: 'episodes', + name: 'channels.detail.episodes', + component: () => import('~/views/channels/DetailEpisodes.vue') + } + ] + }, + { + path: '/subscriptions', + name: 'subscriptions', + component: () => import('~/views/channels/SubscriptionsList.vue'), + props: route => ({ defaultQuery: route.query.q }) + }, + { + path: '/:pathMatch(.*)*', + name: '404', + component: () => import('~/components/PageNotFound.vue') + } +] as RouteRecordRaw[] diff --git a/front/src/router/routes/library.ts b/front/src/router/routes/library.ts new file mode 100644 index 0000000000000000000000000000000000000000..34989e5dec86855d08fed947367377bd25609646 --- /dev/null +++ b/front/src/router/routes/library.ts @@ -0,0 +1,241 @@ +import type { RouteRecordRaw } from 'vue-router' + +export default [ + { + path: '/library', + component: () => import('~/components/library/Library.vue'), + children: [ + { + path: '', + component: () => import('~/components/library/Home.vue'), + name: 'library.index' + }, + { + path: 'me', + component: () => import('~/components/library/Home.vue'), + name: 'library.me', + props: () => ({ scope: 'me' }) + }, + { + path: 'artists/', + name: 'library.artists.browse', + component: () => import('~/components/library/Artists.vue'), + meta: { + paginateBy: 30 + } + }, + { + path: 'me/artists', + name: 'library.artists.me', + component: () => import('~/components/library/Artists.vue'), + props: { scope: 'me' }, + meta: { + paginateBy: 30 + } + }, + { + path: 'albums/', + name: 'library.albums.browse', + component: () => import('~/components/library/Albums.vue'), + meta: { + paginateBy: 25 + } + }, + { + path: 'me/albums', + name: 'library.albums.me', + component: () => import('~/components/library/Albums.vue'), + props: { scope: 'me' }, + meta: { + paginateBy: 25 + } + }, + { + path: 'podcasts/', + name: 'library.podcasts.browse', + component: () => import('~/components/library/Podcasts.vue'), + meta: { + paginateBy: 30 + } + }, + { + path: 'radios/', + name: 'library.radios.browse', + component: () => import('~/components/library/Radios.vue'), + meta: { + paginateBy: 12 + } + }, + { + path: 'me/radios/', + name: 'library.radios.me', + component: () => import('~/components/library/Radios.vue'), + props: { scope: 'me' }, + meta: { + paginateBy: 12 + } + }, + { + path: 'radios/build', + name: 'library.radios.build', + component: () => import('~/components/library/radios/Builder.vue'), + props: true + }, + { + path: 'radios/build/:id', + name: 'library.radios.edit', + component: () => import('~/components/library/radios/Builder.vue'), + props: true + }, + { + path: 'radios/:id', + name: 'library.radios.detail', + component: () => import('~/views/radios/Detail.vue'), + props: true + }, + { + path: 'playlists/', + name: 'library.playlists.browse', + component: () => import('~/views/playlists/List.vue'), + meta: { + paginateBy: 25 + } + }, + { + path: 'me/playlists/', + name: 'library.playlists.me', + component: () => import('~/views/playlists/List.vue'), + props: { scope: 'me' }, + meta: { + paginateBy: 25 + } + }, + { + path: 'playlists/:id', + name: 'library.playlists.detail', + component: () => import('~/views/playlists/Detail.vue'), + props: route => ({ + id: route.params.id, + defaultEdit: route.query.mode === 'edit' + }) + }, + { + path: 'tags/:id', + name: 'library.tags.detail', + component: () => import('~/components/library/TagDetail.vue'), + props: true + }, + { + path: 'artists/:id', + component: () => import('~/components/library/ArtistBase.vue'), + props: true, + children: [ + { + path: '', + name: 'library.artists.detail', + component: () => import('~/components/library/ArtistDetail.vue') + }, + { + path: 'edit', + name: 'library.artists.edit', + component: () => import('~/components/library/ArtistEdit.vue') + }, + { + path: 'edit/:editId', + name: 'library.artists.edit.detail', + component: () => import('~/components/library/EditDetail.vue'), + props: true + } + ] + }, + { + path: 'albums/:id', + component: () => import('~/components/library/AlbumBase.vue'), + props: true, + children: [ + { + path: '', + name: 'library.albums.detail', + component: () => import('~/components/library/AlbumDetail.vue') + }, + { + path: 'edit', + name: 'library.albums.edit', + component: () => import('~/components/library/AlbumEdit.vue') + }, + { + path: 'edit/:editId', + name: 'library.albums.edit.detail', + component: () => import('~/components/library/EditDetail.vue'), + props: true + } + ] + }, + { + path: 'tracks/:id', + component: () => import('~/components/library/TrackBase.vue'), + props: true, + children: [ + { + path: '', + name: 'library.tracks.detail', + component: () => import('~/components/library/TrackDetail.vue') + }, + { + path: 'edit', + name: 'library.tracks.edit', + component: () => import('~/components/library/TrackEdit.vue') + }, + { + path: 'edit/:editId', + name: 'library.tracks.edit.detail', + component: () => import('~/components/library/EditDetail.vue'), + props: true + } + ] + }, + { + path: 'uploads/:id', + name: 'library.uploads.detail', + props: true, + component: () => import('~/components/library/UploadDetail.vue') + }, + { + // browse a single library via it's uuid + path: ':id([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})', + props: true, + component: () => import('~/views/library/LibraryBase.vue'), + children: [ + { + path: '', + name: 'library.detail', + component: () => import('~/views/library/DetailOverview.vue') + }, + { + path: 'albums', + name: 'library.detail.albums', + component: () => import('~/views/library/DetailAlbums.vue') + }, + { + path: 'tracks', + name: 'library.detail.tracks', + component: () => import('~/views/library/DetailTracks.vue') + }, + { + path: 'edit', + name: 'library.detail.edit', + component: () => import('~/views/library/Edit.vue') + }, + { + path: 'upload', + name: 'library.detail.upload', + component: () => import('~/views/library/Upload.vue'), + props: route => ({ + defaultImportReference: route.query.import + }) + } + ] + } + ] + } +] as RouteRecordRaw[] diff --git a/front/src/router/routes/manage.ts b/front/src/router/routes/manage.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0aecf4687503c33238fa28687d7fbcdcb14a5aa --- /dev/null +++ b/front/src/router/routes/manage.ts @@ -0,0 +1,186 @@ +import type { RouteRecordRaw } from 'vue-router' + +import { hasPermissions } from '~/router/guards' + +export default [ + { + path: '/manage/settings', + name: 'manage.settings', + beforeEnter: hasPermissions('settings'), + component: () => import('~/views/admin/Settings.vue') + }, + { + path: '/manage/library', + beforeEnter: hasPermissions('library'), + component: () => import('~/views/admin/library/Base.vue'), + children: [ + { + path: 'edits', + name: 'manage.library.edits', + component: () => import('~/views/admin/library/EditsList.vue'), + props: route => ({ defaultQuery: route.query.q }) + }, + { + path: 'artists', + name: 'manage.library.artists', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ defaultQuery: route.query.q, type: 'artists' }) + }, + { + path: 'artists/:id', + name: 'manage.library.artists.detail', + component: () => import('~/views/admin/library/ArtistDetail.vue'), + props: true + }, + { + path: 'channels', + name: 'manage.channels', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ defaultQuery: route.query.q, type: 'channels' }) + }, + { + path: 'channels/:id', + name: 'manage.channels.detail', + component: () => import('~/views/admin/ChannelDetail.vue'), + props: true + }, + { + path: 'albums', + name: 'manage.library.albums', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ defaultQuery: route.query.q, type: 'albums' }) + }, + { + path: 'albums/:id', + name: 'manage.library.albums.detail', + component: () => import('~/views/admin/library/AlbumDetail.vue'), + props: true + }, + { + path: 'tracks', + name: 'manage.library.tracks', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ defaultQuery: route.query.q, type: 'tracks' }) + }, + { + path: 'tracks/:id', + name: 'manage.library.tracks.detail', + component: () => import('~/views/admin/library/TrackDetail.vue'), + props: true + }, + { + path: 'libraries', + name: 'manage.library.libraries', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ defaultQuery: route.query.q, type: 'libraries' }) + }, + { + path: 'libraries/:id', + name: 'manage.library.libraries.detail', + component: () => import('~/views/admin/library/LibraryDetail.vue'), + props: true + }, + { + path: 'uploads', + name: 'manage.library.uploads', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ defaultQuery: route.query.q, type: 'uploads' }) + }, + { + path: 'uploads/:id', + name: 'manage.library.uploads.detail', + component: () => import('~/views/admin/library/UploadDetail.vue'), + props: true + }, + { + path: 'tags', + name: 'manage.library.tags', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ defaultQuery: route.query.q, type: 'tags' }) + }, + { + path: 'tags/:id', + name: 'manage.library.tags.detail', + component: () => import('~/views/admin/library/TagDetail.vue'), + props: true + } + ] + }, + { + path: '/manage/users', + beforeEnter: hasPermissions('settings'), + component: () => import('~/views/admin/users/Base.vue'), + children: [ + { + path: 'users', + name: 'manage.users.users.list', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ type: 'users' }) + }, + { + path: 'invitations', + name: 'manage.users.invitations.list', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ type: 'invitations' }) + } + ] + }, + { + path: '/manage/moderation', + beforeEnter: hasPermissions('moderation'), + component: () => import('~/views/admin/moderation/Base.vue'), + children: [ + { + path: 'domains', + name: 'manage.moderation.domains.list', + component: () => import('~/views/admin/moderation/DomainsList.vue') + }, + { + path: 'domains/:id', + name: 'manage.moderation.domains.detail', + component: () => import('~/views/admin/moderation/DomainsDetail.vue'), + props: true + }, + { + path: 'accounts', + name: 'manage.moderation.accounts.list', + component: () => import('~/views/admin/CommonList.vue'), + props: route => ({ defaultQuery: route.query.q, type: 'accounts' }) + }, + { + path: 'accounts/:id', + name: 'manage.moderation.accounts.detail', + component: () => import('~/views/admin/moderation/AccountsDetail.vue'), + props: true + }, + { + path: 'reports', + name: 'manage.moderation.reports.list', + component: () => import('~/views/admin/moderation/ReportsList.vue'), + meta: { + paginateBy: 25 + } + }, + { + path: 'reports/:id', + name: 'manage.moderation.reports.detail', + component: () => import('~/views/admin/moderation/ReportDetail.vue'), + props: true + }, + { + path: 'requests', + name: 'manage.moderation.requests.list', + component: () => import('~/views/admin/moderation/RequestsList.vue'), + meta: { + paginateBy: 25 + } + }, + { + path: 'requests/:id', + name: 'manage.moderation.requests.detail', + component: () => import('~/views/admin/moderation/RequestDetail.vue'), + props: true + } + ] + } +] as RouteRecordRaw[] diff --git a/front/src/router/routes/settings.ts b/front/src/router/routes/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..6446443b6f8868a48a28a6d550a76233e3f3ceda --- /dev/null +++ b/front/src/router/routes/settings.ts @@ -0,0 +1,30 @@ +import type { RouteRecordRaw } from 'vue-router' + +export default [ + { + path: '/settings', + name: 'settings', + component: () => import('~/components/auth/Settings.vue') + }, + { + path: '/settings/applications/new', + name: 'settings.applications.new', + props: route => ({ + scopes: route.query.scopes, + name: route.query.name, + redirect_uris: route.query.redirect_uris + }), + component: () => import('~/components/auth/ApplicationNew.vue') + }, + { + path: '/settings/plugins', + name: 'settings.plugins', + component: () => import('~/views/auth/Plugins.vue') + }, + { + path: '/settings/applications/:id/edit', + name: 'settings.applications.edit', + component: () => import('~/components/auth/ApplicationEdit.vue'), + props: true + } +] as RouteRecordRaw[] diff --git a/front/src/router/routes/user.ts b/front/src/router/routes/user.ts new file mode 100644 index 0000000000000000000000000000000000000000..c968029a675e91c8e5b7bf4d669e3ba98e28e327 --- /dev/null +++ b/front/src/router/routes/user.ts @@ -0,0 +1,34 @@ +import type { RouteRecordRaw } from 'vue-router' + +import store from '~/store' + +export default [ + { suffix: '.full', path: '/@:username@:domain' }, + { suffix: '', path: '/@:username' } +].map((route) => { + return { + path: route.path, + name: `profile${route.suffix}`, + component: () => import('~/views/auth/ProfileBase.vue'), + beforeEnter (to, from, next) { + if (!store.state.auth.authenticated && to.query.domain && store.getters['instance/domain'] !== to.query.domain) { + return next({ name: 'login', query: { next: to.fullPath } }) + } + + next() + }, + props: true, + children: [ + { + path: '', + name: `profile${route.suffix}.overview`, + component: () => import('~/views/auth/ProfileOverview.vue') + }, + { + path: 'activity', + name: `profile${route.suffix}.activity`, + component: () => import('~/views/auth/ProfileActivity.vue') + } + ] + } +}) as RouteRecordRaw[] diff --git a/front/src/sanitize.js b/front/src/sanitize.js deleted file mode 100644 index f43a69875d41af5e38ce7ae3a53679c16fdd2de2..0000000000000000000000000000000000000000 --- a/front/src/sanitize.js +++ /dev/null @@ -1,43 +0,0 @@ -import sanitizeHtml from 'sanitize-html' - -const allowedTags = [ - 'h3', - 'h4', - 'h5', - 'h6', - 'blockquote', - 'p', - 'a', - 'ul', - 'ol', - 'nl', - 'li', - 'b', - 'i', - 'strong', - 'em', - 'strike', - 'code', - 'hr', - 'br', - 'div', - 'table', - 'thead', - 'caption', - 'tbody', - 'tr', - 'th', - 'td', - 'pre' -] -const allowedAttributes = { - a: ['href', 'name', 'target'], - // We don't currently allow img itself by default, but this - // would make sense if we did. You could add srcset here, - // and if you do the URL is checked for safety - img: ['src'] -} - -export default function sanitize (input) { - return sanitizeHtml(input, { allowedAttributes, allowedTags }) -} diff --git a/front/src/search.js b/front/src/search.js deleted file mode 100644 index adb2e63fb02e6e9795a94130f8f0038631903261..0000000000000000000000000000000000000000 --- a/front/src/search.js +++ /dev/null @@ -1,69 +0,0 @@ -export function normalizeQuery (query) { - // given a string such as 'this is "my query" go', returns - // an array of tokens like this: ['this', 'is', 'my query', 'go'] - if (!query) { - return [] - } - return query.match(/\\?.|^$/g).reduce((p, c) => { - if (c === '"') { - p.quote ^= 1 - } else if (!p.quote && c === ' ') { - p.a.push('') - } else { - p.a[p.a.length - 1] += c.replace(/\\(.)/, '$1') - } - return p - }, { a: [''] }).a -} - -export function parseTokens (tokens) { - // given an array of tokens as returned by normalizeQuery, - // returns a list of objects such as [ - // { - // field: 'status', - // value: 'pending' - // }, - // { - // field: null, - // value: 'hello' - // } - // ] - return tokens.map(t => { - // we split the token on ":" - const parts = t.split(/:(.+)/) - if (parts.length === 1) { - // no field specified - return { field: null, value: t } - } - // first item is the field, second is the value, possibly quoted - const field = parts[0] - let rawValue = parts[1] - - // we remove surrounding quotes if any - if (rawValue[0] === '"') { - rawValue = rawValue.substring(1) - } - if (rawValue.slice(-1) === '"') { - rawValue = rawValue.substring(0, rawValue.length - 1) - } - return { field, value: rawValue } - }) -} - -export function compileTokens (tokens) { - // given a list of tokens as returned by parseTokens, - // returns a string query - const parts = tokens.map(t => { - let v = t.value - const k = t.field - if (v.indexOf(' ') > -1) { - v = `"${v}"` - } - if (k) { - return `${k}:${v}` - } else { - return v - } - }) - return parts.join(' ') -} diff --git a/front/src/serviceWorker.ts b/front/src/serviceWorker.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fe63365b4a25c8f2cf33ef27addcb51c9ab8888 --- /dev/null +++ b/front/src/serviceWorker.ts @@ -0,0 +1,51 @@ +/// <reference lib="webworker" /> + +import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' +import { NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies' +import { ExpirationPlugin } from 'workbox-expiration' +import { registerRoute } from 'workbox-routing' +import { clientsClaim } from 'workbox-core' + +declare let self: ServiceWorkerGlobalScope + +// NOTE: Clean up outdated caches +// With each new production build, all precached assets +// that were modified are added to the cache. The old versions +// need to be removed manually. +cleanupOutdatedCaches() + +// Let new service worker claim control of already open web pages +// https://developer.chrome.com/docs/workbox/modules/workbox-core/#clients-claim +clientsClaim() + +// Support for an update prompt handled by VitePWA: +// https://vite-plugin-pwa.netlify.app/guide/prompt-for-update.html +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + return self.skipWaiting() + } +}) + +// NOTE: Network-First cache for API calls +// We're using cache only when the user goes offline +registerRoute(({ url }) => { + if (url.pathname.startsWith('/api/v1/listen')) return false + return url.pathname.startsWith('/api/v1') +}, new NetworkFirst({ + cacheName: 'API Routes', + plugins: [ + // Expire after a week + new ExpirationPlugin({ maxAgeSeconds: 7 * 24 * 3600 }) + ] +})) + +// NOTE: Stale-While-Revalidate cache for album covers +// We're serving from cache if available and making a request +// in the background to update the cache for next request +registerRoute(({ url }) => { + return url.pathname.startsWith('/media') +}, new StaleWhileRevalidate()) + +// Precache all assets and add routes for them +// https://developer.chrome.com/docs/workbox/reference/workbox-precaching/#method-precacheAndRoute +precacheAndRoute(self.__WB_MANIFEST) diff --git a/front/src/shims-vue-router.d.ts b/front/src/shims-vue-router.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..b755fbb8778056a2f231ce1f46a65b2d5105630e --- /dev/null +++ b/front/src/shims-vue-router.d.ts @@ -0,0 +1,9 @@ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + orderingDirection?: '-' | '+' + ordering?: OrderingField + paginateBy?: number + } + } diff --git a/front/src/shims-vue.d.ts b/front/src/shims-vue.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..8337a1d5fc7764dfd8433c1240b3e87cb5cea2f4 --- /dev/null +++ b/front/src/shims-vue.d.ts @@ -0,0 +1,6 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + // eslint-disable-next-line @typescript-eslint/ban-types + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/front/src/shims-vuex.d.ts b/front/src/shims-vuex.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..9658d05c753b2369bfc3dc29c1aadb53589bf615 --- /dev/null +++ b/front/src/shims-vuex.d.ts @@ -0,0 +1,8 @@ +import { Store } from 'vuex' +import { RootState } from '~/store' + +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $store: Store<RootState> + } +} diff --git a/front/src/store/auth.js b/front/src/store/auth.js deleted file mode 100644 index 8a086180c8490a88a4873f303ecacc85c9c3b901..0000000000000000000000000000000000000000 --- a/front/src/store/auth.js +++ /dev/null @@ -1,251 +0,0 @@ -import Vue from 'vue' -import axios from 'axios' -import logger from '@/logging' -import lodash from 'lodash' - -function getDefaultScopedTokens () { - return { - listen: null - } -} - -function asForm (obj) { - const data = new FormData() - Object.entries(obj).forEach((e) => { - data.set(e[0], e[1]) - }) - return data -} - -let baseUrl = `${window.location.protocol}//${window.location.hostname}` -if (window.location.port) { - baseUrl = `${baseUrl}:${window.location.port}` -} -function getDefaultOauth () { - return { - clientId: null, - clientSecret: null, - accessToken: null, - refreshToken: null - } -} -const NEEDED_SCOPES = [ - 'read', - 'write' -].join(' ') -async function createOauthApp (domain) { - const payload = { - name: `Funkwhale web client at ${window.location.hostname}`, - website: baseUrl, - scopes: NEEDED_SCOPES, - redirect_uris: `${baseUrl}/auth/callback` - } - return (await axios.post('oauth/apps/', payload)).data -} -export default { - namespaced: true, - state: { - authenticated: false, - username: '', - fullUsername: '', - availablePermissions: { - settings: false, - library: false, - moderation: false - }, - profile: null, - oauth: getDefaultOauth(), - scopedTokens: getDefaultScopedTokens() - }, - getters: { - header: state => { - if (state.oauth.accessToken) { - return 'Bearer ' + state.oauth.accessToken - } - } - }, - mutations: { - reset (state) { - state.authenticated = false - state.profile = null - state.username = '' - state.fullUsername = '' - state.scopedTokens = getDefaultScopedTokens() - state.oauth = getDefaultOauth() - state.availablePermissions = { - federation: false, - settings: false, - library: false, - upload: false - } - }, - profile: (state, value) => { - state.profile = value - }, - authenticated: (state, value) => { - state.authenticated = value - if (value === false) { - state.username = null - state.fullUsername = null - state.profile = null - state.scopedTokens = getDefaultScopedTokens() - state.availablePermissions = {} - } - }, - username: (state, value) => { - state.username = value - }, - fullUsername: (state, value) => { - state.fullUsername = value - }, - avatar: (state, value) => { - if (state.profile) { - state.profile.avatar = value - } - }, - scopedTokens: (state, value) => { - state.scopedTokens = { ...value } - }, - permission: (state, { key, status }) => { - state.availablePermissions[key] = status - }, - profilePartialUpdate: (state, payload) => { - lodash.keys(payload).forEach((k) => { - Vue.set(state.profile, k, payload[k]) - }) - }, - oauthApp: (state, payload) => { - state.oauth.clientId = payload.client_id - state.oauth.clientSecret = payload.client_secret - }, - oauthToken: (state, payload) => { - state.oauth.accessToken = payload.access_token - state.oauth.refreshToken = payload.refresh_token - } - }, - actions: { - // Send a request to the login URL and save the returned JWT - login ({ commit, dispatch }, { next, credentials, onError }) { - const form = new FormData() - Object.keys(credentials).forEach((k) => { - form.set(k, credentials[k]) - }) - return axios.post('users/login', form).then(response => { - logger.default.info('Successfully logged in as', credentials.username) - dispatch('fetchProfile').then(() => { - // Redirect to a specified route - import('@/router').then((router) => { - return router.default.push(next) - }) - }) - }, response => { - logger.default.error('Error while logging in', response.data) - onError(response) - }) - }, - async logout ({ state, commit }) { - try { - await axios.post('users/logout') - } catch (error) { - console.log('Error while logging out, probably logged in via oauth') - } - const modules = [ - 'auth', - 'favorites', - 'player', - 'playlists', - 'queue', - 'radios' - ] - modules.forEach(m => { - commit(`${m}/reset`, null, { root: true }) - }) - logger.default.info('Log out, goodbye!') - }, - fetchProfile ({ commit, dispatch, state }) { - return new Promise((resolve, reject) => { - axios.get('users/me/').then((response) => { - logger.default.info('Successfully fetched user profile') - dispatch('ui/initSettings', response.data.settings, { root: true }) - dispatch('updateProfile', response.data) - dispatch('ui/fetchUnreadNotifications', null, { root: true }) - if (response.data.permissions.library) { - dispatch('ui/fetchPendingReviewEdits', null, { root: true }) - } - if (response.data.permissions.moderation) { - dispatch('ui/fetchPendingReviewReports', null, { root: true }) - dispatch('ui/fetchPendingReviewRequests', null, { root: true }) - } - dispatch('favorites/fetch', null, { root: true }) - dispatch('channels/fetchSubscriptions', null, { root: true }) - dispatch('libraries/fetchFollows', null, { root: true }) - dispatch('moderation/fetchContentFilters', null, { root: true }) - dispatch('playlists/fetchOwn', null, { root: true }) - resolve(response.data) - }, (response) => { - logger.default.info('Error while fetching user profile') - reject(new Error('Error while fetching user profile')) - }) - }) - }, - updateProfile ({ commit }, data) { - return new Promise((resolve, reject) => { - commit('authenticated', true) - commit('profile', data) - commit('username', data.username) - commit('fullUsername', data.full_username) - if (data.tokens) { - commit('scopedTokens', data.tokens) - } - Object.keys(data.permissions).forEach(function (key) { - // this makes it easier to check for permissions in templates - commit('permission', { - key, - status: data.permissions[String(key)] - }) - }) - resolve() - }) - }, - async oauthLogin ({ state, rootState, commit, getters }, next) { - const app = await createOauthApp(getters.appDomain) - commit('oauthApp', app) - const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`) - const params = `response_type=code&scope=${encodeURIComponent(NEEDED_SCOPES)}&redirect_uri=${redirectUri}&state=${next}&client_id=${state.oauth.clientId}` - const authorizeUrl = `${rootState.instance.instanceUrl}authorize?${params}` - console.log('Redirecting user...', authorizeUrl) - window.location = authorizeUrl - }, - async handleOauthCallback ({ state, commit, dispatch }, authorizationCode) { - console.log('Fetching token...') - const payload = { - client_id: state.oauth.clientId, - client_secret: state.oauth.clientSecret, - grant_type: 'authorization_code', - code: authorizationCode, - redirect_uri: `${baseUrl}/auth/callback` - } - const response = await axios.post( - 'oauth/token/', - asForm(payload), - { headers: { 'Content-Type': 'multipart/form-data' } } - ) - commit('oauthToken', response.data) - await dispatch('fetchProfile') - }, - async refreshOauthToken ({ state, commit }, authorizationCode) { - const payload = { - client_id: state.oauth.clientId, - client_secret: state.oauth.clientSecret, - grant_type: 'refresh_token', - refresh_token: state.oauth.refreshToken - } - const response = await axios.post( - 'oauth/token/', - asForm(payload), - { headers: { 'Content-Type': 'multipart/form-data' } } - ) - commit('oauthToken', response.data) - } - } -} diff --git a/front/src/store/auth.ts b/front/src/store/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..f52a181c77b546baef77a8253e8405cf89ed2501 --- /dev/null +++ b/front/src/store/auth.ts @@ -0,0 +1,268 @@ +import type { BackendError, User } from '~/types' +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' +import type { RouteLocationRaw } from 'vue-router' + +import axios from 'axios' +import useLogger from '~/composables/useLogger' +import useFormData from '~/composables/useFormData' + +export type Permission = 'settings' | 'library' | 'moderation' +export interface State { + authenticated: boolean + username: string + fullUsername: string + availablePermissions: Record<Permission, boolean>, + profile: null | User + oauth: OAuthTokens + scopedTokens: ScopedTokens +} + +interface ScopedTokens { + listen: null | string +} + +interface OAuthTokens { + clientId: null | string + clientSecret: null | string + accessToken: null | string + refreshToken: null | string +} + +const NEEDED_SCOPES = 'read write' + +const logger = useLogger() + +function getDefaultScopedTokens (): ScopedTokens { + return { + listen: null + } +} + +function getDefaultOauth (): OAuthTokens { + return { + clientId: null, + clientSecret: null, + accessToken: null, + refreshToken: null + } +} + +async function createOauthApp () { + const payload = { + name: `Funkwhale web client at ${window.location.hostname}`, + website: location.origin, + scopes: NEEDED_SCOPES, + redirect_uris: `${location.origin}/auth/callback` + } + return (await axios.post('oauth/apps/', payload)).data +} + +const store: Module<State, RootState> = { + namespaced: true, + state: { + authenticated: false, + username: '', + fullUsername: '', + availablePermissions: { + settings: false, + library: false, + moderation: false + }, + profile: null, + oauth: getDefaultOauth(), + scopedTokens: getDefaultScopedTokens() + }, + getters: { + header: state => { + if (state.oauth.accessToken) { + return 'Bearer ' + state.oauth.accessToken + } + } + }, + mutations: { + reset (state) { + state.authenticated = false + state.profile = null + state.username = '' + state.fullUsername = '' + state.scopedTokens = getDefaultScopedTokens() + state.oauth = getDefaultOauth() + state.availablePermissions = { + settings: false, + library: false, + moderation: false + } + }, + profile: (state, value) => { + state.profile = value + }, + authenticated: (state, value) => { + state.authenticated = value + if (value === false) { + state.username = '' + state.fullUsername = '' + state.profile = null + state.scopedTokens = getDefaultScopedTokens() + state.availablePermissions = { + settings: false, + library: false, + moderation: false + } + } + }, + username: (state, value) => { + state.username = value + }, + fullUsername: (state, value) => { + state.fullUsername = value + }, + avatar: (state, value) => { + if (state.profile) { + state.profile.avatar = value + } + }, + scopedTokens: (state, value) => { + state.scopedTokens = { ...value } + }, + permission: (state, { key, status }: { key: Permission, status: boolean }) => { + state.availablePermissions[key] = status + }, + profilePartialUpdate: (state, payload: User) => { + if (!state.profile) { + state.profile = {} as User + } + + for (const [key, value] of Object.entries(payload)) { + state.profile[key as keyof User] = value as never + } + }, + oauthApp: (state, payload) => { + state.oauth.clientId = payload.client_id + state.oauth.clientSecret = payload.client_secret + }, + oauthToken: (state, payload) => { + state.oauth.accessToken = payload.access_token + state.oauth.refreshToken = payload.refresh_token + } + }, + actions: { + // Send a request to the login URL and save the returned JWT + async login ({ dispatch }, { credentials }) { + const form = useFormData(credentials) + await axios.post('users/login', form) + + logger.info('Successfully logged in as', credentials.username) + await dispatch('fetchUser') + }, + async logout ({ commit }) { + try { + await axios.post('users/logout') + } catch (error) { + console.log('Error while logging out, probably logged in via oauth') + } + const modules = [ + 'auth', + 'favorites', + 'player', + 'playlists', + 'queue', + 'radios' + ] + modules.forEach(m => { + commit(`${m}/reset`, null, { root: true }) + }) + logger.info('Log out, goodbye!') + }, + + async fetchNotifications ({ dispatch, state }) { + return Promise.all([ + dispatch('ui/fetchUnreadNotifications', null, { root: true }), + state.availablePermissions.library && dispatch('ui/fetchPendingReviewEdits', null, { root: true }), + state.availablePermissions.moderation && dispatch('ui/fetchPendingReviewReports', null, { root: true }), + state.availablePermissions.moderation && dispatch('ui/fetchPendingReviewRequests', null, { root: true }) + ]) + }, + async fetchUser ({ dispatch }) { + try { + const response = await axios.get('users/me/') + logger.info('Successfully fetched user profile') + + dispatch('updateUser', response.data) + + await Promise.all([ + dispatch('fetchNotifications'), + dispatch('favorites/fetch', null, { root: true }), + dispatch('playlists/fetchOwn', null, { root: true }), + dispatch('libraries/fetchFollows', null, { root: true }), + dispatch('channels/fetchSubscriptions', null, { root: true }), + dispatch('moderation/fetchContentFilters', null, { root: true }) + ]) + } catch (error) { + if ((error as BackendError).response?.status === 401) { + logger.info('User is not authenticated') + return + } + + logger.error('Error while fetching user profile', error) + } + }, + updateUser ({ commit }, data) { + commit('authenticated', true) + commit('profile', data) + commit('username', data.username) + commit('fullUsername', data.full_username) + + if (data.tokens) { + commit('scopedTokens', data.tokens) + } + + for (const [permission, hasPermission] of Object.entries(data.permissions)) { + // this makes it easier to check for permissions in templates + commit('permission', { key: permission, status: hasPermission }) + } + }, + async oauthLogin ({ state, rootState, commit }, next: RouteLocationRaw) { + const app = await createOauthApp() + commit('oauthApp', app) + const redirectUri = encodeURIComponent(`${location.origin}/auth/callback`) + const params = `response_type=code&scope=${encodeURIComponent(NEEDED_SCOPES)}&redirect_uri=${redirectUri}&state=${next}&client_id=${state.oauth.clientId}` + const authorizeUrl = `${rootState.instance.instanceUrl}authorize?${params}` + console.log('Redirecting user...', authorizeUrl) + window.location.href = authorizeUrl + }, + async handleOauthCallback ({ state, commit, dispatch }, authorizationCode) { + console.log('Fetching token...') + const payload = { + client_id: state.oauth.clientId, + client_secret: state.oauth.clientSecret, + grant_type: 'authorization_code', + code: authorizationCode, + redirect_uri: `${location.origin}/auth/callback` + } + const response = await axios.post( + 'oauth/token/', + useFormData(payload), + { headers: { 'Content-Type': 'multipart/form-data' } } + ) + commit('oauthToken', response.data) + await dispatch('fetchUser') + }, + async refreshOauthToken ({ state, commit }) { + const payload = { + client_id: state.oauth.clientId, + client_secret: state.oauth.clientSecret, + grant_type: 'refresh_token', + refresh_token: state.oauth.refreshToken + } + const response = await axios.post( + 'oauth/token/', + useFormData(payload), + { headers: { 'Content-Type': 'multipart/form-data' } } + ) + commit('oauthToken', response.data) + } + } +} + +export default store diff --git a/front/src/store/channels.js b/front/src/store/channels.js deleted file mode 100644 index d5189fa7f12e28e2c9493acba1aa27595c1699fd..0000000000000000000000000000000000000000 --- a/front/src/store/channels.js +++ /dev/null @@ -1,86 +0,0 @@ -import axios from 'axios' -import logger from '@/logging' - -export default { - namespaced: true, - state: { - subscriptions: [], - count: 0, - showUploadModal: false, - latestPublication: null, - uploadModalConfig: { - channel: null - } - }, - mutations: { - subscriptions: (state, { uuid, value }) => { - if (value) { - if (state.subscriptions.indexOf(uuid) === -1) { - state.subscriptions.push(uuid) - } - } else { - const i = state.subscriptions.indexOf(uuid) - if (i > -1) { - state.subscriptions.splice(i, 1) - } - } - state.count = state.subscriptions.length - }, - reset (state) { - state.subscriptions = [] - state.count = 0 - }, - showUploadModal (state, value) { - state.showUploadModal = value.show - if (value.config) { - state.uploadModalConfig = { - ...value.config - } - } - }, - publish (state, { uploads, channel }) { - state.latestPublication = { - date: new Date(), - uploads, - channel - } - state.showUploadModal = false - } - }, - getters: { - isSubscribed: (state) => (uuid) => { - return state.subscriptions.indexOf(uuid) > -1 - } - }, - actions: { - set ({ commit, state }, { uuid, value }) { - commit('subscriptions', { uuid, value }) - if (value) { - return axios.post(`channels/${uuid}/subscribe/`).then((response) => { - logger.default.info('Successfully subscribed to channel') - }, (response) => { - logger.default.info('Error while subscribing to channel') - commit('subscriptions', { uuid, value: !value }) - }) - } else { - return axios.post(`channels/${uuid}/unsubscribe/`).then((response) => { - logger.default.info('Successfully unsubscribed from channel') - }, (response) => { - logger.default.info('Error while unsubscribing from channel') - commit('subscriptions', { uuid, value: !value }) - }) - } - }, - toggle ({ getters, dispatch }, uuid) { - dispatch('set', { uuid, value: !getters.isSubscribed(uuid) }) - }, - fetchSubscriptions ({ dispatch, state, commit, rootState }, url) { - const promise = axios.get('subscriptions/all/') - return promise.then((response) => { - response.data.results.forEach(result => { - commit('subscriptions', { uuid: result.channel, value: true }) - }) - }) - } - } -} diff --git a/front/src/store/channels.ts b/front/src/store/channels.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4f72c9ad720c331a5d179974631bbc388e92e8b --- /dev/null +++ b/front/src/store/channels.ts @@ -0,0 +1,107 @@ +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' +import type { Channel, Upload } from '~/types' + +import axios from 'axios' +import useLogger from '~/composables/useLogger' + +export interface State { + subscriptions: string[] + count: number + showUploadModal: boolean + latestPublication: null | Publication + uploadModalConfig: { + channel: null | Channel + } +} + +interface Publication { + date: Date + uploads: Upload[] + channel: Channel +} + +const logger = useLogger() + +const store: Module<State, RootState> = { + namespaced: true, + state: { + subscriptions: [], + count: 0, + showUploadModal: false, + latestPublication: null, + uploadModalConfig: { + channel: null + } + }, + mutations: { + subscriptions: (state, { uuid, value }) => { + if (value) { + if (!state.subscriptions.includes(uuid)) { + state.subscriptions.push(uuid) + } + } else { + const index = state.subscriptions.indexOf(uuid) + if (index > -1) { + state.subscriptions.splice(index, 1) + } + } + + state.count = state.subscriptions.length + }, + reset (state) { + state.subscriptions.length = 0 + state.count = 0 + }, + showUploadModal (state, value) { + state.showUploadModal = value.show + if (value.config) { + state.uploadModalConfig = { + ...value.config + } + } + }, + publish (state, { uploads, channel }) { + state.latestPublication = { + date: new Date(), + uploads, + channel + } + state.showUploadModal = false + } + }, + getters: { + isSubscribed: (state) => (uuid: string) => state.subscriptions.includes(uuid) + }, + actions: { + set ({ commit }, { uuid, value }) { + commit('subscriptions', { uuid, value }) + if (value) { + return axios.post(`channels/${uuid}/subscribe/`).then(() => { + logger.info('Successfully subscribed to channel') + }, () => { + logger.info('Error while subscribing to channel') + commit('subscriptions', { uuid, value: !value }) + }) + } else { + return axios.post(`channels/${uuid}/unsubscribe/`).then(() => { + logger.info('Successfully unsubscribed from channel') + }, () => { + logger.info('Error while unsubscribing from channel') + commit('subscriptions', { uuid, value: !value }) + }) + } + }, + toggle ({ getters, dispatch }, uuid) { + dispatch('set', { uuid, value: !getters.isSubscribed(uuid) }) + }, + async fetchSubscriptions ({ commit }) { + const response = await axios.get('subscriptions/all/') + for (const result of response.data.results) { + commit('subscriptions', { uuid: result.channel, value: true }) + } + } + } +} + +export default store diff --git a/front/src/store/favorites.js b/front/src/store/favorites.js deleted file mode 100644 index 7dede7b583cdb7483e5bef5bc5aa15da1d7210c2..0000000000000000000000000000000000000000 --- a/front/src/store/favorites.js +++ /dev/null @@ -1,72 +0,0 @@ -import axios from 'axios' -import logger from '@/logging' - -export default { - namespaced: true, - state: { - tracks: [], - count: 0 - }, - mutations: { - track: (state, { id, value }) => { - if (value) { - if (state.tracks.indexOf(id) === -1) { - state.tracks.push(id) - } - } else { - const i = state.tracks.indexOf(id) - if (i > -1) { - state.tracks.splice(i, 1) - } - } - state.count = state.tracks.length - }, - reset (state) { - state.tracks = [] - state.count = 0 - } - }, - getters: { - isFavorite: (state) => (id) => { - return state.tracks.indexOf(id) > -1 - } - }, - actions: { - set ({ commit, state }, { id, value }) { - commit('track', { id, value }) - if (value) { - 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 { - 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') - commit('track', { id, value: !value }) - }) - } - }, - toggle ({ getters, dispatch }, id) { - dispatch('set', { id, value: !getters.isFavorite(id) }) - }, - fetch ({ dispatch, state, commit, rootState }, url) { - // will fetch favorites by batches from API to have them locally - const params = { - user: rootState.auth.profile.id, - page_size: 50, - ordering: '-creation_date' - } - const promise = axios.get('favorites/tracks/all/', { params: params }) - return promise.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/favorites.ts b/front/src/store/favorites.ts new file mode 100644 index 0000000000000000000000000000000000000000..e39f3f02f1094b3500577dcf461649f1f0310828 --- /dev/null +++ b/front/src/store/favorites.ts @@ -0,0 +1,85 @@ +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' + +import axios from 'axios' +import useLogger from '~/composables/useLogger' + +export interface State { + tracks: string[] + count: number +} + +const logger = useLogger() + +const store: Module<State, RootState> = { + namespaced: true, + state: { + tracks: [], + count: 0 + }, + mutations: { + track: (state, { id, value }) => { + if (value) { + if (!state.tracks.includes(id)) { + state.tracks.push(id) + } + } else { + const index = state.tracks.indexOf(id) + if (index > -1) { + state.tracks.splice(index, 1) + } + } + + state.count = state.tracks.length + }, + reset (state) { + state.tracks.length = 0 + state.count = 0 + } + }, + getters: { + isFavorite: (state) => (id: string) => { + return state.tracks.includes(id) + } + }, + actions: { + set ({ commit }, { id, value }) { + commit('track', { id, value }) + if (value) { + return axios.post('favorites/tracks/', { track: id }).then(() => { + logger.info('Successfully added track to favorites') + }, () => { + logger.info('Error while adding track to favorites') + commit('track', { id, value: !value }) + }) + } else { + return axios.post('favorites/tracks/remove/', { track: id }).then(() => { + logger.info('Successfully removed track from favorites') + }, () => { + logger.info('Error while removing track from favorites') + commit('track', { id, value: !value }) + }) + } + }, + toggle ({ getters, dispatch }, id) { + dispatch('set', { id, value: !getters.isFavorite(id) }) + }, + async fetch ({ commit, rootState }) { + // will fetch favorites by batches from API to have them locally + const params = { + user: rootState.auth.profile?.id, + page_size: 50, + ordering: '-creation_date' + } + + const response = await axios.get('favorites/tracks/all/', { params }) + logger.info('Fetched a batch of ' + response.data.results.length + ' favorites') + + for (const result of response.data.results) { + commit('track', { id: result.track, value: true }) + } + } + } +} + +export default store diff --git a/front/src/store/index.js b/front/src/store/index.ts similarity index 63% rename from front/src/store/index.js rename to front/src/store/index.ts index a7954420346599c335856b82980522dc6239730c..d890ad0bc569ec58f7adeaa7ca7a483e3bbceed2 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.ts @@ -1,34 +1,59 @@ -import Vue from 'vue' -import Vuex from 'vuex' -import createPersistedState from 'vuex-persistedstate' +import type { State as ModerationState } from './moderation' +import type { State as PlaylistsState } from './playlists' +import type { State as FavoritesState } from './favorites' +import type { State as LibrariesState } from './libraries' +import type { State as ChannelsState } from './channels' +import type { State as InstanceState } from './instance' +import type { State as RadiosState } from './radios' +import type { State as PlayerState } from './player' +import type { State as QueueState } from './queue' +import type { State as AuthState } from './auth' +import type { State as UiState } from './ui' +import type { InjectionKey } from 'vue' + +import { createStore, Store, useStore as baseUseStore } from 'vuex' +import createPersistedState from 'vuex-persistedstate' +import moderation from './moderation' +import playlists from './playlists' import favorites from './favorites' -import channels from './channels' import libraries from './libraries' -import auth from './auth' +import channels from './channels' import instance from './instance' -import moderation from './moderation' -import queue from './queue' import radios from './radios' import player from './player' -import playlists from './playlists' +import queue from './queue' +import auth from './auth' import ui from './ui' -Vue.use(Vuex) +export interface RootState { + moderation: ModerationState + playlists: PlaylistsState + favorites: FavoritesState + libraries: LibrariesState + channels: ChannelsState + instance: InstanceState + radios: RadiosState + player: PlayerState + queue: QueueState + auth: AuthState + ui: UiState +} -export default new Vuex.Store({ +export const key: InjectionKey<Store<RootState>> = Symbol('vuex state injection key') +export default createStore<RootState>({ modules: { - ui, - auth, - channels, - libraries, + moderation, + playlists, favorites, + libraries, + channels, instance, - moderation, - queue, radios, - playlists, - player + player, + queue, + auth, + ui }, plugins: [ createPersistedState({ @@ -40,11 +65,11 @@ export default new Vuex.Store({ }), createPersistedState({ key: 'instance', - paths: ['instance.events', 'instance.instanceUrl', 'instance.knownInstances'] + paths: ['instance.instanceUrl', 'instance.knownInstances'] }), createPersistedState({ key: 'ui', - paths: ['ui.currentLanguage', 'ui.selectedLanguage', 'ui.momentLocale', 'ui.theme', 'ui.routePreferences'] + paths: ['ui.currentLanguage', 'ui.selectedLanguage', 'ui.momentLocale', 'ui.routePreferences'] }), createPersistedState({ key: 'radios', @@ -72,7 +97,7 @@ export default new Vuex.Store({ return { queue: { currentIndex: state.queue.currentIndex, - tracks: state.queue.tracks.map(track => { + tracks: state.queue.tracks.map((track: any) => { // we keep only valuable fields to make the cache lighter and avoid // cyclic value serialization errors const artist = { @@ -86,7 +111,8 @@ export default new Vuex.Store({ mbid: track.mbid, uploads: track.uploads, listen_url: track.listen_url, - artist: artist + artist, + album: {} } if (track.album) { data.album = { @@ -94,7 +120,7 @@ export default new Vuex.Store({ title: track.album.title, mbid: track.album.mbid, cover: track.album.cover, - artist: artist + artist } } return data @@ -105,3 +131,7 @@ export default new Vuex.Store({ }) ] }) + +export const useStore = () => { + return baseUseStore(key) +} diff --git a/front/src/store/instance.js b/front/src/store/instance.js deleted file mode 100644 index 257727c746c579b011e0b8fae4ea1ddbd0b2b8a9..0000000000000000000000000000000000000000 --- a/front/src/store/instance.js +++ /dev/null @@ -1,175 +0,0 @@ -import axios from 'axios' -import logger from '@/logging' -import _ from 'lodash' - -function getDefaultUrl () { - return ( - window.location.protocol + '//' + window.location.hostname + - (window.location.port ? ':' + window.location.port : '') + '/' - ) -} - -function notifyServiceWorker (registration, message) { - if (registration && registration.active) { - registration.active.postMessage(message) - } -} - -export default { - namespaced: true, - state: { - maxEvents: 200, - frontSettings: {}, - instanceUrl: import.meta.env.VUE_APP_INSTANCE_URL, - events: [], - knownInstances: [], - nodeinfo: null, - settings: { - instance: { - name: { - value: '' - }, - short_description: { - value: '' - }, - long_description: { - value: '' - }, - funkwhale_support_message_enabled: { - value: true - }, - support_message: { - value: '' - } - }, - users: { - registration_enabled: { - value: true - }, - upload_quota: { - value: 0 - } - }, - moderation: { - signup_approval_enabled: { - value: false - }, - signup_form_customization: { value: null } - }, - subsonic: { - enabled: { - value: true - } - } - } - }, - mutations: { - settings: (state, value) => { - _.merge(state.settings, value) - }, - event: (state, value) => { - state.events.unshift(value) - if (state.events.length > state.maxEvents) { - state.events = state.events.slice(0, state.maxEvents) - } - }, - events: (state, value) => { - state.events = value - }, - nodeinfo: (state, value) => { - state.nodeinfo = value - }, - frontSettings: (state, value) => { - state.frontSettings = value - }, - instanceUrl: (state, value) => { - if (value && !value.endsWith('/')) { - value = value + '/' - } - state.instanceUrl = value - notifyServiceWorker(state.registration, { command: 'serverChosen', serverUrl: state.instanceUrl }) - // append the URL to the list (and remove existing one if needed) - if (value) { - const index = state.knownInstances.indexOf(value) - if (index > -1) { - state.knownInstances.splice(index, 1) - } - state.knownInstances.splice(0, 0, value) - } - if (!value) { - axios.defaults.baseURL = null - return - } - const suffix = 'api/v1/' - axios.defaults.baseURL = state.instanceUrl + suffix - } - }, - getters: { - defaultUrl: (state) => () => { - return getDefaultUrl() - }, - absoluteUrl: (state) => (relativeUrl) => { - if (relativeUrl.startsWith('http')) { - return relativeUrl - } - if (state.instanceUrl.endsWith('/') && relativeUrl.startsWith('/')) { - relativeUrl = relativeUrl.slice(1) - } - - const instanceUrl = state.instanceUrl || getDefaultUrl() - return instanceUrl + relativeUrl - }, - domain: (state) => { - const url = state.instanceUrl - const parser = document.createElement('a') - parser.href = url - return parser.hostname - }, - appDomain: (state) => { - return location.hostname - } - }, - actions: { - setUrl ({ commit, dispatch }, url) { - commit('instanceUrl', url) - const modules = [ - 'auth', - 'favorites', - 'moderation', - 'player', - 'playlists', - 'queue', - 'radios' - ] - modules.forEach(m => { - commit(`${m}/reset`, null, { root: true }) - }) - }, - // Send a request to the login URL and save the returned JWT - fetchSettings ({ commit }, payload) { - return axios.get('instance/settings/').then(response => { - logger.default.info('Successfully fetched instance settings') - const sections = {} - response.data.forEach(e => { - sections[e.section] = {} - }) - response.data.forEach(e => { - sections[e.section][e.name] = e - }) - commit('settings', sections) - if (payload && payload.callback) { - payload.callback() - } - }, response => { - logger.default.error('Error while fetching settings', response.data) - }) - }, - fetchFrontSettings ({ commit }) { - return axios.get('/settings.json').then(response => { - commit('frontSettings', response.data) - }, response => { - logger.default.error('Error when fetching front-end configuration (or no customization available)') - }) - } - } -} diff --git a/front/src/store/instance.ts b/front/src/store/instance.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e527649b79385bff23f1ab44f7a7dbf161629ef --- /dev/null +++ b/front/src/store/instance.ts @@ -0,0 +1,201 @@ +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' + +import axios from 'axios' +import { merge } from 'lodash-es' +import useLogger from '~/composables/useLogger' + +export interface State { + frontSettings: FrontendSettings + instanceUrl?: string + knownInstances: string[] + nodeinfo: unknown | null + settings: Settings +} + +interface FrontendSettings { + defaultServerUrl: string + additionalStylesheets: string[] +} + +interface InstanceSettings { + name: { value: string } + short_description: { value: string } + long_description: { value: string } + funkwhale_support_message_enabled: { value: boolean } + support_message: { value: string } +} + +interface UsersSettings { + registration_enabled: { value: boolean } + upload_quota: { value: number } +} + +interface ModerationSettings { + signup_approval_enabled: { value: boolean } + signup_form_customization: { value: null } +} + +interface SubsonicSettings { + enabled: { value: boolean } +} + +interface Settings { + instance: InstanceSettings + users: UsersSettings + moderation: ModerationSettings + subsonic: SubsonicSettings +} + +const logger = useLogger() + +// We have several way to guess the API server url. By order of precedence: +// 1. use the url provided in settings.json, if any +// 2. use the url specified when building via VUE_APP_INSTANCE_URL +// 3. use the current url +const instanceUrl = import.meta.env.VUE_APP_INSTANCE_URL as string ?? location.origin + +const store: Module<State, RootState> = { + namespaced: true, + state: { + frontSettings: { + defaultServerUrl: instanceUrl, + additionalStylesheets: [] + }, + instanceUrl, + knownInstances: [], + nodeinfo: null, + settings: { + instance: { + name: { + value: '' + }, + short_description: { + value: '' + }, + long_description: { + value: '' + }, + funkwhale_support_message_enabled: { + value: true + }, + support_message: { + value: '' + } + }, + users: { + registration_enabled: { + value: true + }, + upload_quota: { + value: 0 + } + }, + moderation: { + signup_approval_enabled: { + value: false + }, + signup_form_customization: { value: null } + }, + subsonic: { + enabled: { + value: true + } + } + } + }, + mutations: { + settings: (state, value) => { + merge(state.settings, value) + }, + nodeinfo: (state, value) => { + state.nodeinfo = value + }, + instanceUrl: (state, value) => { + if (value && !value.endsWith('/')) { + value = value + '/' + } + + state.instanceUrl = value + + // append the URL to the list (and remove existing one if needed) + if (value) { + const index = state.knownInstances.indexOf(value) + if (index > -1) { + state.knownInstances.splice(index, 1) + } + state.knownInstances.splice(0, 0, value) + } + + if (!value) { + axios.defaults.baseURL = undefined + return + } + const suffix = 'api/v1/' + axios.defaults.baseURL = state.instanceUrl + suffix + } + }, + getters: { + absoluteUrl: (state) => (relativeUrl: string) => { + if (relativeUrl.startsWith('http')) return relativeUrl + if (state.instanceUrl?.endsWith('/') && relativeUrl.startsWith('/')) { + relativeUrl = relativeUrl.slice(1) + } + + return (state.instanceUrl ?? instanceUrl) + relativeUrl + }, + domain: (state) => new URL(state.instanceUrl ?? instanceUrl).hostname, + defaultInstance: () => instanceUrl + }, + actions: { + setUrl ({ commit }, url) { + commit('instanceUrl', url) + const modules = [ + 'auth', + 'favorites', + 'moderation', + 'player', + 'playlists', + 'queue', + 'radios' + ] + modules.forEach(m => { + commit(`${m}/reset`, null, { root: true }) + }) + }, + async fetchSettings ({ commit }) { + const response = await axios.get('instance/settings/') + .catch(err => logger.error('Error while fetching settings', err.response.data)) + + if (!response) return + + logger.info('Successfully fetched instance settings') + + type SettingsSection = { section: string, name: string } + const sections = response.data.reduce((map: Record<string, Record<string, SettingsSection>>, entry: SettingsSection) => { + map[entry.section] ??= {} + map[entry.section][entry.name] = entry + return map + }, {}) + + commit('settings', sections) + }, + async fetchFrontSettings ({ state }) { + const response = await axios.get(`${import.meta.env.BASE_URL}settings.json`) + .catch(() => logger.error('Error when fetching front-end configuration (or no customization available)')) + + if (!response) return + + for (const [key, value] of Object.entries(response.data as FrontendSettings)) { + if (key === 'defaultServerUrl' && !value) { + state.frontSettings.defaultServerUrl = instanceUrl + continue + } + + state.frontSettings[key as keyof FrontendSettings] = value + } + } + } +} + +export default store diff --git a/front/src/store/libraries.js b/front/src/store/libraries.js deleted file mode 100644 index 5e002f36c9274817d8517f6247b7b3f9713f39c0..0000000000000000000000000000000000000000 --- a/front/src/store/libraries.js +++ /dev/null @@ -1,73 +0,0 @@ -import axios from 'axios' -import logger from '@/logging' - -export default { - namespaced: true, - state: { - followedLibraries: [], - followsByLibrary: {}, - count: 0 - }, - mutations: { - follows: (state, { library, follow }) => { - const replacement = { ...state.followsByLibrary } - if (follow) { - if (state.followedLibraries.indexOf(library) === -1) { - state.followedLibraries.push(library) - replacement[library] = follow - } - } else { - const i = state.followedLibraries.indexOf(library) - if (i > -1) { - state.followedLibraries.splice(i, 1) - replacement[library] = null - } - } - state.followsByLibrary = replacement - state.count = state.followedLibraries.length - }, - reset (state) { - state.followedLibraries = [] - state.followsByLibrary = {} - state.count = 0 - } - }, - getters: { - follow: (state) => (library) => { - return state.followsByLibrary[library] - } - }, - actions: { - set ({ commit, state }, { uuid, value }) { - if (value) { - return axios.post('federation/follows/library/', { target: uuid }).then((response) => { - logger.default.info('Successfully subscribed to library') - commit('follows', { library: uuid, follow: response.data }) - }, (response) => { - logger.default.info('Error while subscribing to library') - commit('follows', { library: uuid, follow: null }) - }) - } else { - const follow = state.followsByLibrary[uuid] - return axios.delete(`federation/follows/library/${follow.uuid}/`).then((response) => { - logger.default.info('Successfully unsubscribed from library') - commit('follows', { library: uuid, follow: null }) - }, (response) => { - logger.default.info('Error while unsubscribing from library') - commit('follows', { library: uuid, follow: follow }) - }) - } - }, - toggle ({ getters, dispatch }, uuid) { - dispatch('set', { uuid, value: !getters.follow(uuid) }) - }, - fetchFollows ({ dispatch, state, commit, rootState }, url) { - const promise = axios.get('federation/follows/library/all/') - return promise.then((response) => { - response.data.results.forEach(result => { - commit('follows', { library: result.library, follow: result }) - }) - }) - } - } -} diff --git a/front/src/store/libraries.ts b/front/src/store/libraries.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f3137be36441cc7e0804857a5ab9f72e22d7064 --- /dev/null +++ b/front/src/store/libraries.ts @@ -0,0 +1,79 @@ +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' + +import axios from 'axios' +import useLogger from '~/composables/useLogger' + +export interface State { + followsByLibrary: { + [key: string]: Library + } + count: number +} + +interface Library { + uuid: string +} + +const logger = useLogger() + +const store: Module<State, RootState> = { + namespaced: true, + state: { + followsByLibrary: {}, + count: 0 + }, + mutations: { + follows: (state, { library, follow }) => { + if (follow) { + state.followsByLibrary[library] = follow + } else { + delete state.followsByLibrary[library] + } + + state.count = Object.keys(state.followsByLibrary).length + }, + reset (state) { + state.followsByLibrary = {} + state.count = 0 + } + }, + getters: { + follow: (state) => (library: string) => { + return state.followsByLibrary[library] + } + }, + actions: { + set ({ commit, state }, { uuid, value }) { + if (value) { + return axios.post('federation/follows/library/', { target: uuid }).then((response) => { + logger.info('Successfully subscribed to library') + commit('follows', { library: uuid, follow: response.data }) + }, () => { + logger.info('Error while subscribing to library') + commit('follows', { library: uuid, follow: null }) + }) + } else { + const follow = state.followsByLibrary[uuid] + return axios.delete(`federation/follows/library/${follow.uuid}/`).then(() => { + logger.info('Successfully unsubscribed from library') + commit('follows', { library: uuid, follow: null }) + }, () => { + logger.info('Error while unsubscribing from library') + commit('follows', { library: uuid, follow }) + }) + } + }, + toggle ({ getters, dispatch }, uuid) { + dispatch('set', { uuid, value: !getters.follow(uuid) }) + }, + async fetchFollows ({ dispatch, state, commit, rootState }, url) { + const response = await axios.get('federation/follows/library/all/') + for (const result of response.data.results) { + commit('follows', { library: result.library, follow: result }) + } + } + } +} + +export default store diff --git a/front/src/store/moderation.js b/front/src/store/moderation.js deleted file mode 100644 index 4e507ee85e472544de455f72e43f7150e9ed6772..0000000000000000000000000000000000000000 --- a/front/src/store/moderation.js +++ /dev/null @@ -1,116 +0,0 @@ -import axios from 'axios' -import logger from '@/logging' -import _ from 'lodash' - -export default { - namespaced: true, - state: { - filters: [], - showFilterModal: false, - showReportModal: false, - lastUpdate: new Date(), - filterModalTarget: { - type: null, - target: null - }, - reportModalTarget: { - type: null, - target: null - } - }, - mutations: { - filterModalTarget (state, value) { - state.filterModalTarget = value - }, - reportModalTarget (state, value) { - state.reportModalTarget = value - }, - empty (state) { - state.filters = [] - }, - lastUpdate (state, value) { - state.lastUpdate = value - }, - contentFilter (state, value) { - state.filters.push(value) - }, - showFilterModal (state, value) { - state.showFilterModal = value - if (!value) { - state.filterModalTarget = { - type: null, - target: null - } - } - }, - showReportModal (state, value) { - state.showReportModal = value - if (!value) { - state.reportModalTarget = { - type: null, - target: null - } - } - }, - reset (state) { - state.filters = [] - state.filterModalTarget = null - state.showFilterModal = false - state.showReportModal = false - state.reportModalTarget = {} - }, - deleteContentFilter (state, uuid) { - state.filters = state.filters.filter((e) => { - return e.uuid !== uuid - }) - } - }, - getters: { - artistFilters: (state) => () => { - const f = state.filters.filter((f) => { - return f.target.type === 'artist' - }) - const p = _.sortBy(f, [(e) => { return e.creation_date }]) - p.reverse() - return p - } - }, - actions: { - hide ({ commit }, payload) { - commit('filterModalTarget', payload) - commit('showFilterModal', true) - }, - report ({ commit }, payload) { - commit('reportModalTarget', payload) - commit('showReportModal', true) - }, - fetchContentFilters ({ dispatch, state, commit, rootState }, url) { - let params = {} - let promise - if (url) { - promise = axios.get(url) - } else { - commit('empty') - params = { - page_size: 100, - ordering: '-creation_date' - } - promise = axios.get('moderation/content-filters/', { params: params }) - } - return promise.then((response) => { - logger.default.info('Fetched a batch of ' + response.data.results.length + ' filters') - if (response.data.next) { - dispatch('fetchContentFilters', response.data.next) - } - response.data.results.forEach(result => { - commit('contentFilter', result) - }) - }) - }, - deleteContentFilter ({ commit }, uuid) { - return axios.delete(`moderation/content-filters/${uuid}/`).then((response) => { - commit('deleteContentFilter', uuid) - }) - } - } -} diff --git a/front/src/store/moderation.ts b/front/src/store/moderation.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cd35a217575bd20e35f1c6a11aaf74147a01501 --- /dev/null +++ b/front/src/store/moderation.ts @@ -0,0 +1,157 @@ +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' + +import axios from 'axios' +import { sortBy } from 'lodash-es' +import useLogger from '~/composables/useLogger' + +export interface State { + filters: ContentFilter[] + showFilterModal: boolean + showReportModal: boolean + lastUpdate: Date, + filterModalTarget: { + type: null + target: null | { id: string, name: string } + } + reportModalTarget: { + type: null | 'channel' + target: null + typeLabel: string + label: string + _obj?: { + fid?: string + actor?: { fid: string } + } + } +} + +export interface ContentFilter { + uuid: string + creation_date: Date + target: { + type: 'artist' + id: number + } +} + +const logger = useLogger() + +const store: Module<State, RootState> = { + namespaced: true, + state: { + filters: [], + showFilterModal: false, + showReportModal: false, + lastUpdate: new Date(), + filterModalTarget: { + type: null, + target: null + }, + reportModalTarget: { + type: null, + target: null, + typeLabel: '', + label: '' + } + }, + mutations: { + filterModalTarget (state, value) { + state.filterModalTarget = value + }, + reportModalTarget (state, value) { + state.reportModalTarget = value + }, + empty (state) { + state.filters = [] + }, + contentFilter (state, value) { + state.filters.push(value) + }, + showFilterModal (state, value) { + state.showFilterModal = value + if (!value) { + state.filterModalTarget = { + type: null, + target: null + } + } + }, + showReportModal (state, value) { + state.showReportModal = value + if (!value) { + state.reportModalTarget = { + type: null, + target: null, + typeLabel: '', + label: '' + } + } + }, + reset (state) { + state.filters = [] + state.filterModalTarget = { + type: null, + target: null + } + state.showFilterModal = false + state.showReportModal = false + state.reportModalTarget = { + type: null, + target: null, + typeLabel: '', + label: '' + } + }, + deleteContentFilter (state, uuid) { + state.filters = state.filters.filter((e) => { + return e.uuid !== uuid + }) + } + }, + getters: { + artistFilters: (state) => () => { + const filters = state.filters.filter((filter) => filter.target.type === 'artist') + const sorted = sortBy(filters, [(e) => { return e.creation_date }]) + return sorted.reverse() + } + }, + actions: { + hide ({ commit }, payload) { + commit('filterModalTarget', payload) + commit('showFilterModal', true) + }, + report ({ commit }, payload) { + commit('reportModalTarget', payload) + commit('showReportModal', true) + }, + async fetchContentFilters ({ dispatch, commit }, url) { + const params = url + ? {} + : { + page_size: 100, + ordering: '-creation_date' + } + + if (!url) commit('empty') + const response = await axios.get(url ?? 'moderation/content-filters/', { params }) + + logger.info(`Fetched a batch of ${response.data.results.length} filters`) + + for (const result of response.data.results) { + commit('contentFilter', result) + } + + if (response.data.next) { + await dispatch('fetchContentFilters', response.data.next) + } + }, + async deleteContentFilter ({ commit }, uuid) { + return axios.delete(`moderation/content-filters/${uuid}/`).then(() => { + commit('deleteContentFilter', uuid) + }) + } + } +} + +export default store diff --git a/front/src/store/player.js b/front/src/store/player.ts similarity index 75% rename from front/src/store/player.js rename to front/src/store/player.ts index 1129ef5d4a9e95774cdd67ec6f27e7428217516a..db7f68f7c5657dd2db8636ec5f2d5146c59ad047 100644 --- a/front/src/store/player.js +++ b/front/src/store/player.ts @@ -1,8 +1,27 @@ +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' + import axios from 'axios' -import logger from '@/logging' -import time from '@/utils/time' +import time from '~/utils/time' +import useLogger from '~/composables/useLogger' + +export interface State { + maxConsecutiveErrors: number + errorCount: number + playing: boolean + isLoadingAudio: boolean + volume: number + tempVolume: number + duration: number + currentTime: number + errored: boolean + bufferProgress: number + looping: 0 | 1 | 2 // 0 -> no, 1 -> on track, 2 -> on queue +} -export default { +const logger = useLogger() + +const store: Module<State, RootState> = { namespaced: true, state: { maxConsecutiveErrors: 5, @@ -15,7 +34,7 @@ export default { currentTime: 0, errored: false, bufferProgress: 0, - looping: 0 // 0 -> no, 1 -> on track, 2 -> on queue + looping: 0 }, mutations: { reset (state) { @@ -77,18 +96,13 @@ export default { }, getters: { durationFormatted: state => { - let duration = parseInt(state.duration) - if (duration % 1 !== 0) { - return time.parse(0) - } - duration = Math.round(state.duration) - return time.parse(duration) + return time.parse(Math.round(state.duration)) }, currentTimeFormatted: state => { return time.parse(Math.round(state.currentTime)) }, progress: state => { - return Math.round((state.currentTime / state.duration * 100) * 10) / 10 + return Math.min(state.currentTime / state.duration * 100, 100) } }, actions: { @@ -109,14 +123,14 @@ export default { }, 3000) } }, - resumePlayback ({ commit, state, dispatch }) { + async resumePlayback ({ commit, state, dispatch }) { commit('playing', true) if (state.errored && state.errorCount < state.maxConsecutiveErrors) { - setTimeout(() => { - if (state.playing) { - dispatch('queue/next', null, { root: true }) - } - }, 3000) + // TODO (wvffle): Cancel whenever we skip track + await new Promise(resolve => setTimeout(resolve, 3000)) + if (state.playing) { + return dispatch('queue/next', null, { root: true }) + } } }, pausePlayback ({ commit }) { @@ -130,15 +144,16 @@ export default { commit('volume', state.tempVolume) } }, - trackListened ({ commit, rootState }, track) { + trackListened ({ rootState }, track) { if (!rootState.auth.authenticated) { return } - return axios.post('history/listenings/', { track: track.id }).then((response) => {}, (response) => { - logger.default.error('Could not record track in history') + + return axios.post('history/listenings/', { track: track.id }).catch((error) => { + logger.error('Could not record track in history', error) }) }, - trackEnded ({ commit, dispatch, rootState }, track) { + trackEnded ({ commit, dispatch, rootState }) { const queueState = rootState.queue if (queueState.currentIndex === queueState.tracks.length - 1) { // we've reached last track of queue, trigger a reload @@ -163,8 +178,8 @@ export default { }, 3000) } }, - updateProgress ({ commit }, t) { - commit('currentTime', t) + updateProgress ({ commit }, time: number) { + commit('currentTime', time) }, mute ({ commit, state }) { commit('tempVolume', state.volume) @@ -175,3 +190,5 @@ export default { } } } + +export default store diff --git a/front/src/store/playlists.js b/front/src/store/playlists.ts similarity index 51% rename from front/src/store/playlists.js rename to front/src/store/playlists.ts index 1c55a71b5e8de5ac936082b37abed02a81560746..87aad86fb4d6228891873122c0273e2c51ce0d50 100644 --- a/front/src/store/playlists.js +++ b/front/src/store/playlists.ts @@ -1,6 +1,16 @@ +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' +import type { Playlist, Track } from '~/types' + import axios from 'axios' -export default { +export interface State { + playlists: Playlist[] + showModal: boolean + modalTrack: null | Track +} + +const store: Module<State, RootState> = { namespaced: true, state: { playlists: [], @@ -11,9 +21,11 @@ export default { playlists (state, value) { state.playlists = value }, - chooseTrack (state, value) { - state.showModal = true - state.modalTrack = value + chooseTrack (state, value: Track | null) { + if (value !== null) { + state.showModal = true + state.modalTrack = value + } }, showModal (state, value) { state.showModal = value @@ -26,18 +38,20 @@ export default { }, actions: { async fetchOwn ({ commit, rootState }) { - const userId = rootState.auth.profile.id - if (!userId) { - return - } - let playlists = [] + const userId = rootState.auth.profile?.id + if (!userId) return + + const playlists = [] let url = 'playlists/' - while (url != null) { + while (url !== null) { const response = await axios.get(url, { params: { scope: 'me' } }) - playlists = [...playlists, ...response.data.results] + playlists.push(...response.data.results) url = response.data.next } + commit('playlists', playlists) } } } + +export default store diff --git a/front/src/store/queue.js b/front/src/store/queue.ts similarity index 67% rename from front/src/store/queue.js rename to front/src/store/queue.ts index ca0997997efa155d113bfd99f4aded5d415e7339..06e1c83ff4c3538447f2fc8aef1d16939ada6b4c 100644 --- a/front/src/store/queue.js +++ b/front/src/store/queue.ts @@ -1,7 +1,19 @@ -import logger from '@/logging' -import _ from 'lodash' +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' +import type { Track } from '~/types' -export default { +import { shuffle } from 'lodash-es' +import useLogger from '~/composables/useLogger' + +export interface State { + tracks: Track[] + currentIndex: number + ended: boolean +} + +const logger = useLogger() + +const store: Module<State, RootState> = { namespaced: true, state: { tracks: [], @@ -10,7 +22,7 @@ export default { }, mutations: { reset (state) { - state.tracks = [] + state.tracks.length = 0 state.currentIndex = -1 state.ended = true }, @@ -26,21 +38,23 @@ export default { tracks (state, value) { state.tracks = value }, - insert (state, { track, index }) { - state.tracks.splice(index, 0, track) - }, - reorder (state, { tracks, oldIndex, newIndex }) { + reorder (state, { oldIndex, newIndex }) { // called when the user uses drag / drop to reorder // tracks in queue - state.tracks = tracks + + const [track] = state.tracks.splice(oldIndex, 1) + state.tracks.splice(newIndex, 0, track) + if (oldIndex === state.currentIndex) { state.currentIndex = newIndex return } + if (oldIndex < state.currentIndex && newIndex >= state.currentIndex) { // item before was moved after state.currentIndex -= 1 } + if (oldIndex > state.currentIndex && newIndex <= state.currentIndex) { // item after was moved before state.currentIndex += 1 @@ -60,47 +74,43 @@ export default { isEmpty: state => state.tracks.length === 0 }, actions: { - append ({ commit, state, dispatch }, { track, index }) { - index = index || state.tracks.length - if (index > state.tracks.length - 1) { + append ({ dispatch, state }, { track, index = state.tracks.length }) { + return dispatch('appendMany', { tracks: [track], index }) + }, + + appendMany ({ state, dispatch }, { tracks, index = state.tracks.length }) { + logger.info( + 'Enqueueing tracks', + tracks.map((track: Track) => [track.artist?.name, track.title].join(' - ')) + ) + + const shouldPlay = state.tracks.length === 0 + if (shouldPlay) { + index = 0 + state.currentIndex = 0 + } + + if (index >= state.tracks.length) { // we simply push to the end - commit('insert', { track, index: state.tracks.length }) + state.tracks.push(...tracks) } else { // we insert the track at given position - commit('insert', { track, index }) + state.tracks.splice(index, 0, ...tracks) } - }, - appendMany ({ state, commit, dispatch }, { tracks, index, callback }) { - logger.default.info('Appending many tracks to the queue', tracks.map(e => { return e.title })) - let shouldPlay = false - if (state.tracks.length === 0) { - index = 0 - shouldPlay = true - } else { - index = index || state.tracks.length + if (shouldPlay) { + return dispatch('next') } - const total = tracks.length - tracks.forEach((t, i) => { - const p = dispatch('append', { track: t, index: index }) - index += 1 - if (callback && i + 1 === total) { - p.then(callback) - } - if (shouldPlay && p && i + 1 === total) { - p.then(() => { - dispatch('next') - }) - } - }) }, cleanTrack ({ state, dispatch, commit }, index) { // are we removing current playin track const current = index === state.currentIndex + if (current) { dispatch('player/stop', null, { root: true }) } + commit('splice', { start: index, size: 1 }) if (index < state.currentIndex) { commit('currentIndex', state.currentIndex - 1) @@ -114,6 +124,7 @@ export default { // we play next track, which now have the same index commit('currentIndex', index) } + if (state.currentIndex + 1 === state.tracks.length) { dispatch('radios/populateQueue', null, { root: true }) } @@ -128,11 +139,11 @@ export default { }, next ({ state, dispatch, commit, rootState }) { if (rootState.player.looping === 2 && state.currentIndex >= state.tracks.length - 1) { - logger.default.info('Going back to the beginning of the queue') + logger.info('Going back to the beginning of the queue') return dispatch('currentIndex', 0) } else { if (state.currentIndex < state.tracks.length - 1) { - logger.default.debug('Playing next track') + logger.debug('Playing next track') return dispatch('currentIndex', state.currentIndex + 1) } else { commit('ended', true) @@ -140,33 +151,33 @@ export default { } }, last ({ state, dispatch }) { - dispatch('currentIndex', state.tracks.length - 1) + return dispatch('currentIndex', state.tracks.length - 1) }, currentIndex ({ commit, state, rootState, dispatch }, index) { commit('ended', false) commit('player/currentTime', 0, { root: true }) commit('currentIndex', index) + if (state.tracks.length - index <= 2 && rootState.radios.running) { - dispatch('radios/populateQueue', null, { root: true }) + return dispatch('radios/populateQueue', null, { root: true }) } }, - clean ({ dispatch, commit }) { + clean ({ dispatch, commit, state }) { dispatch('radios/stop', null, { root: true }) dispatch('player/stop', null, { root: true }) - commit('tracks', []) + state.tracks.length = 0 dispatch('currentIndex', -1) // so we replay automatically on next track append commit('ended', true) }, - async shuffle ({ dispatch, commit, state }, callback) { - const shuffled = _.shuffle(state.tracks) - commit('tracks', []) - const params = { tracks: shuffled } - if (callback) { - params.callback = callback - } - await dispatch('appendMany', params) + async shuffle ({ dispatch, state }) { + const shuffled = shuffle(state.tracks) + state.tracks.length = 0 + + await dispatch('appendMany', { tracks: shuffled }) await dispatch('currentIndex', 0) } } } + +export default store diff --git a/front/src/store/radios.js b/front/src/store/radios.ts similarity index 54% rename from front/src/store/radios.js rename to front/src/store/radios.ts index 6bc5834a434f2c1efb77f730dbc1bde8f2dfe693..b92f2350338bc2f2925b098e24bff606fafa234c 100644 --- a/front/src/store/radios.js +++ b/front/src/store/radios.ts @@ -1,16 +1,47 @@ +import type { Dispatch, Module } from 'vuex' +import type { RootState } from '~/store/index' + import axios from 'axios' -import logger from '@/logging' +import { CLIENT_RADIOS } from '~/utils/clientRadios' +import useLogger from '~/composables/useLogger' + +export interface State { + current: null | CurrentRadio + running: boolean +} + +export interface ObjectId { + username: string + fullUsername: string +} + +export interface CurrentRadio { + clientOnly: boolean + session: null + type: 'account' + customRadioId: number + config: RadioConfig + objectId: ObjectId | null +} -import { getClientOnlyRadio } from '@/radios' +export type RadioConfig = { type: 'tag', names: string[] } | { type: 'artist', ids: string[] } -export default { +export interface PopulateQueuePayload { + current: CurrentRadio + playNow: boolean + dispatch: Dispatch +} + +const logger = useLogger() + +const store: Module<State, RootState> = { namespaced: true, state: { current: null, running: false }, getters: { - types: state => { + types: () => { return { 'actor-content': { name: 'Your content', @@ -38,7 +69,7 @@ export default { mutations: { reset (state) { state.running = false - state.current = false + state.current = null }, current: (state, value) => { state.current = value @@ -53,56 +84,63 @@ export default { radio_type: type, related_object_id: objectId, custom_radio: customRadioId, - config: config + config } + if (clientOnly) { commit('current', { type, objectId, customRadioId, clientOnly, config }) commit('running', true) dispatch('populateQueue', true) return } + return axios.post('radios/sessions/', params).then((response) => { - logger.default.info('Successfully started radio ', type) + logger.info('Successfully started radio ', type) commit('current', { type, objectId, session: response.data.id, customRadioId }) commit('running', true) dispatch('populateQueue', true) - }, (response) => { - logger.default.error('Error while starting radio', type) + }, () => { + logger.error('Error while starting radio', type) }) }, stop ({ commit, state }) { - if (state.current && state.current.clientOnly) { - getClientOnlyRadio(state.current).stop() + if (state.current?.clientOnly) { + CLIENT_RADIOS[state.current.type].stop() } commit('current', null) commit('running', false) }, - populateQueue ({ commit, rootState, state, dispatch }, playNow) { + async populateQueue ({ commit, rootState, state, dispatch }, playNow) { if (!state.running) { return } + if (rootState.player.errorCount >= rootState.player.maxConsecutiveErrors - 1) { return } - const params = { - session: state.current.session - } - if (state.current.clientOnly) { - return getClientOnlyRadio(state.current).populateQueue({ current: state.current, dispatch, state, rootState, playNow }) + + const params = { session: state.current?.session } + + if (state.current?.clientOnly) { + return CLIENT_RADIOS[state.current.type].populateQueue({ current: state.current, dispatch, playNow }) } - return axios.post('radios/tracks/', params).then((response) => { - logger.default.info('Adding track to queue from radio') - const append = dispatch('queue/append', { track: response.data.track }, { root: true }) + + try { + const response = await axios.post('radios/tracks/', params) + + logger.info('Adding track to queue from radio') + await dispatch('queue/append', { track: response.data.track }, { root: true }) + if (playNow) { - append.then(() => { - dispatch('queue/last', null, { root: true }) - }) + await dispatch('queue/last', null, { root: true }) + await dispatch('player/resumePlayback', null, { root: true }) } - }, () => { - logger.default.error('Error while adding track to queue from radio') + } catch (error) { + logger.error('Error while adding track to queue from radio', error) commit('reset') - }) + } } } - } + +export default store diff --git a/front/src/store/ui.js b/front/src/store/ui.js deleted file mode 100644 index de77b8bba3a588952d23c27293528d9857bc52c7..0000000000000000000000000000000000000000 --- a/front/src/store/ui.js +++ /dev/null @@ -1,382 +0,0 @@ -import axios from 'axios' -import moment from 'moment' - -export default { - namespaced: true, - state: { - currentLanguage: 'en_US', - selectedLanguage: false, - queueFocused: null, - momentLocale: 'en', - lastDate: new Date(), - maxMessages: 100, - messageDisplayDuration: 5 * 1000, - supportedExtensions: ['flac', 'ogg', 'mp3', 'opus', 'aac', 'm4a', 'aiff', 'aif'], - messages: [], - theme: 'system', - window: { - height: 0, - width: 0 - }, - notifications: { - inbox: 0, - pendingReviewEdits: 0, - pendingReviewReports: 0, - pendingReviewRequests: 0 - }, - websocketEventsHandlers: { - 'inbox.item_added': {}, - 'import.status_updated': {}, - 'mutation.created': {}, - 'mutation.updated': {}, - 'report.created': {}, - 'user_request.created': {}, - Listen: {} - }, - pageTitle: null, - routePreferences: { - 'library.albums.browse': { - paginateBy: 25, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.artists.browse': { - paginateBy: 30, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.podcasts.browse': { - paginateBy: 30, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.radios.browse': { - paginateBy: 12, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.playlists.browse': { - paginateBy: 25, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.albums.me': { - paginateBy: 25, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.artists.me': { - paginateBy: 30, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.radios.me': { - paginateBy: 12, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.playlists.me': { - paginateBy: 25, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'content.libraries.files': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.detail.upload': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.detail.edit': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'library.detail': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - favorites: { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.channels': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.library.tags': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.library.uploads': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.library.libraries': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.library.tracks': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.library.albums': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.library.artists': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.library.edits': { - paginateBy: 25, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.users.users.list': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.users.invitations.list': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.moderation.accounts.list': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.moderation.domains.list': { - paginateBy: 50, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.moderation.requests.list': { - paginateBy: 25, - orderingDirection: '-', - ordering: 'creation_date' - }, - 'manage.moderation.reports.list': { - paginateBy: 25, - orderingDirection: '-', - ordering: 'creation_date' - } - }, - serviceWorker: { - refreshing: false, - registration: null, - updateAvailable: false - } - }, - getters: { - showInstanceSupportMessage: (state, getters, rootState) => { - if (!rootState.auth.profile) { - return false - } - if (!rootState.instance.settings.instance.support_message.value) { - return false - } - const displayDate = rootState.auth.profile.instance_support_message_display_date - if (!displayDate) { - return false - } - return moment(displayDate) < moment(state.lastDate) - }, - showFunkwhaleSupportMessage: (state, getters, rootState) => { - if (!rootState.auth.profile) { - return false - } - if (!rootState.instance.settings.instance.funkwhale_support_message_enabled.value) { - return false - } - const displayDate = rootState.auth.profile.funkwhale_support_message_display_date - if (!displayDate) { - return false - } - return moment(displayDate) < moment(state.lastDate) - }, - additionalNotifications: (state, getters) => { - let count = 0 - if (getters.showInstanceSupportMessage) { - count += 1 - } - if (getters.showFunkwhaleSupportMessage) { - count += 1 - } - return count - }, - - windowSize: (state, getters) => { - // IMPORTANT: if you modify these breakpoints, also modify the values in - // style/vendor/_media.scss - const width = state.window.width - const breakpoints = [ - { name: 'widedesktop', width: 1200 }, - { name: 'desktop', width: 1024 }, - { name: 'tablet', width: 768 }, - { name: 'phone', width: 320 } - ] - for (let index = 0; index < breakpoints.length; index++) { - const element = breakpoints[index] - if (width >= element.width) { - return element.name - } - } - return 'phone' - }, - layoutVersion: (state, getters) => { - if (['tablet', 'phone'].indexOf(getters.windowSize) > -1) { - return 'small' - } else { - return 'large' - } - } - }, - mutations: { - addWebsocketEventHandler: (state, { eventName, id, handler }) => { - state.websocketEventsHandlers[eventName][id] = handler - }, - removeWebsocketEventHandler: (state, { eventName, id }) => { - delete state.websocketEventsHandlers[eventName][id] - }, - currentLanguage: (state, value) => { - state.currentLanguage = value - state.selectedLanguage = true - }, - momentLocale: (state, value) => { - state.momentLocale = value - moment.locale(value) - }, - computeLastDate: (state) => { - state.lastDate = new Date() - }, - queueFocused: (state, value) => { - state.queueFocused = value - }, - - theme: (state, value) => { - state.theme = value - }, - addMessage (state, message) { - const finalMessage = { - displayTime: state.messageDisplayDuration, - key: String(new Date()), - ...message - } - const key = finalMessage.key - state.messages = state.messages.filter((m) => { - return m.key !== key - }) - state.messages.push(finalMessage) - if (state.messages.length > state.maxMessages) { - state.messages.shift() - } - }, - removeMessage (state, key) { - state.messages = state.messages.filter((m) => { - return m.key !== key - }) - }, - notifications (state, { type, count }) { - state.notifications[type] = count - }, - incrementNotifications (state, { type, count, value }) { - if (value !== undefined) { - state.notifications[type] = Math.max(0, value) - } else { - state.notifications[type] = Math.max(0, state.notifications[type] + count) - } - }, - pageTitle: (state, value) => { - state.pageTitle = value - }, - paginateBy: (state, { route, value }) => { - state.routePreferences[route].paginateBy = value - }, - ordering: (state, { route, value }) => { - state.routePreferences[route].ordering = value - }, - orderingDirection: (state, { route, value }) => { - state.routePreferences[route].orderingDirection = value - }, - - serviceWorker: (state, value) => { - state.serviceWorker = { ...state.serviceWorker, ...value } - }, - window: (state, value) => { - state.window = value - } - }, - actions: { - fetchUnreadNotifications ({ commit }, payload) { - axios.get('federation/inbox/', { params: { is_read: false, page_size: 1 } }).then((response) => { - commit('notifications', { type: 'inbox', count: response.data.count }) - }) - }, - fetchPendingReviewEdits ({ commit, rootState }, payload) { - axios.get('mutations/', { params: { is_approved: 'null', page_size: 1 } }).then((response) => { - commit('notifications', { type: 'pendingReviewEdits', count: response.data.count }) - }) - }, - fetchPendingReviewReports ({ commit, rootState }, payload) { - axios.get('manage/moderation/reports/', { params: { is_handled: 'false', page_size: 1 } }).then((response) => { - commit('notifications', { type: 'pendingReviewReports', count: response.data.count }) - }) - }, - fetchPendingReviewRequests ({ commit, rootState }, payload) { - axios.get('manage/moderation/requests/', { params: { status: 'pending', page_size: 1 } }).then((response) => { - commit('notifications', { type: 'pendingReviewRequests', count: response.data.count }) - }) - }, - - async currentLanguage ({ state, commit, rootState }, value) { - commit('currentLanguage', value) - if (rootState.auth.authenticated) { - await axios.post('users/settings', { language: value }) - } - }, - - async theme ({ state, commit, rootState }, value) { - commit('theme', value) - if (rootState.auth.authenticated) { - await axios.post('users/settings', { theme: value }) - } - }, - - async initSettings ({ commit }, settings) { - settings = settings || {} - if (settings.language) { - commit('currentLanguage', settings.language) - } - if (settings.theme) { - commit('theme', settings.theme) - } - }, - websocketEvent ({ state }, event) { - const handlers = state.websocketEventsHandlers[event.type] - console.log('Dispatching websocket event', event, handlers) - if (!handlers) { - return - } - const names = Object.keys(handlers) - names.forEach((k) => { - const handler = handlers[k] - handler(event) - }) - } - } -} diff --git a/front/src/store/ui.ts b/front/src/store/ui.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd496f33d20a67cd077168a267bf8656d8c189bd --- /dev/null +++ b/front/src/store/ui.ts @@ -0,0 +1,251 @@ +import type { Module } from 'vuex' +import type { RootState } from '~/store/index' +import type { availableLanguages } from '~/init/locale' + +import axios from 'axios' +import moment from 'moment' + +type SupportedExtension = 'flac' | 'ogg' | 'mp3' | 'opus' | 'aac' | 'm4a' | 'aiff' | 'aif' + +export type WebSocketEventName = 'inbox.item_added' | 'import.status_updated' | 'mutation.created' | 'mutation.updated' + | 'report.created' | 'user_request.created' | 'Listen' + +export type OrderingField = 'creation_date' | 'title' | 'album__title' | 'artist__name' | 'release_date' | 'name' + | 'applied_date' | 'followers_count' | 'uploads_count' | 'length' | 'items_count' | 'modification_date' | 'size' + | 'accessed_date' | 'bitrate' | 'duration' | 'last_fetch_date' | 'preferred_username' | 'domain' | 'handled_date' + | 'album_title' | 'artist_name' | 'actors_count' | 'outbox_activities_count' | 'expiration_date' | 'date_joined' + | 'last_activity' | 'username' + +export type OrderingDirection = '-' | '+' +interface WebSocketEvent { + type: WebSocketEventName +} + +type WebSocketHandlers = Record<string, (event: WebSocketEvent) => void> + +interface Message { + displayTime: number + key: string +} + +type NotificationsKey = 'inbox' | 'pendingReviewEdits' | 'pendingReviewReports' | 'pendingReviewRequests' + +export interface State { + currentLanguage: 'en_US' | keyof typeof availableLanguages + selectedLanguage: boolean + queueFocused: null | 'queue' | 'player' + momentLocale: 'en' + lastDate: Date + maxMessages: number + messageDisplayDuration: number + supportedExtensions: SupportedExtension[] + messages: Message[] + window: { + height: number + width: number + } + pageTitle: null + + notifications: Record<NotificationsKey, number> + websocketEventsHandlers: Record<WebSocketEventName, WebSocketHandlers> +} + +const store: Module<State, RootState> = { + namespaced: true, + state: { + currentLanguage: 'en_US', + selectedLanguage: false, + queueFocused: null, + momentLocale: 'en', + lastDate: new Date(), + maxMessages: 100, + messageDisplayDuration: 5 * 1000, + supportedExtensions: ['flac', 'ogg', 'mp3', 'opus', 'aac', 'm4a', 'aiff', 'aif'], + messages: [], + window: { + height: 0, + width: 0 + }, + notifications: { + inbox: 0, + pendingReviewEdits: 0, + pendingReviewReports: 0, + pendingReviewRequests: 0 + }, + websocketEventsHandlers: { + 'inbox.item_added': {}, + 'import.status_updated': {}, + 'mutation.created': {}, + 'mutation.updated': {}, + 'report.created': {}, + 'user_request.created': {}, + Listen: {} + }, + pageTitle: null + }, + getters: { + showInstanceSupportMessage: (state, getters, rootState) => { + if (!rootState.auth.profile) { + return false + } + if (!rootState.instance.settings.instance.support_message.value) { + return false + } + const displayDate = rootState.auth.profile.instance_support_message_display_date + if (!displayDate) { + return false + } + return moment(displayDate) < moment(state.lastDate) + }, + showFunkwhaleSupportMessage: (state, getters, rootState) => { + if (!rootState.auth.profile) { + return false + } + if (!rootState.instance.settings.instance.funkwhale_support_message_enabled.value) { + return false + } + const displayDate = rootState.auth.profile.funkwhale_support_message_display_date + if (!displayDate) { + return false + } + return moment(displayDate) < moment(state.lastDate) + }, + additionalNotifications: (state, getters) => { + let count = 0 + if (getters.showInstanceSupportMessage) { + count += 1 + } + if (getters.showFunkwhaleSupportMessage) { + count += 1 + } + return count + }, + + windowSize: (state) => { + // IMPORTANT: if you modify these breakpoints, also modify the values in + // style/vendor/_media.scss + const width = state.window.width + const breakpoints = [ + { name: 'widedesktop', width: 1200 }, + { name: 'desktop', width: 1024 }, + { name: 'tablet', width: 768 }, + { name: 'phone', width: 320 } + ] + for (let index = 0; index < breakpoints.length; index++) { + const element = breakpoints[index] + if (width >= element.width) { + return element.name + } + } + return 'phone' + }, + layoutVersion: (state, getters) => { + if (['tablet', 'phone'].indexOf(getters.windowSize) > -1) { + return 'small' + } else { + return 'large' + } + } + }, + mutations: { + addWebsocketEventHandler: (state, { eventName, id, handler }: { eventName: WebSocketEventName, id: string, handler: (event: any) => void}) => { + state.websocketEventsHandlers[eventName][id] = handler + }, + removeWebsocketEventHandler: (state, { eventName, id }: { eventName: WebSocketEventName, id: string }) => { + delete state.websocketEventsHandlers[eventName][id] + }, + currentLanguage: (state, value) => { + state.currentLanguage = value + state.selectedLanguage = true + }, + momentLocale: (state, value) => { + state.momentLocale = value + moment.locale(value) + }, + computeLastDate: (state) => { + state.lastDate = new Date() + }, + queueFocused: (state, value) => { + state.queueFocused = value + }, + + addMessage (state, message) { + const finalMessage = { + displayTime: state.messageDisplayDuration, + key: String(new Date()), + ...message + } + + const key = finalMessage.key + state.messages.splice(state.messages.findIndex(message => message.key === key), 1) + state.messages.push(finalMessage) + if (state.messages.length > state.maxMessages) { + state.messages.shift() + } + }, + removeMessage (state, key) { + state.messages.splice(state.messages.findIndex(message => message.key === key), 1) + }, + notifications (state, { type, count }: { type: NotificationsKey, count: number }) { + state.notifications[type] = count + }, + incrementNotifications (state, { type, count, value }: { type: NotificationsKey, count: number, value?: number }) { + if (value !== undefined) { + state.notifications[type] = Math.max(0, value) + } else { + state.notifications[type] = Math.max(0, state.notifications[type] + count) + } + }, + pageTitle: (state, value) => { + state.pageTitle = value + }, + window: (state, value) => { + state.window = value + } + }, + actions: { + async fetchUnreadNotifications ({ commit }) { + const response = await axios.get('federation/inbox/', { params: { is_read: false, page_size: 1 } }) + .catch(() => ({ data: { count: 0 } })) + commit('notifications', { type: 'inbox', count: response.data.count }) + }, + async fetchPendingReviewEdits ({ commit }) { + const response = await axios.get('mutations/', { params: { is_approved: 'null', page_size: 1 } }) + .catch(() => ({ data: { count: 0 } })) + commit('notifications', { type: 'pendingReviewEdits', count: response.data.count }) + }, + async fetchPendingReviewReports ({ commit }) { + const response = await axios.get('manage/moderation/reports/', { params: { is_handled: 'false', page_size: 1 } }) + .catch(() => ({ data: { count: 0 } })) + commit('notifications', { type: 'pendingReviewReports', count: response.data.count }) + }, + async fetchPendingReviewRequests ({ commit }) { + const response = await axios.get('manage/moderation/requests/', { params: { status: 'pending', page_size: 1 } }) + .catch(() => ({ data: { count: 0 } })) + commit('notifications', { type: 'pendingReviewRequests', count: response.data.count }) + }, + + async currentLanguage ({ commit, rootState }, value) { + commit('currentLanguage', value) + if (rootState.auth.authenticated) { + await axios.post('users/settings', { language: value }) + } + }, + + websocketEvent ({ state }, event: WebSocketEvent) { + const handlers = state.websocketEventsHandlers[event.type] + console.log('Dispatching websocket event', event, handlers) + if (!handlers) { + return + } + + const names = Object.keys(handlers) + names.forEach((k) => { + const handler = handlers[k] + handler(event) + }) + } + } +} + +export default store diff --git a/front/src/style/components/_content_form.scss b/front/src/style/components/_content_form.scss index dc237d363b54b8cc58dd44948439acccd50d7fe2..45cf62118b317b8fc7cba9358176526ec1d059fc 100644 --- a/front/src/style/components/_content_form.scss +++ b/front/src/style/components/_content_form.scss @@ -1,16 +1,29 @@ .content-form { background: var(--input-background); + .segment { background: none; } + .segment:first-child { min-height: 15em; + display: grid; + grid-template-rows: auto 1fr auto; + + textarea { + height: 100%; + resize: none; + overflow-y: hidden; + max-height: none; + } } + .ui.secondary.menu { background: none; margin-top: -0.5em; } + .input { width: 100%; } diff --git a/front/src/style/components/_player.scss b/front/src/style/components/_player.scss index c261bb07f34ca4b08ffcc7207391c6ae4604d507..6002c7ce6cae8775317b9cc080d839b8841ec2bf 100644 --- a/front/src/style/components/_player.scss +++ b/front/src/style/components/_player.scss @@ -3,15 +3,24 @@ z-index: 999999; width: 100%; width: 100vw; + .ui.top.attached.progress { top: 0; + height: 1rem; + z-index: 1; + padding-bottom: 0.8rem; + border-radius: 0; + + .bar { + height: 0.2rem; + } } + } .ui.bottom-player > .segment.fixed-controls { color: var(--player-color); background: var(--player-background); width: 100%; - width: 100vw; border-radius: 0; padding: 0em; position: fixed; @@ -38,10 +47,21 @@ .ui.progress .bar { transition: none; - } - - .ui.progress .buffer.bar { + width: 100%; + transform: translateX(-100%); + transform-origin: top left; + will-change: transform; position: absolute; + + &.seek { + background: var(--player-color); + opacity: 0; + transition: opacity .2s ease; + mix-blend-mode: overlay; + } + } + .ui.progress:hover .bar.seek { + opacity: 0.4; } @keyframes MOVE-BG { @@ -72,10 +92,9 @@ animation-timing-function: linear; animation-iteration-count: infinite; } - .ui.progress:not([data-percent]):not(.indeterminate) + .ui.progress:not(.indeterminate) .bar.position:not(.buffer) { background: var(--vibrant-color); - min-width: 0; } .track-controls { @@ -184,7 +203,7 @@ } } } - .shuffling.loader.inline { + .shuffling .loader.inline { margin: 0; } .control.circular.button { @@ -201,14 +220,14 @@ align-items: center; justify-content: space-between; min-width: 10em; + z-index: 2; > .control.button { padding: 0.5em; } .position.control { - padding-right: 1em; flex-grow: 1; - padding-left: 0; + i.stream.icon { position: relative; top: -2px; diff --git a/front/src/style/components/_queue.scss b/front/src/style/components/_queue.scss index b2bcde76b696334397129ca1ff9619bb5f536aa6..03e7e1d6576f67654dc4762919fc873d0da20bfb 100644 --- a/front/src/style/components/_queue.scss +++ b/front/src/style/components/_queue.scss @@ -1,28 +1,26 @@ -.queue.segment.player-focused #queue-grid #player { - @include media("<desktop") { - padding-bottom: $bottom-player-height + 2rem; - } -} .queue-controls { @include media("<desktop") { height: $bottom-player-height; } } -.ui.fixed-header.segment { +.ui.clearing.segment { background-color: var(--site-background); box-shadow: var(--secondary-menu-box-shadow); + margin: 0 !important; } -.queue-enter-active, .queue-leave-active { - transition: all 0.2s ease-in-out; - .current-track, .queue-column { - opacity: 0; - } + +.queue-enter-active, +.queue-leave-active { + transition: all 0.2s ease; + will-change: transform, opacity; } -.queue-enter, .queue-leave-to { - transform: translateY(100vh); + +.queue-enter-from, +.queue-leave-to { opacity: 0; + transform: translateY(5vh); } .component-queue { @@ -33,11 +31,10 @@ } } &.main { - position: absolute; + position: fixed; min-height: 100vh; width: 100vw; z-index: 1000; - padding-bottom: 3em; } &.main > .button { position: fixed; @@ -48,25 +45,13 @@ display: none; } } - .queue.segment:not(.player-focused) { - #player { - @include media("<desktop") { - height: 0; - display: none; - } - } - } + .queue.segment #player { padding: 0em; > * { padding: 0.5em; } } - .player-focused .grid > .ui.queue-column { - @include media("<desktop") { - display: none; - } - } .queue-column { overflow-y: auto; } @@ -116,7 +101,7 @@ @include media("<desktop") { padding: 1em; } - @include media(">desktop") { + @include media(">=desktop") { right: 1em; left: 38%; } @@ -138,8 +123,10 @@ top: 0; width: 32%; > img { - height: 50vh; - width: 50vh; + width: 100%; + height: auto; + max-height: 50vh; + max-width: 50vh; } @include media("<desktop") { padding: 0.5em; @@ -163,15 +150,25 @@ .progress-area { overflow: hidden; } + .progress-area .progress { + border-radius: 0.28571429rem; + overflow: hidden; + } .progress-wrapper, .warning.message { - max-width: 25em; - margin: 0 auto; + width: 25em; + } + .ui.progress .bar { + transition: none; + width: 100%; + transform: translateX(-100%); + transform-origin: top left; + will-change: transform; } .ui.progress .buffer.bar { position: absolute; background-color: rgba(255, 255, 255, 0.15); } - .ui.progress:not([data-percent]):not(.indeterminate) + .ui.progress:not(.indeterminate) .bar.position:not(.buffer) { background: var(--vibrant-color); } @@ -201,9 +198,6 @@ } .progress { cursor: pointer; - .bar { - min-width: 0 !important; - } } .player-controls { @@ -235,3 +229,124 @@ } } + + +// Wvffle's styles +.component-queue { + #queue-grid { + display: grid; + grid-template-columns: 37.5% 62.5%; + + #queue { + position: relative; + } + + @include media("<desktop") { + grid-template-columns: 1fr 0; + &.show-player { + #queue { + display: none; + } + } + + &.show-queue { + #player { + display: none; + } + } + } + + #player { + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + text-align: center; + + .ui.header { + width: 100%; + max-width: 90%; + } + + .cover-container { + width: 50vh; + max-width: 90%; + + .cover { + height: 0; + width: 100%; + padding-bottom: 100%; + position: relative; + + img { + height: 100%; + width: 100%; + position: absolute; + top: 0; + left: 0; + } + } + } + + .progress-wrapper { + font-size: 1.5rem; + width: 54vh; + max-width: 90%; + } + } + + > div { + height: calc(100vh - 4rem); + margin: 0 !important; + + &:nth-child(2) { + display: grid; + grid-template-rows: auto 1fr; + + > :nth-child(2) { + overflow-y: hidden; + } + + .virtual-list { + height: 100%; + overflow-y: scroll; + padding-bottom: 2rem; + } + } + } + } + + .queue-item { + height: 50px; + display: grid; + grid-template-columns: 10% auto 1fr 10% auto; + cursor: pointer; + padding: 0 0.875rem; + + .heart { + &:not(.pink) { + color: rgba(0, 0, 0, 0.2) + } + } + + .handle > .grip { + pointer-events: none; + } + + > div { + display: flex; + align-items: center; + } + + // NOTE: Taken from semantic ui + &.active { + background: #E0E0E0; + color: #000000de; + } + } +} + +.drag-container:not(.dragging) .hover .queue-item { + background: rgba(0,0,0,.05); + color: #000000f2; +} \ No newline at end of file diff --git a/front/src/style/components/_sidebar.scss b/front/src/style/components/_sidebar.scss index ada61d833e6b647f4711a1e6c6f512912317f03e..2da18d5140ef0ec841db98b069645ab7a673a469 100644 --- a/front/src/style/components/_sidebar.scss +++ b/front/src/style/components/_sidebar.scss @@ -84,7 +84,7 @@ margin: 0 0.5em 0 0; } } - .item.active { + .item.router-link-exact-active { border-right: 5px solid var(--vibrant-color); border-radius: 0 !important; background: var(--sidebar-active-item-background) !important; diff --git a/front/src/style/components/_volume_control.scss b/front/src/style/components/_volume_control.scss index 4eb660db2ec7f33dea9a05016dd8793d2eaf1547..d7572607081b37101a9d0b3c061f6028d51630ce 100644 --- a/front/src/style/components/_volume_control.scss +++ b/front/src/style/components/_volume_control.scss @@ -4,7 +4,6 @@ align-items: center; position: relative; overflow: visible; - top: 3px; input { max-width: 5.5em; height: 4px; @@ -14,13 +13,12 @@ background-color: #1B1C1D; position: absolute; left: -4em; - top: -7em; + top: calc(-7em + 5px); transform: rotate(-90deg); display: flex; align-items: center; height: 2.5em; padding: 0 0.5em; - box-shadow: 1px 1px 3px rgba(125, 125, 125, 0.5); } input { max-width: 8.5em; diff --git a/front/src/style/globals/_app.scss b/front/src/style/globals/_app.scss index 63cea66afaf695a488d2d9d82556504932e5c3e5..7c1199f6bf0342e204a173e7ee6147ef56046a68 100644 --- a/front/src/style/globals/_app.scss +++ b/front/src/style/globals/_app.scss @@ -7,3 +7,7 @@ html { scroll-behavior: auto; } } + +input[type=search]::-webkit-search-cancel-button { + appearance: none; +} \ No newline at end of file diff --git a/front/src/style/globals/_layout.scss b/front/src/style/globals/_layout.scss index b7cfab2a76f17bf78bcd9d97c3f4c6be337e85c6..a6759410f9843cb29527190dcf20258f9ab1a788 100644 --- a/front/src/style/globals/_layout.scss +++ b/front/src/style/globals/_layout.scss @@ -28,7 +28,7 @@ justify-content: space-between !important; } } -#app:not(.queue-focused) { +#app > :not(.queue-focused) { .when-queue-focused { @include media("<desktop") { display: none; @@ -36,7 +36,7 @@ } } -#app { +#app > div { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: flex; @@ -44,22 +44,33 @@ flex-direction: column; &.has-bottom-player { padding-bottom: $bottom-player-height; - .toast-container { + + .toast-container { bottom: $bottom-player-height + 1rem; + pointer-events: none; } } } +.toast-container { + pointer-events: none; + top: 1em; + min-height: auto !important; + + > div { + pointer-events: auto; + } +} + #footer { border-bottom: none; border-top: 1px solid rgba(34, 36, 38, 0.15); } -#app > main, #app > .main { +#app > div > main, #app > .main { flex: 1; } -#app { +#app > div { > .main.pusher, > .footer { position: relative; @@ -144,7 +155,7 @@ } } } -#app.queue-focused { +#app > .queue-focused { .queue-not-focused { @include media("<desktop") { display: none; @@ -181,4 +192,4 @@ overflow: auto; position: absolute; width: auto; -} \ No newline at end of file +} diff --git a/front/src/style/globals/_utils.scss b/front/src/style/globals/_utils.scss index 37a8bc5d0b5dcf5a78667c6483472c956bb7d61b..c595d8bfad90a660f0f5a56f88906fd34ee419c9 100644 --- a/front/src/style/globals/_utils.scss +++ b/front/src/style/globals/_utils.scss @@ -53,6 +53,11 @@ a { display: none !important; } } +.desktop-and-below { + @include media(">=desktop") { + display: none !important; + } +} .tablet-and-up { @include media("<tablet") { display: none !important; diff --git a/front/src/types.ts b/front/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f76aaf22de349e7440ac8cb9d8dc6799a4c0c91 --- /dev/null +++ b/front/src/types.ts @@ -0,0 +1,504 @@ +import type { App } from 'vue' +import type { Store } from 'vuex' +import type { Router } from 'vue-router' +import type { AxiosError } from 'axios' +import type { RootState } from '~/store' + +// eslint-disable-next-line +import type { ComponentPublicInstance } from '@vue/runtime-core' + +export type FunctionRef = Element | ComponentPublicInstance | null + +// App structure stuff +export interface InitModuleContext { + app: App + router: Router + store: Store<RootState> +} + +export type InitModule = (ctx: InitModuleContext) => void | Promise<void> + +export interface QueueItemSource { + id: string + track: Track + duration: string + coverUrl: string + + labels: { + remove: string + selectTrack: string + favorite: string + } +} + +// Theme stuff +export type Theme = 'auto' | 'light' | 'dark' + +export interface ThemeEntry { + icon: string + name: string + key: Theme +} + +// Track stuff +export type ContentCategory = 'podcast' | 'music' + +export interface Artist { + id: number + fid: string + mbid?: string + + name: string + description: Content + cover?: Cover + channel?: Channel + // TODO (wvffle): Check if it's Tag[] or string[] + tags: string[] + + content_category: ContentCategory + albums: Album[] + tracks_count: number + attributed_to: Actor + is_local: boolean + is_playable: boolean + modification_date?: string +} + +export interface Album { + id: number + fid: string + mbid?: string + + title: string + description: Content + release_date?: string + cover?: Cover + tags: string[] + + artist: Artist + tracks_count: number + tracks: Track[] + + is_playable: boolean + is_local: boolean +} + +export interface Track { + id: number + fid: string + mbid?: string + + title: string + description: Content + cover?: Cover + position?: number + copyright?: string + license?: License + tags: string[] + uploads: Upload[] + downloads_count: number + + album?: Album + artist?: Artist + disc_number: number + + listen_url: string + creation_date: string + attributed_to: Actor + + is_playable: boolean + is_local: boolean +} + +export interface Channel { + id: number + uuid: string + artist?: Artist + actor: Actor + attributed_to: Actor + url?: string + rss_url: string + subscriptions_count: number + downloads_count: number + content_category: ContentCategory + + metadata?: { + itunes_category?: unknown + itunes_subcategory?: unknown + language?: string + owner_name?: string + owner_email?: string + } +} + +export type PrivacyLevel = 'everyone' | 'instance' | 'me' + +export interface Library { + id: number + uuid: string + fid?: string + name: string + actor: Actor + uploads_count: number + size: number + description: string + privacy_level: PrivacyLevel + creation_date: string + follow?: LibraryFollow + latest_scan: LibraryScan +} + +export type ImportStatus = 'scanning' | 'pending' | 'finished' | 'errored' | 'draft' | 'skipped' +export interface LibraryScan { + processed_files: number + total_files: number + status: ImportStatus + errored_files: number + modification_date: string +} + +export interface LibraryFollow { + uuid: string + approved: boolean + + name: string + type?: 'music.Library' | 'federation.LibraryFollow' + target: Library +} + +export interface Cover { + uuid: string + urls: { + original: string + medium_square_crop: string + large_square_crop: string + } +} + +export interface License { + code: string + name: string + url: string +} + +export interface Playlist { + id: number + name: string + modification_date: string + user: User + privacy_level: PrivacyLevel + tracks_count: number + duration: number + album_covers: string[] + + is_playable: boolean +} + +export interface PlaylistTrack { + track: Track + position?: number +} + +export interface Radio { + id: number + name: string + user: User +} + +export interface Listening { + id: number + track: Track + user: User + actor: Actor + creation_date: string +} + +// API stuff +// eslint-disable-next-line +export interface APIErrorResponse extends Record<string, APIErrorResponse | string[] | { code: string }[]> {} + +export interface BackendError extends AxiosError { + isHandled: boolean + backendErrors: string[] + rawPayload?: APIErrorResponse +} + +export interface BackendResponse<T> { + count: number + results: T[] +} + +export interface RateLimitStatus { + limit: string + scope: string + remaining: string + duration: string + availableSeconds: number + reset: string + resetSeconds: string +} + +// WebSocket stuff + +// FS Browser +export interface FSEntry { + dir: boolean + name: string +} + +export interface FileSystem { + root: boolean + content: FSEntry[] + import: FSLogs +} + +export interface FSLogs { + status: 'pending' | 'started' + reference: unknown + logs: string[] +} + +// Content stuff +export interface Content { + content_type: 'text/plain' | 'text/markdown' + text: string +} + +// Form stuff +export interface FormField { + label: string + input_type: 'short_text' | 'long_text' + required: boolean +} + +export interface Form { + fields: FormField[] + help_text: Content +} + +// Upload stuff +export interface Upload { + id: number + uuid: string + filename?: string + source?: string + duration?: number + mimetype: string + extension: string + listen_url: string + bitrate?: number + size?: number + + import_status: ImportStatus + import_details?: { + detail: object + error_code: string + } + + import_metadata?: Record<string, string> & { tags?: string[] } +} + +// Profile stuff +export interface Actor { + id: number + fid?: string + name?: string + icon?: Cover + summary: string + preferred_username: string + full_username: string + is_local: boolean + domain: string +} + +export interface User { + id: number + avatar?: Cover + email: string + summary: { text: string, content_type: string } + username: string + full_username: string + instance_support_message_display_date: string + funkwhale_support_message_display_date: string + is_superuser: boolean + privacy_level: PrivacyLevel +} + +// Settings stuff +export type SettingsId = 'instance' +export interface SettingsGroup { + label: string + id: SettingsId + settings: SettingsField[] +} + +export interface SettingsField { + name: string + fieldType?: 'markdown' + fieldParams?: { + charLimit: number | null + permissive: boolean + } +} + +export interface SettingsDataEntry { + identifier: string + fieldType: string + fieldParams: object + help_text: string + verbose_name: string + value: unknown + field: { + class: string + widget: { + class: string + } + } + + additional_data: { + choices: [string, string] + } +} + +// Note stuff +export interface Note { + uuid: string + type: 'request' | 'report' + author?: Actor + summary?: string + creation_date?: string +} + +// Instance policy stuff +export interface InstancePolicy { + id: number + uuid: string + creation_date: string + actor: Actor + + summary: string + is_active: boolean + block_all: boolean + silence_activity: boolean + silence_notifications: boolean + reject_media: boolean +} + +// Plugin stuff +export interface Plugin { + name: string + label: string + homepage?: string + enabled: boolean + description?: string + source?: string + values?: Record<string, string> + conf?: { + name: string + label: string + type: 'text' | 'long_text' | 'url' | 'password' + help?: string + }[] +} + +// Report stuff +export type EntityObjectType = 'artist' | 'album' | 'track' | 'library' | 'playlist' | 'account' | 'channel' + +export interface ReportTarget { + id: number + type: EntityObjectType +} + +export type ReviewStatePayload = { value: unknown } | Partial<Artist> | Partial<Album> | Partial<Track> +export interface ReviewState { + [id: string]: ReviewStatePayload +} + +export interface Review { + uuid: string + is_applied: boolean | null + is_approved: boolean | null + created_by: Actor + previous_state: ReviewState + payload: ReviewState + target?: ReportTarget & { + type: 'artist' | 'album' | 'track' + repr: string + } + creation_date: string + summary?: string + type: 'update' +} + +export interface Report { + uuid: string + summary?: string + is_applied: boolean + is_handled: boolean + previous_state: string + notes: Note[] + type: string + + assigned_to?: Actor + submitter?: Actor + submitter_email?: string + + target_owner?: Actor + target?: ReportTarget + target_state: { + _target: ReportTarget + domain: string + [k: string]: unknown + } + + creation_date: string + handled_date: string +} + +// User request stuff +export type UserRequestStatus = 'approved' | 'refused' | 'pending' +export interface UserRequest { + uuid: string + notes: Note[] + status: UserRequestStatus + + assigned_to?: Actor + submitter?: Actor + submitter_email?: string + + creation_date: string + handled_date: string + + metadata: Record<string, string> +} + +// Notification stuff +export type Activity = { + actor: Actor + creation_date: string + related_object: LibraryFollow + type: 'Follow' | 'Accept' + object: LibraryFollow +} + +export interface Notification { + id: number + is_read: boolean + activity: Activity +} + +// Tags stuff +export interface Tag { + name: string +} + +// Application stuff +export interface Application { + client_id: string + name: string + redirect_uris: string + scopes: string + + // This is actually a date string + created: string +} diff --git a/front/src/utils.js b/front/src/utils.js deleted file mode 100644 index 621b51e0356585ed3f71f60257d568f6193a829b..0000000000000000000000000000000000000000 --- a/front/src/utils.js +++ /dev/null @@ -1,59 +0,0 @@ -import lodash from 'lodash' - -export function setUpdate (obj, statuses, value) { - const updatedKeys = lodash.keys(obj) - updatedKeys.forEach((k) => { - statuses[k] = value - }) -} - -export function parseAPIErrors (responseData, parentField) { - let errors = [] - for (const field in responseData) { - if (Object.prototype.hasOwnProperty.call(responseData, field)) { - const value = responseData[field] - let fieldName = lodash.startCase(field.replace('_', ' ')) - if (parentField) { - fieldName = `${parentField} - ${fieldName}` - } - if (value.forEach) { - value.forEach(e => { - if (e.toLocaleLowerCase().includes('this field ')) { - errors.push(`${fieldName}: ${e}`) - } else { - errors.push(e) - } - }) - } else if (typeof value === 'object') { - // nested errors - const nestedErrors = parseAPIErrors(value, fieldName) - errors = [...errors, ...nestedErrors] - } - } - } - return errors -} - -export function getCookie (name) { - return document.cookie - .split('; ') - .find(row => row.startsWith(name)) - .split('=')[1] -} -export function setCsrf (xhr) { - if (getCookie('csrftoken')) { - xhr.setRequestHeader('X-CSRFToken', getCookie('csrftoken')) - } -} - -export function checkRedirectToLogin (store, router) { - if (!store.state.auth.authenticated) { - router.push({ name: 'login', query: { next: router.currentRoute.fullPath } }) - } -} - -export function getDomain (url) { - const parser = document.createElement('a') - parser.href = url - return parser.hostname -} diff --git a/front/src/utils/clientRadios.ts b/front/src/utils/clientRadios.ts new file mode 100644 index 0000000000000000000000000000000000000000..356ce07317f140c941df44340b4cea9087ad8235 --- /dev/null +++ b/front/src/utils/clientRadios.ts @@ -0,0 +1,53 @@ +import type { CurrentRadio, PopulateQueuePayload } from '~/store/radios' +import type { ListenWS } from '~/composables/useWebSocketHandler' +import type { RootState } from '~/store' +import type { Store } from 'vuex' + +import axios from 'axios' + +import useLogger from '~/composables/useLogger' + +const logger = useLogger() + +export const CLIENT_RADIOS = { + // some radios are client side only, so we have to implement the populateQueue + // method by hand + account: { + offset: 1, + populateQueue ({ current, dispatch, playNow }: PopulateQueuePayload) { + const params = { scope: `actor:${current.objectId?.fullUsername}`, ordering: '-creation_date', page_size: 1, page: this.offset } + axios.get('history/listenings', { params }).then(async (response) => { + const latest = response.data.results[0] + if (!latest) { + logger.error('No more tracks') + await dispatch('stop') + } + + this.offset += 1 + const append = dispatch('queue/append', { track: latest.track }, { root: true }) + if (playNow) { + append.then(() => dispatch('queue/last', null, { root: true })) + } + }, async (error) => { + logger.error('Error while fetching listenings', error) + await dispatch('stop') + }) + }, + stop () { + this.offset = 1 + }, + handleListen (current: CurrentRadio, event: ListenWS, store: Store<RootState>) { + // TODO: handle actors from other pods + if (event.actor.local_id === current.objectId?.username) { + axios.get(`tracks/${event.object.local_id}`).then(async (response) => { + if (response.data.uploads.length > 0) { + await store.dispatch('queue/append', { track: response.data }) + this.offset += 1 + } + }, (error) => { + logger.error('Cannot retrieve track info', error) + }) + } + } + } +} diff --git a/front/src/utils/color.js b/front/src/utils/color.ts similarity index 69% rename from front/src/utils/color.js rename to front/src/utils/color.ts index b4ad6d8d273b63736fa20a697b3ed6f900a5f78c..b30b7385627d9f2dbf429a0f915b90772426629b 100644 --- a/front/src/utils/color.js +++ b/front/src/utils/color.ts @@ -1,4 +1,5 @@ -export function hashCode (str) { // java String#hashCode +// java String#hashCode +export function hashCode (str: string) { let hash = 0 for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash) @@ -6,7 +7,7 @@ export function hashCode (str) { // java String#hashCode return hash } -export function intToRGB (i) { +export function intToRGB (i: number) { const c = (i & 0x00FFFFFF).toString(16).toUpperCase() return '00000'.substring(0, 6 - c.length) + c } diff --git a/front/src/utils/filters.ts b/front/src/utils/filters.ts new file mode 100644 index 0000000000000000000000000000000000000000..90520b5b57f7ddce1f93b6137840de9e32109c8b --- /dev/null +++ b/front/src/utils/filters.ts @@ -0,0 +1,38 @@ +import moment from 'moment' + +export function truncate (str: string, max = 100, ellipsis = '…', middle = false) { + if (max === 0) return '' + if (str.length <= max) return str + if (!middle) return str.slice(0, max) + ellipsis + + const charsToShow = max - ellipsis.length + return str.slice(0, Math.ceil(charsToShow / 2)) + + ellipsis + + str.slice(-Math.floor(charsToShow / 2)) +} + +export function momentFormat (date: Date, format = 'lll') { + return moment(date).format(format) +} + +const HUMAN_UNITS = { + SI: ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], + powerOf2: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] +} + +export function humanSize (bytes: number, isSI = true) { + const threshold = isSI ? 1000 : 1024 + + if (Math.abs(bytes) < threshold) { + return `${bytes} B` + } + + const units = HUMAN_UNITS[isSI ? 'SI' : 'powerOf2'] + let u = -1 + do { + bytes /= threshold + ++u + } while (Math.abs(bytes) >= threshold && u < units.length - 1) + + return `${bytes.toFixed(1)} ${units[u]}` +} diff --git a/front/src/utils/fomantic.ts b/front/src/utils/fomantic.ts new file mode 100644 index 0000000000000000000000000000000000000000..b56083b1197aa4c8b74a85de905b3fbb34734dde --- /dev/null +++ b/front/src/utils/fomantic.ts @@ -0,0 +1,22 @@ +/// <reference types="semantic-ui" /> + +import $ from 'jquery' + +export const setupDropdown = (selector: string | HTMLElement = '.ui.dropdown', el: Element = document.body) => { + const $dropdown = typeof selector === 'string' + ? $(el).find(selector) + : $(selector) + + $dropdown.dropdown({ + selectOnKeydown: false, + action (text: unknown, value: unknown, $el: JQuery) { + // used to ensure focusing the dropdown and clicking via keyboard + // works as expected + $el[0]?.click() + + $dropdown.dropdown('hide') + } + }) + + return $dropdown +} diff --git a/front/src/utils/index.ts b/front/src/utils/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fd4b0f3d3d7c2b4199c1559ecc754af9fcea036 --- /dev/null +++ b/front/src/utils/index.ts @@ -0,0 +1,45 @@ +import type { APIErrorResponse } from '~/types' + +import { startCase } from 'lodash-es' + +export function parseAPIErrors (responseData: APIErrorResponse, parentField?: string): string[] { + const errors = [] + for (const [field, value] of Object.entries(responseData)) { + let fieldName = startCase(field.replace(/_/g, ' ')) + if (parentField) { + fieldName = `${parentField} - ${fieldName}` + } + + if (Array.isArray(value)) { + errors.push(...value.map(err => { + if (typeof err === 'string') { + return err.toLocaleLowerCase().includes('this field ') + ? `${fieldName}: ${err}` + : err + } + + return startCase(err.code.replace(/_/g, ' ')) + })) + + continue + } + + // Handle nested errors + errors.push(...parseAPIErrors(value, fieldName)) + } + + return errors +} + +export function getDomain (url: string) { + return new URL(url).hostname +} + +export function arrayMove (arr: unknown[], oldIndex: number, newIndex: number) { + if (newIndex >= arr.length) { + arr.push(...Array(newIndex - arr.length + 1)) + } + + arr.splice(newIndex, 0, arr.splice(oldIndex, 1)[0]) + return arr +} diff --git a/front/src/utils/search.ts b/front/src/utils/search.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a6d4fcfd230c7c5d8770c29b7b80d0a1f964e1e --- /dev/null +++ b/front/src/utils/search.ts @@ -0,0 +1,82 @@ +export interface Token { + field: string | null + value: string +} + +export function normalizeQuery (query: string): string[] { + // given a string such as 'this is "my query" go', returns + // an array of tokens like this: ['this', 'is', 'my query', 'go'] + if (!query) return [] + + const match = query.match(/\\?.|^$/g) + if (!match) return [] + + const { tokens } = match.reduce((state, c) => { + if (c === '"') { + state.quote ^= 1 + } else if (!state.quote && c === ' ') { + state.tokens.push('') + } else { + state.tokens[state.tokens.length - 1] += c.replace(/\\(.)/, '$1') + } + + return state + }, { tokens: [''], quote: 0 }) + + return tokens +} + +const unquote = (str: string) => { + if (str[0] === '"') str = str.slice(1) + if (str[str.length - 1] === '"') str = str.slice(0, -1) + return str +} + +export function parseTokens (normalizedQuery: string[]): Token[] { + // given an array of tokens as returned by normalizeQuery, + // returns a list of objects such as [ + // { + // field: 'status', + // value: 'pending' + // }, + // { + // field: null, + // value: 'hello' + // } + // ] + return normalizedQuery.map(t => { + // we split the token on ":" + const parts = t.split(/:(.+)/) + if (parts.length === 1) { + // no field specified + return { field: null, value: t } + } + + // first item is the field, second is the value, possibly quoted + const [field, value] = parts + + // we remove surrounding quotes if any + return { field, value: unquote(value) } + }) +} + +export function compileTokens (tokens: Token[]) { + // given a list of tokens as returned by parseTokens, + // returns a string query + const parts = tokens.map(token => { + const { field } = token + let { value } = token + + if (value.includes(' ')) { + value = `"${value}"` + } + + if (field) { + return `${field}:${value}` + } + + return value + }) + + return parts.join(' ') +} diff --git a/front/src/utils/time.js b/front/src/utils/time.js deleted file mode 100644 index 7a5f66ccf86a5c395949c5f18245cd0573cd0d94..0000000000000000000000000000000000000000 --- a/front/src/utils/time.js +++ /dev/null @@ -1,31 +0,0 @@ -function pad (val) { - val = Math.floor(val) - if (val < 10) { - return '0' + val - } - return val + '' -} - -export default { - parse: function (sec) { - let min = 0 - const hours = Math.floor(sec / 3600) - if (hours >= 1) { - sec = sec % 3600 - } - min = Math.floor(sec / 60) - sec = sec - min * 60 - if (hours >= 1) { - return hours + ':' + pad(min) + ':' + pad(sec) - } - return min + ':' + pad(sec) - }, - durationFormatted (v) { - let duration = parseInt(v) - if (duration % 1 !== 0) { - return this.parse(0) - } - duration = Math.round(duration) - return this.parse(duration) - } -} diff --git a/front/src/utils/time.ts b/front/src/utils/time.ts new file mode 100644 index 0000000000000000000000000000000000000000..cac843cadd80c718c1bd01c6d331430be8dab5cd --- /dev/null +++ b/front/src/utils/time.ts @@ -0,0 +1,28 @@ +function pad (val: number) { + val = Math.floor(val) + if (val < 10) { + return '0' + val + } + + return val + '' +} + +export default { + parse: function (sec: number) { + const hours = Math.floor(sec / 3600) + if (hours >= 1) { + sec = sec % 3600 + } + + const min = Math.floor(sec / 60) + sec -= min * 60 + + return hours >= 1 + ? `${hours}:${pad(min)}:${pad(sec)}` + : `${min}:${pad(sec)}` + }, + durationFormatted (v: string | number) { + const duration = typeof v === 'number' ? v : parseInt(v) + return this.parse(duration % 1 !== 0 ? 0 : Math.round(duration)) + } +} diff --git a/front/src/utils/url.js b/front/src/utils/url.js deleted file mode 100644 index 2055ec675d90108c57faa741f7501b2b18163f1d..0000000000000000000000000000000000000000 --- a/front/src/utils/url.js +++ /dev/null @@ -1,11 +0,0 @@ -export default { - updateQueryString (uri, key, value) { - const re = new RegExp('([?&])' + key + '=.*?(&|$)', 'i') - const separator = uri.indexOf('?') !== -1 ? '&' : '?' - if (uri.match(re)) { - return uri.replace(re, '$1' + key + '=' + value + '$2') - } else { - return uri + separator + key + '=' + value - } - } -} diff --git a/front/src/views/Notifications.vue b/front/src/views/Notifications.vue index 1555a5d243353b229db3be7c43ec8300b8baf0f1..3f44e73276ed8336fd3183bf46f4fe92ddfaf2dd 100644 --- a/front/src/views/Notifications.vue +++ b/front/src/views/Notifications.vue @@ -1,3 +1,94 @@ +<script setup lang="ts"> +import type { Notification } from '~/types' + +import moment from 'moment' +import axios from 'axios' + +import { ref, reactive, computed, watch, markRaw } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import NotificationRow from '~/components/notifications/NotificationRow.vue' + +import useWebSocketHandler from '~/composables/useWebSocketHandler' +import useErrorHandler from '~/composables/useErrorHandler' +import useMarkdown from '~/composables/useMarkdown' + +const store = useStore() +const supportMessage = useMarkdown(() => store.state.instance.settings.instance.support_message.value) +const { $pgettext } = useGettext() + +const additionalNotifications = computed(() => store.getters['ui/additionalNotifications']) +const showInstanceSupportMessage = computed(() => store.getters['ui/showInstanceSupportMessage']) +const showFunkwhaleSupportMessage = computed(() => store.getters['ui/showFunkwhaleSupportMessage']) + +const labels = computed(() => ({ + title: $pgettext('*/Notifications/*', 'Notifications') +})) + +const filters = reactive({ + is_read: false +}) + +const isLoading = ref(false) +const notifications = reactive({ count: 0, results: [] as Notification[] }) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get('federation/inbox/', { params: filters }) + notifications.count = response.data.count + notifications.results = response.data.results.map(markRaw) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +watch(filters, fetchData, { immediate: true }) + +useWebSocketHandler('inbox.item_added', (event) => { + notifications.count += 1 + notifications.results.unshift(markRaw((event.item))) +}) + +const instanceSupportMessageDelay = ref(60) +const funkwhaleSupportMessageDelay = ref(60) + +const setDisplayDate = async (field: string, days: number) => { + try { + const response = await axios.patch(`users/${store.state.auth.username}/`, { + [field]: days + ? moment().add({ days }) + : undefined + }) + + store.commit('auth/profilePartialUpdate', response.data) + } catch (error) { + useErrorHandler(error as Error) + } +} + +const markAllAsRead = async () => { + try { + await axios.post('federation/inbox/action/', { + action: 'read', + objects: 'all', + filters: { + is_read: false, + before: notifications.results[0]?.id + } + }) + + store.commit('ui/notifications', { type: 'inbox', count: 0 }) + notifications.results = notifications.results.map(notification => ({ ...notification, is_read: true })) + } catch (error) { + useErrorHandler(error as Error) + } +} +</script> + <template> <main v-title="labels.title" @@ -25,7 +116,7 @@ Support this Funkwhale pod </translate> </h4> - <div v-html="markdown.makeHtml($store.state.instance.settings.instance.support_message.value)" /> + <sanitized-html :html="supportMessage" /> </div> <div class="ui bottom attached segment"> <form @@ -210,110 +301,3 @@ </section> </main> </template> - -<script> -import { mapState, mapGetters } from 'vuex' -import axios from 'axios' -import showdown from 'showdown' -import moment from 'moment' - -import NotificationRow from '@/components/notifications/NotificationRow.vue' - -export default { - components: { - NotificationRow - }, - data () { - return { - isLoading: false, - markdown: new showdown.Converter(), - notifications: { count: 0, results: [] }, - instanceSupportMessageDelay: 60, - funkwhaleSupportMessageDelay: 60, - filters: { - is_read: false - } - } - }, - computed: { - ...mapState({ - events: state => state.instance.events - }), - ...mapGetters({ - additionalNotifications: 'ui/additionalNotifications', - showInstanceSupportMessage: 'ui/showInstanceSupportMessage', - showFunkwhaleSupportMessage: 'ui/showFunkwhaleSupportMessage' - }), - labels () { - return { - title: this.$pgettext('*/Notifications/*', 'Notifications') - } - } - }, - watch: { - 'filters.is_read' () { - this.fetch(this.filters) - } - }, - created () { - this.fetch(this.filters) - this.$store.commit('ui/addWebsocketEventHandler', { - eventName: 'inbox.item_added', - id: 'notificationPage', - handler: this.handleNewNotification - }) - }, - destroyed () { - this.$store.commit('ui/removeWebsocketEventHandler', { - eventName: 'inbox.item_added', - id: 'notificationPage' - }) - }, - methods: { - handleNewNotification (event) { - this.notifications.count += 1 - this.notifications.results.unshift(event.item) - }, - setDisplayDate (field, days) { - const payload = {} - let newDisplayDate - if (days) { - newDisplayDate = moment().add({ days }) - } else { - newDisplayDate = null - } - payload[field] = newDisplayDate - const self = this - axios.patch(`users/${this.$store.state.auth.username}/`, payload).then((response) => { - self.$store.commit('auth/profilePartialUpdate', response.data) - }) - }, - fetch (params) { - this.isLoading = true - const self = this - axios.get('federation/inbox/', { params: params }).then(response => { - self.isLoading = false - self.notifications = response.data - }) - }, - markAllAsRead () { - const self = this - const before = this.notifications.results[0].id - const payload = { - action: 'read', - objects: 'all', - filters: { - is_read: false, - before - } - } - axios.post('federation/inbox/action/', payload).then(response => { - self.$store.commit('ui/notifications', { type: 'inbox', count: 0 }) - self.notifications.results.forEach(n => { - n.is_read = true - }) - }) - } - } -} -</script> diff --git a/front/src/views/Search.vue b/front/src/views/Search.vue index b0c9e08adec3f491f0f26d3b953bdd7ab033f67d..6112c6658b9bd11ccce659f71617d071e6538b77 100644 --- a/front/src/views/Search.vue +++ b/front/src/views/Search.vue @@ -1,3 +1,218 @@ +<script setup lang="ts"> +import type { RadioConfig } from '~/store/radios' + +import { ref, reactive, computed, watch } from 'vue' +import { useRouteQuery } from '@vueuse/router' +import { useGettext } from 'vue3-gettext' +import { syncRef } from '@vueuse/core' + +import axios from 'axios' + +import PlaylistCardList from '~/components/playlists/CardList.vue' +import RemoteSearchForm from '~/components/RemoteSearchForm.vue' +import ArtistCard from '~/components/audio/artist/Card.vue' +import TrackTable from '~/components/audio/track/Table.vue' +import AlbumCard from '~/components/audio/album/Card.vue' +import Pagination from '~/components/vui/Pagination.vue' +import RadioButton from '~/components/radios/Button.vue' +import RadioCard from '~/components/radios/Card.vue' +import TagsList from '~/components/tags/List.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +type QueryType = 'artists' | 'albums' | 'tracks' | 'playlists' | 'tags' | 'radios' | 'podcasts' | 'series' | 'rss' + +const type = useRouteQuery<QueryType>('type', 'artists') +const id = useRouteQuery<string>('id') + +const pageQuery = useRouteQuery<string>('page', '1') +const page = ref(+pageQuery.value) +syncRef(pageQuery, page, { + transform: { + ltr: (left) => +left, + rtl: (right) => right.toString() + } +}) + +const q = useRouteQuery('q', '') +const query = ref(q.value) +syncRef(q, query, { direction: 'ltr' }) + +type ResponseType = { count: number, results: any[] } +const results = reactive({ + artists: null, + albums: null, + tracks: null, + playlists: null, + radios: null, + tags: null, + podcasts: null, + series: null +} as Record<QueryType, null | ResponseType>) + +const paginateBy = ref(25) + +const { $pgettext } = useGettext() + +interface SearchType { + id: QueryType + label: string + includeChannels?: boolean + contentCategory?: string + endpoint?: string +} + +const types = computed(() => [ + { + id: 'artists', + label: $pgettext('*/*/*/Noun', 'Artists'), + includeChannels: true, + contentCategory: 'music' + }, + { + id: 'albums', + label: $pgettext('*/*/*', 'Albums'), + includeChannels: true, + contentCategory: 'music' + }, + { + id: 'tracks', + label: $pgettext('*/*/*', 'Tracks') + }, + { + id: 'playlists', + label: $pgettext('*/*/*', 'Playlists') + }, + { + id: 'radios', + label: $pgettext('*/*/*', 'Radios'), + endpoint: 'radios/radios' + }, + { + id: 'tags', + label: $pgettext('*/*/*', 'Tags') + }, + { + id: 'podcasts', + label: $pgettext('*/*/*', 'Podcasts'), + endpoint: '/artists', + contentCategory: 'podcast', + includeChannels: true + }, + { + id: 'series', + label: $pgettext('*/*/*', 'Series'), + endpoint: '/albums', + includeChannels: true, + contentCategory: 'podcast' + } +] as SearchType[]) + +const currentType = computed(() => types.value.find(({ id }) => id === type.value)) + +const axiosParams = computed(() => { + const params = new URLSearchParams({ + q: query.value, + page: page.value as unknown as string, + page_size: paginateBy.value as unknown as string + }) + + if (currentType.value?.contentCategory) params.append('content_category', currentType.value.contentCategory) + if (currentType.value?.includeChannels) params.append('include_channels', currentType.value.includeChannels as unknown as string) + + return params +}) + +const currentResults = computed(() => results[currentType.value?.id ?? 'artists']) + +const isLoading = ref(false) +const search = async () => { + if (!currentType.value) return + + q.value = query.value + + if (!query.value) { + for (const type of types.value) { + results[type.id] = null + } + + return + } + + isLoading.value = true + + try { + const response = await axios.get(currentType.value.endpoint ?? currentType.value.id, { + params: axiosParams.value + }) + + results[currentType.value.id] = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false + + // TODO (wvffle): Resolve race condition + for (const type of types.value) { + if (type.id !== currentType.value.id) { + axios.get(type.endpoint ?? type.id, { + params: { + q: query.value, + page_size: 1, + content_category: type.contentCategory, + include_channels: type.includeChannels + } + }).then(response => { + results[type.id] = response.data + }).catch(() => undefined) + } + } +} + +watch(type, () => { + page.value = 1 + search() +}) + +search() + +const labels = computed(() => ({ + title: id.value + ? ( + type.value === 'rss' + ? $pgettext('Head/Fetch/Title', 'Subscribe to a podcast RSS feed') + : $pgettext('Head/Fetch/Title', 'Search a remote object') + ) + : $pgettext('Content/Search/Input.Label/Noun', 'Search'), + submitSearch: $pgettext('Content/Search/Button.Label/Verb', 'Submit Search Query') +})) + +const radioConfig = computed(() => { + const results = Object.values(currentResults.value?.results ?? {}) + if (results.length) { + if (currentType.value?.id === 'tags') { + return { + type: 'tag', + names: results.map(({ name }) => name) + } as RadioConfig + } + + if (currentType.value?.id === 'artists') { + return { + type: 'artist', + ids: results.map(({ id }) => id) + } as RadioConfig + } + + // TODO (wvffle): Use logger + console.info('This type is not yet supported for radio') + } + + return null +}) +</script> + <template> <main v-title="labels.title" @@ -5,13 +220,13 @@ > <section class="ui vertical stripe segment"> <div - v-if="initialId" + v-if="id" class="ui small text container" > <h2>{{ labels.title }}</h2> <remote-search-form - :initial-id="initialId" - :type="initialType" + :initial-id="id" + :type="type" /> </div> <div @@ -51,10 +266,10 @@ </div> <div class="column"> <radio-button - v-if="currentResults && currentConfigValidated && ( type === 'tags' || type === 'artists' ) " + v-if="radioConfig" class="ui right floated medium button" type="custom_multiple" - :config="currentConfig" + :radio-config="radioConfig" /> </div> </div> @@ -71,7 +286,8 @@ v-if="results[t.id]" class="ui circular mini right floated label" > - {{ results[t.id].count }}</span> + {{ results[t.id]?.count ?? 0 }} + </span> </a> </div> <div v-if="isLoading"> @@ -138,241 +354,11 @@ <pagination v-if="currentResults && currentResults.count > paginateBy" - :current="page" + v-model:current="page" :paginate-by="paginateBy" :total="currentResults.count" - @page-changed="page = $event" /> </div> </section> </main> </template> - -<script> -import RemoteSearchForm from '@/components/RemoteSearchForm.vue' -import ArtistCard from '@/components/audio/artist/Card.vue' -import AlbumCard from '@/components/audio/album/Card.vue' -import TrackTable from '@/components/audio/track/Table.vue' -import Pagination from '@/components/Pagination.vue' -import PlaylistCardList from '@/components/playlists/CardList.vue' -import RadioButton from '@/components/radios/Button.vue' -import RadioCard from '@/components/radios/Card.vue' -import TagsList from '@/components/tags/List.vue' - -import axios from 'axios' - -export default { - components: { - RemoteSearchForm, - ArtistCard, - AlbumCard, - TrackTable, - Pagination, - PlaylistCardList, - RadioCard, - RadioButton, - TagsList - }, - props: { - initialId: { type: String, required: false, default: '' }, - initialType: { type: String, required: false, default: '' }, - initialQuery: { type: String, required: false, default: '' }, - initialPage: { type: Number, required: false, default: 0 } - }, - data () { - return { - query: this.initialQuery, - type: this.initialType, - page: this.initialPage, - results: { - artists: null, - albums: null, - tracks: null, - playlists: null, - radios: null, - tags: null, - podcasts: null, - series: null - }, - isLoading: false, - paginateBy: 25, - config: null - } - }, - computed: { - labels () { - const submitSearch = this.$pgettext('Content/Search/Button.Label/Verb', 'Submit Search Query') - let title = this.$pgettext('Content/Search/Input.Label/Noun', 'Search') - if (this.initialId) { - title = this.$pgettext('Head/Fetch/Title', 'Search a remote object') - if (this.type === 'rss') { - title = this.$pgettext('Head/Fetch/Title', 'Subscribe to a podcast RSS feed') - } - } - return { - title, - submitSearch - } - }, - axiosParams () { - const params = new URLSearchParams() - params.append('q', this.query) - params.append('page', this.page) - params.append('page_size', this.paginateBy) - if (this.currentType.contentCategory !== undefined) { params.append('content_category', this.currentType.contentCategory) }; - if (this.currentType.includeChannels !== undefined) { params.append('include_channels', this.currentType.includeChannels) }; - return params - }, - types () { - return [ - { - id: 'artists', - label: this.$pgettext('*/*/*/Noun', 'Artists'), - includeChannels: true, - contentCategory: 'music' - }, - { - id: 'albums', - label: this.$pgettext('*/*/*', 'Albums'), - includeChannels: true, - contentCategory: 'music' - }, - { - id: 'tracks', - label: this.$pgettext('*/*/*', 'Tracks') - }, - { - id: 'playlists', - label: this.$pgettext('*/*/*', 'Playlists') - }, - { - id: 'radios', - label: this.$pgettext('*/*/*', 'Radios'), - endpoint: 'radios/radios' - }, - { - id: 'tags', - label: this.$pgettext('*/*/*', 'Tags') - }, - { - id: 'podcasts', - label: this.$pgettext('*/*/*', 'Podcasts'), - endpoint: '/artists', - contentCategory: 'podcast', - includeChannels: true - }, - { - id: 'series', - label: this.$pgettext('*/*/*', 'Series'), - endpoint: '/albums', - includeChannels: true, - contentCategory: 'podcast' - } - ] - }, - currentType () { - return this.types.filter(t => { - return t.id === this.type - })[0] - }, - currentResults () { - return this.results[this.currentType.id] - }, - currentConfig () { - const resultDict = this.currentResults.results - return this.generateConfig(this.currentType.id, resultDict) - }, - currentConfigValidated () { - const configValidate = this.currentConfig - const array = configValidate[0][Object.keys(configValidate[0])[1]] - return array.length >= 1 - } - }, - watch: { - async type () { - this.page = 1 - this.updateQueryString() - await this.search() - }, - async page () { - this.updateQueryString() - await this.search() - }, - '$route.query.q': async function (v) { - this.query = v - this.updateQueryString() - await this.search() - } - }, - created () { - this.search() - }, - methods: { - async search () { - this.updateQueryString() - if (!this.query) { - this.types.forEach(t => { - this.results[t.id] = null - }) - return - } - this.isLoading = true - const response = await axios.get( - this.currentType.endpoint || this.currentType.id, - { params: this.axiosParams } - ) - this.results[this.currentType.id] = response.data - this.isLoading = false - this.types.forEach(t => { - if (t.id !== this.currentType.id) { - axios.get(t.endpoint || t.id, { - params: { - q: this.query, - page_size: 1, - content_category: t.contentCategory, - include_channels: t.includeChannels - } - }).then(response => { - this.results[t.id] = response.data - }) - } - }) - }, - updateQueryString: function () { - history.pushState( - {}, - null, - this.$route.path + '?' + new URLSearchParams( - { - q: this.query, - page: this.page, - type: this.type - }).toString() - ) - }, - generateConfig: function (type, resultDict) { - const obj = { - type: type.slice(0, -1) - } - switch (type) { - case 'tags': - obj.names = this.generateTagConfig(resultDict, type) - break - case 'artists': - obj.ids = this.generateArtistConfig(resultDict, type) - break - default: - console.info('This type is not yet supported for radio') - obj.ids = 0 - } - return [obj] - }, - generateTagConfig: function (resultDict, type) { - return Object.values(resultDict).map(({ name }) => name) - }, - generateArtistConfig: function (resultDict, type) { - return Object.values(resultDict).map(({ id }) => id) - } - } -} -</script> diff --git a/front/src/views/admin/ChannelDetail.vue b/front/src/views/admin/ChannelDetail.vue index 71af70a1c3b2e5301171e98ad8f49c26c52762de..fcfdc8adf776cafaa43d3f8831de99c89fb61c2a 100644 --- a/front/src/views/admin/ChannelDetail.vue +++ b/front/src/views/admin/ChannelDetail.vue @@ -1,3 +1,78 @@ +<script setup lang="ts"> +import { humanSize, truncate } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { computed, ref } from 'vue' + +import axios from 'axios' + +import FetchButton from '~/components/federation/FetchButton.vue' +import TagsList from '~/components/tags/List.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() +const router = useRouter() + +const labels = computed(() => ({ + statsWarning: $pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') +})) + +const isLoading = ref(false) +const object = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/channels/${props.id}/`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const isLoadingStats = ref(false) +const stats = ref() +const fetchStats = async () => { + isLoadingStats.value = true + + try { + const response = await axios.get(`manage/channels/${props.id}/stats/`) + stats.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingStats.value = false +} + +fetchStats() +fetchData() + +const remove = async () => { + isLoading.value = true + + try { + await axios.delete(`manage/channels/${props.id}/`) + router.push({ name: 'manage.channels' }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const getQuery = (field: string, value: string) => `${field}:"${value}"` +</script> + <template> <main> <div @@ -26,7 +101,7 @@ src="../../assets/audio/default-cover.png" > <div class="content"> - {{ object.artist.name | truncate(100) }} + {{ truncate(object.artist.name) }} <div class="sub header"> <template v-if="object.artist.is_local"> <span class="ui tiny accent label"> @@ -105,23 +180,29 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Delete this channel? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The channel will be removed, as well as associated uploads, tracks, and albums. This action is irreversible. + <translate translate-context="Popup/Library/Title"> + Delete this channel? </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The channel will be removed, as well as associated uploads, tracks, and albums. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb"> + Delete + </translate> + </p> + </template> </dangerous-button> </div> </div> @@ -195,7 +276,10 @@ Description </translate> </td> - <td v-html="object.artist.description.html" /> + <sanitized-html + tag="td" + :html="object.artist.description.html" + /> </tr> <tr v-if="object.actor.url"> <td> @@ -354,7 +438,7 @@ </translate> </td> <td> - {{ stats.media_downloaded_size | humanSize }} + {{ humanSize(stats.media_downloaded_size) }} </td> </tr> <tr> @@ -364,7 +448,7 @@ </translate> </td> <td> - {{ stats.media_total_size | humanSize }} + {{ humanSize(stats.media_total_size) }} </td> </tr> <tr> @@ -412,68 +496,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' - -import TagsList from '@/components/tags/List.vue' -import FetchButton from '@/components/federation/FetchButton.vue' - -export default { - components: { - FetchButton, - TagsList - }, - props: { id: { type: String, required: true } }, - data () { - return { - isLoading: true, - isLoadingStats: false, - object: null, - stats: null - } - }, - computed: { - labels () { - return { - statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') - } - } - }, - created () { - this.fetchData() - this.fetchStats() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = `manage/channels/${this.id}/` - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - }) - }, - fetchStats () { - const self = this - this.isLoadingStats = true - const url = `manage/channels/${this.id}/stats/` - axios.get(url).then(response => { - self.stats = response.data - self.isLoadingStats = false - }) - }, - remove () { - const self = this - this.isLoading = true - const url = `manage/channels/${this.id}/` - axios.delete(url).then(response => { - self.$router.push({ name: 'manage.channels' }) - }) - }, - getQuery (field, value) { - return `${field}:"${value}"` - } - } -} -</script> diff --git a/front/src/views/admin/ChannelsList.vue b/front/src/views/admin/ChannelsList.vue deleted file mode 100644 index 3ab794dd7a0b9992b0290d9823414b7b9b33869f..0000000000000000000000000000000000000000 --- a/front/src/views/admin/ChannelsList.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <main v-title="labels.title"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - {{ labels.title }} - </h2> - <div class="ui hidden divider" /> - <channels-table - :update-url="true" - :default-query="defaultQuery" - /> - </section> - </main> -</template> - -<script> -import ChannelsTable from '@/components/manage/ChannelsTable.vue' - -export default { - components: { - ChannelsTable - }, - props: { - defaultQuery: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - title: this.$pgettext('*/*/*', 'Channels') - } - } - } -} -</script> diff --git a/front/src/views/admin/CommonList.vue b/front/src/views/admin/CommonList.vue new file mode 100644 index 0000000000000000000000000000000000000000..7f5bdd061d63f57bc2ed7e36b6d175ac75401d30 --- /dev/null +++ b/front/src/views/admin/CommonList.vue @@ -0,0 +1,97 @@ +<script setup lang="ts"> +import AccountsTable from '~/components/manage/moderation/AccountsTable.vue' +import ArtistsTable from '~/components/manage/library/ArtistsTable.vue' +import AlbumsTable from '~/components/manage/library/AlbumsTable.vue' +import ChannelsTable from '~/components/manage/ChannelsTable.vue' +import InvitationForm from '~/components/manage/users/InvitationForm.vue' +import InvitationsTable from '~/components/manage/users/InvitationsTable.vue' +import LibrariesTable from '~/components/manage/library/LibrariesTable.vue' +import TagsTable from '~/components/manage/library/TagsTable.vue' +import TracksTable from '~/components/manage/library/TracksTable.vue' +import UploadsTable from '~/components/manage/library/UploadsTable.vue' +import UsersTable from '~/components/manage/users/UsersTable.vue' + +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +type ViewType = 'accounts' | 'albums' | 'artists' | 'channels' | 'invitations' | 'libraries' | 'tags' | 'tracks' | 'uploads' | 'users' + +interface Props { + defaultQuery?: string, + type: ViewType +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '' +}) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + accounts: $pgettext('*/Moderation/Title', 'Accounts'), + albums: $pgettext('*/*/*', 'Albums'), + artists: $pgettext('*/*/*/Noun', 'Artists'), + channels: $pgettext('*/*/*', 'Channels'), + invitations: $pgettext('*/Admin/*/Noun', 'Invitations'), + libraries: $pgettext('*/*/*/Noun', 'Libraries'), + tags: $pgettext('*/*/*/Noun', 'Tags'), + tracks: $pgettext('*/*/*', 'Tracks'), + uploads: $pgettext('*/*/*', 'Uploads'), + users: $pgettext('*/*/*/Noun', 'Users') +})) + +const title = computed(() => labels.value[props.type]) +</script> + +<template> + <main v-title="title"> + <section class="ui vertical stripe segment"> + <h2 class="ui header"> + {{ title }} + </h2> + <invitation-form v-if="type === 'invitations'" /> + <div class="ui hidden divider" /> + <accounts-table + v-if="type === 'accounts'" + :update-url="true" + :default-query="defaultQuery" + /> + <albums-table + v-else-if="type === 'albums'" + :update-url="true" + :default-query="defaultQuery" + /> + <artists-table + v-else-if="type === 'artists'" + :update-url="true" + :default-query="defaultQuery" + /> + <channels-table + v-else-if="type === 'channels'" + :update-url="true" + :default-query="defaultQuery" + /> + <invitations-table v-else-if="type === 'invitations'" /> + <libraries-table + v-else-if="type === 'libraries'" + :update-url="true" + :default-query="defaultQuery" + /> + <tags-table + v-else-if="type === 'tags'" + :update-url="true" + :default-query="defaultQuery" + /> + <tracks-table + v-else-if="type === 'tracks'" + :update-url="true" + :default-query="defaultQuery" + /> + <uploads-table + v-else-if="type === 'uploads'" + :update-url="true" + :default-query="defaultQuery" + /> + <users-table v-else-if="type === 'users'" /> + </section> + </main> +</template> diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue index d1848c7a73d0a2daac863c9552aaea5b083c6dbf..6cdc74db12c8c3eb12f8df0355332fb2be6b95ef 100644 --- a/front/src/views/admin/Settings.vue +++ b/front/src/views/admin/Settings.vue @@ -1,3 +1,167 @@ +<script setup lang="ts"> +import type { SettingsGroup as SettingsGroupType } from '~/types' + +import axios from 'axios' +import $ from 'jquery' + +import { ref, nextTick, onMounted, computed, watch } from 'vue' +import { useCurrentElement } from '@vueuse/core' +import { useGettext } from 'vue3-gettext' +import { useRoute } from 'vue-router' + +import SettingsGroup from '~/components/admin/SettingsGroup.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +const current = ref() +const settingsData = ref() + +const { $pgettext } = useGettext() + +const groups = computed(() => [ + { + label: $pgettext('Content/Admin/Menu', 'Instance information'), + id: 'instance', + settings: [ + { name: 'instance__name' }, + { name: 'instance__short_description' }, + { name: 'instance__long_description', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } }, + { name: 'instance__contact_email' }, + { name: 'instance__rules', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } }, + { name: 'instance__terms', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } }, + { name: 'instance__banner' }, + { name: 'instance__support_message', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } } + ] + }, + { + label: $pgettext('*/*/*/Noun', 'Sign-ups'), + id: 'signup', + settings: [ + { name: 'users__registration_enabled' }, + { name: 'moderation__signup_approval_enabled' }, + { name: 'moderation__signup_form_customization', fieldType: 'formBuilder' } + ] + }, + { + label: $pgettext('*/*/*/Noun', 'Security'), + id: 'security', + settings: [ + { name: 'common__api_authentication_required' }, + { name: 'users__default_permissions' }, + { name: 'users__upload_quota' } + ] + }, + { + label: $pgettext('*/*/*/Noun', 'Music'), + id: 'music', + settings: [ + { name: 'music__transcoding_enabled' }, + { name: 'music__transcoding_cache_duration' } + ] + }, + { + label: $pgettext('*/*/*', 'Channels'), + id: 'channels', + settings: [ + { name: 'audio__channels_enabled' }, + { name: 'audio__max_channels' } + ] + }, + { + label: $pgettext('*/*/*', 'Playlists'), + id: 'playlists', + settings: [ + { name: 'playlists__max_tracks' } + ] + }, + { + label: $pgettext('*/Moderation/*', 'Moderation'), + id: 'moderation', + settings: [ + { name: 'moderation__allow_list_enabled' }, + { name: 'moderation__allow_list_public' }, + { name: 'moderation__unauthenticated_report_types' } + ] + }, + { + label: $pgettext('*/*/*', 'Federation'), + id: 'federation', + settings: [ + { name: 'federation__enabled' }, + { name: 'federation__public_index' }, + { name: 'federation__collection_page_size' }, + { name: 'federation__music_cache_duration' }, + { name: 'federation__actor_fetch_delay' } + ] + }, + { + label: $pgettext('Content/Admin/Menu', 'Subsonic'), + id: 'subsonic', + settings: [ + { name: 'subsonic__enabled' } + ] + }, + { + label: $pgettext('Content/Home/Header', 'Statistics'), + id: 'ui', + settings: [ + { name: 'ui__custom_css' }, + { name: 'instance__funkwhale_support_message_enabled' } + ] + }, + { + label: $pgettext('Content/Admin/Menu', 'User Interface'), + id: 'statistics', + settings: [ + { name: 'instance__nodeinfo_stats_enabled' }, + { name: 'instance__nodeinfo_private' } + ] + } +] as SettingsGroupType[]) + +const labels = computed(() => ({ + settings: $pgettext('Head/Admin/Title', 'Instance settings') +})) + +const scrollTo = (id: string) => { + current.value = id + document.getElementById(id)?.scrollIntoView() +} + +const route = useRoute() +if (route.hash) { + scrollTo(route.hash.slice(1)) +} + +const el = useCurrentElement() +onMounted(async () => { + await nextTick() + $(el.value).find('select.dropdown').dropdown() +}) + +watch(settingsData, async () => { + await nextTick() + $(el.value).find('.sticky').sticky({ context: '#settings-grid' }) +}) + +const isLoading = ref(false) +const fetchSettings = async () => { + isLoading.value = true + + try { + const response = await axios.get('instance/admin/settings/') + settingsData.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +await fetchSettings() +await nextTick() +</script> + <template> <main v-title="labels.settings" @@ -14,7 +178,7 @@ <div class="twelve wide stretched column"> <settings-group v-for="group in groups" - :key="group.title" + :key="group.id" :settings-data="settingsData" :group="group" /> @@ -40,179 +204,3 @@ </div> </main> </template> - -<script> -import axios from 'axios' -import $ from 'jquery' - -import SettingsGroup from '@/components/admin/SettingsGroup.vue' - -export default { - components: { - SettingsGroup - }, - data () { - return { - isLoading: false, - settingsData: null, - current: null - } - }, - computed: { - labels () { - return { - settings: this.$pgettext('Head/Admin/Title', 'Instance settings') - } - }, - groups () { - // somehow, extraction fails if in the return block directly - const instanceLabel = this.$pgettext('Content/Admin/Menu', 'Instance information') - const signupsLabel = this.$pgettext('*/*/*/Noun', 'Sign-ups') - const securityLabel = this.$pgettext('*/*/*/Noun', 'Security') - const musicLabel = this.$pgettext('*/*/*/Noun', 'Music') - const channelsLabel = this.$pgettext('*/*/*', 'Channels') - const playlistsLabel = this.$pgettext('*/*/*', 'Playlists') - const federationLabel = this.$pgettext('*/*/*', 'Federation') - const moderationLabel = this.$pgettext('*/Moderation/*', 'Moderation') - const subsonicLabel = this.$pgettext('Content/Admin/Menu', 'Subsonic') - const statisticsLabel = this.$pgettext('Content/Home/Header', 'Statistics') - const uiLabel = this.$pgettext('Content/Admin/Menu', 'User Interface') - return [ - { - label: instanceLabel, - id: 'instance', - settings: [ - { name: 'instance__name' }, - { name: 'instance__short_description' }, - { name: 'instance__long_description', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } }, - { name: 'instance__contact_email' }, - { name: 'instance__rules', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } }, - { name: 'instance__terms', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } }, - { name: 'instance__banner' }, - { name: 'instance__support_message', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } } - ] - }, - { - label: signupsLabel, - id: 'signup', - settings: [ - { name: 'users__registration_enabled' }, - { name: 'moderation__signup_approval_enabled' }, - { name: 'moderation__signup_form_customization', fieldType: 'formBuilder' } - ] - }, - { - label: securityLabel, - id: 'security', - settings: [ - { name: 'common__api_authentication_required' }, - { name: 'users__default_permissions' }, - { name: 'users__upload_quota' } - ] - }, - { - label: musicLabel, - id: 'music', - settings: [ - { name: 'music__transcoding_enabled' }, - { name: 'music__transcoding_cache_duration' } - ] - }, - { - label: channelsLabel, - id: 'channels', - settings: [ - { name: 'audio__channels_enabled' }, - { name: 'audio__max_channels' } - ] - }, - { - label: playlistsLabel, - id: 'playlists', - settings: [ - { name: 'playlists__max_tracks' } - ] - }, - { - label: moderationLabel, - id: 'moderation', - settings: [ - { name: 'moderation__allow_list_enabled' }, - { name: 'moderation__allow_list_public' }, - { name: 'moderation__unauthenticated_report_types' } - ] - }, - { - label: federationLabel, - id: 'federation', - settings: [ - { name: 'federation__enabled' }, - { name: 'federation__public_index' }, - { name: 'federation__collection_page_size' }, - { name: 'federation__music_cache_duration' }, - { name: 'federation__actor_fetch_delay' } - ] - }, - { - label: subsonicLabel, - id: 'subsonic', - settings: [ - { name: 'subsonic__enabled' } - ] - }, - { - label: uiLabel, - id: 'ui', - settings: [ - { name: 'ui__custom_css' }, - { name: 'instance__funkwhale_support_message_enabled' } - ] - }, - { - label: statisticsLabel, - id: 'statistics', - settings: [ - { name: 'instance__nodeinfo_stats_enabled' }, - { name: 'instance__nodeinfo_private' } - ] - } - ] - } - }, - watch: { - settingsData () { - const self = this - this.$nextTick(() => { - $(self.$el) - .find('.sticky') - .sticky({ context: '#settings-grid' }) - }) - } - }, - created () { - const self = this - this.fetchSettings().then(r => { - self.$nextTick(() => { - if (self.$store.state.route.hash) { - self.scrollTo(self.$store.state.route.hash.substr(1)) - } - $('select.dropdown').dropdown() - }) - }) - }, - methods: { - scrollTo (id) { - this.current = id - document.getElementById(id).scrollIntoView() - }, - fetchSettings () { - const self = this - self.isLoading = true - return axios.get('instance/admin/settings/').then(response => { - self.settingsData = response.data - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/views/admin/library/AlbumDetail.vue b/front/src/views/admin/library/AlbumDetail.vue index cb4c68261dcd3a1846e62769c97eeaf29aa64b29..ba35026afc2b10c903cfbc085b78b2803aa50035 100644 --- a/front/src/views/admin/library/AlbumDetail.vue +++ b/front/src/views/admin/library/AlbumDetail.vue @@ -1,3 +1,78 @@ +<script setup lang="ts"> +import { humanSize, truncate } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { computed, ref } from 'vue' + +import axios from 'axios' + +import FetchButton from '~/components/federation/FetchButton.vue' +import TagsList from '~/components/tags/List.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() +const router = useRouter() + +const labels = computed(() => ({ + statsWarning: $pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') +})) + +const isLoading = ref(false) +const object = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/library/albums/${props.id}/`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const isLoadingStats = ref(false) +const stats = ref() +const fetchStats = async () => { + isLoadingStats.value = true + + try { + const response = await axios.get(`manage/library/albums/${props.id}/stats/`) + stats.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingStats.value = false +} + +fetchStats() +fetchData() + +const remove = async () => { + isLoading.value = true + + try { + await axios.delete(`manage/library/albums/${props.id}/`) + router.push({ name: 'manage.library.albums' }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const getQuery = (field: string, value: string) => `${field}:"${value}"` +</script> + <template> <main> <div @@ -16,7 +91,7 @@ <div class="segment-content"> <h2 class="ui header"> <img - v-if="object.cover && object.cover.urls.original" + v-if="object.cover?.urls.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)" alt="" > @@ -26,7 +101,7 @@ src="../../../assets/audio/default-cover.png" > <div class="content"> - {{ object.title | truncate(100) }} + {{ truncate(object.title) }} <div class="sub header"> <template v-if="object.is_local"> <span class="ui tiny accent label"> @@ -128,23 +203,29 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Delete this album? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The album will be removed, as well as associated uploads, tracks, favorites and listening history. This action is irreversible. + <translate translate-context="Popup/Library/Title"> + Delete this album? </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The album will be removed, as well as associated uploads, tracks, favorites and listening history. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb"> + Delete + </translate> + </p> + </template> </dangerous-button> </div> </div> @@ -206,7 +287,10 @@ Description </translate> </td> - <td v-html="object.description.html" /> + <sanitized-html + tag="td" + :html="object.description.html" + /> </tr> </tbody> </table> @@ -337,7 +421,7 @@ </translate> </td> <td> - {{ stats.media_downloaded_size | humanSize }} + {{ humanSize(stats.media_downloaded_size) }} </td> </tr> <tr> @@ -347,7 +431,7 @@ </translate> </td> <td> - {{ stats.media_total_size | humanSize }} + {{ humanSize(stats.media_total_size) }} </td> </tr> @@ -396,67 +480,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' -import FetchButton from '@/components/federation/FetchButton.vue' -import TagsList from '@/components/tags/List.vue' - -export default { - components: { - FetchButton, - TagsList - }, - props: { id: { type: Number, required: true } }, - data () { - return { - isLoading: true, - isLoadingStats: false, - object: null, - stats: null - } - }, - computed: { - labels () { - return { - statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') - } - } - }, - created () { - this.fetchData() - this.fetchStats() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = `manage/library/albums/${this.id}/` - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - }) - }, - fetchStats () { - const self = this - this.isLoadingStats = true - const url = `manage/library/albums/${this.id}/stats/` - axios.get(url).then(response => { - self.stats = response.data - self.isLoadingStats = false - }) - }, - remove () { - const self = this - this.isLoading = true - const url = `manage/library/albums/${this.id}/` - axios.delete(url).then(response => { - self.$router.push({ name: 'manage.library.albums' }) - }) - }, - getQuery (field, value) { - return `${field}:"${value}"` - } - } -} -</script> diff --git a/front/src/views/admin/library/AlbumsList.vue b/front/src/views/admin/library/AlbumsList.vue deleted file mode 100644 index 82ceca196b1be139ca68bfaa622c0d7b908802c1..0000000000000000000000000000000000000000 --- a/front/src/views/admin/library/AlbumsList.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <main v-title="labels.title"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - {{ labels.title }} - </h2> - <div class="ui hidden divider" /> - <albums-table - :update-url="true" - :default-query="defaultQuery" - /> - </section> - </main> -</template> - -<script> -import AlbumsTable from '@/components/manage/library/AlbumsTable.vue' - -export default { - components: { - AlbumsTable - }, - props: { - defaultQuery: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - title: this.$pgettext('*/*/*', 'Albums') - } - } - } -} -</script> diff --git a/front/src/views/admin/library/ArtistDetail.vue b/front/src/views/admin/library/ArtistDetail.vue index 875082bbbe12841324b87f00998ebc2083eeecc6..9bad1cb18106848ed10a6b50465bdb89abe39563 100644 --- a/front/src/views/admin/library/ArtistDetail.vue +++ b/front/src/views/admin/library/ArtistDetail.vue @@ -1,3 +1,78 @@ +<script setup lang="ts"> +import { humanSize, truncate } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { computed, ref } from 'vue' + +import axios from 'axios' + +import FetchButton from '~/components/federation/FetchButton.vue' +import TagsList from '~/components/tags/List.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() +const router = useRouter() + +const labels = computed(() => ({ + statsWarning: $pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') +})) + +const isLoading = ref(false) +const object = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/library/artists/${props.id}/`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const isLoadingStats = ref(false) +const stats = ref() +const fetchStats = async () => { + isLoadingStats.value = true + + try { + const response = await axios.get(`manage/library/artists/${props.id}/stats/`) + stats.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingStats.value = false +} + +fetchStats() +fetchData() + +const remove = async () => { + isLoading.value = true + + try { + await axios.delete(`manage/library/artists/${props.id}/`) + router.push({ name: 'manage.library.artists' }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const getQuery = (field: string, value: string) => `${field}:"${value}"` +</script> + <template> <main> <div @@ -26,7 +101,7 @@ src="../../../assets/audio/default-cover.png" > <div class="content"> - {{ object.name | truncate(100) }} + {{ truncate(object.name) }} <div class="sub header"> <template v-if="object.is_local"> <span class="ui tiny accent label"> @@ -127,23 +202,29 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Delete this artist? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible. + <translate translate-context="Popup/Library/Title"> + Delete this artist? </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb"> + Delete + </translate> + </p> + </template> </dangerous-button> </div> </div> @@ -205,7 +286,10 @@ Description </translate> </td> - <td v-html="object.description.html" /> + <sanitized-html + tag="td" + :html="object.description.html" + /> </tr> </tbody> </table> @@ -336,7 +420,7 @@ </translate> </td> <td> - {{ stats.media_downloaded_size | humanSize }} + {{ humanSize(stats.media_downloaded_size) }} </td> </tr> <tr> @@ -346,7 +430,7 @@ </translate> </td> <td> - {{ stats.media_total_size | humanSize }} + {{ humanSize(stats.media_total_size) }} </td> </tr> @@ -407,72 +491,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' - -import TagsList from '@/components/tags/List.vue' -import FetchButton from '@/components/federation/FetchButton.vue' - -export default { - components: { - FetchButton, - TagsList - }, - props: { id: { type: Number, required: true } }, - data () { - return { - isLoading: true, - isLoadingStats: false, - object: null, - stats: null - } - }, - computed: { - labels () { - return { - statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') - } - } - }, - created () { - this.fetchData() - this.fetchStats() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = `manage/library/artists/${this.id}/` - axios.get(url).then(response => { - if (response.data.channel) { - self.$router.push({ name: 'manage.channels.detail', params: { id: response.data.channel } }) - } else { - self.object = response.data - self.isLoading = false - } - }) - }, - fetchStats () { - const self = this - this.isLoadingStats = true - const url = `manage/library/artists/${this.id}/stats/` - axios.get(url).then(response => { - self.stats = response.data - self.isLoadingStats = false - }) - }, - remove () { - const self = this - this.isLoading = true - const url = `manage/library/artists/${this.id}/` - axios.delete(url).then(response => { - self.$router.push({ name: 'manage.library.artists' }) - }) - }, - getQuery (field, value) { - return `${field}:"${value}"` - } - } -} -</script> diff --git a/front/src/views/admin/library/ArtistsList.vue b/front/src/views/admin/library/ArtistsList.vue deleted file mode 100644 index fcbca0f977e128dd2036ac28ee40dc8d9f794321..0000000000000000000000000000000000000000 --- a/front/src/views/admin/library/ArtistsList.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <main v-title="labels.title"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - {{ labels.title }} - </h2> - <div class="ui hidden divider" /> - <artists-table - :update-url="true" - :default-query="defaultQuery" - /> - </section> - </main> -</template> - -<script> -import ArtistsTable from '@/components/manage/library/ArtistsTable.vue' - -export default { - components: { - ArtistsTable - }, - props: { - defaultQuery: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - title: this.$pgettext('*/*/*/Noun', 'Artists') - } - } - } -} -</script> diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index 97f9013e7b6cfd9c7e811a784b3fa339a139f167..4b50bea0dcad8466256ce8fdebeb9bdbab43be2b 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -1,3 +1,14 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/Admin/Title', 'Manage library'), + secondaryMenu: $pgettext('Menu/*/Hidden text', 'Secondary menu') +})) +</script> + <template> <div v-title="labels.title" @@ -76,18 +87,3 @@ <router-view :key="$route.fullPath" /> </div> </template> - -<script> -export default { - computed: { - labels () { - const title = this.$pgettext('Head/Admin/Title', 'Manage library') - const secondaryMenu = this.$pgettext('Menu/*/Hidden text', 'Secondary menu') - return { - title, - secondaryMenu - } - } - } -} -</script> diff --git a/front/src/views/admin/library/EditsList.vue b/front/src/views/admin/library/EditsList.vue index 9ce83dcd692801205893dba48bd42d6289710324..12c707c9d62d8d2c6cec88846702e0c30229649b 100644 --- a/front/src/views/admin/library/EditsList.vue +++ b/front/src/views/admin/library/EditsList.vue @@ -1,3 +1,23 @@ +<script setup lang="ts"> +import { useGettext } from 'vue3-gettext' +import { computed } from 'vue' + +import EditsCardList from '~/components/manage/library/EditsCardList.vue' + +interface Props { + defaultQuery?: string +} + +withDefaults(defineProps<Props>(), { + defaultQuery: '' +}) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('*/Admin/*/Noun', 'Edits') +})) +</script> + <template> <main v-title="labels.title"> <section class="ui vertical stripe segment"> @@ -14,23 +34,3 @@ </section> </main> </template> - -<script> -import EditsCardList from '@/components/manage/library/EditsCardList.vue' - -export default { - components: { - EditsCardList - }, - props: { - defaultQuery: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - title: this.$pgettext('*/Admin/*/Noun', 'Edits') - } - } - } -} -</script> diff --git a/front/src/views/admin/library/LibrariesList.vue b/front/src/views/admin/library/LibrariesList.vue deleted file mode 100644 index 7a9b03446822fd1f097da064ba18aed4b4dd23cc..0000000000000000000000000000000000000000 --- a/front/src/views/admin/library/LibrariesList.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <main v-title="labels.title"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - {{ labels.title }} - </h2> - <div class="ui hidden divider" /> - <libraries-table - :update-url="true" - :default-query="defaultQuery" - /> - </section> - </main> -</template> - -<script> -import LibrariesTable from '@/components/manage/library/LibrariesTable.vue' - -export default { - components: { - LibrariesTable - }, - props: { - defaultQuery: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - title: this.$pgettext('*/*/*/Noun', 'Libraries') - } - } - } -} -</script> diff --git a/front/src/views/admin/library/LibraryDetail.vue b/front/src/views/admin/library/LibraryDetail.vue index a5d3abc3debf72e10ed0a609079168cdafdb7324..2c81a6b5b7eb8ca64cdd7c9f0f81a827e424f2b5 100644 --- a/front/src/views/admin/library/LibraryDetail.vue +++ b/front/src/views/admin/library/LibraryDetail.vue @@ -1,3 +1,98 @@ +<script setup lang="ts"> +import type { PrivacyLevel } from '~/types' + +import { humanSize, truncate } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { computed, ref } from 'vue' + +import axios from 'axios' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useErrorHandler from '~/composables/useErrorHandler' +import useLogger from '~/composables/useLogger' + +const PRIVACY_LEVELS = ['me', 'instance', 'everyone'] as PrivacyLevel[] + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() + +const sharedLabels = useSharedLabels() +const router = useRouter() +const logger = useLogger() + +const labels = computed(() => ({ + statsWarning: $pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') +})) + +const isLoading = ref(false) +const object = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/library/libraries/${props.id}/`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const isLoadingStats = ref(false) +const stats = ref() +const fetchStats = async () => { + isLoadingStats.value = true + + try { + const response = await axios.get(`manage/library/libraries/${props.id}/stats/`) + stats.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingStats.value = false +} + +fetchStats() +fetchData() + +const remove = async () => { + isLoading.value = true + + try { + await axios.delete(`manage/library/libraries/${props.id}/`) + router.push({ name: 'manage.library.libraries' }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const getQuery = (field: string, value: string) => `${field}:"${value}"` + +const updateObj = async (attr: string) => { + const params = { + [attr]: object.value[attr] + } + + try { + await axios.patch(`manage/library/libraries/${props.id}/`, params) + logger.info(`${attr} was updated succcessfully to ${params[attr]}`) + } catch (error) { + logger.error(`Error while setting ${attr} to ${params[attr]}`, error) + // TODO (wvffle): Use error handler with custom msg + } +} +</script> + <template> <main> <div @@ -17,7 +112,7 @@ <h2 class="ui header"> <i class="circular inverted book icon" /> <div class="content"> - {{ object.name | truncate(100) }} + {{ truncate(object.name) }} <div class="sub header"> <template v-if="object.is_local"> <span class="ui tiny accent label"> @@ -77,23 +172,29 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Delete this library? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The library will be removed, as well as associated uploads, and follows. This action is irreversible. + <translate translate-context="Popup/Library/Title"> + Delete this library? </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The library will be removed, as well as associated uploads, and follows. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb"> + Delete + </translate> + </p> + </template> </dangerous-button> </div> </div> @@ -142,15 +243,15 @@ @change="updateObj('privacy_level')" > <option - v-for="(p, key) in ['me', 'instance', 'everyone']" - :key="key" + v-for="p in PRIVACY_LEVELS" + :key="p" :value="p" > {{ sharedLabels.fields.privacy_level.shortChoices[p] }} </option> </select> <template v-else> - {{ sharedLabels.fields.privacy_level.shortChoices[object.privacy_level] }} + {{ sharedLabels.fields.privacy_level.shortChoices[object.privacy_level as PrivacyLevel] }} </template> </td> </tr> @@ -285,7 +386,7 @@ </translate> </td> <td> - {{ stats.media_downloaded_size | humanSize }} + {{ humanSize(stats.media_downloaded_size) }} </td> </tr> <tr> @@ -295,7 +396,7 @@ </translate> </td> <td> - {{ stats.media_total_size | humanSize }} + {{ humanSize(stats.media_total_size) }} </td> </tr> <tr> @@ -355,87 +456,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' -import logger from '@/logging' -import TranslationsMixin from '@/components/mixins/Translations.vue' - -export default { - mixins: [ - TranslationsMixin - ], - props: { id: { type: String, required: true } }, - data () { - return { - isLoading: true, - isLoadingStats: false, - object: null, - stats: null - } - }, - computed: { - labels () { - return { - statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') - } - } - }, - created () { - this.fetchData() - this.fetchStats() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = `manage/library/libraries/${this.id}/` - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - }) - }, - fetchStats () { - const self = this - this.isLoadingStats = true - const url = `manage/library/libraries/${this.id}/stats/` - axios.get(url).then(response => { - self.stats = response.data - self.isLoadingStats = false - }) - }, - remove () { - const self = this - this.isLoading = true - const url = `manage/library/libraries/${this.id}/` - axios.delete(url).then(response => { - self.$router.push({ name: 'manage.library.libraries' }) - }) - }, - getQuery (field, value) { - return `${field}:"${value}"` - }, - updateObj (attr, toNull) { - let newValue = this.object[attr] - if (toNull && !newValue) { - newValue = null - } - const params = {} - params[attr] = newValue - axios.patch(`manage/library/libraries/${this.id}/`, params).then( - response => { - logger.default.info( - `${attr} was updated succcessfully to ${newValue}` - ) - }, - error => { - logger.default.error( - `Error while setting ${attr} to ${newValue}`, - error - ) - } - ) - } - } -} -</script> diff --git a/front/src/views/admin/library/TagDetail.vue b/front/src/views/admin/library/TagDetail.vue index b6becc0463ce8856f5336cc2bef11116655ddc4e..5827d12330ddb17ad9d68f88587ba995ff207726 100644 --- a/front/src/views/admin/library/TagDetail.vue +++ b/front/src/views/admin/library/TagDetail.vue @@ -1,3 +1,53 @@ +<script setup lang="ts"> +import { truncate } from '~/utils/filters' +import { useRouter } from 'vue-router' +import { ref } from 'vue' + +import axios from 'axios' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const router = useRouter() + +const isLoading = ref(false) +const object = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/tags/${props.id}/`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() + +const remove = async () => { + isLoading.value = true + + try { + await axios.delete(`manage/tags/${props.id}/`) + router.push({ name: 'manage.library.tags' }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const getQuery = (field: string, value: string) => `${field}:"${value}"` +</script> + <template> <main> <div @@ -17,7 +67,7 @@ <h2 class="ui header"> <i class="circular inverted hashtag icon" /> <div class="content"> - {{ object.name | truncate(100) }} + {{ truncate(object.name) }} </div> </h2> <div class="header-buttons"> @@ -58,23 +108,29 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Delete this tag? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The tag will be removed and unlinked from any existing entity. This action is irreversible. + <translate translate-context="Popup/Library/Title"> + Delete this tag? </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The tag will be removed and unlinked from any existing entity. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb"> + Delete + </translate> + </p> + </template> </dangerous-button> </div> </div> @@ -118,22 +174,9 @@ <translate translate-context="Content/Moderation/Title"> Activity </translate> - <span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span> </div> </h3> - <div - v-if="isLoadingStats" - class="ui placeholder" - > - <div class="full line" /> - <div class="short line" /> - <div class="medium line" /> - <div class="long line" /> - </div> - <table - v-else - class="ui very basic table" - > + <table class="ui very basic table"> <tbody> <tr> <td> @@ -157,7 +200,6 @@ <translate translate-context="Content/Moderation/Title"> Audio content </translate> - <span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span> </div> </h3> <table class="ui very basic table"> @@ -207,51 +249,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' - -export default { - props: { id: { type: Number, required: true } }, - data () { - return { - isLoading: true, - isLoadingStats: false, - object: null, - stats: null - } - }, - computed: { - labels () { - return { - statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') - } - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = `manage/tags/${this.id}/` - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - }) - }, - remove () { - const self = this - this.isLoading = true - const url = `manage/tags/${this.id}/` - axios.delete(url).then(response => { - self.$router.push({ name: 'manage.library.tags' }) - }) - }, - getQuery (field, value) { - return `${field}:"${value}"` - } - } -} -</script> diff --git a/front/src/views/admin/library/TagsList.vue b/front/src/views/admin/library/TagsList.vue deleted file mode 100644 index 5ca37165ae4aaf9fa0103974ef20b764c33b24c9..0000000000000000000000000000000000000000 --- a/front/src/views/admin/library/TagsList.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <main v-title="labels.title"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - {{ labels.title }} - </h2> - <div class="ui hidden divider" /> - <tags-table - :update-url="true" - :default-query="defaultQuery" - /> - </section> - </main> -</template> - -<script> -import TagsTable from '@/components/manage/library/TagsTable.vue' - -export default { - components: { - TagsTable - }, - props: { - defaultQuery: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - title: this.$pgettext('*/*/*/Noun', 'Tags') - } - } - } -} -</script> diff --git a/front/src/views/admin/library/TrackDetail.vue b/front/src/views/admin/library/TrackDetail.vue index de8074cdf5c0eaba537055a121457f3c4e0efd85..6123e8427913047ea5531b899f7c7f1c63dfab66 100644 --- a/front/src/views/admin/library/TrackDetail.vue +++ b/front/src/views/admin/library/TrackDetail.vue @@ -1,3 +1,77 @@ +<script setup lang="ts"> +import { humanSize, truncate } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { ref, computed } from 'vue' + +import axios from 'axios' + +import FetchButton from '~/components/federation/FetchButton.vue' +import TagsList from '~/components/tags/List.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + statsWarning: $pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') +})) + +const track = ref() +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/library/tracks/${props.id}/`) + track.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const stats = ref() +const isLoadingStats = ref(false) +const fetchStats = async () => { + isLoadingStats.value = true + + try { + const response = await axios.get(`manage/library/tracks/${props.id}/stats/`) + stats.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingStats.value = false +} + +fetchData() +fetchStats() + +const router = useRouter() +const remove = async () => { + isLoading.value = true + + try { + await axios.delete(`manage/library/tracks/${props.id}/`) + await router.push({ name: 'manage.library.tracks' }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const getQuery = (field: string, value: string) => `${field}:"${value}"` +</script> + <template> <main> <div @@ -6,9 +80,9 @@ > <div :class="['ui', 'centered', 'active', 'inline', 'loader']" /> </div> - <template v-if="object"> + <template v-if="track"> <section - v-title="object.title" + v-title="track.title" :class="['ui', 'head', 'vertical', 'stripe', 'segment']" > <div class="ui stackable one column grid"> @@ -16,8 +90,8 @@ <div class="segment-content"> <h2 class="ui header"> <img - v-if="object.cover && object.cover.urls.medium_square_crop" - v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)" + v-if="track.cover && track.cover.urls.medium_square_crop" + v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" alt="" > <img @@ -26,9 +100,9 @@ src="../../../assets/audio/default-cover.png" > <div class="content"> - {{ object.title | truncate(100) }} + {{ truncate(track.title) }} <div class="sub header"> - <template v-if="object.is_local"> + <template v-if="track.is_local"> <span class="ui tiny accent label"> <i class="home icon" /> <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> @@ -39,11 +113,11 @@ </div> </h2> - <template v-if="object.tags && object.tags.length > 0"> + <template v-if="track.tags && track.tags.length > 0"> <tags-list :limit="5" detail-route="manage.library.tags.detail" - :tags="object.tags" + :tags="track.tags" /> <div class="ui hidden divider" /> </template> @@ -52,7 +126,7 @@ <div class="ui icon buttons"> <router-link class="ui icon labeled button" - :to="{name: 'library.tracks.detail', params: {id: object.id }}" + :to="{name: 'library.tracks.detail', params: {id: track.id }}" > <i class="info icon" /> <translate translate-context="Content/Moderation/Link/Verb"> @@ -68,7 +142,7 @@ <a v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" class="basic item" - :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/track/${object.id}`)" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/track/${track.id}`)" target="_blank" rel="noopener noreferrer" > @@ -76,9 +150,9 @@ <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> </a> <a - v-if="object.mbid" + v-if="track.mbid" class="basic item" - :href="`https://musicbrainz.org/recording/${object.mbid}`" + :href="`https://musicbrainz.org/recording/${track.mbid}`" target="_blank" rel="noopener noreferrer" > @@ -86,9 +160,9 @@ <translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate> </a> <fetch-button - v-if="!object.is_local" + v-if="!track.is_local" class="basic item" - :url="`tracks/${object.id}/fetches/`" + :url="`tracks/${track.id}/fetches/`" @refresh="fetchData" > <i class="refresh icon" /> @@ -98,7 +172,7 @@ </fetch-button> <a class="basic item" - :href="object.url || object.fid" + :href="track.url || track.fid" target="_blank" rel="noopener noreferrer" > @@ -110,8 +184,8 @@ </div> <div class="ui buttons"> <router-link - v-if="object.is_local" - :to="{name: 'library.tracks.edit', params: {id: object.id }}" + v-if="track.is_local" + :to="{name: 'library.tracks.edit', params: {id: track.id }}" class="ui labeled icon button" > <i class="edit icon" /> @@ -128,23 +202,29 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Delete this track? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The track will be removed, as well as associated uploads, favorites and listening history. This action is irreversible. + <translate translate-context="Popup/Library/Title"> + Delete this track? </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The track will be removed, as well as associated uploads, favorites and listening history. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb"> + Delete + </translate> + </p> + </template> </dangerous-button> </div> </div> @@ -173,44 +253,44 @@ </translate> </td> <td> - {{ object.title }} + {{ track.title }} </td> </tr> - <tr v-if="object.album"> + <tr v-if="track.album"> <td> - <router-link :to="{name: 'manage.library.albums.detail', params: {id: object.album.id }}"> + <router-link :to="{name: 'manage.library.albums.detail', params: {id: track.album.id }}"> <translate translate-context="*/*/*"> Album </translate> </router-link> </td> <td> - {{ object.album.title }} + {{ track.album.title }} </td> </tr> <tr> <td> - <router-link :to="{name: 'manage.library.artists.detail', params: {id: object.artist.id }}"> + <router-link :to="{name: 'manage.library.artists.detail', params: {id: track.artist.id }}"> <translate translate-context="*/*/*/Noun"> Artist </translate> </router-link> </td> <td> - {{ object.artist.name }} + {{ track.artist.name }} </td> </tr> - <tr v-if="object.album"> + <tr v-if="track.album"> <td> - <router-link :to="{name: 'manage.library.artists.detail', params: {id: object.album.artist.id }}"> + <router-link :to="{name: 'manage.library.artists.detail', params: {id: track.album.artist.id }}"> <translate translate-context="*/*/*/Noun"> Album artist </translate> </router-link> </td> <td> - {{ object.album.artist.name }} + {{ track.album.artist.name }} </td> </tr> <tr> @@ -220,58 +300,61 @@ </translate> </td> <td> - {{ object.position }} + {{ track.position }} </td> </tr> - <tr v-if="object.disc_number"> + <tr v-if="track.disc_number"> <td> <translate translate-context="*/*/*/Noun"> Disc number </translate> </td> <td> - {{ object.disc_number }} + {{ track.disc_number }} </td> </tr> - <tr v-if="object.copyright"> + <tr v-if="track.copyright"> <td> <translate translate-context="Content/Track/*/Noun"> Copyright </translate> </td> - <td>{{ object.copyright }}</td> + <td>{{ track.copyright }}</td> </tr> - <tr v-if="object.license"> + <tr v-if="track.license"> <td> <translate translate-context="Content/*/*/Noun"> License </translate> </td> <td> - <router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('license', object.license)}}"> - {{ object.license }} + <router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('license', track.license)}}"> + {{ track.license }} </router-link> </td> </tr> - <tr v-if="!object.is_local"> + <tr v-if="!track.is_local"> <td> - <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}"> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: track.domain }}"> <translate translate-context="Content/Moderation/*/Noun"> Domain </translate> </router-link> </td> <td> - {{ object.domain }} + {{ track.domain }} </td> </tr> - <tr v-if="object.description"> + <tr v-if="track.description"> <td> <translate translate-context="'*/*/*/Noun"> Description </translate> </td> - <td v-html="object.description.html" /> + <sanitized-html + tag="td" + :html="track.description.html" + /> </tr> </tbody> </table> @@ -309,7 +392,7 @@ </translate> </td> <td> - <human-date :date="object.creation_date" /> + <human-date :date="track.creation_date" /> </td> </tr> <tr> @@ -344,7 +427,7 @@ </tr> <tr> <td> - <router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `track:${object.id}`) }}"> + <router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `track:${track.id}`) }}"> <translate translate-context="Content/Moderation/Table.Label/Noun"> Linked reports </translate> @@ -356,7 +439,7 @@ </tr> <tr> <td> - <router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'track ' + object.id)}}"> + <router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'track ' + track.id)}}"> <translate translate-context="*/Admin/*/Noun"> Edits </translate> @@ -402,7 +485,7 @@ </translate> </td> <td> - {{ stats.media_downloaded_size | humanSize }} + {{ humanSize(stats.media_downloaded_size) }} </td> </tr> <tr> @@ -412,13 +495,13 @@ </translate> </td> <td> - {{ stats.media_total_size | humanSize }} + {{ humanSize(stats.media_total_size) }} </td> </tr> <tr> <td> - <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('track_id', object.id) }}"> + <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('track_id', track.id) }}"> <translate translate-context="*/*/*/Noun"> Libraries </translate> @@ -430,7 +513,7 @@ </tr> <tr> <td> - <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('track_id', object.id) }}"> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('track_id', track.id) }}"> <translate translate-context="*/*/*"> Uploads </translate> @@ -449,67 +532,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' -import FetchButton from '@/components/federation/FetchButton.vue' -import TagsList from '@/components/tags/List.vue' - -export default { - components: { - FetchButton, - TagsList - }, - props: { id: { type: Number, required: true } }, - data () { - return { - isLoading: true, - isLoadingStats: false, - object: null, - stats: null - } - }, - computed: { - labels () { - return { - statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') - } - } - }, - created () { - this.fetchData() - this.fetchStats() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = `manage/library/tracks/${this.id}/` - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - }) - }, - fetchStats () { - const self = this - this.isLoadingStats = true - const url = `manage/library/tracks/${this.id}/stats/` - axios.get(url).then(response => { - self.stats = response.data - self.isLoadingStats = false - }) - }, - remove () { - const self = this - this.isLoading = true - const url = `manage/library/tracks/${this.id}/` - axios.delete(url).then(response => { - self.$router.push({ name: 'manage.library.tracks' }) - }) - }, - getQuery (field, value) { - return `${field}:"${value}"` - } - } -} -</script> diff --git a/front/src/views/admin/library/TracksList.vue b/front/src/views/admin/library/TracksList.vue deleted file mode 100644 index e171e8b01fd7b318d3a7c254779891826d89ac42..0000000000000000000000000000000000000000 --- a/front/src/views/admin/library/TracksList.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <main v-title="labels.title"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - {{ labels.title }} - </h2> - <div class="ui hidden divider" /> - <tracks-table - :update-url="true" - :default-query="defaultQuery" - /> - </section> - </main> -</template> - -<script> -import TracksTable from '@/components/manage/library/TracksTable.vue' - -export default { - components: { - TracksTable - }, - props: { - defaultQuery: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - title: this.$pgettext('*/*/*', 'Tracks') - } - } - } -} -</script> diff --git a/front/src/views/admin/library/UploadDetail.vue b/front/src/views/admin/library/UploadDetail.vue index 6c5e41ca49fa2c859e1caea5cf4ba5565f60188d..38461595efc61580f824d55a0257ab37639e843d 100644 --- a/front/src/views/admin/library/UploadDetail.vue +++ b/front/src/views/admin/library/UploadDetail.vue @@ -1,3 +1,66 @@ +<script setup lang="ts"> +import type { PrivacyLevel, ImportStatus } from '~/types' + +import { humanSize, truncate } from '~/utils/filters' +import { useRouter } from 'vue-router' +import { computed, ref } from 'vue' + +import time from '~/utils/time' +import axios from 'axios' + +import ImportStatusModal from '~/components/library/ImportStatusModal.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const sharedLabels = useSharedLabels() +const router = useRouter() + +const privacyLevels = computed(() => sharedLabels.fields.privacy_level.shortChoices[object.value.library.privacy_level as PrivacyLevel]) +const importStatus = computed(() => sharedLabels.fields.import_status.choices[object.value.import_status as ImportStatus].label) + +const isLoading = ref(false) +const object = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/library/uploads/${props.id}/`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() + +const remove = async () => { + isLoading.value = true + + try { + await axios.delete(`manage/uploads/${props.id}/`) + router.push({ name: 'manage.library.uploads' }) + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const getQuery = (field: string, value: string) => `${field}:"${value}"` +const displayName = (object: any) => object.filename ?? object.source ?? object.uuid + +const showUploadDetailModal = ref(false) +</script> + <template> <main> <div @@ -8,8 +71,8 @@ </div> <template v-if="object"> <import-status-modal + v-model:show="showUploadDetailModal" :upload="object" - :show.sync="showUploadDetailModal" /> <section v-title="displayName(object)" @@ -21,7 +84,7 @@ <h2 class="ui header"> <i class="circular inverted file icon" /> <div class="content"> - {{ displayName(object) | truncate(100) }} + {{ truncate(displayName(object)) }} <div class="sub header"> <template v-if="object.is_local"> <span class="ui tiny accent label"> @@ -93,23 +156,29 @@ <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Delete this upload? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The upload will be removed. This action is irreversible. + <translate translate-context="Popup/Library/Title"> + Delete this upload? </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The upload will be removed. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb"> + Delete + </translate> + </p> + </template> </dangerous-button> </div> </div> @@ -150,7 +219,7 @@ </router-link> </td> <td> - {{ sharedLabels.fields.privacy_level.shortChoices[object.library.privacy_level] }} + {{ privacyLevels }} </td> </tr> <tr> @@ -186,11 +255,11 @@ </router-link> </td> <td> - {{ sharedLabels.fields.import_status.choices[object.import_status].label }} + {{ importStatus }} <button class="ui tiny basic icon button" - :title="sharedLabels.fields.import_status.detailTitle" - @click="detailedUpload = object; showUploadDetailModal = true" + :title="sharedLabels.fields.import_status.label" + @click="showUploadDetailModal = true" > <i class="question circle outline icon" /> </button> @@ -289,7 +358,7 @@ </td> <td> <template v-if="object.audio_file"> - {{ object.size | humanSize }} + {{ humanSize(object.size) }} </template> <translate v-else @@ -306,7 +375,7 @@ </translate> </td> <td> - {{ object.size | humanSize }} + {{ humanSize(object.size) }} </td> </tr> <tr> @@ -317,7 +386,7 @@ </td> <td> <template v-if="object.bitrate"> - {{ object.bitrate | humanSize }}/s + {{ humanSize(object.bitrate) }}/s </template> <translate v-else @@ -335,7 +404,7 @@ </td> <td> <template v-if="object.duration"> - {{ object.duration | duration }} + {{ time.parse(object.duration) }} </template> <translate v-else @@ -374,71 +443,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import ImportStatusModal from '@/components/library/ImportStatusModal.vue' -import time from '@/utils/time.js' - -export default { - components: { - ImportStatusModal - }, - mixins: [ - TranslationsMixin - ], - props: { id: { type: Number, required: true } }, - data () { - return { - time, - detailedUpload: {}, - showUploadDetailModal: false, - isLoading: true, - object: null, - stats: null - } - }, - computed: { - labels () { - return { - statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') - } - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = `manage/library/uploads/${this.id}/` - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - }) - }, - remove () { - const self = this - this.isLoading = true - const url = `manage/library/uploads/${this.id}/` - axios.delete(url).then(response => { - self.$router.push({ name: 'manage.library.uploads' }) - }) - }, - getQuery (field, value) { - return `${field}:"${value}"` - }, - displayName (upload) { - if (upload.filename) { - return upload.filename - } - if (upload.source) { - return upload.source - } - return upload.uuid - } - } -} -</script> diff --git a/front/src/views/admin/library/UploadsList.vue b/front/src/views/admin/library/UploadsList.vue deleted file mode 100644 index 247e9fd4e78d31aaea02bfd03a86d403b2a90428..0000000000000000000000000000000000000000 --- a/front/src/views/admin/library/UploadsList.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <main v-title="labels.title"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - {{ labels.title }} - </h2> - <div class="ui hidden divider" /> - <uploads-table - :update-url="true" - :default-query="defaultQuery" - /> - </section> - </main> -</template> - -<script> -import UploadsTable from '@/components/manage/library/UploadsTable.vue' - -export default { - components: { - UploadsTable - }, - props: { - defaultQuery: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - title: this.$pgettext('*/*/*', 'Uploads') - } - } - } -} -</script> diff --git a/front/src/views/admin/moderation/AccountsDetail.vue b/front/src/views/admin/moderation/AccountsDetail.vue index 35015710e03077d121e03bac13d3448e48aa0717..1da76f245ae163ce96b9bda32b70ac6d731312c7 100644 --- a/front/src/views/admin/moderation/AccountsDetail.vue +++ b/front/src/views/admin/moderation/AccountsDetail.vue @@ -1,3 +1,148 @@ +<script setup lang="ts"> +import type { InstancePolicy } from '~/types' + +import { computed, ref, reactive, nextTick, watch } from 'vue' +import { useCurrentElement } from '@vueuse/core' +import { humanSize } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' +import $ from 'jquery' + +import InstancePolicyForm from '~/components/manage/moderation/InstancePolicyForm.vue' +import InstancePolicyCard from '~/components/manage/moderation/InstancePolicyCard.vue' + +import useErrorHandler from '~/composables/useErrorHandler' +import useLogger from '~/composables/useLogger' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() + +const logger = useLogger() + +const labels = computed(() => ({ + statsWarning: $pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object'), + uploadQuota: $pgettext('Content/Moderation/Help text', 'Determine how much content the user can upload. Leave empty to use the default value of the instance.') +})) + +const allPermissions = computed(() => [ + { code: 'library', label: $pgettext('*/*/*/Noun', 'Library') }, + { code: 'moderation', label: $pgettext('*/Moderation/*', 'Moderation') }, + { code: 'settings', label: $pgettext('*/*/*/Noun', 'Settings') } +]) + +const isLoadingPolicy = ref(false) +const policy = ref() +const fetchPolicy = async (id: number) => { + isLoadingPolicy.value = true + + try { + const response = await axios.get(`manage/moderation/instance-policies/${id}/`) + policy.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingPolicy.value = false +} + +const permissions = reactive([] as string[]) +const isLoading = ref(false) +const object = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/accounts/${props.id}/`) + object.value = response.data + + if (response.data.instance_policy) { + fetchPolicy(response.data.instance_policy) + } + + if (response.data.user) { + for (const { code } of allPermissions.value) { + if (response.data.user.permissions[code]) { + permissions.push(code) + } + } + } + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const isLoadingStats = ref(false) +const stats = ref() +const fetchStats = async () => { + isLoadingStats.value = true + + try { + const response = await axios.get(`manage/accounts/${props.id}/stats/`) + stats.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingStats.value = false +} + +fetchStats() +fetchData() + +const el = useCurrentElement() +watch(object, async () => { + await nextTick() + $(el.value).find('select.dropdown').dropdown() +}) + +const getQuery = (field: string, value: string) => `${field}:"${value}"` + +const updating = reactive(new Set<string>()) +const updateUser = async (attr: string, toNull = false) => { + let newValue = object.value.user[attr] + if (toNull && !newValue) { + newValue = null + } + + updating.add(attr) + + const params = { + [attr]: newValue + } + + if (attr === 'permissions') { + params.permissions = allPermissions.value.reduce((acc, { code }) => { + acc[code] = permissions.includes(code) + return acc + }, {} as Record<string, boolean>) + } + + try { + await axios.patch(`manage/users/users/${object.value.user.id}/`, params) + logger.info(`${attr} was updated succcessfully to ${newValue}`) + } catch (error) { + logger.error(`Error while setting ${attr} to ${newValue}`, error) + // TODO: Use error handler + } + + updating.delete(attr) +} + +const showPolicyForm = ref(false) +const updatePolicy = (newPolicy: InstancePolicy) => { + policy.value = newPolicy + showPolicyForm.value = false +} +</script> + <template> <main class="page-admin-account-detail"> <div @@ -207,7 +352,7 @@ </td> <td> <div - v-if="object.user.username != $store.state.auth.profile.username" + v-if="object.user.username != $store.state.auth.profile?.username" class="ui toggle checkbox" > <input @@ -219,26 +364,22 @@ <label for="is-active"> <translate v-if="object.user.is_active" - key="1" translate-context="*/*/*/State of feature" >Enabled</translate> <translate v-else - key="2" translate-context="*/*/*/State of feature" >Disabled</translate> </label> </div> <translate v-else-if="object.user.is_active" - key="1" translate-context="*/*/*/State of feature" > Enabled </translate> <translate v-else - key="2" translate-context="*/*/*/State of feature" > Disabled @@ -266,7 +407,7 @@ {{ p.label }} </option> </select> - <action-feedback :is-loading="updating.permissions" /> + <action-feedback :is-loading="updating.has('permissions')" /> </td> </tr> <tr> @@ -447,7 +588,7 @@ </translate> </td> <td> - {{ stats.media_downloaded_size | humanSize }} + {{ humanSize(stats.media_downloaded_size) }} </td> </tr> <tr v-if="object.user"> @@ -474,7 +615,7 @@ <action-feedback class="ui basic label" size="tiny" - :is-loading="updating.upload_quota" + :is-loading="updating.has('upload_quota')" /> </div> </td> @@ -486,7 +627,7 @@ </translate> </td> <td> - {{ stats.media_total_size | humanSize }} + {{ humanSize(stats.media_total_size) }} </td> </tr> <tr> @@ -564,151 +705,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' -import logger from '@/logging' -import lodash from 'lodash' -import $ from 'jquery' - -import InstancePolicyForm from '@/components/manage/moderation/InstancePolicyForm.vue' -import InstancePolicyCard from '@/components/manage/moderation/InstancePolicyCard.vue' - -export default { - components: { - InstancePolicyForm, - InstancePolicyCard - }, - props: { id: { type: Number, required: true } }, - data () { - return { - lodash, - isLoading: true, - isLoadingStats: false, - isLoadingPolicy: false, - object: null, - stats: null, - showPolicyForm: false, - permissions: [], - updating: { - permissions: false, - upload_quota: false - } - } - }, - computed: { - labels () { - return { - statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this account'), - uploadQuota: this.$pgettext('Content/Moderation/Help text', 'Determine how much content the user can upload. Leave empty to use the default value of the instance.') - } - }, - allPermissions () { - return [ - { - code: 'library', - label: this.$pgettext('*/*/*/Noun', 'Library') - }, - { - code: 'moderation', - label: this.$pgettext('*/Moderation/*', 'Moderation') - }, - { - code: 'settings', - label: this.$pgettext('*/*/*/Noun', 'Settings') - } - ] - } - }, - watch: { - object () { - this.$nextTick(() => { - $(this.$el).find('select.dropdown').dropdown() - }) - } - }, - created () { - this.fetchData() - this.fetchStats() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = 'manage/accounts/' + this.id + '/' - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - if (self.object.instance_policy) { - self.fetchPolicy(self.object.instance_policy) - } - if (response.data.user) { - self.allPermissions.forEach(p => { - if (self.object.user.permissions[p.code]) { - self.permissions.push(p.code) - } - }) - } - }) - }, - fetchPolicy (id) { - const self = this - this.isLoadingPolicy = true - const url = `manage/moderation/instance-policies/${id}/` - axios.get(url).then(response => { - self.policy = response.data - self.isLoadingPolicy = false - }) - }, - fetchStats () { - const self = this - this.isLoadingStats = true - const url = 'manage/accounts/' + this.id + '/stats/' - axios.get(url).then(response => { - self.stats = response.data - self.isLoadingStats = false - }) - }, - refreshNodeInfo (data) { - this.object.nodeinfo = data - this.object.nodeinfo_fetch_date = new Date() - }, - - updateUser (attr, toNull) { - let newValue = this.object.user[attr] - if (toNull && !newValue) { - newValue = null - } - const self = this - this.updating[attr] = true - const params = {} - if (attr === 'permissions') { - params.permissions = {} - this.allPermissions.forEach(p => { - params.permissions[p.code] = this.permissions.indexOf(p.code) > -1 - }) - } else { - params[attr] = newValue - } - axios.patch(`manage/users/users/${this.object.user.id}/`, params).then( - response => { - logger.default.info( - `${attr} was updated succcessfully to ${newValue}` - ) - self.updating[attr] = false - }, - error => { - logger.default.error( - `Error while setting ${attr} to ${newValue}`, - error - ) - self.updating[attr] = false - } - ) - }, - getQuery (field, value) { - return `${field}:"${value}"` - } - } -} -</script> diff --git a/front/src/views/admin/moderation/AccountsList.vue b/front/src/views/admin/moderation/AccountsList.vue deleted file mode 100644 index fa53798915b3616e87d4bf6e96ebecf23059d930..0000000000000000000000000000000000000000 --- a/front/src/views/admin/moderation/AccountsList.vue +++ /dev/null @@ -1,36 +0,0 @@ -<template> - <main v-title="labels.accounts"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - <translate translate-context="*/Moderation/Title"> - Accounts - </translate> - </h2> - <div class="ui hidden divider" /> - <accounts-table - :update-url="true" - :default-query="defaultQuery" - /> - </section> - </main> -</template> - -<script> -import AccountsTable from '@/components/manage/moderation/AccountsTable.vue' - -export default { - components: { - AccountsTable - }, - props: { - defaultQuery: { type: String, required: false, default: '' } - }, - computed: { - labels () { - return { - accounts: this.$pgettext('*/Moderation/Title', 'Accounts') - } - } - } -} -</script> diff --git a/front/src/views/admin/moderation/Base.vue b/front/src/views/admin/moderation/Base.vue index 5d2182bac99d4211e96d2aba9a7680f2c929080e..b74e90641d6b08b9a831c5f00c9977d704c42c4a 100644 --- a/front/src/views/admin/moderation/Base.vue +++ b/front/src/views/admin/moderation/Base.vue @@ -1,3 +1,26 @@ +<script setup lang="ts"> +import { useGettext } from 'vue3-gettext' +import { computed, ref } from 'vue' +import { get } from 'lodash-es' + +import axios from 'axios' + +const { $pgettext } = useGettext() + +const allowListEnabled = ref(false) +const labels = computed(() => ({ + moderation: $pgettext('*/Moderation/*', 'Moderation'), + secondaryMenu: $pgettext('Menu/*/Hidden text', 'Secondary menu') +})) + +const fetchNodeInfo = async () => { + const response = await axios.get('instance/nodeinfo/2.0/') + allowListEnabled.value = get(response.data, 'metadata.allowList.enabled', false) +} + +fetchNodeInfo() +</script> + <template> <div v-title="labels.moderation" @@ -59,35 +82,3 @@ /> </div> </template> - -<script> -import _ from 'lodash' -import axios from 'axios' - -export default { - data () { - return { - allowListEnabled: false - } - }, - computed: { - labels () { - return { - moderation: this.$pgettext('*/Moderation/*', 'Moderation'), - secondaryMenu: this.$pgettext('Menu/*/Hidden text', 'Secondary menu') - } - } - }, - created () { - this.fetchNodeInfo() - }, - methods: { - fetchNodeInfo () { - const self = this - axios.get('instance/nodeinfo/2.0/').then(response => { - self.allowListEnabled = _.get(response.data, 'metadata.allowList.enabled', false) - }) - } - } -} -</script> diff --git a/front/src/views/admin/moderation/DomainsDetail.vue b/front/src/views/admin/moderation/DomainsDetail.vue index cfccac97d4846dc0ead602e5788719be50b48a11..4b1eac0b993d65ddaa696e2c4703d41efee18261 100644 --- a/front/src/views/admin/moderation/DomainsDetail.vue +++ b/front/src/views/admin/moderation/DomainsDetail.vue @@ -1,3 +1,111 @@ +<script setup lang="ts"> +import type { InstancePolicy } from '~/types' + +import { humanSize } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { computed, ref } from 'vue' +import { get } from 'lodash-es' + +import axios from 'axios' + +import InstancePolicyForm from '~/components/manage/moderation/InstancePolicyForm.vue' +import InstancePolicyCard from '~/components/manage/moderation/InstancePolicyCard.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number + allowListEnabled: boolean +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + statsWarning: $pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object') +})) + +const isLoadingPolicy = ref(false) +const policy = ref() +const fetchPolicy = async (id: number) => { + isLoadingPolicy.value = true + + try { + const response = await axios.get(`manage/moderation/instance-policies/${id}/`) + policy.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingPolicy.value = false +} + +const isLoading = ref(false) +const object = ref() +const externalUrl = computed(() => `https://${object.value?.name}`) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/federation/domains/${props.id}/`) + object.value = response.data + if (response.data.instance_policy) { + fetchPolicy(response.data.instance_policy) + } + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +const isLoadingStats = ref(false) +const stats = ref() +const fetchStats = async () => { + isLoadingStats.value = true + + try { + const response = await axios.get(`manage/federation/domains/${props.id}/stats/`) + stats.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingStats.value = false +} + +fetchStats() +fetchData() + +const refreshNodeInfo = (data: any) => { + object.value.nodeinfo = data + object.value.nodeinfo_fetch_date = new Date() +} + +const getQuery = (field: string, value: string) => `${field}:"${value}"` + +const showPolicyForm = ref(false) +const updatePolicy = (newPolicy: InstancePolicy) => { + policy.value = newPolicy + showPolicyForm.value = false +} + +const isLoadingAllowList = ref(false) +const setAllowList = async (value: boolean) => { + isLoadingAllowList.value = true + + try { + const response = await axios.patch(`manage/federation/domains/${props.id}/`, { allowed: value }) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoadingAllowList.value = false +} +</script> + <template> <main class="page-admin-domain-detail"> <div @@ -34,7 +142,7 @@ <div class="header-buttons"> <div class="ui icon buttons"> <a - v-if="$store.state.auth.profile.is_superuser" + v-if="$store.state.auth.profile?.is_superuser" class="ui labeled icon button" :href="$store.getters['instance/absoluteUrl'](`/api/admin/federation/domain/${object.name}`)" target="_blank" @@ -193,7 +301,7 @@ </translate> </td> <td> - {{ lodash.get(object, 'nodeinfo.payload.software.name', $pgettext('*/*/*', 'N/A')) }} ({{ lodash.get(object, 'nodeinfo.payload.software.version', $pgettext('*/*/*', 'N/A')) }}) + {{ get(object, 'nodeinfo.payload.software.name', $pgettext('*/*/*', 'N/A')) }} ({{ get(object, 'nodeinfo.payload.software.version', $pgettext('*/*/*', 'N/A')) }}) </td> </tr> <tr> @@ -203,7 +311,7 @@ </translate> </td> <td> - {{ lodash.get(object, 'nodeinfo.payload.metadata.nodeName', $pgettext('*/*/*', 'N/A')) }} + {{ get(object, 'nodeinfo.payload.metadata.nodeName', $pgettext('*/*/*', 'N/A')) }} </td> </tr> <tr> @@ -213,7 +321,7 @@ </translate> </td> <td> - {{ lodash.get(object, 'nodeinfo.payload.usage.users.total', $pgettext('*/*/*', 'N/A')) }} + {{ get(object, 'nodeinfo.payload.usage.users.total', $pgettext('*/*/*', 'N/A')) }} </td> </tr> </template> @@ -361,7 +469,7 @@ </translate> </td> <td> - {{ stats.media_downloaded_size | humanSize }} + {{ humanSize(stats.media_downloaded_size) }} </td> </tr> <tr> @@ -371,7 +479,7 @@ </translate> </td> <td> - {{ stats.media_total_size | humanSize }} + {{ humanSize(stats.media_total_size) }} </td> </tr> <tr> @@ -455,99 +563,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' -import lodash from 'lodash' - -import InstancePolicyForm from '@/components/manage/moderation/InstancePolicyForm.vue' -import InstancePolicyCard from '@/components/manage/moderation/InstancePolicyCard.vue' - -export default { - components: { - InstancePolicyForm, - InstancePolicyCard - }, - props: { id: { type: String, required: true }, allowListEnabled: { type: Boolean, required: true } }, - data () { - return { - lodash, - isLoading: true, - isLoadingStats: false, - isLoadingPolicy: false, - isLoadingAllowList: false, - policy: null, - object: null, - stats: null, - showPolicyForm: false, - permissions: [] - } - }, - computed: { - labels () { - return { - statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this domain') - } - }, - externalUrl () { - return `https://${this.object.name}` - } - }, - created () { - this.fetchData() - this.fetchStats() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = 'manage/federation/domains/' + this.id + '/' - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - if (self.object.instance_policy) { - self.fetchPolicy(self.object.instance_policy) - } - }) - }, - fetchStats () { - const self = this - this.isLoadingStats = true - const url = 'manage/federation/domains/' + this.id + '/stats/' - axios.get(url).then(response => { - self.stats = response.data - self.isLoadingStats = false - }) - }, - fetchPolicy (id) { - const self = this - this.isLoadingPolicy = true - const url = `manage/moderation/instance-policies/${id}/` - axios.get(url).then(response => { - self.policy = response.data - self.isLoadingPolicy = false - }) - }, - setAllowList (value) { - const self = this - this.isLoadingAllowList = true - const url = `manage/federation/domains/${this.id}/` - axios.patch(url, { allowed: value }).then(response => { - self.object = response.data - self.isLoadingAllowList = false - }) - }, - refreshNodeInfo (data) { - this.object.nodeinfo = data - this.object.nodeinfo_fetch_date = new Date() - }, - updatePolicy (policy) { - this.policy = policy - this.showPolicyForm = false - }, - getQuery (field, value) { - return `${field}:"${value}"` - } - } -} -</script> diff --git a/front/src/views/admin/moderation/DomainsList.vue b/front/src/views/admin/moderation/DomainsList.vue index 3095817a25623ad2b19abf17322cea75215297bd..08cefbe9019395c34d83630005646fecfb7c05ff 100644 --- a/front/src/views/admin/moderation/DomainsList.vue +++ b/front/src/views/admin/moderation/DomainsList.vue @@ -1,3 +1,51 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { computed, ref } from 'vue' + +import axios from 'axios' + +import DomainsTable from '~/components/manage/moderation/DomainsTable.vue' + +interface Props { + allowListEnabled: boolean +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() + +const router = useRouter() + +const labels = computed(() => ({ + domains: $pgettext('*/Moderation/*/Noun', 'Domains') +})) + +const domainName = ref('') +const domainAllowed = ref(props.allowListEnabled || undefined) + +const isCreating = ref(false) +const errors = ref([] as string[]) +const createDomain = async () => { + isCreating.value = true + errors.value = [] + + try { + const response = await axios.post('manage/federation/domains/', { name: domainName.value, allowed: domainAllowed.value }) + router.push({ + name: 'manage.moderation.domains.detail', + params: { id: response.data.name } + }) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isCreating.value = false +} +</script> + <template> <main v-title="labels.domains"> <section class="ui vertical stripe segment"> @@ -69,47 +117,3 @@ </section> </main> </template> - -<script> -import axios from 'axios' - -import DomainsTable from '@/components/manage/moderation/DomainsTable.vue' -export default { - components: { - DomainsTable - }, - props: { allowListEnabled: { type: Boolean, required: true } }, - data () { - return { - domainName: '', - domainAllowed: this.allowListEnabled ? true : null, - isCreating: false, - errors: [] - } - }, - computed: { - labels () { - return { - domains: this.$pgettext('*/Moderation/*/Noun', 'Domains') - } - } - }, - methods: { - createDomain () { - const self = this - this.isCreating = true - this.errors = [] - axios.post('manage/federation/domains/', { name: this.domainName, allowed: this.domainAllowed }).then((response) => { - this.isCreating = false - this.$router.push({ - name: 'manage.moderation.domains.detail', - params: { id: response.data.name } - }) - }, (error) => { - self.isCreating = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/views/admin/moderation/ReportDetail.vue b/front/src/views/admin/moderation/ReportDetail.vue index b59192d75bd00e7037e8f4c3ffa2644bf2bccb64..ecb87847b4d69aadd55d5f266c12b2088f06de0f 100644 --- a/front/src/views/admin/moderation/ReportDetail.vue +++ b/front/src/views/admin/moderation/ReportDetail.vue @@ -1,3 +1,36 @@ +<script setup lang="ts"> +import { ref } from 'vue' + +import axios from 'axios' + +import ReportCard from '~/components/manage/moderation/ReportCard.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const isLoading = ref(false) +const object = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/moderation/reports/${props.id}/`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() +</script> + <template> <main> <div @@ -8,41 +41,8 @@ </div> <template v-if="object"> <div class="ui vertical stripe segment"> - <report-card :obj="object" /> + <report-card :init-obj="object" /> </div> </template> </main> </template> - -<script> -import axios from 'axios' - -import ReportCard from '@/components/manage/moderation/ReportCard.vue' - -export default { - components: { - ReportCard - }, - props: { id: { type: Number, required: true } }, - data () { - return { - isLoading: true, - object: null - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = `manage/moderation/reports/${this.id}/` - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/views/admin/moderation/ReportsList.vue b/front/src/views/admin/moderation/ReportsList.vue index 97d4646fe54f61797bd7784fc087bc8e47e602fe..4d80320a3009f33901847b6262352f747b00b945 100644 --- a/front/src/views/admin/moderation/ReportsList.vue +++ b/front/src/views/admin/moderation/ReportsList.vue @@ -1,3 +1,98 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { Report, BackendResponse } from '~/types' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' + +import ReportCategoryDropdown from '~/components/moderation/ReportCategoryDropdown.vue' +import ReportCard from '~/components/manage/moderation/ReportCard.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + mode?: 'card' + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + updateUrl: false, + mode: 'card', + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +const result = ref<BackendResponse<Report>>() + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) +const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['applied_date', 'applied_date'] +] + +const store = useStore() +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value + } + + try { + const response = await axios.get('manage/moderation/reports/', { + params + }) + + result.value = response.data + if (query.value === 'resolved:no') { + console.log('Refreshing sidebar notifications') + store.commit('ui/incrementNotifications', { + type: 'pendingReviewReports', + value: response.data.count + }) + } + } catch (error) { + useErrorHandler(error as Error) + result.value = undefined + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const { $pgettext } = useGettext() +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by account, summary, domain…'), + reports: $pgettext('*/Moderation/*/Noun', 'Reports') +})) +</script> + <template> <main v-title="labels.reports"> <section class="ui vertical stripe segment"> @@ -11,13 +106,13 @@ <div class="fields"> <div class="ui field"> <label for="reports-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="reports-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -28,7 +123,7 @@ id="reports-status" class="ui dropdown" :value="getTokenValue('resolved', '')" - @change="addSearchToken('resolved', $event.target.value)" + @change="addSearchToken('resolved', ($event.target as HTMLSelectElement).value)" > <option value=""> <translate translate-context="Content/*/Dropdown"> @@ -51,8 +146,8 @@ class="field" :all="true" :label="true" - :value="getTokenValue('category', '')" - @input="addSearchToken('category', $event)" + :model-value="getTokenValue('category', '')" + @update:model-value="addSearchToken('category', $event)" /> <div class="field"> <label for="reports-ordering"><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> @@ -114,131 +209,11 @@ <div class="ui center aligned basic segment"> <pagination v-if="result && result.count > paginateBy" - :current="page" + v-model:current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> </div> </section> </main> </template> - -<script> - -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import Pagination from '@/components/Pagination.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import ReportCard from '@/components/manage/moderation/ReportCard.vue' -import ReportCategoryDropdown from '@/components/moderation/ReportCategoryDropdown.vue' -import { normalizeQuery, parseTokens } from '@/search' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' - -export default { - components: { - Pagination, - ReportCard, - ReportCategoryDropdown - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - mode: { type: String, default: 'card' } - }, - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['applied_date', 'applied_date'] - ], - targets: { - track: {} - } - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by account, summary, domain…'), - reports: this.$pgettext('*/Moderation/*/Noun', 'Reports') - } - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - this.result = null - axios.get('manage/moderation/reports/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - if (self.search.query === 'resolved:no') { - console.log('Refreshing sidebar notifications') - self.$store.commit('ui/incrementNotifications', { type: 'pendingReviewReports', value: response.data.count }) - } - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - }, - handle (type, id, value) { - if (type === 'delete') { - this.exclude.push(id) - } - - this.result.results.forEach((e) => { - if (e.uuid === id) { - e.is_approved = value - } - }) - }, - getCurrentState (target) { - if (!target) { - return {} - } - if (this.targets[target.type] && this.targets[target.type][String(target.id)]) { - return this.targets[target.type][String(target.id)].currentState - } - return {} - } - } -} - -</script> diff --git a/front/src/views/admin/moderation/RequestDetail.vue b/front/src/views/admin/moderation/RequestDetail.vue index e25fb8360b106c24e39b3bf7c27823b1ff96e1e5..ce743464c10a630f725780a76510a4e76bf6a045 100644 --- a/front/src/views/admin/moderation/RequestDetail.vue +++ b/front/src/views/admin/moderation/RequestDetail.vue @@ -1,3 +1,36 @@ +<script setup lang="ts"> +import { ref } from 'vue' + +import axios from 'axios' + +import UserRequestCard from '~/components/manage/moderation/UserRequestCard.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const isLoading = ref(false) +const object = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`manage/moderation/requests/${props.id}/`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() +</script> + <template> <main> <div @@ -8,41 +41,8 @@ </div> <template v-if="object"> <div class="ui vertical stripe segment"> - <user-request-card :obj="object" /> + <user-request-card :init-obj="object" /> </div> </template> </main> </template> - -<script> -import axios from 'axios' - -import UserRequestCard from '@/components/manage/moderation/UserRequestCard.vue' - -export default { - components: { - UserRequestCard - }, - props: { id: { type: Number, required: true } }, - data () { - return { - isLoading: true, - object: null - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - const url = `manage/moderation/requests/${this.id}/` - axios.get(url).then(response => { - self.object = response.data - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/views/admin/moderation/RequestsList.vue b/front/src/views/admin/moderation/RequestsList.vue index 0fe6e129c37d52405b6e13f143ad413fde068b46..a331802cacaac4d468f275cd271e009bc5959fd1 100644 --- a/front/src/views/admin/moderation/RequestsList.vue +++ b/front/src/views/admin/moderation/RequestsList.vue @@ -1,3 +1,94 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { UserRequest, BackendResponse } from '~/types' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { ref, computed, watch } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' + +import UserRequestCard from '~/components/manage/moderation/UserRequestCard.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Props extends SmartSearchProps, OrderingProps { + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + updateUrl?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + updateUrl: false, + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +const result = ref<BackendResponse<UserRequest>>() + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) +const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['handled_date', 'handled_date'] +] + +const store = useStore() +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value + } + + try { + const response = await axios.get('manage/moderation/requests/', { + params + }) + + result.value = response.data + + if (query.value === 'status:pending') { + store.commit('ui/incrementNotifications', { + type: 'pendingReviewRequests', + value: response.data.count + }) + } + } catch (error) { + useErrorHandler(error as Error) + result.value = undefined + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +watch(page, fetchData) +onOrderingUpdate(fetchData) +fetchData() + +const { $pgettext } = useGettext() +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Search/Input.Placeholder', 'Search by username…'), + reports: $pgettext('*/Moderation/*/Noun', 'User Requests') +})) +</script> + <template> <main v-title="labels.reports"> <section class="ui vertical stripe segment"> @@ -11,13 +102,13 @@ <div class="fields"> <div class="ui field"> <label for="requests-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="requests-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -28,7 +119,7 @@ id="requests-status" class="ui dropdown" :value="getTokenValue('status', '')" - @change="addSearchToken('status', $event.target.value)" + @change="addSearchToken('status', ($event.target as HTMLSelectElement).value)" > <option value=""> <translate translate-context="Content/*/Dropdown"> @@ -111,106 +202,12 @@ <div class="ui center aligned basic segment"> <pagination v-if="result.count > paginateBy" - :current="page" + v-model:current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> </div> </template> </section> </main> </template> - -<script> - -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import Pagination from '@/components/Pagination.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import UserRequestCard from '@/components/manage/moderation/UserRequestCard.vue' -import { normalizeQuery, parseTokens } from '@/search' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' - -export default { - components: { - Pagination, - UserRequestCard - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - data () { - return { - time, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['handled_date', 'handled_date'] - ], - targets: { - track: {} - } - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by username…'), - reports: this.$pgettext('*/Moderation/*/Noun', 'User Requests') - } - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const params = _.merge({ - page: this.page, - page_size: this.paginateBy, - q: this.search.query, - ordering: this.getOrderingAsString() - }, this.filters) - const self = this - self.isLoading = true - this.result = null - axios.get('manage/moderation/requests/', { params: params }).then((response) => { - self.result = response.data - self.isLoading = false - if (self.search.query === 'status:pending') { - self.$store.commit('ui/incrementNotifications', { type: 'pendingReviewRequests', value: response.data.count }) - } - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - } -} - -</script> diff --git a/front/src/views/admin/users/Base.vue b/front/src/views/admin/users/Base.vue index 96e9e44a554858e63f61b620d2fdf780769ec6a1..bfb673a2295dfdbdd377480b12fb9abab3a4138c 100644 --- a/front/src/views/admin/users/Base.vue +++ b/front/src/views/admin/users/Base.vue @@ -1,3 +1,15 @@ +<script setup lang="ts"> +import { useGettext } from 'vue3-gettext' +import { computed } from 'vue' + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + manageUsers: $pgettext('Head/Admin/Title', 'Manage users'), + secondaryMenu: $pgettext('Menu/*/Hidden text', 'Secondary menu') +})) +</script> + <template> <div v-title="labels.manageUsers" @@ -28,16 +40,3 @@ <router-view :key="$route.fullPath" /> </div> </template> - -<script> -export default { - computed: { - labels () { - return { - manageUsers: this.$pgettext('Head/Admin/Title', 'Manage users'), - secondaryMenu: this.$pgettext('Menu/*/Hidden text', 'Secondary menu') - } - } - } -} -</script> diff --git a/front/src/views/admin/users/InvitationsList.vue b/front/src/views/admin/users/InvitationsList.vue deleted file mode 100644 index dd4cb4d63cf12dd051636394212b9774f4a27e92..0000000000000000000000000000000000000000 --- a/front/src/views/admin/users/InvitationsList.vue +++ /dev/null @@ -1,31 +0,0 @@ -<template> - <main v-title="labels.invitations"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - {{ labels.invitations }} - </h2> - <invitation-form /> - <div class="ui hidden divider" /> - <invitations-table /> - </section> - </main> -</template> - -<script> -import InvitationForm from '@/components/manage/users/InvitationForm.vue' -import InvitationsTable from '@/components/manage/users/InvitationsTable.vue' - -export default { - components: { - InvitationForm, - InvitationsTable - }, - computed: { - labels () { - return { - invitations: this.$pgettext('*/Admin/*/Noun', 'Invitations') - } - } - } -} -</script> diff --git a/front/src/views/admin/users/UsersList.vue b/front/src/views/admin/users/UsersList.vue deleted file mode 100644 index 42efc38fb419085100d71e14e5f7b4f8b9417a72..0000000000000000000000000000000000000000 --- a/front/src/views/admin/users/UsersList.vue +++ /dev/null @@ -1,28 +0,0 @@ -<template> - <main v-title="labels.users"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"> - {{ labels.users }} - </h2> - <div class="ui hidden divider" /> - <users-table /> - </section> - </main> -</template> - -<script> -import UsersTable from '@/components/manage/users/UsersTable.vue' - -export default { - components: { - UsersTable - }, - computed: { - labels () { - return { - users: this.$pgettext('*/*/*/Noun', 'Users') - } - } - } -} -</script> diff --git a/front/src/views/auth/Callback.vue b/front/src/views/auth/Callback.vue index b16acae8c678588ee348b54a8444500225cb413c..6003af6009dd2f360dd721f6c505ab90aa8869e0 100644 --- a/front/src/views/auth/Callback.vue +++ b/front/src/views/auth/Callback.vue @@ -1,3 +1,24 @@ +<script setup lang="ts"> +import { useRouter } from 'vue-router' +import { useStore } from '~/store' +import { onMounted } from 'vue' + +interface Props { + state: string + code: string +} + +const props = defineProps<Props>() + +const router = useRouter() +const store = useStore() + +onMounted(async () => { + await store.dispatch('auth/handleOauthCallback', props.code) + router.push(props.state ?? '/library') +}) +</script> + <template> <main class="main pusher"> <section class="ui vertical stripe segment"> @@ -16,17 +37,3 @@ </section> </main> </template> - -<script> - -export default { - props: { - state: { type: String, required: true }, - code: { type: String, required: true } - }, - async mounted () { - await this.$store.dispatch('auth/handleOauthCallback', this.code) - this.$router.push(this.state || '/library') - } -} -</script> diff --git a/front/src/views/auth/EmailConfirm.vue b/front/src/views/auth/EmailConfirm.vue index 61952a274435ee540ae2306c40b167d03301ad42..58fd27efdc80640e07d566a57007ee20cd294f23 100644 --- a/front/src/views/auth/EmailConfirm.vue +++ b/front/src/views/auth/EmailConfirm.vue @@ -1,3 +1,46 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { computed, ref, onMounted } from 'vue' +import { useGettext } from 'vue3-gettext' + +import axios from 'axios' + +interface Props { + defaultKey: string +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + confirm: $pgettext('Head/Signup/Title', 'Confirm your e-mail address') +})) + +const errors = ref([] as string[]) +const key = ref(props.defaultKey) +const isLoading = ref(false) +const success = ref(false) +const submit = async () => { + isLoading.value = true + errors.value = [] + + try { + await axios.post('auth/registration/verify-email/', { key: key.value }) + success.value = true + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +onMounted(() => { + if (key.value) submit() +}) +</script> + <template> <main v-title="labels.confirm" @@ -76,51 +119,3 @@ </section> </main> </template> - -<script> -import axios from 'axios' - -export default { - props: { defaultKey: { type: String, required: true } }, - data () { - return { - isLoading: false, - errors: [], - key: this.defaultKey, - success: false - } - }, - computed: { - labels () { - return { - confirm: this.$pgettext('Head/Signup/Title', 'Confirm your e-mail address') - } - } - }, - mounted () { - if (this.key) { - this.submit() - } - }, - methods: { - submit () { - const self = this - self.isLoading = true - self.errors = [] - const payload = { - key: this.key - } - return axios.post('auth/registration/verify-email/', payload).then( - response => { - self.isLoading = false - self.success = true - }, - error => { - self.errors = error.backendErrors - self.isLoading = false - } - ) - } - } -} -</script> diff --git a/front/src/views/auth/Login.vue b/front/src/views/auth/Login.vue index 771d99bb71cbcbcd570121745cf5ff09226a67c4..b3e23f2192a6f7c1b0cdcc45f99308c2b712e641 100644 --- a/front/src/views/auth/Login.vue +++ b/front/src/views/auth/Login.vue @@ -1,3 +1,34 @@ +<script setup lang="ts"> +import type { RouteLocationRaw } from 'vue-router' + +import LoginForm from '~/components/auth/LoginForm.vue' +import { useRouter } from 'vue-router' +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' +import { whenever } from '@vueuse/core' + +interface Props { + next?: RouteLocationRaw +} + +const props = withDefaults(defineProps<Props>(), { + next: '/library' +}) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/Login/Title', 'Log In') +})) + +const store = useStore() +const router = useRouter() +whenever(() => store.state.auth.authenticated, () => { + const resolved = router.resolve(props.next) + router.push(resolved.name === '404' ? '/library' : props.next) +}) +</script> + <template> <main v-title="labels.title" @@ -10,44 +41,8 @@ Log in to your Funkwhale account </translate> </h2> - <login-form :next="redirectTo" /> + <login-form :next="next" /> </div> </section> </main> </template> - -<script> -import LoginForm from '@/components/auth/LoginForm.vue' - -export default { - components: { - LoginForm - }, - props: { - next: { type: String, default: '/library' } - }, - data () { - return { - redirectTo: this.next - } - }, - computed: { - labels () { - const title = this.$pgettext('Head/Login/Title', 'Log In') - return { - title - } - } - }, - created () { - const resolved = this.$router.resolve(this.redirectTo) - console.log(resolved.route.name) - if (resolved.route.name === '404') { - this.redirectTo = '/library' - } - if (this.$store.state.auth.authenticated) { - this.$router.push(this.redirectTo) - } - } -} -</script> diff --git a/front/src/views/auth/PasswordReset.vue b/front/src/views/auth/PasswordReset.vue index 47ea5490b20509cb9061a348a2c21f22e84ed4bb..b8788995fec9601442b82dd76bf6a7bf8aa02df7 100644 --- a/front/src/views/auth/PasswordReset.vue +++ b/front/src/views/auth/PasswordReset.vue @@ -1,3 +1,48 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { computed, ref, onMounted } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' + +import axios from 'axios' + +interface Props { + defaultEmail: string +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() + +const router = useRouter() + +const labels = computed(() => ({ + placeholder: $pgettext('Content/Signup/Input.Placeholder', 'Enter the e-mail address linked to your account'), + reset: $pgettext('*/Login/*/Verb', 'Reset your password') +})) + +const email = ref(props.defaultEmail) +const errors = ref([] as string[]) +const isLoading = ref(false) +const submit = async () => { + isLoading.value = true + errors.value = [] + + try { + await axios.post('auth/password/reset/', { email: email.value }) + router.push({ name: 'auth.password-reset-confirm' }) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const emailInput = ref() +onMounted(() => emailInput.value.focus()) +</script> + <template> <main v-title="labels.reset" @@ -42,7 +87,7 @@ <label for="account-email"><translate translate-context="Content/Signup/Input.Label">Account's e-mail address</translate></label> <input id="account-email" - ref="email" + ref="emailInput" v-model="email" required type="email" @@ -69,54 +114,3 @@ </section> </main> </template> - -<script> -import axios from 'axios' - -export default { - props: { defaultEmail: { type: String, required: true } }, - data () { - return { - email: this.defaultEmail, - isLoading: false, - errors: [] - } - }, - computed: { - labels () { - const reset = this.$pgettext('*/Login/*/Verb', 'Reset your password') - const placeholder = this.$pgettext('Content/Signup/Input.Placeholder', 'Enter the e-mail address linked to your account' - ) - return { - reset, - placeholder - } - } - }, - mounted () { - this.$refs.email.focus() - }, - methods: { - submit () { - const self = this - self.isLoading = true - self.errors = [] - const payload = { - email: this.email - } - return axios.post('auth/password/reset/', payload).then( - response => { - self.isLoading = false - self.$router.push({ - name: 'auth.password-reset-confirm' - }) - }, - error => { - self.errors = error.backendErrors - self.isLoading = false - } - ) - } - } -} -</script> diff --git a/front/src/views/auth/PasswordResetConfirm.vue b/front/src/views/auth/PasswordResetConfirm.vue index bbc01870f52daad943b8d20bd93c278a3e318474..1b7ce3333a6e0b8ca82f14b2d07b6bf02ad2be38 100644 --- a/front/src/views/auth/PasswordResetConfirm.vue +++ b/front/src/views/auth/PasswordResetConfirm.vue @@ -1,3 +1,54 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { computed, ref } from 'vue' + +import axios from 'axios' + +import PasswordInput from '~/components/forms/PasswordInput.vue' + +interface Props { + defaultToken: string + defaultUid: string +} + +const props = defineProps<Props>() + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + changePassword: $pgettext('*/Signup/Title', 'Change your password') +})) + +const newPassword = ref('') +const token = ref(props.defaultToken) +const uid = ref(props.defaultUid) + +const errors = ref([] as string[]) +const isLoading = ref(false) +const success = ref(false) +const submit = async () => { + isLoading.value = true + errors.value = [] + + try { + await axios.post('auth/password/reset/confirm/', { + uid: uid.value, + token: token.value, + new_password1: newPassword.value, + new_password2: newPassword.value + }) + + success.value = true + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> <main v-title="labels.changePassword" @@ -84,58 +135,3 @@ </section> </main> </template> - -<script> -import axios from 'axios' -import PasswordInput from '@/components/forms/PasswordInput.vue' - -export default { - components: { - PasswordInput - }, - props: { - defaultToken: { type: String, required: true }, - defaultUid: { type: String, required: true } - }, - data () { - return { - newPassword: '', - isLoading: false, - errors: [], - token: this.defaultToken, - uid: this.defaultUid, - success: false - } - }, - computed: { - labels () { - return { - changePassword: this.$pgettext('*/Signup/Title', 'Change your password') - } - } - }, - methods: { - submit () { - const self = this - self.isLoading = true - self.errors = [] - const payload = { - uid: this.uid, - token: this.token, - new_password1: this.newPassword, - new_password2: this.newPassword - } - return axios.post('auth/password/reset/confirm/', payload).then( - response => { - self.isLoading = false - self.success = true - }, - error => { - self.errors = error.backendErrors - self.isLoading = false - } - ) - } - } -} -</script> diff --git a/front/src/views/auth/Plugins.vue b/front/src/views/auth/Plugins.vue index 501a0377da427098d7d2f4a25869aaa4d4218638..9bffebea61f3cdc545afaaff2517d8443cbb4e39 100644 --- a/front/src/views/auth/Plugins.vue +++ b/front/src/views/auth/Plugins.vue @@ -1,3 +1,43 @@ +<script setup lang="ts"> +import { useGettext } from 'vue3-gettext' +import { computed, ref } from 'vue' + +import axios from 'axios' + +import PluginForm from '~/components/auth/Plugin.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + title: $pgettext('Head/Login/Title', 'Manage plugins') +})) + +const isLoading = ref(false) +const plugins = ref() +const libraries = ref() +const fetchData = async () => { + isLoading.value = true + + try { + const [pluginsResponse, librariesResponse] = await Promise.all([ + axios.get('plugins'), + axios.get('libraries', { params: { scope: 'me', page_size: 50 } }) + ]) + + plugins.value = pluginsResponse.data + libraries.value = librariesResponse.data.results + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() +</script> + <template> <main v-title="labels.title" @@ -26,42 +66,3 @@ </section> </main> </template> - -<script> -import axios from 'axios' -import PluginForm from '@/components/auth/Plugin.vue' - -export default { - components: { - PluginForm - }, - data () { - return { - isLoading: true, - plugins: null, - libraries: null - } - }, - computed: { - labels () { - const title = this.$pgettext('Head/Login/Title', 'Manage plugins') - return { - title - } - } - }, - async created () { - await this.fetchData() - }, - methods: { - async fetchData () { - this.isLoading = true - let response = await axios.get('plugins') - this.plugins = response.data - response = await axios.get('libraries', { paramis: { scope: 'me', page_size: 50 } }) - this.libraries = response.data.results - this.isLoading = false - } - } -} -</script> diff --git a/front/src/views/auth/ProfileActivity.vue b/front/src/views/auth/ProfileActivity.vue index ca7f8dc64cb9578c3e29d195455abaac326557cc..7fe4ba95b917542fc51b8d07e5fa767fbe9fa85f 100644 --- a/front/src/views/auth/ProfileActivity.vue +++ b/front/src/views/auth/ProfileActivity.vue @@ -1,3 +1,21 @@ +<script setup lang="ts"> +import type { Actor } from '~/types' + +import { ref } from 'vue' + +import PlaylistWidget from '~/components/playlists/Widget.vue' +import TrackWidget from '~/components/audio/track/Widget.vue' +import RadioButton from '~/components/radios/Button.vue' + +interface Props { + object: Actor +} + +defineProps<Props>() + +const recentActivity = ref(0) +</script> + <template> <section> <div> @@ -48,19 +66,3 @@ </div> </section> </template> - -<script> -import TrackWidget from '@/components/audio/track/Widget.vue' -import PlaylistWidget from '@/components/playlists/Widget.vue' -import RadioButton from '@/components/radios/Button.vue' - -export default { - components: { TrackWidget, PlaylistWidget, RadioButton }, - props: { object: { type: Object, required: true } }, - data () { - return { - recentActivity: 0 - } - } -} -</script> diff --git a/front/src/views/auth/ProfileBase.vue b/front/src/views/auth/ProfileBase.vue index 7650f8ab5a1805722ce6e4f54727524c44d9567e..5a747c03f57d101e08fc3a490671aea81c02b8ef 100644 --- a/front/src/views/auth/ProfileBase.vue +++ b/front/src/views/auth/ProfileBase.vue @@ -1,3 +1,73 @@ +<script setup lang="ts"> +import type { Actor } from '~/types' + +import { onBeforeRouteUpdate } from 'vue-router' +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' + +import useErrorHandler from '~/composables/useErrorHandler' +import useReport from '~/composables/moderation/useReport' + +interface Events { + (e: 'updated', value: Actor): void +} + +interface Props { + username: string + domain?: string | null +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + domain: null +}) + +const { report, getReportableObjects } = useReport() +const store = useStore() + +const object = ref<Actor | null>(null) + +const displayName = computed(() => object.value?.name ?? object.value?.preferred_username) +const fullUsername = computed(() => props.domain + ? `${props.username}@${props.domain}` + : `${props.username}@${store.getters['instance/domain']}` +) + +const routerParams = computed(() => props.domain + ? { username: props.username, domain: props.domain } + : { username: props.username } +) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + usernameProfile: $pgettext('Head/Profile/Title', "%{ username }'s profile", { username: props.username }) +})) + +onBeforeRouteUpdate((to) => { + to.meta.preserveScrollPosition = true +}) + +const isLoading = ref(false) +const fetchData = async () => { + object.value = null + isLoading.value = true + + try { + const response = await axios.get(`federation/actors/${fullUsername.value}/`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +watch(props, fetchData, { immediate: true }) +</script> + <template> <main v-title="labels.usernameProfile" @@ -36,11 +106,11 @@ >View on %{ domain }</translate> </a> <div - v-for="obj in getReportableObjs({account: object})" + v-for="obj in getReportableObjects({account: object})" :key="obj.target.type + obj.target.id" role="button" class="basic item" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <i class="share icon" /> {{ obj.label }} </div> @@ -96,7 +166,7 @@ :field-name="'summary'" :update-url="`users/${$store.state.auth.username}/`" :can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername" - @updated="$emit('updated', $event)" + @updated="emit('updated', $event)" /> </div> </div> @@ -106,7 +176,6 @@ <div class="ui secondary pointing center aligned menu"> <router-link class="item" - :exact="true" :to="{name: 'profile.overview', params: routerParams}" > <translate translate-context="Content/Profile/Link"> @@ -115,7 +184,6 @@ </router-link> <router-link class="item" - :exact="true" :to="{name: 'profile.activity', params: routerParams}" > <translate translate-context="Content/Profile/*"> @@ -126,7 +194,7 @@ <div class="ui hidden divider" /> <router-view :object="object" - @updated="fetch" + @updated="fetchData" /> </div> </div> @@ -135,82 +203,3 @@ </div> </main> </template> - -<script> -import axios from 'axios' - -import ReportMixin from '@/components/mixins/Report.vue' - -export default { - mixins: [ReportMixin], - beforeRouteUpdate (to, from, next) { - to.meta.preserveScrollPosition = true - next() - }, - props: { - username: { type: String, required: true }, - domain: { type: String, required: false, default: null } - }, - data () { - return { - object: null, - isLoading: false - } - }, - computed: { - labels () { - const msg = this.$pgettext('Head/Profile/Title', "%{ username }'s profile") - const usernameProfile = this.$gettextInterpolate(msg, { - username: this.username - }) - return { - usernameProfile - } - }, - fullUsername () { - if (this.username && this.domain) { - return `${this.username}@${this.domain}` - } else { - return `${this.username}@${this.$store.getters['instance/domain']}` - } - }, - routerParams () { - if (this.domain) { - return { username: this.username, domain: this.domain } - } else { - return { username: this.username } - } - }, - displayName () { - return this.object.name || this.object.preferred_username - } - }, - watch: { - domain () { - this.fetch() - }, - username () { - this.fetch() - } - }, - created () { - const authenticated = this.$store.state.auth.authenticated - if (!authenticated && this.domain && this.$store.getters['instance/domain'] !== this.domain) { - this.$router.push({ name: 'login', query: { next: this.$route.fullPath } }) - } else { - this.fetch() - } - }, - methods: { - fetch () { - const self = this - self.object = null - self.isLoading = true - axios.get(`federation/actors/${this.fullUsername}/`).then((response) => { - self.object = response.data - self.isLoading = false - }) - } - } -} -</script> diff --git a/front/src/views/auth/ProfileOverview.vue b/front/src/views/auth/ProfileOverview.vue index d71f45cecb5c392434ad2d0fdf96aca304516b9a..022e00366d66bcee0af1cc1fd2be51da08075b84 100644 --- a/front/src/views/auth/ProfileOverview.vue +++ b/front/src/views/auth/ProfileOverview.vue @@ -1,3 +1,33 @@ +<script setup lang="ts"> +import type { Actor } from '~/types' + +import SemanticModal from '~/components/semantic/Modal.vue' +import LibraryWidget from '~/components/federation/LibraryWidget.vue' +import ChannelsWidget from '~/components/audio/ChannelsWidget.vue' +import ChannelForm from '~/components/audio/ChannelForm.vue' +import { ref } from 'vue' + +interface Events { + (e: 'updated', value: Actor): void +} + +interface Props { + object: Actor +} + +const emit = defineEmits<Events>() +defineProps<Props>() + +const step = ref(1) +const showCreateModal = ref(false) +const loading = ref(false) +const submittable = ref(false) +const category = ref('podcast') + +const modalContent = ref() +const createForm = ref() +</script> + <template> <section> <div v-if="$store.getters['ui/layoutVersion'] === 'small'"> @@ -6,7 +36,7 @@ :field-name="'summary'" :update-url="`users/${$store.state.auth.username}/`" :can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername" - @updated="$emit('updated', $event)" + @updated="emit('updated', $event)" /> <div class="ui hidden divider" /> </div> @@ -46,34 +76,30 @@ </div> </h2> <library-widget :url="`federation/actors/${object.full_username}/libraries/`"> - <translate - slot="title" - translate-context="Content/Profile/Paragraph" - > - This user shared the following libraries - </translate> + <template #title> + <translate translate-context="Content/Profile/Paragraph"> + This user shared the following libraries + </translate> + </template> </library-widget> </div> - <modal :show.sync="showCreateModal"> + <semantic-modal v-model:show="showCreateModal"> <h4 class="header"> <translate v-if="step === 1" - key="1" translate-context="Content/Channel/*/Verb" > Create channel </translate> <translate v-else-if="category === 'podcast'" - key="2" translate-context="Content/Channel/*" > Podcast channel </translate> <translate v-else - key="3" translate-context="Content/Channel/*" > Artist channel @@ -87,10 +113,10 @@ ref="createForm" :object="null" :step="step" - @loading="isLoading = $event" + @loading="loading = $event" @submittable="submittable = $event" @category="category = $event" - @errored="$refs.modalContent.scrollTop = 0" + @errored="modalContent.scrollTop = 0" @created="$router.push({name: 'channels.detail', params: {id: $event.actor.preferred_username}})" /> <div class="ui hidden divider" /> @@ -124,37 +150,16 @@ </button> <button v-if="step === 2" - :class="['ui', 'primary button', {loading: isLoading}]" + :class="['ui', 'primary button', { loading }]" type="submit" - :disabled="!submittable && !isLoading" - @click.prevent.stop="$refs.createForm.submit" + :disabled="!submittable && !loading" + @click.prevent.stop="createForm.submit" > <translate translate-context="*/Channels/Button.Label"> Create channel </translate> </button> </div> - </modal> + </semantic-modal> </section> </template> - -<script> -import Modal from '@/components/semantic/Modal.vue' -import LibraryWidget from '@/components/federation/LibraryWidget.vue' -import ChannelsWidget from '@/components/audio/ChannelsWidget.vue' -import ChannelForm from '@/components/audio/ChannelForm.vue' - -export default { - components: { ChannelsWidget, LibraryWidget, ChannelForm, Modal }, - props: { object: { type: Object, required: true } }, - data () { - return { - showCreateModal: false, - isLoading: false, - submittable: false, - step: 1, - category: 'podcast' - } - } -} -</script> diff --git a/front/src/views/auth/Signup.vue b/front/src/views/auth/Signup.vue index 7a4353b7373d325e6f5ef94483cccc5b3db56931..4b16abbd15f8cc3f896f1f918772ccf5946a29f6 100644 --- a/front/src/views/auth/Signup.vue +++ b/front/src/views/auth/Signup.vue @@ -1,3 +1,28 @@ +<script setup lang="ts"> +import type { RouteLocationRaw } from 'vue-router' + +import { useGettext } from 'vue3-gettext' +import { computed } from 'vue' + +import SignupForm from '~/components/auth/SignupForm.vue' + +interface Props { + defaultInvitation?: string + next?: RouteLocationRaw +} + +withDefaults(defineProps<Props>(), { + defaultInvitation: undefined, + next: '/' +}) + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + title: $pgettext('*/Signup/Title', 'Sign Up') +})) +</script> + <template> <main v-title="labels.title" @@ -18,37 +43,3 @@ </section> </main> </template> - -<script> - -import SignupForm from '@/components/auth/SignupForm.vue' - -export default { - components: { - SignupForm - }, - props: { - defaultInvitation: { type: String, required: false, default: null }, - next: { type: String, default: '/' } - }, - data () { - return { - username: '', - email: '', - password: '', - isLoadingInstanceSetting: true, - errors: [], - isLoading: false, - invitation: this.defaultInvitation - } - }, - computed: { - labels () { - const title = this.$pgettext('*/Signup/Title', 'Sign Up') - return { - title - } - } - } -} -</script> diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue index a8d29f65f5e56843ad19356ed67a98db9f85240b..9b6162454e828b82d8ce2d7480a0ec8fd36d7389 100644 --- a/front/src/views/channels/DetailBase.vue +++ b/front/src/views/channels/DetailBase.vue @@ -1,3 +1,128 @@ +<script setup lang="ts"> +import type { Channel } from '~/types' + +import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router' +import { computed, ref, reactive, watch, watchEffect } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' + +import SubscribeButton from '~/components/channels/SubscribeButton.vue' +import ChannelForm from '~/components/audio/ChannelForm.vue' +import EmbedWizard from '~/components/audio/EmbedWizard.vue' +import SemanticModal from '~/components/semantic/Modal.vue' +import PlayButton from '~/components/audio/PlayButton.vue' +import TagsList from '~/components/tags/List.vue' + +import useErrorHandler from '~/composables/useErrorHandler' +import useReport from '~/composables/moderation/useReport' + +interface Events { + (e: 'deleted'): void +} + +interface Props { + id: number +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() +const { report, getReportableObjects } = useReport() +const store = useStore() + +const object = ref<Channel | null>(null) +const editForm = ref() +const totalTracks = ref(0) + +const edit = reactive({ + submittable: false, + loading: false +}) + +const showEmbedModal = ref(false) +const showEditModal = ref(false) +const showSubscribeModal = ref(false) + +const isOwner = computed(() => store.state.auth.authenticated && object.value?.attributed_to.full_username === store.state.auth.fullUsername) +const isPodcast = computed(() => object.value?.artist?.content_category === 'podcast') +const isPlayable = computed(() => totalTracks.value > 0) +const externalDomain = computed(() => { + const parser = document.createElement('a') + parser.href = object.value?.url ?? object.value?.rss_url ?? '' + return parser.hostname +}) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('*/*/*', 'Channel') +})) + +onBeforeRouteUpdate((to) => { + to.meta.preserveScrollPosition = true +}) + +const router = useRouter() +const isLoading = ref(false) +const fetchData = async () => { + showEditModal.value = false + edit.loading = false + isLoading.value = true + + try { + const response = await axios.get(`channels/${props.id}`, { params: { refresh: 'true' } }) + object.value = response.data + totalTracks.value = response.data.artist.tracks_count + + if (props.id === response.data.uuid && response.data.actor) { + // replace with the pretty channel url if possible + const actor = response.data.actor + if (actor.is_local) { + await router.replace({ name: 'channels.detail', params: { id: actor.preferred_username } }) + } else { + await router.replace({ name: 'channels.detail', params: { id: actor.full_username } }) + } + } + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +watch(() => props.id, fetchData, { immediate: true }) +watchEffect(() => { + const publication = store.state.channels.latestPublication + if (publication?.uploads && publication.channel.uuid === object.value?.uuid) { + fetchData() + } +}) + +const route = useRoute() +watchEffect(() => { + if (!store.state.auth.authenticated && store.getters['instance/domain'] !== object.value?.actor.domain) { + router.push({ name: 'login', query: { next: route.fullPath } }) + } +}) + +const remove = async () => { + isLoading.value = true + try { + await axios.delete(`channels/${object.value?.uuid}`) + emit('deleted') + return router.push({ name: 'profile.overview', params: { username: store.state.auth.username } }) + } catch (error) { + useErrorHandler(error as Error) + } +} + +const updateSubscriptionCount = (delta: number) => { + if (object.value) { + object.value.subscriptions_count += delta + } +} +</script> + <template> <main v-title="labels.title" @@ -11,7 +136,7 @@ </div> <template v-if="object && !isLoading"> <section - v-title="object.artist.name" + v-title="object.artist?.name" class="ui head vertical stripe segment container" > <div class="ui stackable grid"> @@ -19,7 +144,7 @@ <div class="ui two column grid"> <div class="column"> <img - v-if="object.artist.cover" + v-if="object.artist?.cover" alt="" class="huge channel-image" :src="$store.getters['instance/absoluteUrl'](object.artist.cover.urls.medium_square_crop)" @@ -31,7 +156,7 @@ </div> <div class="ui column right aligned"> <tags-list - v-if="object.artist.tags && object.artist.tags.length > 0" + v-if="object.artist?.tags && object.artist?.tags.length > 0" :tags="object.artist.tags" /> <actor-link @@ -43,8 +168,7 @@ <template v-if="totalTracks > 0"> <div class="ui hidden very small divider" /> <translate - v-if="object.artist.content_category === 'podcast'" - key="1" + v-if="object.artist?.content_category === 'podcast'" translate-context="Content/Channel/Paragraph" translate-plural="%{ count } episodes" :translate-n="totalTracks" @@ -54,7 +178,6 @@ </translate> <translate v-else - key="2" translate-context="*/*/*" :translate-params="{count: totalTracks}" :translate-n="totalTracks" @@ -67,16 +190,16 @@ <br><translate translate-context="Content/Channel/Paragraph" translate-plural="%{ count } subscribers" - :translate-n="object.subscriptions_count" - :translate-params="{count: object.subscriptions_count}" + :translate-n="object?.subscriptions_count" + :translate-params="{count: object?.subscriptions_count}" > %{ count } subscriber </translate> <br><translate translate-context="Content/Channel/Paragraph" translate-plural="%{ count } listenings" - :translate-n="object.downloads_count" - :translate-params="{count: object.downloads_count}" + :translate-n="object?.downloads_count" + :translate-params="{count: object?.downloads_count}" > %{ count } listening </translate> @@ -88,9 +211,9 @@ > <i class="feed icon" /> </a> - <modal + <semantic-modal + v-model:show="showSubscribeModal" class="tiny" - :show.sync="showSubscribeModal" > <h4 class="header"> <translate translate-context="Popup/Channel/Title/Verb"> @@ -108,8 +231,8 @@ </h3> <subscribe-button :channel="object" - @subscribed="object.subscriptions_count += 1" - @unsubscribed="object.subscriptions_count -= 1" + @subscribed="updateSubscriptionCount(1)" + @unsubscribed="updateSubscriptionCount(-1)" /> </template> <template v-if="object.rss_url"> @@ -138,7 +261,10 @@ If you're using Mastodon or other fediverse applications, you can subscribe to this account: </translate> </p> - <copy-input :value="`@${object.actor.full_username}`" /> + <copy-input + id="copy-tag" + :value="`@${object.actor.full_username}`" + /> </template> </div> </div> @@ -149,7 +275,7 @@ </translate> </button> </div> - </modal> + </semantic-modal> <button ref="dropdown" v-dropdown="{direction: 'downward'}" @@ -180,11 +306,11 @@ </a> <div class="divider" /> <a - v-for="obj in getReportableObjs({account: object.attributed_to, channel: object})" + v-for="obj in getReportableObjects({account: object.attributed_to, channel: object})" :key="obj.target.type + obj.target.id" href="" class="basic item" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <i class="share icon" /> {{ obj.label }} </a> @@ -208,23 +334,29 @@ <translate translate-context="*/*/*/Verb"> Delete… </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Channel/Title"> - Delete this Channel? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Content/Moderation/Paragraph"> - The channel will be deleted, as well as any related files and data. This action is irreversible. + <translate translate-context="Popup/Channel/Title"> + Delete this Channel? </translate> </p> - </div> - <p slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Delete - </translate> - </p> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Content/Moderation/Paragraph"> + The channel will be deleted, as well as any related files and data. This action is irreversible. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <p> + <translate translate-context="*/*/*/Verb"> + Delete + </translate> + </p> + </template> </dangerous-button> </template> <template v-if="$store.state.auth.availablePermissions['library']"> @@ -246,9 +378,9 @@ <h1 class="ui header"> <div class="left aligned" - :title="object.artist.name" + :title="object.artist?.name" > - {{ object.artist.name }} + {{ object.artist?.name }} <div class="ui hidden very small divider" /> <div v-if="object.actor" @@ -304,14 +436,14 @@ <div class="ui buttons"> <subscribe-button :channel="object" - @subscribed="object.subscriptions_count += 1" - @unsubscribed="object.subscriptions_count -= 1" + @subscribed="updateSubscriptionCount(1)" + @unsubscribed="updateSubscriptionCount(-1)" /> </div> - <modal + <semantic-modal v-if="totalTracks > 0" - :show.sync="showEmbedModal" + v-model:show="showEmbedModal" > <h4 class="header"> <translate translate-context="Popup/Artist/Title/Verb"> @@ -321,7 +453,7 @@ <div class="scrolling content"> <div class="description"> <embed-wizard - :id="object.artist.id" + :id="object.artist!.id" type="artist" /> </div> @@ -333,22 +465,20 @@ </translate> </button> </div> - </modal> - <modal + </semantic-modal> + <semantic-modal v-if="isOwner" - :show.sync="showEditModal" + v-model:show="showEditModal" > <h4 class="header"> <translate - v-if="object.artist.content_category === 'podcast'" - key="1" + v-if="object.artist?.content_category === 'podcast'" translate-context="Content/Channel/*" > Podcast channel </translate> <translate v-else - key="2" translate-context="Content/Channel/*" > Artist channel @@ -358,7 +488,7 @@ <channel-form ref="editForm" :object="object" - @loading="edit.isLoading = $event" + @loading="edit.loading = $event" @submittable="edit.submittable = $event" @updated="fetchData" /> @@ -371,20 +501,20 @@ </translate> </button> <button - :class="['ui', 'primary', 'confirm', {loading: edit.isLoading}, 'button']" + :class="['ui', 'primary', 'confirm', {loading: edit.loading}, 'button']" :disabled="!edit.submittable" - @click.stop="$refs.editForm.submit" + @click.stop="editForm?.submit" > <translate translate-context="*/Channels/Button.Label"> Update channel </translate> </button> </div> - </modal> + </semantic-modal> </div> <div v-if="$store.getters['ui/layoutVersion'] === 'large'"> <rendered-description - :content="object.artist.description" + :content="object.artist?.description" :update-url="`channels/${object.uuid}/`" :can-update="false" @updated="object = $event" @@ -395,7 +525,7 @@ <div class="ui secondary pointing center aligned menu"> <router-link class="item" - :exact="true" + :to="{name: 'channels.detail', params: {id: id}}" > <translate translate-context="Content/Channels/Link"> @@ -404,19 +534,17 @@ </router-link> <router-link class="item" - :exact="true" + :to="{name: 'channels.detail.episodes', params: {id: id}}" > <translate v-if="isPodcast" - key="1" translate-context="Content/Channels/*" > All Episodes </translate> <translate v-else - key="2" translate-context="*/*/*" > Tracks @@ -435,126 +563,3 @@ </template> </main> </template> - -<script> -import axios from 'axios' -import PlayButton from '@/components/audio/PlayButton.vue' -import EmbedWizard from '@/components/audio/EmbedWizard.vue' -import Modal from '@/components/semantic/Modal.vue' -import TagsList from '@/components/tags/List.vue' -import ReportMixin from '@/components/mixins/Report.vue' - -import SubscribeButton from '@/components/channels/SubscribeButton.vue' -import ChannelForm from '@/components/audio/ChannelForm.vue' - -export default { - components: { - PlayButton, - EmbedWizard, - Modal, - TagsList, - SubscribeButton, - ChannelForm - }, - mixins: [ReportMixin], - beforeRouteUpdate (to, from, next) { - to.meta.preserveScrollPosition = true - next() - }, - props: { id: { type: String, required: true } }, - data () { - return { - isLoading: true, - object: null, - totalTracks: 0, - latestTracks: null, - showEmbedModal: false, - showEditModal: false, - showSubscribeModal: false, - edit: { - submittable: false, - loading: false - } - } - }, - computed: { - externalDomain () { - const parser = document.createElement('a') - parser.href = this.object.url || this.object.rss_url - return parser.hostname - }, - - isOwner () { - return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername - }, - isPodcast () { - return this.object.artist.content_category === 'podcast' - }, - labels () { - return { - title: this.$pgettext('*/*/*', 'Channel') - } - }, - contentFilter () { - return this.$store.getters['moderation/artistFilters']().filter((e) => { - return e.target.id === this.object.artist.id - })[0] - }, - isPlayable () { - return this.totalTracks > 0 - } - }, - watch: { - id () { - this.fetchData() - }, - '$store.state.channels.latestPublication' (v) { - if (v && v.uploads && v.channel.uuid === this.object.uuid) { - this.fetchData() - } - } - }, - async created () { - await this.fetchData() - const authenticated = this.$store.state.auth.authenticated - if (!authenticated && this.$store.getters['instance/domain'] !== this.object.actor.domain) { - this.$router.push({ name: 'login', query: { next: this.$route.fullPath } }) - } - }, - methods: { - async fetchData () { - const self = this - this.showEditModal = false - this.edit.isLoading = false - this.isLoading = true - const channelPromise = axios.get(`channels/${this.id}`, { params: { refresh: 'true' } }).then(response => { - self.object = response.data - if ((self.id === response.data.uuid) && response.data.actor) { - // replace with the pretty channel url if possible - const actor = response.data.actor - if (actor.is_local) { - self.$router.replace({ name: 'channels.detail', params: { id: actor.preferred_username } }) - } else { - self.$router.replace({ name: 'channels.detail', params: { id: actor.full_username } }) - } - } - self.totalTracks = response.data.artist.tracks_count - self.isLoading = false - }) - await channelPromise - }, - remove () { - const self = this - self.isLoading = true - axios.delete(`channels/${this.object.uuid}`).then((response) => { - self.isLoading = false - self.$emit('deleted') - self.$router.push({ name: 'profile.overview', params: { username: self.$store.state.auth.username } }) - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/views/channels/DetailEpisodes.vue b/front/src/views/channels/DetailEpisodes.vue index 6aafda2fcebd921ee2b2c2a3b3a63b874dad07f5..3cd33c7a355c21eb3a04acf00d3222130afd35c8 100644 --- a/front/src/views/channels/DetailEpisodes.vue +++ b/front/src/views/channels/DetailEpisodes.vue @@ -1,21 +1,22 @@ +<script setup lang="ts"> +import type { Channel } from '~/types' + +import ChannelEntries from '~/components/audio/ChannelEntries.vue' + +interface Props { + object: Channel +} + +defineProps<Props>() +</script> + <template> <section> <channel-entries - :default-cover="object.artist.cover" - :is-podcast="object.artist.content_category === 'podcast'" + :default-cover="object.artist?.cover" + :is-podcast="object.artist?.content_category === 'podcast'" :limit="25" :filters="{channel: object.uuid, ordering: 'creation_date'}" /> </section> </template> - -<script> -import ChannelEntries from '@/components/audio/ChannelEntries.vue' - -export default { - components: { - ChannelEntries - }, - props: { object: { type: Object, required: true } } -} -</script> diff --git a/front/src/views/channels/DetailOverview.vue b/front/src/views/channels/DetailOverview.vue index 6e12a74cb9f26e6f06f1f1c1f8f32ee6824d7137..c5537a6dd938785ed112b5d77e3734450ee225bc 100644 --- a/front/src/views/channels/DetailOverview.vue +++ b/front/src/views/channels/DetailOverview.vue @@ -1,3 +1,94 @@ +<script setup lang="ts"> +import type { Channel, Upload } from '~/types' + +import { computed, ref, reactive, watch } from 'vue' +import { whenever } from '@vueuse/core' +import { useStore } from '~/store' + +import axios from 'axios' +import qs from 'qs' + +import ChannelEntries from '~/components/audio/ChannelEntries.vue' +import ChannelSeries from '~/components/audio/ChannelSeries.vue' +import AlbumModal from '~/components/channels/AlbumModal.vue' + +import useWebSocketHandler from '~/composables/useWebSocketHandler' + +interface Props { + object: Channel +} + +const props = defineProps<Props>() + +const store = useStore() + +const isPodcast = computed(() => props.object.artist?.content_category === 'podcast') +const isOwner = computed(() => store.state.auth.authenticated && props.object.attributed_to.full_username === store.state.auth.fullUsername) + +const seriesFilters = computed(() => ({ + artist: props.object.artist?.id, + ordering: '-creation_date', + playable: isOwner.value + ? undefined + : true +})) + +const pendingUploads = reactive([] as Upload[]) +const processedUploads = computed(() => pendingUploads.filter(upload => upload.import_status !== 'pending')) +const finishedUploads = computed(() => pendingUploads.filter(upload => upload.import_status === 'finished')) +const erroredUploads = computed(() => pendingUploads.filter(upload => upload.import_status === 'errored')) +const skippedUploads = computed(() => pendingUploads.filter(upload => upload.import_status === 'skipped')) + +const pendingUploadsById = computed(() => pendingUploads.reduce((acc, upload) => { + acc[upload.uuid] = upload + return acc +}, {} as Record<string, Upload>)) + +const isOver = computed(() => pendingUploads.length === processedUploads.value.length) +const isSuccessfull = computed(() => pendingUploads.length === finishedUploads.value.length) + +watch(() => store.state.channels.latestPublication, (value) => { + if (value?.channel.uuid === props.object.uuid && value.uploads.length > 0) { + pendingUploads.push(...value.uploads) + } +}) + +const episodesKey = ref(new Date()) +const seriesKey = ref(new Date()) +whenever(isOver, () => { + episodesKey.value = new Date() + seriesKey.value = new Date() +}) + +const fetchPendingUploads = async () => { + try { + const response = await axios.get('uploads/', { + params: { channel: props.object.uuid, import_status: ['pending', 'skipped', 'errored'], include_channels: 'true' }, + paramsSerializer: function (params) { + return qs.stringify(params, { indices: false }) + } + }) + + pendingUploads.length = 0 + pendingUploads.push(...response.data.results) + } catch (error) { + + } +} + +if (isOwner.value) { + fetchPendingUploads() + .then(() => { + useWebSocketHandler('import.status_updated', (event) => { + if (!pendingUploadsById.value[event.upload.uuid]) return + Object.assign(pendingUploadsById.value[event.upload.uuid], event.upload) + }) + }) +} + +const albumModal = ref() +</script> + <template> <section> <div @@ -8,7 +99,7 @@ <i role="button" class="close icon" - @click="pendingUploads = []" + @click="pendingUploads.length = 0" /> <h3 class="ui header"> <translate translate-context="Content/Channel/Header"> @@ -68,7 +159,7 @@ </div> <div v-if="$store.getters['ui/layoutVersion'] === 'small'"> <rendered-description - :content="object.artist.description" + :content="object.artist?.description" :update-url="`channels/${object.uuid}/`" :can-update="false" /> @@ -77,21 +168,19 @@ <channel-entries :key="String(episodesKey) + 'entries'" :is-podcast="isPodcast" - :default-cover="object.artist.cover" + :default-cover="object.artist?.cover" :limit="25" :filters="{channel: object.uuid, ordering: '-creation_date', page_size: '25'}" > <h2 class="ui header"> <translate v-if="isPodcast" - key="1" translate-context="Content/Channel/Paragraph" > Latest episodes </translate> <translate v-else - key="2" translate-context="Content/Channel/Paragraph" > Latest tracks @@ -107,14 +196,12 @@ <h2 class="ui with-actions header"> <translate v-if="isPodcast" - key="1" translate-context="Content/Channel/Paragraph" > Series </translate> <translate v-else - key="2" translate-context="*/*/*" > Albums @@ -123,7 +210,7 @@ v-if="isOwner" class="actions" > - <a @click.stop.prevent="$refs.albumModal.show = true"> + <a @click.stop.prevent="albumModal.show = true"> <i class="plus icon" /> <translate translate-context="Content/Profile/Button">Add new</translate> </a> @@ -134,126 +221,7 @@ v-if="isOwner" ref="albumModal" :channel="object" - @created="$refs.albumModal.show = false; seriesKey = new Date()" + @created="albumModal.show = false; seriesKey = new Date()" /> </section> </template> - -<script> -import axios from 'axios' -import qs from 'qs' - -import ChannelEntries from '@/components/audio/ChannelEntries.vue' -import ChannelSeries from '@/components/audio/ChannelSeries.vue' -import AlbumModal from '@/components/channels/AlbumModal.vue' - -export default { - components: { - ChannelEntries, - ChannelSeries, - AlbumModal - }, - props: { object: { type: Object, required: true } }, - data () { - return { - seriesKey: new Date(), - episodesKey: new Date(), - pendingUploads: [] - } - }, - computed: { - isPodcast () { - return this.object.artist.content_category === 'podcast' - }, - isOwner () { - return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername - }, - seriesFilters () { - const filters = { artist: this.object.artist.id, ordering: '-creation_date' } - if (!this.isOwner) { - filters.playable = 'true' - } - return filters - }, - processedUploads () { - return this.pendingUploads.filter((u) => { - return u.import_status !== 'pending' - }) - }, - erroredUploads () { - return this.pendingUploads.filter((u) => { - return u.import_status === 'errored' - }) - }, - skippedUploads () { - return this.pendingUploads.filter((u) => { - return u.import_status === 'skipped' - }) - }, - finishedUploads () { - return this.pendingUploads.filter((u) => { - return u.import_status === 'finished' - }) - }, - pendingUploadsById () { - const d = {} - this.pendingUploads.forEach((u) => { - d[u.uuid] = u - }) - return d - }, - isOver () { - return this.pendingUploads && this.processedUploads.length === this.pendingUploads.length - }, - isSuccessfull () { - return this.pendingUploads && this.finishedUploads.length === this.pendingUploads.length - } - }, - watch: { - '$store.state.channels.latestPublication' (v) { - if (v && v.uploads && v.channel.uuid === this.object.uuid) { - this.pendingUploads = [...this.pendingUploads, ...v.uploads] - } - }, - 'isOver' (v) { - if (v) { - this.seriesKey = new Date() - this.episodesKey = new Date() - } - } - }, - async created () { - if (this.isOwner) { - await this.fetchPendingUploads() - this.$store.commit('ui/addWebsocketEventHandler', { - eventName: 'import.status_updated', - id: 'fileUploadChannel', - handler: this.handleImportEvent - }) - } - }, - destroyed () { - this.$store.commit('ui/removeWebsocketEventHandler', { - eventName: 'import.status_updated', - id: 'fileUploadChannel' - }) - }, - methods: { - handleImportEvent (event) { - if (!this.pendingUploadsById[event.upload.uuid]) { - return - } - Object.assign(this.pendingUploadsById[event.upload.uuid], event.upload) - }, - async fetchPendingUploads () { - const response = await axios.get('uploads/', { - params: { channel: this.object.uuid, import_status: ['pending', 'skipped', 'errored'], include_channels: 'true' }, - paramsSerializer: function (params) { - return qs.stringify(params, { indices: false }) - } - }) - this.pendingUploads = response.data.results - } - } -} -</script> diff --git a/front/src/views/channels/SubscriptionsList.vue b/front/src/views/channels/SubscriptionsList.vue index c2f86df32fdad639a89eba5eedc619a9e132bd3c..3e14aa0b6dd1b40b982980fbea1aa40be0ff6a95 100644 --- a/front/src/views/channels/SubscriptionsList.vue +++ b/front/src/views/channels/SubscriptionsList.vue @@ -1,3 +1,60 @@ +<script setup lang="ts"> +import type { Channel } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { ref, computed } from 'vue' + +import axios from 'axios' + +import ChannelsWidget from '~/components/audio/ChannelsWidget.vue' +import RemoteSearchForm from '~/components/RemoteSearchForm.vue' +import SemanticModal from '~/components/semantic/Modal.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + defaultQuery?: string +} + +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '' +}) + +const query = ref(props.defaultQuery) +const widgetKey = ref(new Date().toLocaleString()) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Content/Subscriptions/Header', 'Subscribed Channels'), + searchPlaceholder: $pgettext('Content/Subscriptions/Form.Placeholder', 'Filter by name…') +})) + +const previousPage = ref() +const nextPage = ref() +const channels = ref([] as Channel[]) +const count = ref(0) +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get('channels/', { params: { subscribed: 'true', q: query.value } }) + previousPage.value = response.data.previous + nextPage.value = response.data.next + channels.value.push(...response.data.results) + count.value = response.data.count + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} +fetchData() + +const reloadWidget = () => (widgetKey.value = new Date().toLocaleString()) +const showSubscribeModal = ref(false) +</script> + <template> <main v-title="labels.title" @@ -13,9 +70,9 @@ </a> </div> </h1> - <modal + <semantic-modal + v-model:show="showSubscribeModal" class="tiny" - :show.sync="showSubscribeModal" :fullscreen="false" > <h2 class="header"> @@ -52,7 +109,7 @@ </translate> </button> </div> - </modal> + </semantic-modal> <inline-search-bar v-model="query" @@ -68,60 +125,3 @@ </section> </main> </template> - -<script> -import axios from 'axios' -import Modal from '@/components/semantic/Modal.vue' - -import ChannelsWidget from '@/components/audio/ChannelsWidget.vue' -import RemoteSearchForm from '@/components/RemoteSearchForm.vue' - -export default { - components: { - ChannelsWidget, - RemoteSearchForm, - Modal - }, - props: { defaultQuery: { type: String, required: false, default: '' } }, - data () { - return { - query: this.defaultQuery || '', - channels: [], - count: 0, - isLoading: false, - errors: null, - previousPage: null, - nextPage: null, - widgetKey: String(new Date()), - showSubscribeModal: false - } - }, - computed: { - labels () { - return { - title: this.$pgettext('Content/Subscriptions/Header', 'Subscribed Channels'), - searchPlaceholder: this.$pgettext('Content/Subscriptions/Form.Placeholder', 'Filter by name…') - } - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - const self = this - this.isLoading = true - axios.get('channels/', { params: { subscribed: 'true', q: this.query } }).then(response => { - self.previousPage = response.data.previous - self.nextPage = response.data.next - self.isLoading = false - self.channels = [...self.channels, ...response.data.results] - self.count = response.data.count - }) - }, - reloadWidget () { - this.widgetKey = String(new Date()) - } - } -} -</script> diff --git a/front/src/views/content/Base.vue b/front/src/views/content/Base.vue index e7c700d3b23bf06b6e92458074d8c353e7ea2aaa..796438a3f9ed72f995f32de77d314e87b33f4d7a 100644 --- a/front/src/views/content/Base.vue +++ b/front/src/views/content/Base.vue @@ -1,3 +1,15 @@ +<script setup lang="ts"> +import { useGettext } from 'vue3-gettext' +import { computed } from 'vue' + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + secondaryMenu: $pgettext('Menu/*/Hidden text', 'Secondary menu'), + title: $pgettext('*/Library/*/Verb', 'Add content') +})) +</script> + <template> <main v-title="labels.title" @@ -28,17 +40,3 @@ <router-view :key="$route.fullPath" /> </main> </template> -<script> -export default { - computed: { - labels () { - const title = this.$pgettext('*/Library/*/Verb', 'Add content') - const secondaryMenu = this.$pgettext('Menu/*/Hidden text', 'Secondary menu') - return { - title, - secondaryMenu - } - } - } -} -</script> diff --git a/front/src/views/content/Home.vue b/front/src/views/content/Home.vue index 8532999ee0f0682a9782d610ec2a1f58a1a30ac9..420f5a29943b6bfdcb71c7edadd3edfe858764b9 100644 --- a/front/src/views/content/Home.vue +++ b/front/src/views/content/Home.vue @@ -1,3 +1,20 @@ +<script setup lang="ts"> +import { humanSize } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { computed } from 'vue' +import { useStore } from '~/store' + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + title: $pgettext('Content/Library/Title/Verb', 'Add and manage content') +})) + +const store = useStore() +const quota = computed(() => store.state.instance.settings.users.upload_quota.value) +const defaultQuota = computed(() => humanSize(quota.value * 1e6)) +</script> + <template> <section v-title="labels.title" @@ -27,7 +44,7 @@ </translate> </p> <router-link - :to="{name: 'profile.overview', params: {username: $store.state.auth.username}, hash: '#channels'}" + :to="{name: 'profile.overview', params: {username: store.state.auth.username}, hash: '#channels'}" class="ui primary button" > <translate translate-context="Content/Library/Button.Label/Verb"> @@ -80,24 +97,3 @@ </div> </section> </template> - -<script> -import { humanSize } from '@/filters' - -export default { - computed: { - labels () { - return { - title: this.$pgettext('Content/Library/Title/Verb', 'Add and manage content') - } - }, - defaultQuota () { - const quota = - this.$store.state.instance.settings.users.upload_quota.value * - 1000 * - 1000 - return humanSize(quota) - } - } -} -</script> diff --git a/front/src/views/content/libraries/Card.vue b/front/src/views/content/libraries/Card.vue index 837d0889c2e5547d1a2b1a6f49d6c0b6010e6b0c..477112c99fd4b7d1815fbadd6bf93eca7178ee4e 100644 --- a/front/src/views/content/libraries/Card.vue +++ b/front/src/views/content/libraries/Card.vue @@ -1,3 +1,27 @@ +<script setup lang="ts"> +import type { Library, PrivacyLevel } from '~/types' + +import { humanSize } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { computed } from 'vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' + +interface Props { + library: Library +} + +defineProps<Props>() + +const { $pgettext } = useGettext() + +const sharedLabels = useSharedLabels() + +const sizeLabel = computed(() => $pgettext('Content/Library/Card.Help text', 'Total size of the files in this library')) + +const privacyTooltips = (level: PrivacyLevel) => `Visibility: ${sharedLabels.fields.privacy_level.choices[level].toLowerCase()}` +</script> + <template> <div class="ui card"> <div class="content"> @@ -6,21 +30,21 @@ <span v-if="library.privacy_level === 'me'" class="right floated" - :data-tooltip="privacy_tooltips('me')" + :data-tooltip="privacyTooltips('me')" > <i class="small lock icon" /> </span> <span v-else-if="library.privacy_level === 'instance'" class="right floated" - :data-tooltip="privacy_tooltips('instance')" + :data-tooltip="privacyTooltips('instance')" > <i class="small circle outline icon" /> </span> <span v-else-if="library.privacy_level === 'everyone'" class="right floated" - :data-tooltip="privacy_tooltips('everyone')" + :data-tooltip="privacyTooltips('everyone')" > <i class="small globe icon" /> </span> @@ -39,10 +63,10 @@ <span v-if="library.size" class="right floated" - :data-tooltip="size_label" + :data-tooltip="sizeLabel" > <i class="database icon" /> - {{ library.size | humanSize }} + {{ humanSize(library.size) }} </span> <i class="music icon" /> <translate @@ -66,7 +90,6 @@ </router-link> <router-link :to="{name: 'library.detail', params: {id: library.uuid}}" - exact class="ui button" > <translate translate-context="Content/Library/Card.Button.Label/Noun"> @@ -76,22 +99,3 @@ </div> </div> </template> - -<script> -import TranslationsMixin from '@/components/mixins/Translations.vue' - -export default { - mixins: [TranslationsMixin], - props: { library: { type: Object, required: true } }, - computed: { - size_label () { - return this.$pgettext('Content/Library/Card.Help text', 'Total size of the files in this library') - } - }, - methods: { - privacy_tooltips (level) { - return 'Visibility: ' + this.sharedLabels.fields.privacy_level.choices[level].toLowerCase() - } - } -} -</script> diff --git a/front/src/views/content/libraries/Files.vue b/front/src/views/content/libraries/Files.vue index 078198ec27c641a21fdaabd631ac06a6f6f99f7d..7bc98c4f9d752ee84d365b0d60944aed35a3cefe 100644 --- a/front/src/views/content/libraries/Files.vue +++ b/front/src/views/content/libraries/Files.vue @@ -1,16 +1,15 @@ +<script setup lang="ts"> +import LibraryFilesTable from './FilesTable.vue' + +interface Props { + query: string +} + +defineProps<Props>() +</script> + <template> <section class="ui vertical aligned stripe segment"> <library-files-table :default-query="query" /> </section> </template> - -<script> -import LibraryFilesTable from './FilesTable.vue' - -export default { - components: { - LibraryFilesTable - }, - props: { query: { type: String, required: true } } -} -</script> diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue index e425475b3786ce27c6b484abe865f11a89bde284..953c5251c50404ecd12fa123b02434216d10f679 100644 --- a/front/src/views/content/libraries/FilesTable.vue +++ b/front/src/views/content/libraries/FilesTable.vue @@ -1,3 +1,137 @@ +<script setup lang="ts"> +import type { SmartSearchProps } from '~/composables/navigation/useSmartSearch' +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { ImportStatus, BackendResponse, Upload } from '~/types' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { humanSize, truncate } from '~/utils/filters' +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' + +import time from '~/utils/time' +import axios from 'axios' + +import ImportStatusModal from '~/components/library/ImportStatusModal.vue' +import ActionTable from '~/components/common/ActionTable.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useSmartSearch from '~/composables/navigation/useSmartSearch' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' + +interface Events { + (e: 'fetch-start'): void +} + +interface Props extends SmartSearchProps, OrderingProps { + filters?: object + needsRefresh?: boolean + customObjects?: any[] + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName + defaultQuery?: string + updateUrl?: boolean +} + +const emit = defineEmits<Events>() +const props = withDefaults(defineProps<Props>(), { + defaultQuery: '', + updateUrl: false, + filters: () => ({}), + needsRefresh: false, + customObjects: () => [], + orderingConfigName: undefined +}) + +const search = ref() + +const page = usePage() +const result = ref<BackendResponse<Upload>>() + +const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props) +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['title', 'track_title'], + ['size', 'size'], + ['duration', 'duration'], + ['bitrate', 'bitrate'], + ['album_title', 'album_title'], + ['artist_name', 'artist_name'] +] + +const { $pgettext } = useGettext() +const actionFilters = computed(() => ({ q: query.value, ...props.filters })) +const actions = computed(() => [ + { + name: 'delete', + label: $pgettext('*/*/*/Verb', 'Delete'), + isDangerous: true, + allowAll: true, + confirmColor: 'danger' + }, + { + name: 'relaunch_import', + label: $pgettext('Content/Library/Dropdown/Verb', 'Restart import'), + isDangerous: true, + allowAll: true, + filterCheckable: (filter: { import_status: ImportStatus }) => { + return filter.import_status !== 'finished' + } + } +]) + +const isLoading = ref(false) +const fetchData = async () => { + emit('fetch-start') + isLoading.value = true + const params = { + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + include_channels: 'true', + ...props.filters + } + + try { + const response = await axios.get('/uploads/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = undefined + } finally { + isLoading.value = false + } +} + +onSearch(() => (page.value = 1)) +onOrderingUpdate(() => (page.value = 1)) +watch(page, fetchData) +fetchData() + +const sharedLabels = useSharedLabels() +const labels = computed(() => ({ + searchPlaceholder: $pgettext('Content/Library/Input.Placeholder', 'Search by title, artist, album…'), + showStatus: $pgettext('Content/Library/Button.Label/Verb', 'Show information about the upload status for this track') +})) + +const detailedUpload = ref() +const showUploadDetailModal = ref(false) + +const getImportStatusChoice = (importStatus: ImportStatus) => { + return sharedLabels.fields.import_status.choices[importStatus] +} +</script> + <template> <div> <div class="ui inline form"> @@ -6,13 +140,13 @@ <label for="files-search"> <translate translate-context="Content/Search/Input.Label/Noun">Search</translate> </label> - <form @submit.prevent="search.query = $refs.search.value"> + <form @submit.prevent="query = search.value"> <input id="files-search" ref="search" name="search" type="text" - :value="search.query" + :value="query" :placeholder="labels.searchPlaceholder" > </form> @@ -25,7 +159,7 @@ id="import-status" class="ui dropdown" :value="getTokenValue('status', '')" - @change="addSearchToken('status', $event.target.value)" + @change="addSearchToken('status', ($event.target as HTMLSelectElement).value)" > <option value> <translate translate-context="Content/*/Dropdown"> @@ -101,8 +235,9 @@ </div> </div> <import-status-modal + v-if="detailedUpload" + v-model:show="showUploadDetailModal" :upload="detailedUpload" - :show.sync="showUploadDetailModal" /> <div class="dimmable"> <div @@ -112,7 +247,7 @@ <div class="ui loader" /> </div> <div - v-else-if="!result && result.results.length === 0 && !needsRefresh" + v-else-if="!result || result?.results.length === 0 && !needsRefresh" class="ui placeholder segment" > <div class="ui icon header"> @@ -137,7 +272,7 @@ @action-launched="fetchData" @refresh="fetchData" > - <template slot="header-cells"> + <template #header-cells> <th> <translate translate-context="*/*/*/Noun"> Title @@ -175,13 +310,12 @@ </th> </template> <template - slot="row-cells" - slot-scope="scope" + #row-cells="scope" > <template v-if="scope.obj.track"> <td> <router-link :to="{name: 'library.tracks.detail', params: {id: scope.obj.track.id }}"> - {{ scope.obj.track.title|truncate(25) }} + {{ truncate(scope.obj.track.title, 25) }} </router-link> </td> <td> @@ -189,7 +323,7 @@ href="" class="discrete link" @click.prevent="addSearchToken('artist', scope.obj.track.artist.name)" - >{{ scope.obj.track.artist.name|truncate(20) }}</a> + >{{ truncate(scope.obj.track.artist.name, 20) }}</a> </td> <td> <a @@ -197,12 +331,12 @@ href="" class="discrete link" @click.prevent="addSearchToken('album', scope.obj.track.album.title)" - >{{ scope.obj.track.album.title|truncate(20) }}</a> + >{{ truncate(scope.obj.track.album.title, 20) }}</a> </td> </template> <template v-else> <td :title="scope.obj.source"> - {{ scope.obj.source | truncate(25) }} + {{ truncate(scope.obj.source, 25) }} </td> <td /> <td /> @@ -214,12 +348,12 @@ <a href="" class="discrete link" - :title="sharedLabels.fields.import_status.choices[scope.obj.import_status].help" + :title="getImportStatusChoice(scope.obj.import_status).help" @click.prevent="addSearchToken('status', scope.obj.import_status)" - >{{ sharedLabels.fields.import_status.choices[scope.obj.import_status].label }}</a> + >{{ getImportStatusChoice(scope.obj.import_status).label }}</a> <button class="ui tiny basic icon button" - :title="sharedLabels.fields.import_status.detailTitle" + :title="sharedLabels.fields.import_status.label" :aria-label="labels.showStatus" @click="detailedUpload = scope.obj; showUploadDetailModal = true" > @@ -227,7 +361,7 @@ </button> </td> <td v-if="scope.obj.duration"> - {{ scope.obj.duration | duration }} + {{ time.parse(scope.obj.duration) }} </td> <td v-else> <translate translate-context="*/*/*"> @@ -235,7 +369,7 @@ </translate> </td> <td v-if="scope.obj.size"> - {{ scope.obj.size | humanSize }} + {{ humanSize(scope.obj.size) }} </td> <td v-else> <translate translate-context="*/*/*"> @@ -248,11 +382,10 @@ <div> <pagination v-if="result && result.count > paginateBy" + v-model:current="page" :compact="true" - :current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="page = $event; fetchData()" /> <span v-if="result && result.results.length > 0"> @@ -264,154 +397,3 @@ </div> </div> </template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import time from '@/utils/time.js' -import { normalizeQuery, parseTokens } from '@/search' - -import Pagination from '@/components/Pagination.vue' -import ActionTable from '@/components/common/ActionTable.vue' -import OrderingMixin from '@/components/mixins/Ordering.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import SmartSearchMixin from '@/components/mixins/SmartSearch.vue' -import ImportStatusModal from '@/components/library/ImportStatusModal.vue' - -export default { - components: { - Pagination, - ActionTable, - ImportStatusModal - }, - mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], - props: { - filters: { type: Object, required: false, default: function () { return {} } }, - needsRefresh: { type: Boolean, required: false, default: false }, - customObjects: { - type: Array, - required: false, - default: () => { - return [] - } - } - }, - data () { - return { - time, - detailedUpload: {}, - showUploadDetailModal: false, - isLoading: false, - result: null, - page: 1, - search: { - query: this.defaultQuery, - tokens: parseTokens(normalizeQuery(this.defaultQuery)) - }, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['title', 'track_title'], - ['size', 'size'], - ['duration', 'duration'], - ['bitrate', 'bitrate'], - ['album_title', 'album_title'], - ['artist_name', 'artist_name'] - ] - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext( - 'Content/Library/Input.Placeholder', - 'Search by title, artist, album…' - ), - showStatus: this.$pgettext('Content/Library/Button.Label/Verb', 'Show information about the upload status for this track') - } - }, - actionFilters () { - const currentFilters = { - q: this.search.query, - include_channels: 'true' - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - const deleteMsg = this.$pgettext('*/*/*/Verb', 'Delete') - const relaunchMsg = this.$pgettext( - 'Content/Library/Dropdown/Verb', - 'Restart import' - ) - return [ - { - name: 'delete', - label: deleteMsg, - isDangerous: true, - allowAll: true - }, - { - name: 'relaunch_import', - label: relaunchMsg, - isDangerous: true, - allowAll: true, - filterCheckable: f => { - return f.import_status !== 'finished' - } - } - ] - } - }, - watch: { - orderingDirection: function () { - this.page = 1 - this.fetchData() - }, - page: function () { - this.fetchData() - }, - ordering: function () { - this.page = 1 - this.fetchData() - }, - search (newValue) { - this.page = 1 - this.fetchData() - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - this.$emit('fetch-start') - const params = _.merge( - { - page: this.page, - page_size: this.paginateBy, - ordering: this.getOrderingAsString(), - q: this.search.query, - include_channels: 'true' - }, - this.filters || {} - ) - const self = this - self.isLoading = true - self.checked = [] - axios.get('/uploads/', { params: params }).then( - response => { - self.result = response.data - self.isLoading = false - }, - error => { - self.isLoading = false - self.errors = error.backendErrors - } - ) - } - } -} -</script> diff --git a/front/src/views/content/libraries/Form.vue b/front/src/views/content/libraries/Form.vue index 326028fb11a4ffbf2a2594b792b57f2ac98b5cf3..030ef437c155942a9242547f26b06437753fe787 100644 --- a/front/src/views/content/libraries/Form.vue +++ b/front/src/views/content/libraries/Form.vue @@ -1,3 +1,93 @@ +<script setup lang="ts"> +import type { Library, BackendError, PrivacyLevel } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { computed, ref } from 'vue' +import { useStore } from '~/store' + +import axios from 'axios' + +import useSharedLabels from '~/composables/locale/useSharedLabels' + +const PRIVACY_LEVELS = ['me', 'instance', 'everyone'] as PrivacyLevel[] + +interface Events { + (e: 'updated', data: Library): void + (e: 'created', data: Library): void + (e: 'deleted'): void +} + +interface Props { + library?: Library +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +const { $pgettext } = useGettext() + +const sharedLabels = useSharedLabels() +const store = useStore() + +const labels = computed(() => ({ + descriptionPlaceholder: $pgettext('Content/Library/Input.Placeholder', 'This library contains my personal music, I hope you like it.'), + namePlaceholder: $pgettext('Content/Library/Input.Placeholder', 'My awesome library') +})) + +const currentVisibilityLevel = ref(props.library?.privacy_level ?? 'me') +const currentDescription = ref(props.library?.description ?? '') +const currentName = ref(props.library?.name ?? '') + +const errors = ref([] as string[]) +const isLoading = ref(false) +const submit = async () => { + isLoading.value = true + + try { + const payload = { + name: currentName.value, + description: currentDescription.value, + privacy_level: currentVisibilityLevel.value + } + + const response = props.library + ? await axios.patch(`libraries/${props.library.uuid}/`, payload) + : await axios.post('libraries/', payload) + + if (props.library) emit('updated', response.data) + else emit('created', response.data) + + store.commit('ui/addMessage', { + content: props.library + ? $pgettext('Content/Library/Message', 'Library updated') + : $pgettext('Content/Library/Message', 'Library created'), + date: new Date() + }) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} + +const remove = async () => { + isLoading.value = true + + try { + await axios.delete(`libraries/${props.library?.uuid}/`) + emit('deleted') + store.commit('ui/addMessage', { + content: $pgettext('Content/Library/Message', 'Library deleted'), + date: new Date() + }) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> <form class="ui form" @@ -60,8 +150,8 @@ class="ui dropdown" > <option - v-for="(c, key) in ['me', 'instance', 'everyone']" - :key="key" + v-for="c in PRIVACY_LEVELS" + :key="c" :value="c" > {{ sharedLabels.fields.privacy_level.choices[c] }} @@ -89,118 +179,32 @@ v-if="library" type="button" class="ui right floated basic danger button" - @confirm="remove()" + @confirm="remove" > <translate translate-context="*/*/*/Verb"> Delete </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Delete this library? - </translate> - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Library/Paragraph"> - The library and all its tracks will be deleted. This can not be undone. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="Popup/Library/Button.Label/Verb"> - Delete library - </translate> - </div> + <template #modal-header> + <p> + <translate translate-context="Popup/Library/Title"> + Delete this library? + </translate> + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Library/Paragraph"> + The library and all its tracks will be deleted. This can not be undone. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="Popup/Library/Button.Label/Verb"> + Delete library + </translate> + </div> + </template> </dangerous-button> </form> </template> - -<script> -import axios from 'axios' -import MixinsTranslation from '@/components/mixins/Translations.vue' - -export default { - mixins: [MixinsTranslation], - props: { library: { type: Object, default: null } }, - data () { - const d = { - isLoading: false, - over: false, - errors: [] - } - if (this.library) { - d.currentVisibilityLevel = this.library.privacy_level - d.currentName = this.library.name - d.currentDescription = this.library.description - } else { - d.currentVisibilityLevel = 'me' - d.currentName = '' - d.currentDescription = '' - } - return d - }, - computed: { - labels () { - const namePlaceholder = this.$pgettext('Content/Library/Input.Placeholder', 'My awesome library') - const descriptionPlaceholder = this.$pgettext('Content/Library/Input.Placeholder', 'This library contains my personal music, I hope you like it.') - return { - namePlaceholder, - descriptionPlaceholder - } - } - }, - methods: { - submit () { - const self = this - this.isLoading = true - const payload = { - name: this.currentName, - description: this.currentDescription, - privacy_level: this.currentVisibilityLevel - } - let promise - if (this.library) { - promise = axios.patch(`libraries/${this.library.uuid}/`, payload) - } else { - promise = axios.post('libraries/', payload) - } - promise.then((response) => { - self.isLoading = false - let msg - if (self.library) { - self.$emit('updated', response.data) - msg = this.$pgettext('Content/Library/Message', 'Library updated') - } else { - self.$emit('created', response.data) - msg = this.$pgettext('Content/Library/Message', 'Library created') - } - self.$store.commit('ui/addMessage', { - content: msg, - date: new Date() - }) - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - reset () { - this.currentVisibilityLevel = 'me' - this.currentName = '' - this.currentDescription = '' - }, - remove () { - const self = this - axios.delete(`libraries/${this.library.uuid}/`).then((response) => { - self.isLoading = false - const msg = this.$pgettext('Content/Library/Message', 'Library deleted') - self.$emit('deleted', {}) - self.$store.commit('ui/addMessage', { - content: msg, - date: new Date() - }) - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/views/content/libraries/Home.vue b/front/src/views/content/libraries/Home.vue index 977f2123d7166d0f22d00fd79e60ade35823a91b..7b8c64c11f891dcb63ed5e9e8327cce91f78f55f 100644 --- a/front/src/views/content/libraries/Home.vue +++ b/front/src/views/content/libraries/Home.vue @@ -1,3 +1,44 @@ +<script setup lang="ts"> +import type { Library } from '~/types' +import { useRouter } from 'vue-router' +import { ref } from 'vue' + +import axios from 'axios' + +import LibraryForm from './Form.vue' +import LibraryCard from './Card.vue' +import Quota from './Quota.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +const router = useRouter() + +const libraries = ref([] as Library[]) +const isLoading = ref(false) +const hiddenForm = ref(true) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get('libraries/', { params: { scope: 'me' } }) + libraries.value = response.data.results + if (libraries.value.length === 0) { + hiddenForm.value = false + } + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() + +const libraryCreated = (library: Library) => { + router.push({ name: 'library.detail', params: { id: library.uuid } }) +} +</script> + <template> <section class="ui vertical aligned stripe segment"> <div @@ -42,7 +83,6 @@ </a> <library-form v-if="!hiddenForm" - :library="null" @created="libraryCreated" /> <div class="ui hidden divider" /> @@ -63,44 +103,3 @@ </div> </section> </template> - -<script> -import axios from 'axios' -import LibraryForm from './Form.vue' -import LibraryCard from './Card.vue' -import Quota from './Quota.vue' - -export default { - components: { - LibraryForm, - LibraryCard, - Quota - }, - data () { - return { - isLoading: false, - hiddenForm: true, - libraries: [] - } - }, - created () { - this.fetch() - }, - methods: { - fetch () { - this.isLoading = true - const self = this - axios.get('libraries/', { params: { scope: 'me' } }).then(response => { - self.isLoading = false - self.libraries = response.data.results - if (self.libraries.length === 0) { - self.hiddenForm = false - } - }) - }, - libraryCreated (library) { - this.$router.push({ name: 'library.detail', params: { id: library.uuid } }) - } - } -} -</script> diff --git a/front/src/views/content/libraries/Quota.vue b/front/src/views/content/libraries/Quota.vue index 4ebc5a5a70e169c434fede2a27a8edcc65180126..40a66befa063d66c6aaf77f1445f6e65b2d2d47c 100644 --- a/front/src/views/content/libraries/Quota.vue +++ b/front/src/views/content/libraries/Quota.vue @@ -1,3 +1,55 @@ +<script setup lang="ts"> +import type { ImportStatus } from '~/types' + +import { compileTokens } from '~/utils/search' +import { humanSize } from '~/utils/filters' +import { computed, ref } from 'vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +import axios from 'axios' + +const quotaStatus = ref() +const progress = computed(() => !quotaStatus.value + ? 0 + : Math.min(quotaStatus.value.current * 100 / quotaStatus.value.max, 100) +) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get('users/me/') + quotaStatus.value = response.data.quota_status + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() + +const purge = async (status: ImportStatus) => { + try { + await axios.post('uploads/action/', { + action: 'delete', + objects: 'all', + filters: { import_status: status } + }) + + fetchData() + } catch (error) { + useErrorHandler(error as Error) + } +} + +const purgeSkippedFiles = () => purge('skipped') +const purgePendingFiles = () => purge('pending') +const purgeErroredFiles = () => purge('errored') +</script> + <template> <div class="ui segment"> <h3 class="ui header"> @@ -75,21 +127,27 @@ <translate translate-context="*/*/*/Verb"> Purge </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Purge pending files? - </translate> - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Library/Paragraph"> - Removes uploaded but yet to be processed tracks completely, adding the corresponding data to your quota. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Purge - </translate> - </div> + <template #modal-header> + <p> + <translate translate-context="Popup/Library/Title"> + Purge pending files? + </translate> + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Library/Paragraph"> + Removes uploaded but yet to be processed tracks completely, adding the corresponding data to your quota. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="*/*/*/Verb"> + Purge + </translate> + </div> + </template> </dangerous-button> </div> </div> @@ -123,21 +181,27 @@ <translate translate-context="*/*/*/Verb"> Purge </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Purge skipped files? - </translate> - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Library/Paragraph"> - Removes uploaded tracks skipped during the import processes completely, adding the corresponding data to your quota. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Purge - </translate> - </div> + <template #modal-header> + <p> + <translate translate-context="Popup/Library/Title"> + Purge skipped files? + </translate> + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Library/Paragraph"> + Removes uploaded tracks skipped during the import processes completely, adding the corresponding data to your quota. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="*/*/*/Verb"> + Purge + </translate> + </div> + </template> </dangerous-button> </div> </div> @@ -171,83 +235,30 @@ <translate translate-context="*/*/*/Verb"> Purge </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Purge errored files? - </translate> - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Library/Paragraph"> - Removes uploaded tracks that could not be processed by the server completely, adding the corresponding data to your quota. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="*/*/*/Verb"> - Purge - </translate> - </div> + <template #modal-header> + <p> + <translate translate-context="Popup/Library/Title"> + Purge errored files? + </translate> + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Library/Paragraph"> + Removes uploaded tracks that could not be processed by the server completely, adding the corresponding data to your quota. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="*/*/*/Verb"> + Purge + </translate> + </div> + </template> </dangerous-button> </div> </div> </div> </div> </template> -<script> -import axios from 'axios' -import { humanSize } from '@/filters' -import { compileTokens } from '@/search' - -export default { - data () { - return { - quotaStatus: null, - isLoading: false, - humanSize, - compileTokens - } - }, - computed: { - progress () { - if (!this.quotaStatus) { - return 0 - } - return Math.min(parseInt(this.quotaStatus.current * 100 / this.quotaStatus.max), 100) - } - }, - created () { - this.fetch() - }, - methods: { - fetch () { - const self = this - self.isLoading = true - axios.get('users/me/').then((response) => { - self.quotaStatus = response.data.quota_status - self.isLoading = false - }) - }, - purge (status) { - const self = this - const payload = { - action: 'delete', - objects: 'all', - filters: { - import_status: status - } - } - axios.post('uploads/action/', payload).then((response) => { - self.fetch() - }) - }, - purgeSkippedFiles () { - this.purge('skipped') - }, - purgePendingFiles () { - this.purge('pending') - }, - purgeErroredFiles () { - this.purge('errored') - } - } -} -</script> diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue index ca890ba5f29d855d4ae8cf74b3074323411f36ab..ee69032fa91dc921cab8519c7ad71a5c32db7b32 100644 --- a/front/src/views/content/remote/Card.vue +++ b/front/src/views/content/remote/Card.vue @@ -1,3 +1,148 @@ +<script setup lang="ts"> +import type { Library } from '~/types' + +import { useTimeoutFn } from '@vueuse/core' +import { computed, ref, watch } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' + +import RadioButton from '~/components/radios/Button.vue' + +import useErrorHandler from '~/composables/useErrorHandler' +import useReport from '~/composables/moderation/useReport' + +interface Emits { + (e: 'followed'): void +} + +interface Props { + initialLibrary: Library + displayFollow?: boolean + displayScan?: boolean + displayCopyFid?: boolean +} + +const emit = defineEmits<Emits>() +const props = withDefaults(defineProps<Props>(), { + displayFollow: true, + displayScan: true, + displayCopyFid: true +}) + +const { report, getReportableObjects } = useReport() +const store = useStore() + +const library = ref(props.initialLibrary) +const isLoadingFollow = ref(false) +const showScan = ref(false) +const latestScan = ref(props.initialLibrary.latest_scan) + +const scanProgress = computed(() => Math.min(latestScan.value.processed_files * 100 / latestScan.value.total_files, 100)) +const scanStatus = computed(() => latestScan.value?.status ?? 'unknown') +const canLaunchScan = computed(() => scanStatus.value !== 'pending' && scanStatus.value !== 'scanning') +const radioPlayable = computed(() => ( + (library.value.actor.is_local || scanStatus.value === 'finished') + && (library.value.privacy_level === 'everyone' || library.value.follow?.approved) +)) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + tooltips: { + me: $pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content'), + everyone: $pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely') + } +})) + +const launchScan = async () => { + try { + const response = await axios.post(`federation/libraries/${library.value.uuid}/scan/`) + if (response.data.status !== 'skipped') { + latestScan.value = response.data.scan + } + + store.commit('ui/addMessage', { + date: new Date(), + content: response.data.status === 'skipped' + ? $pgettext('Content/Library/Message', 'Scan skipped (previous scan is too recent)') + : $pgettext('Content/Library/Message', 'Scan launched') + }) + } catch (error) { + useErrorHandler(error as Error) + } +} + +const follow = async () => { + isLoadingFollow.value = true + try { + const response = await axios.post('federation/follows/library/', { target: library.value.uuid }) + library.value.follow = response.data + emit('followed') + } catch (error) { + console.error(error) + store.commit('ui/addMessage', { + // TODO (wvffle): Translate + content: 'Cannot follow remote library: ' + error, + date: new Date() + }) + } + + isLoadingFollow.value = false +} + +const unfollow = async () => { + isLoadingFollow.value = true + try { + if (library.value.follow) { + await axios.delete(`federation/follows/library/${library.value.follow.uuid}/`) + library.value.follow = undefined + } + } catch (error) { + store.commit('ui/addMessage', { + // TODO (wvffle): Translate + content: 'Cannot unfollow remote library: ' + error, + date: new Date() + }) + } + + isLoadingFollow.value = false +} + +const fetchScanStatus = async () => { + try { + if (!library.value.follow) { + return + } + + const response = await axios.get(`federation/follows/library/${library.value.follow.uuid}/`) + latestScan.value = response.data.target.latest_scan + + if (scanStatus.value === 'pending' || scanStatus.value === 'scanning') { + startFetching() + } else { + stopFetching() + } + } catch (error) { + useErrorHandler(error as Error) + } +} + +const { start: startFetching, stop: stopFetching } = useTimeoutFn(fetchScanStatus, 5000, { immediate: false }) + +watch(showScan, (shouldShow) => { + if (shouldShow) { + if (scanStatus.value === 'pending' || scanStatus.value === 'scanning') { + fetchScanStatus() + } + + return + } + + stopFetching() +}) +</script> + <template> <div class="ui card"> <div class="content"> @@ -12,10 +157,10 @@ <i class="ellipsis vertical large icon nomargin" /> <div class="menu"> <button - v-for="obj in getReportableObjs({library, account: library.actor})" + v-for="obj in getReportableObjects({library, account: library.actor})" :key="obj.target.type + obj.target.id" class="item basic" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <i class="share icon" /> {{ obj.label }} </button> @@ -199,166 +344,32 @@ <translate translate-context="*/Library/Button.Label/Verb"> Unfollow </translate> - <p slot="modal-header"> - <translate translate-context="Popup/Library/Title"> - Unfollow this library? - </translate> - </p> - <div slot="modal-content"> + <template #modal-header> <p> - <translate translate-context="Popup/Library/Paragraph"> - By unfollowing this library, you loose access to its content. + <translate translate-context="Popup/Library/Title"> + Unfollow this library? </translate> </p> - </div> - <div slot="modal-confirm"> - <translate translate-context="*/Library/Button.Label/Verb"> - Unfollow - </translate> - </div> + </template> + <template #modal-content> + <div> + <p> + <translate translate-context="Popup/Library/Paragraph"> + By unfollowing this library, you loose access to its content. + </translate> + </p> + </div> + </template> + <template #modal-confirm> + <div> + <translate translate-context="*/Library/Button.Label/Verb"> + Unfollow + </translate> + </div> + </template> </dangerous-button> </template> </template> </div> </div> </template> -<script> -import axios from 'axios' -import ReportMixin from '@/components/mixins/Report.vue' -import RadioButton from '@/components/radios/Button.vue' - -export default { - components: { - RadioButton - }, - mixins: [ReportMixin], - props: { - initialLibrary: { type: Object, required: true }, - displayFollow: { type: Boolean, default: true }, - displayScan: { type: Boolean, default: true }, - displayCopyFid: { type: Boolean, default: true } - }, - data () { - return { - library: this.initialLibrary, - isLoadingFollow: false, - showScan: false, - scanTimeout: null, - latestScan: this.initialLibrary.latest_scan - } - }, - computed: { - labels () { - const me = this.$pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content') - const everyone = this.$pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely') - - return { - tooltips: { - me, - everyone - } - } - }, - scanProgress () { - const scan = this.latestScan - const progress = scan.processed_files * 100 / scan.total_files - return Math.min(parseInt(progress), 100) - }, - scanStatus () { - if (this.latestScan) { - return this.latestScan.status - } - return 'unknown' - }, - canLaunchScan () { - if (this.scanStatus === 'pending') { - return false - } - if (this.scanStatus === 'scanning') { - return false - } - return true - }, - radioPlayable () { - return ( - (this.library.actor.is_local || this.scanStatus === 'finished') && - (this.library.privacy_level === 'everyone' || (this.library.follow && this.library.follow.is_approved)) - ) - } - }, - watch: { - showScan (newValue, oldValue) { - if (newValue) { - if (this.scanStatus === 'pending' || this.scanStatus === 'scanning') { - this.fetchScanStatus() - } - } else { - if (this.scanTimeout) { - clearTimeout(this.scanTimeout) - } - } - } - }, - methods: { - launchScan () { - const self = this - const successMsg = this.$pgettext('Content/Library/Message', 'Scan launched') - const skippedMsg = this.$pgettext('Content/Library/Message', 'Scan skipped (previous scan is too recent)') - axios.post(`federation/libraries/${this.library.uuid}/scan/`).then((response) => { - let msg - if (response.data.status === 'skipped') { - msg = skippedMsg - } else { - self.latestScan = response.data.scan - msg = successMsg - } - self.$store.commit('ui/addMessage', { - content: msg, - date: new Date() - }) - }) - }, - follow () { - const self = this - this.isLoadingFollow = true - axios.post('federation/follows/library/', { target: this.library.uuid }).then((response) => { - self.library.follow = response.data - self.isLoadingFollow = false - self.$emit('followed') - }, error => { - self.isLoadingFollow = false - self.$store.commit('ui/addMessage', { - content: 'Cannot follow remote library: ' + error, - date: new Date() - }) - }) - }, - unfollow () { - const self = this - this.isLoadingFollow = true - axios.delete(`federation/follows/library/${this.library.follow.uuid}/`).then((response) => { - self.$emit('deleted') - self.library.follow = null - self.isLoadingFollow = false - }, error => { - self.isLoadingFollow = false - self.$store.commit('ui/addMessage', { - content: 'Cannot unfollow remote library: ' + error, - date: new Date() - }) - }) - }, - fetchScanStatus () { - const self = this - axios.get(`federation/follows/library/${this.library.follow.uuid}/`).then((response) => { - self.latestScan = response.data.target.latest_scan - if (self.scanStatus === 'pending' || self.scanStatus === 'scanning') { - self.scanTimeout = setTimeout(self.fetchScanStatus(), 5000) - } else { - clearTimeout(self.scanTimeout) - } - }) - } - } -} -</script> diff --git a/front/src/views/content/remote/Home.vue b/front/src/views/content/remote/Home.vue index e63fd6e63670332d1ef9a503d71fc5ee65dc26b4..a831746ac38f86681ab13ac2a4b58f35495cc980 100644 --- a/front/src/views/content/remote/Home.vue +++ b/front/src/views/content/remote/Home.vue @@ -1,3 +1,45 @@ +<script setup lang="ts"> +import type { Library, LibraryFollow } from '~/types' + +import { ref } from 'vue' + +import axios from 'axios' + +import ScanForm from './ScanForm.vue' +import LibraryCard from './Card.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +const existingFollows = ref() +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get('federation/follows/library/', { params: { page_size: 100, ordering: '-creation_date' } }) + existingFollows.value = response.data + + for (const follow of existingFollows.value.results) { + follow.target.follow = follow + } + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() + +const getLibraryFromFollow = (follow: LibraryFollow) => { + const { target } = follow + target.follow = follow + return target as Library +} + +const scanResult = ref() +</script> + <template> <div class="ui vertical aligned stripe segment"> <div @@ -45,7 +87,7 @@ <a href="" class="discrete link" - @click.prevent="fetch()" + @click.prevent="fetchData" > <i :class="['ui', 'circular', 'refresh', 'icon']" /> <translate translate-context="Content/*/Button.Label/Short, Verb">Refresh</translate> </a> @@ -55,56 +97,11 @@ v-for="follow in existingFollows.results" :key="follow.fid" :initial-library="getLibraryFromFollow(follow)" - @deleted="fetch()" - @followed="fetch()" + @deleted="fetchData" + @followed="fetchData" /> </div> </template> </div> </div> </template> - -<script> -import axios from 'axios' -import ScanForm from './ScanForm.vue' -import LibraryCard from './Card.vue' - -export default { - components: { - ScanForm, - LibraryCard - }, - data () { - return { - isLoading: false, - scanResult: null, - existingFollows: null, - errors: [] - } - }, - created () { - this.fetch() - }, - methods: { - fetch () { - this.isLoading = true - const self = this - axios.get('federation/follows/library/', { params: { page_size: 100, ordering: '-creation_date' } }).then((response) => { - self.existingFollows = response.data - self.existingFollows.results.forEach(f => { - f.target.follow = f - }) - self.isLoading = false - }, error => { - self.isLoading = false - self.errors.push(error) - }) - }, - getLibraryFromFollow (follow) { - const d = follow.target - d.follow = follow - return d - } - } -} -</script> diff --git a/front/src/views/content/remote/ScanForm.vue b/front/src/views/content/remote/ScanForm.vue index 3d6533c5bc00ebc03dadc258409de27b6957a6c4..7446dc443e7831fdbad01cccee52b8fc8723487b 100644 --- a/front/src/views/content/remote/ScanForm.vue +++ b/front/src/views/content/remote/ScanForm.vue @@ -1,3 +1,43 @@ +<script setup lang="ts"> +import type { BackendError } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { computed, ref } from 'vue' + +import axios from 'axios' + +interface Events { + (e: 'scanned', data: object): void +} + +const emit = defineEmits<Events>() + +const { $pgettext } = useGettext() + +const labels = computed(() => ({ + placeholder: $pgettext('Content/Library/Input.Placeholder', 'Enter a library URL'), + submitLibrarySearch: $pgettext('Content/Library/Input.Label', 'Submit search') +})) + +const errors = ref([] as string[]) +const isLoading = ref(false) +const query = ref('') +const scan = async () => { + if (!query.value) return + isLoading.value = true + errors.value = [] + + try { + const response = await axios.post('federation/libraries/fetch/', { fid: query.value }) + emit('scanned', response.data) + } catch (error) { + errors.value = (error as BackendError).backendErrors + } + + isLoading.value = false +} +</script> + <template> <form class="ui form" @@ -43,41 +83,3 @@ </div> </form> </template> -<script> -import axios from 'axios' - -export default { - data () { - return { - query: '', - isLoading: false, - errors: [] - } - }, - computed: { - labels () { - return { - placeholder: this.$pgettext('Content/Library/Input.Placeholder', 'Enter a library URL'), - submitLibrarySearch: this.$pgettext('Content/Library/Input.Label', 'Submit search') - } - } - }, - methods: { - scan () { - if (!this.query) { - return - } - const self = this - self.errors = [] - self.isLoading = true - axios.post('federation/libraries/fetch/', { fid: this.query }).then((response) => { - self.$emit('scanned', response.data) - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - } - } -} -</script> diff --git a/front/src/views/library/DetailAlbums.vue b/front/src/views/library/DetailAlbums.vue index ac0e2f06b3930fad9217a4b51cc06b6fc4e5878e..2d3be6ac57b456b6fc25eed42360931f18485497 100644 --- a/front/src/views/library/DetailAlbums.vue +++ b/front/src/views/library/DetailAlbums.vue @@ -1,3 +1,16 @@ +<script setup lang="ts"> +import type { Library } from '~/types' + +import AlbumWidget from '~/components/audio/album/Widget.vue' + +interface Props { + object: Library + isOwner: boolean +} + +defineProps<Props>() +</script> + <template> <section> <album-widget @@ -7,38 +20,24 @@ :controls="false" :filters="{playable: true, ordering: '-creation_date', library: object.uuid}" > - <empty-state slot="empty-state"> - <p> - <translate - v-if="isOwner" - key="1" - translate-context="*/*/*" - > - This library is empty, you should upload something in it! - </translate> - <translate - v-else - key="2" - translate-context="*/*/*" - > - You may need to follow this library to see its content. - </translate> - </p> - </empty-state> + <template #empty-state> + <empty-state> + <p> + <translate + v-if="isOwner" + translate-context="*/*/*" + > + This library is empty, you should upload something in it! + </translate> + <translate + v-else + translate-context="*/*/*" + > + You may need to follow this library to see its content. + </translate> + </p> + </empty-state> + </template> </album-widget> </section> </template> - -<script> -import AlbumWidget from '@/components/audio/album/Widget.vue' - -export default { - components: { - AlbumWidget - }, - props: { - object: { type: Object, required: true }, - isOwner: { type: Boolean, required: true } - } -} -</script> diff --git a/front/src/views/library/DetailOverview.vue b/front/src/views/library/DetailOverview.vue index 5743515891ceab1341a5fec22dfecba7fb266687..b1672c06ffad037ac5a1605be6470b31564269c4 100644 --- a/front/src/views/library/DetailOverview.vue +++ b/front/src/views/library/DetailOverview.vue @@ -1,3 +1,16 @@ +<script setup lang="ts"> +import type { Library } from '~/types' + +import ArtistWidget from '~/components/audio/artist/Widget.vue' + +interface Props { + object: Library + isOwner: boolean +} + +defineProps<Props>() +</script> + <template> <section> <template v-if="$store.getters['ui/layoutVersion'] === 'small'"> @@ -16,43 +29,24 @@ :controls="false" :filters="{playable: true, ordering: '-creation_date', library: object.uuid}" > - <empty-state slot="empty-state"> - <p> - <translate - v-if="isOwner" - key="1" - translate-context="*/*/*" - > - This library is empty, you should upload something in it! - </translate> - <translate - v-else - key="2" - translate-context="*/*/*" - > - You may need to follow this library to see its content. - </translate> - </p> - </empty-state> + <template #empty-state> + <empty-state> + <p> + <translate + v-if="isOwner" + translate-context="*/*/*" + > + This library is empty, you should upload something in it! + </translate> + <translate + v-else + translate-context="*/*/*" + > + You may need to follow this library to see its content. + </translate> + </p> + </empty-state> + </template> </artist-widget> </section> </template> - -<script> -import ArtistWidget from '@/components/audio/artist/Widget.vue' - -export default { - components: { - ArtistWidget - }, - props: { - object: { type: Object, required: true }, - isOwner: { type: Boolean, required: true } - }, - data () { - return { - query: '' - } - } -} -</script> diff --git a/front/src/views/library/DetailTracks.vue b/front/src/views/library/DetailTracks.vue index 4271019a510ae3dbc55313cb1f4ca1addca208cc..8983a0991150de552625244f444b961eb78803e8 100644 --- a/front/src/views/library/DetailTracks.vue +++ b/front/src/views/library/DetailTracks.vue @@ -1,3 +1,16 @@ +<script setup lang="ts"> +import type { Library } from '~/types' + +import TrackTable from '~/components/audio/track/Table.vue' + +interface Props { + object: Library + isOwner: boolean +} + +defineProps<Props>() +</script> + <template> <section> <track-table @@ -6,38 +19,24 @@ :search="true" :filters="{playable: true, library: object.uuid, ordering: '-creation_date'}" > - <empty-state slot="empty-state"> - <p> - <translate - v-if="isOwner" - key="1" - translate-context="*/*/*" - > - This library is empty, you should upload something in it! - </translate> - <translate - v-else - key="2" - translate-context="*/*/*" - > - You may need to follow this library to see its content. - </translate> - </p> - </empty-state> + <template #empty-state> + <empty-state> + <p> + <translate + v-if="isOwner" + translate-context="*/*/*" + > + This library is empty, you should upload something in it! + </translate> + <translate + v-else + translate-context="*/*/*" + > + You may need to follow this library to see its content. + </translate> + </p> + </empty-state> + </template> </track-table> </section> </template> - -<script> -import TrackTable from '@/components/audio/track/Table.vue' - -export default { - components: { - TrackTable - }, - props: { - object: { type: [Object, String], required: true }, - isOwner: { type: Boolean, required: true } - } -} -</script> diff --git a/front/src/views/library/Edit.vue b/front/src/views/library/Edit.vue index 55d1bf2c349a8f2a74708f7924c6ce206c7bb0ab..8563c01434e6bff15a2d76941ee4e0b8afa30d3f 100644 --- a/front/src/views/library/Edit.vue +++ b/front/src/views/library/Edit.vue @@ -1,8 +1,60 @@ +<script setup lang="ts"> +import type { Library, LibraryFollow } from '~/types' + +import { ref } from 'vue' + +import axios from 'axios' + +import LibraryFilesTable from '~/views/content/libraries/FilesTable.vue' +import LibraryForm from '~/views/content/libraries/Form.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Events { + (e: 'updated'): void +} + +interface Props { + object: Library +} + +const emit = defineEmits<Events>() +const props = defineProps<Props>() + +type ResponseType = { count: number, results: any[] } +const follows = ref<ResponseType | null>(null) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + try { + const response = await axios.get(`libraries/${props.object.uuid}/follows/`) + follows.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() + +const updateApproved = async (follow: LibraryFollow, approved: boolean) => { + try { + await axios.post(`federation/follows/library/${follow.uuid}/${approved ? 'accept' : 'reject'}/`) + follow.approved = approved + } catch (error) { + useErrorHandler(error as Error) + } +} +</script> + <template> <section> <library-form :library="object" - @updated="$emit('updated')" + @updated="emit('updated')" @deleted="$router.push({name: 'profile.overview', params: {username: $store.state.auth.username}})" /> <div class="ui hidden divider" /> @@ -11,7 +63,7 @@ Library contents </translate> </h2> - <library-files-table :filters="{library: object.uuid}" /> + <library-files-table :filters="{ library: object.uuid }" /> <div class="ui hidden divider" /> <h2 class="ui header"> @@ -20,8 +72,8 @@ </translate> </h2> <div - v-if="isLoadingFollows" - :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']" + v-if="isLoading" + :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']" > <div class="ui text loader"> <translate translate-context="Content/Library/Paragraph"> @@ -30,7 +82,7 @@ </div> </div> <table - v-else-if="follows && follows.count > 0" + v-else-if="(follows ?? { count: 0 }).count > 0" class="ui table" > <thead> @@ -58,7 +110,7 @@ </tr> </thead> <tr - v-for="follow in follows.results" + v-for="follow in follows?.results ?? []" :key="follow.fid" > <td><actor-link :actor="follow.actor" /></td> @@ -112,50 +164,3 @@ </p> </section> </template> - -<script> -import LibraryFilesTable from '@/views/content/libraries/FilesTable.vue' -import LibraryForm from '@/views/content/libraries/Form.vue' -import axios from 'axios' - -export default { - components: { - LibraryForm, - LibraryFilesTable - }, - props: { object: { type: String, required: true } }, - data () { - return { - isLoadingFollows: false, - follows: null - } - }, - created () { - this.fetchFollows() - }, - methods: { - fetchFollows () { - const self = this - self.isLoadingLibrary = true - axios.get(`libraries/${this.object.uuid}/follows/`).then(response => { - self.follows = response.data - self.isLoadingFollows = false - }) - }, - updateApproved (follow, value) { - let action - if (value) { - action = 'accept' - } else { - action = 'reject' - } - axios - .post(`federation/follows/library/${follow.uuid}/${action}/`) - .then(response => { - follow.isLoading = false - follow.approved = value - }) - } - } -} -</script> diff --git a/front/src/views/library/DetailBase.vue b/front/src/views/library/LibraryBase.vue similarity index 70% rename from front/src/views/library/DetailBase.vue rename to front/src/views/library/LibraryBase.vue index e0068f8e273e06ce09e21b9c12b6ee61b426578c..59f6729a464d5de09f9ee4d4ab9acf338fad03fc 100644 --- a/front/src/views/library/DetailBase.vue +++ b/front/src/views/library/LibraryBase.vue @@ -1,3 +1,88 @@ +<script setup lang="ts"> +import type { Library } from '~/types' + +import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router' +import { computed, ref, watch, watchEffect } from 'vue' +import { humanSize } from '~/utils/filters' +import { useGettext } from 'vue3-gettext' +import { useStore } from '~/store' + +import axios from 'axios' + +import LibraryFollowButton from '~/components/audio/LibraryFollowButton.vue' +import RadioButton from '~/components/radios/Button.vue' + +import useErrorHandler from '~/composables/useErrorHandler' +import useReport from '~/composables/moderation/useReport' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const { report, getReportableObjects } = useReport() +const store = useStore() + +const object = ref<Library | null>(null) + +const isOwner = computed(() => store.state.auth.authenticated && object.value?.actor.full_username === store.state.auth.fullUsername) +const isPlayable = computed(() => (object.value?.uploads_count ?? 0) > 0 && ( + isOwner.value + || object.value?.privacy_level === 'everyone' + || (object.value?.privacy_level === 'instance' && store.state.auth.authenticated && object.value.actor.domain === store.getters['instance/domain']) + || (store.getters['libraries/follow'](object.value?.uuid) || {}).approved === true +)) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('*/*/*', 'Library'), + visibility: { + me: $pgettext('Content/Library/Card.Help text', 'Private'), + instance: $pgettext('Content/Library/Card.Help text', 'Restricted'), + everyone: $pgettext('Content/Library/Card.Help text', 'Public') + }, + tooltips: { + me: $pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content'), + instance: $pgettext('Content/Library/Card.Help text', 'This library is restricted to users on this pod only'), + everyone: $pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely') + } +})) + +onBeforeRouteUpdate((to) => { + to.meta.preserveScrollPosition = true +}) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + try { + const response = await axios.get(`libraries/${props.id}`) + object.value = response.data + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +watch(() => props.id, fetchData, { immediate: true }) + +const route = useRoute() +const router = useRouter() +watchEffect(() => { + if (!store.state.auth.authenticated && object.value && store.getters['instance/domain'] !== object.value.actor.domain) { + router.push({ name: 'login', query: { next: route.fullPath } }) + } +}) + +const updateUploads = (count: number) => { + if (object.value) { + object.value.uploads_count += count + } +} +</script> + <template> <main v-title="labels.title"> <div class="ui vertical stripe segment container"> @@ -31,11 +116,11 @@ >View on %{ domain }</translate> </a> <div - v-for="obj in getReportableObjs({library: object})" + v-for="obj in getReportableObjects({library: object})" :key="obj.target.type + obj.target.id" role="button" class="basic item" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" + @click.stop.prevent="report(obj)" > <i class="share icon" /> {{ obj.label }} </div> @@ -61,7 +146,7 @@ <div class="ui very small hidden divider" /> <div class="sub header ellipsis" - :title="object.full_username" + :title="object.actor.full_username" > <actor-link :avatar="false" @@ -111,14 +196,14 @@ </translate> <span v-if="object.size"> · <i class="database icon" /> - {{ object.size | humanSize }} + {{ humanSize(object.size) }} </span> </p> <div class="header-buttons"> <div class="ui small buttons"> <radio-button - :disabled="!isPlayable" + :disabled="!isPlayable || null" type="library" :object-id="object.uuid" /> @@ -162,7 +247,7 @@ <div class="ui secondary pointing center aligned menu"> <router-link class="item" - :exact="true" + :to="{name: 'library.detail'}" > <translate translate-context="*/*/*"> @@ -171,7 +256,7 @@ </router-link> <router-link class="item" - :exact="true" + :to="{name: 'library.detail.albums'}" > <translate translate-context="*/*/*"> @@ -180,7 +265,7 @@ </router-link> <router-link class="item" - :exact="true" + :to="{name: 'library.detail.tracks'}" > <translate translate-context="*/*/*"> @@ -190,7 +275,7 @@ <router-link v-if="isOwner" class="item" - :exact="true" + :to="{name: 'library.detail.upload'}" > <i class="upload icon" /> @@ -201,7 +286,7 @@ <router-link v-if="isOwner" class="item" - :exact="true" + :to="{name: 'library.detail.edit'}" > <i class="pencil icon" /> @@ -215,7 +300,7 @@ :is-owner="isOwner" :object="object" @updated="fetchData" - @uploads-finished="object.uploads_count += $event" + @uploads-finished="updateUploads" /> </div> </div> @@ -224,81 +309,3 @@ </div> </main> </template> - -<script> -import axios from 'axios' -import LibraryFollowButton from '@/components/audio/LibraryFollowButton.vue' -import ReportMixin from '@/components/mixins/Report.vue' -import RadioButton from '@/components/radios/Button.vue' - -export default { - components: { - RadioButton, - LibraryFollowButton - }, - mixins: [ReportMixin], - beforeRouteUpdate (to, from, next) { - to.meta.preserveScrollPosition = true - next() - }, - props: { id: { type: String, required: true } }, - data () { - return { - isLoading: true, - object: null, - latestTracks: null - } - }, - computed: { - isOwner () { - return this.$store.state.auth.authenticated && this.object.actor.full_username === this.$store.state.auth.fullUsername - }, - labels () { - return { - title: this.$pgettext('*/*/*', 'Library'), - visibility: { - me: this.$pgettext('Content/Library/Card.Help text', 'Private'), - instance: this.$pgettext('Content/Library/Card.Help text', 'Restricted'), - everyone: this.$pgettext('Content/Library/Card.Help text', 'Public') - }, - tooltips: { - me: this.$pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content'), - instance: this.$pgettext('Content/Library/Card.Help text', 'This library is restricted to users on this pod only'), - everyone: this.$pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely') - } - } - }, - isPlayable () { - return this.object.uploads_count > 0 && ( - this.isOwner || - this.object.privacy_level === 'everyone' || - (this.object.privacy_level === 'instance' && this.$store.state.auth.authenticated && this.object.actor.domain === this.$store.getters['instance/domain']) || - (this.$store.getters['libraries/follow'](this.object.uuid) || {}).approved === true - ) - } - }, - watch: { - id () { - this.fetchData() - } - }, - async created () { - await this.fetchData() - const authenticated = this.$store.state.auth.authenticated - if (!authenticated && this.$store.getters['instance/domain'] !== this.object.actor.domain) { - this.$router.push({ name: 'login', query: { next: this.$route.fullPath } }) - } - }, - methods: { - async fetchData () { - const self = this - this.isLoading = true - const libraryPromise = axios.get(`libraries/${this.id}`).then(response => { - self.object = response.data - }) - await libraryPromise - self.isLoading = false - } - } -} -</script> diff --git a/front/src/views/library/Upload.vue b/front/src/views/library/Upload.vue index e789ee090ea598db664ca930d6809d1afc126383..8ce6193847344a83e27d3d3ba823dcd21af40e00 100644 --- a/front/src/views/library/Upload.vue +++ b/front/src/views/library/Upload.vue @@ -1,38 +1,47 @@ +<script setup lang="ts"> +import type { Library } from '~/types' + +import { onBeforeRouteLeave } from 'vue-router' +import { ref } from 'vue' + +import FileUpload from '~/components/library/FileUpload.vue' + +interface Events { + (e: 'uploads-finished', data: number): void +} + +interface Props { + object: Library + defaultImportReference?: string +} + +const emit = defineEmits<Events>() +withDefaults(defineProps<Props>(), { + defaultImportReference: '' +}) + +const fileupload = ref() +onBeforeRouteLeave((to, from, next) => { + if (!fileupload.value.hasActiveUploads) { + return next() + } + + const answer = window.confirm('This page is asking you to confirm that you want to leave - data you have entered may not be saved.') + if (answer) { + next() + } else { + next(false) + } +}) +</script> + <template> <section> <file-upload ref="fileupload" :default-import-reference="defaultImportReference" :library="object" - @uploads-finished="$emit('uploads-finished', $event)" + @uploads-finished="emit('uploads-finished', $event)" /> </section> </template> - -<script> - -import FileUpload from '@/components/library/FileUpload.vue' - -export default { - components: { - FileUpload - }, - - beforeRouteLeave (to, from, next) { - if (this.$refs.fileupload.hasActiveUploads) { - const answer = window.confirm('This page is asking you to confirm that you want to leave - data you have entered may not be saved.') - if (answer) { - next() - } else { - next(false) - } - } else { - next() - } - }, - props: { - object: { type: Object, required: true }, - defaultImportReference: { type: String, default: '' } - } -} -</script> diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index b1ac1223843891d5ea971dd8532c37472956937b..a1d87c5d5ac71237766982411be8a6f68a6d97dc 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -1,3 +1,78 @@ +<script setup lang="ts"> +import type { PlaylistTrack, Playlist } from '~/types' + +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { ref, computed } from 'vue' +import { useStore } from '~/store' + +import axios from 'axios' + +import PlaylistEditor from '~/components/playlists/Editor.vue' +import EmbedWizard from '~/components/audio/EmbedWizard.vue' +import SemanticModal from '~/components/semantic/Modal.vue' +import TrackTable from '~/components/audio/track/Table.vue' +import PlayButton from '~/components/audio/PlayButton.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number + defaultEdit?: boolean +} + +const props = withDefaults(defineProps<Props>(), { + defaultEdit: false +}) + +const store = useStore() +const router = useRouter() + +const edit = ref(props.defaultEdit) +const playlist = ref<Playlist | null>(null) +const playlistTracks = ref<PlaylistTrack[]>([]) + +const showEmbedModal = ref(false) + +const tracks = computed(() => playlistTracks.value.map(({ track }, index) => ({ ...track, position: index + 1 }))) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + playlist: $pgettext('*/*/*', 'Playlist') +})) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + try { + const [playlistResponse, tracksResponse] = await Promise.all([ + axios.get(`playlists/${props.id}/`), + axios.get(`playlists/${props.id}/tracks/`) + ]) + + playlist.value = playlistResponse.data + playlistTracks.value = tracksResponse.data.results + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +fetchData() + +const deletePlaylist = async () => { + try { + await axios.delete(`playlists/${props.id}/`) + store.dispatch('playlists/fetchOwn') + return router.push({ path: '/library' }) + } catch (error) { + useErrorHandler(error as Error) + } +} +</script> + <template> <main> <div @@ -45,7 +120,7 @@ </div> <div class="ui buttons"> <button - v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" + v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile?.id" class="ui icon labeled button" @click="edit = !edit" > @@ -81,30 +156,35 @@ <i class="trash icon" /> <translate translate-context="*/*/*/Verb"> Delete </translate> - <p - slot="modal-header" - v-translate="{playlist: playlist.name}" - translate-context="Popup/Playlist/Title/Call to action" - :translate-params="{playlist: playlist.name}" - > - Do you want to delete the playlist "%{ playlist }"? - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Playlist/Paragraph"> - This will completely delete this playlist and cannot be undone. - </translate> - </p> - <div slot="modal-confirm"> - <translate translate-context="Popup/Playlist/Button.Label/Verb"> - Delete playlist - </translate> - </div> + <template #modal-header> + <p + v-translate="{playlist: playlist.name}" + translate-context="Popup/Playlist/Title/Call to action" + :translate-params="{playlist: playlist.name}" + > + Do you want to delete the playlist "%{ playlist }"? + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Playlist/Paragraph"> + This will completely delete this playlist and cannot be undone. + </translate> + </p> + </template> + <template #modal-confirm> + <div> + <translate translate-context="Popup/Playlist/Button.Label/Verb"> + Delete playlist + </translate> + </div> + </template> </dangerous-button> </div> </div> - <modal + <semantic-modal v-if="playlist.privacy_level === 'everyone' && playlist.is_playable" - :show.sync="showEmbedModal" + v-model:show="showEmbedModal" > <h4 class="header"> <translate translate-context="Popup/Album/Title/Verb"> @@ -126,16 +206,14 @@ </translate> </button> </div> - </modal> + </semantic-modal> </div> </section> <section class="ui vertical stripe segment"> <template v-if="edit"> <playlist-editor - :playlist="playlist" - :playlist-tracks="playlistTracks" - @playlist-updated="playlist = $event" - @tracks-updated="updatePlts" + v-model:playlist="playlist" + v-model:playlist-tracks="playlistTracks" /> </template> <template v-else-if="tracks.length > 0"> @@ -147,6 +225,7 @@ <track-table :display-position="true" :tracks="tracks" + :unique="false" /> </template> <div @@ -172,81 +251,3 @@ </section> </main> </template> -<script> -import axios from 'axios' -import TrackTable from '@/components/audio/track/Table.vue' -import PlayButton from '@/components/audio/PlayButton.vue' -import PlaylistEditor from '@/components/playlists/Editor.vue' -import EmbedWizard from '@/components/audio/EmbedWizard.vue' -import Modal from '@/components/semantic/Modal.vue' - -export default { - components: { - PlaylistEditor, - TrackTable, - PlayButton, - Modal, - EmbedWizard - }, - props: { - id: { type: [Number, String], required: true }, - defaultEdit: { type: Boolean, default: false } - }, - data: function () { - return { - edit: this.defaultEdit, - isLoading: false, - playlist: null, - tracks: [], - playlistTracks: [], - showEmbedModal: false - } - }, - computed: { - labels () { - return { - playlist: this.$pgettext('*/*/*', 'Playlist') - } - } - }, - created: function () { - this.fetch() - }, - methods: { - updatePlts (v) { - this.playlistTracks = v - this.tracks = v.map((e, i) => { - const track = e.track - track.position = i + 1 - return track - }) - }, - fetch: function () { - const self = this - self.isLoading = true - const url = 'playlists/' + this.id + '/' - axios.get(url).then(response => { - self.playlist = response.data - axios - .get(url + 'tracks/') - .then(response => { - self.updatePlts(response.data.results) - }) - .then(() => { - self.isLoading = false - }) - }) - }, - deletePlaylist () { - const self = this - const url = 'playlists/' + this.id + '/' - axios.delete(url).then(response => { - self.$store.dispatch('playlists/fetchOwn') - self.$router.push({ - path: '/library' - }) - }) - } - } -} -</script> diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue index 177775e3213d077123630aedb0efff768d02fd02..f6c4292eba531a5b41b34345eaff52d8aa9b0486 100644 --- a/front/src/views/playlists/List.vue +++ b/front/src/views/playlists/List.vue @@ -1,3 +1,109 @@ +<script setup lang="ts"> +import type { OrderingProps } from '~/composables/navigation/useOrdering' +import type { Playlist, BackendResponse } from '~/types' +import type { RouteRecordName } from 'vue-router' +import type { OrderingField } from '~/store/ui' + +import { computed, onMounted, ref, watch } from 'vue' +import { useRouteQuery } from '@vueuse/router' +import { useGettext } from 'vue3-gettext' +import { syncRef } from '@vueuse/core' +import { sortedUniq } from 'lodash-es' + +import axios from 'axios' +import $ from 'jquery' + +import PlaylistCardList from '~/components/playlists/CardList.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useSharedLabels from '~/composables/locale/useSharedLabels' +import useOrdering from '~/composables/navigation/useOrdering' +import useErrorHandler from '~/composables/useErrorHandler' +import usePage from '~/composables/navigation/usePage' +import useLogger from '~/composables/useLogger' + +interface Props extends OrderingProps { + scope?: 'me' | 'all' + + // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged + orderingConfigName?: RouteRecordName +} + +const props = withDefaults(defineProps<Props>(), { + scope: 'all', + orderingConfigName: undefined +}) + +const page = usePage() + +const q = useRouteQuery('query', '') +const query = ref(q.value) +syncRef(q, query, { direction: 'ltr' }) + +const result = ref<BackendResponse<Playlist>>() + +const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ + ['creation_date', 'creation_date'], + ['modification_date', 'modification_date'], + ['name', 'name'] +] + +const logger = useLogger() +const sharedLabels = useSharedLabels() + +const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + const params = { + scope: props.scope, + page: page.value, + page_size: paginateBy.value, + q: query.value, + ordering: orderingString.value, + playable: true + } + + logger.time('Fetching albums') + try { + const response = await axios.get('playlists/', { + params + }) + + result.value = response.data + } catch (error) { + useErrorHandler(error as Error) + result.value = undefined + } finally { + logger.timeEnd('Fetching albums') + isLoading.value = false + } +} +watch([page, q], fetchData) +fetchData() + +const search = () => { + page.value = 1 + q.value = query.value +} + +onOrderingUpdate(() => { + page.value = 1 + fetchData() +}) + +onMounted(() => $('.ui.dropdown').dropdown()) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + playlists: $pgettext('*/*/*', 'Playlists'), + searchPlaceholder: $pgettext('Content/Playlist/Placeholder/Call to action', 'Enter playlist name…') +})) + +const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value].sort((a, b) => a - b))) +</script> + <template> <main v-title="labels.playlists"> <section class="ui vertical stripe segment"> @@ -19,7 +125,7 @@ </template> <form :class="['ui', {'loading': isLoading}, 'form']" - @submit.prevent="updateQueryString();fetchData()" + @submit.prevent="search" > <div class="fields"> <div class="field"> @@ -83,14 +189,12 @@ v-model="paginateBy" class="ui dropdown" > - <option :value="parseInt(12)"> - 12 - </option> - <option :value="parseInt(25)"> - 25 - </option> - <option :value="parseInt(50)"> - 50 + <option + v-for="opt in paginateOptions" + :key="opt" + :value="opt" + > + {{ opt }} </option> </select> </div> @@ -102,7 +206,7 @@ :playlists="result.results" /> <div - v-else-if="result && !result.results.length > 0" + v-else-if="result && result.results.length === 0" class="ui placeholder segment sixteen wide column" style="text-align: center; display: flex; align-items: center" > @@ -126,107 +230,11 @@ <div class="ui center aligned basic segment"> <pagination v-if="result && result.results.length > 0" - :current="page" + v-model:current="page" :paginate-by="paginateBy" :total="result.count" - @page-changed="selectPage" /> </div> </section> </main> </template> - -<script> -import axios from 'axios' -import $ from 'jquery' - -import OrderingMixin from '@/components/mixins/Ordering.vue' -import PaginationMixin from '@/components/mixins/Pagination.vue' -import TranslationsMixin from '@/components/mixins/Translations.vue' -import PlaylistCardList from '@/components/playlists/CardList.vue' -import Pagination from '@/components/Pagination.vue' - -const FETCH_URL = 'playlists/' - -export default { - components: { - PlaylistCardList, - Pagination - }, - mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], - props: { - defaultQuery: { type: String, required: false, default: '' }, - scope: { type: String, required: false, default: 'all' } - }, - data () { - return { - isLoading: true, - result: null, - page: parseInt(this.defaultPage), - query: this.defaultQuery, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['modification_date', 'modification_date'], - ['name', 'name'] - ] - } - }, - computed: { - labels () { - const playlists = this.$pgettext('*/*/*', 'Playlists') - const searchPlaceholder = this.$pgettext('Content/Playlist/Placeholder/Call to action', 'Enter playlist name…') - return { - playlists, - searchPlaceholder - } - } - }, - watch: { - page () { - this.updateQueryString() - this.fetchData() - } - }, - created () { - this.fetchData() - }, - mounted () { - $('.ui.dropdown').dropdown() - }, - methods: { - updateQueryString: function () { - history.pushState( - {}, - null, - this.$route.path + '?' + new URLSearchParams( - { - query: this.query, - page: this.page, - paginateBy: this.paginateBy, - ordering: this.getOrderingAsString() - }).toString() - ) - }, - fetchData: function () { - const self = this - this.isLoading = true - const url = FETCH_URL - const params = { - scope: this.scope, - page: this.page, - page_size: this.paginateBy, - q: this.query, - ordering: this.getOrderingAsString(), - playable: true - } - axios.get(url, { params: params }).then(response => { - self.result = response.data - self.isLoading = false - }) - }, - selectPage: function (page) { - this.page = page - } - } -} -</script> diff --git a/front/src/views/radios/Detail.vue b/front/src/views/radios/Detail.vue index 0fb038512f389e70c2664cce1650cf3a8bd94041..82ced8f97fca454aaf9a8740d5611383ffa1d6b1 100644 --- a/front/src/views/radios/Detail.vue +++ b/front/src/views/radios/Detail.vue @@ -1,3 +1,67 @@ +<script setup lang="ts"> +import type { Track, Radio } from '~/types' + +import { ref, computed, watch } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' + +import axios from 'axios' + +import TrackTable from '~/components/audio/track/Table.vue' +import RadioButton from '~/components/radios/Button.vue' +import Pagination from '~/components/vui/Pagination.vue' + +import useErrorHandler from '~/composables/useErrorHandler' + +interface Props { + id: number +} + +const props = defineProps<Props>() + +const radio = ref<Radio | null>(null) +const tracks = ref([] as Track[]) +const totalTracks = ref(0) +const page = ref(1) + +const { $pgettext } = useGettext() +const labels = computed(() => ({ + title: $pgettext('Head/Radio/Title', 'Radio') +})) + +const isLoading = ref(false) +const fetchData = async () => { + isLoading.value = true + + const url = `radios/radios/${props.id}/` + + try { + const radioResponse = await axios.get(url) + radio.value = radioResponse.data + + const tracksResponse = await axios.get(url + 'tracks/', { params: { page: page.value } }) + totalTracks.value = tracksResponse.data.count + tracks.value = tracksResponse.data.results + } catch (error) { + useErrorHandler(error as Error) + } + + isLoading.value = false +} + +watch(page, fetchData, { immediate: true }) + +const router = useRouter() +const deleteRadio = async () => { + try { + await axios.delete(`radios/radios/${props.id}/`) + return router.push({ path: '/library' }) + } catch (error) { + useErrorHandler(error as Error) + } +} +</script> + <template> <main> <div @@ -32,7 +96,6 @@ <router-link class="ui icon labeled button" :to="{name: 'library.radios.edit', params: {id: radio.id}}" - exact > <i class="pencil icon" /> Edit… @@ -42,24 +105,29 @@ :action="deleteRadio" > <i class="trash icon" /> Delete - <p - slot="modal-header" - v-translate="{radio: radio.name}" - translate-context="Popup/Radio/Title" - :translate-params="{radio: radio.name}" - > - Do you want to delete the radio "%{ radio }"? - </p> - <p slot="modal-content"> - <translate translate-context="Popup/Radio/Paragraph"> - This will completely delete this radio and cannot be undone. - </translate> - </p> - <p slot="modal-confirm"> - <translate translate-context="Popup/Radio/Button.Label/Verb"> - Delete radio - </translate> - </p> + <template #modal-header> + <p + v-translate="{radio: radio.name}" + translate-context="Popup/Radio/Title" + :translate-params="{radio: radio.name}" + > + Do you want to delete the radio "%{ radio }"? + </p> + </template> + <template #modal-content> + <p> + <translate translate-context="Popup/Radio/Paragraph"> + This will completely delete this radio and cannot be undone. + </translate> + </p> + </template> + <template #modal-confirm> + <p> + <translate translate-context="Popup/Radio/Button.Label/Verb"> + Delete radio + </translate> + </p> + </template> </dangerous-button> </template> </div> @@ -77,15 +145,14 @@ <div class="ui center aligned basic segment"> <pagination v-if="totalTracks > 25" - :current="page" + v-model:current="page" :paginate-by="25" :total="totalTracks" - @page-changed="selectPage" /> </div> </section> <div - v-else-if="!isLoading && !totalTracks > 0" + v-else-if="!isLoading && totalTracks === 0" class="ui placeholder segment" > <div class="ui icon header"> @@ -97,10 +164,9 @@ </translate> </div> <router-link - v-if="$store.state.auth.username === radio.user.username" + v-if="$store.state.auth.username === radio?.user.username" class="ui success icon labeled button" - :to="{name: 'library.radios.edit', params: {id: radio.id}}" - exact + :to="{name: 'library.radios.edit', params: { id: radio?.id }}" > <i class="pencil icon" /> Edit… @@ -108,76 +174,3 @@ </div> </main> </template> - -<script> -import axios from 'axios' -import TrackTable from '@/components/audio/track/Table.vue' -import RadioButton from '@/components/radios/Button.vue' -import Pagination from '@/components/Pagination.vue' - -export default { - components: { - TrackTable, - RadioButton, - Pagination - }, - props: { - id: { type: Number, required: true } - }, - data: function () { - return { - isLoading: false, - radio: null, - tracks: [], - totalTracks: 0, - page: 1 - } - }, - computed: { - labels () { - return { - title: this.$pgettext('Head/Radio/Title', 'Radio') - } - } - }, - watch: { - page: function () { - this.fetch() - } - }, - created: function () { - this.fetch() - }, - methods: { - selectPage: function (page) { - this.page = page - }, - fetch: function () { - const self = this - self.isLoading = true - const url = 'radios/radios/' + this.id + '/' - axios.get(url).then(response => { - self.radio = response.data - axios - .get(url + 'tracks/', { params: { page: this.page } }) - .then(response => { - this.totalTracks = response.data.count - this.tracks = response.data.results - }) - .then(() => { - self.isLoading = false - }) - }) - }, - deleteRadio () { - const self = this - const url = 'radios/radios/' + this.id + '/' - axios.delete(url).then(response => { - self.$router.push({ - path: '/library' - }) - }) - } - } -} -</script> diff --git a/front/test/specs/views/admin/library.test.ts b/front/test/specs/views/admin/library.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3d2800b451e645b65a459c78fb06f29e0a77cf2 --- /dev/null +++ b/front/test/specs/views/admin/library.test.ts @@ -0,0 +1,50 @@ +import DangerousButton from '~/components/common/DangerousButton.vue' +import AlbumDetail from '~/views/admin/library/AlbumDetail.vue' +import SanitizedHtml from '~/components/SanitizedHtml.vue' +import HumanDate from '~/components/common/HumanDate.vue' + +import moxios from 'moxios' + +import { shallowMount } from '@vue/test-utils' +import { gettext } from '~/init/locale' +import { sleep } from '?/utils' + +import router from '~/router' +import store from '~/store' + +beforeEach(() => moxios.install()) +afterEach(() => moxios.uninstall()) + +describe('views/admin/library', () => { + describe('Album details', () => { + it('displays default cover', async () => { + const album = { cover: null, artist: { id: 1 }, title: 'dummy', id: 1, creation_date: '2020-01-01' } + + moxios.stubRequest('manage/library/albums/1/', { + status: 200, + response: album + }) + + moxios.stubRequest('manage/library/albums/1/stats/', { + status: 200, + response: {} + }) + + const wrapper = shallowMount(AlbumDetail, { + props: { id: 1 }, + directives: { + dropdown: () => null, + title: () => null, + lazy: () => null + }, + global: { + stubs: { DangerousButton, HumanDate, SanitizedHtml }, + plugins: [gettext, router, store] + } + }) + + await sleep() + expect(wrapper.find('img').attributes('src')).to.include('default-cover') + }) + }) +}) diff --git a/front/test/utils.ts b/front/test/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..febf34fdf8ab0d27d8cf2f7923525db554ea6f08 --- /dev/null +++ b/front/test/utils.ts @@ -0,0 +1 @@ +export const sleep = (ms = 0) => new Promise<void>(resolve => setTimeout(resolve, ms)) diff --git a/front/tests/unit/.eslintrc b/front/tests/unit/.eslintrc deleted file mode 100644 index 959a4f4b5764fc6874b1772efd2a3178b5635bca..0000000000000000000000000000000000000000 --- a/front/tests/unit/.eslintrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "env": { - "mocha": true - }, - "globals": { - "expect": true, - "sinon": true - } -} diff --git a/front/tests/unit/specs/audio/volume.spec.js b/front/tests/unit/specs/audio/volume.spec.js deleted file mode 100644 index 178af562179d13c8278c30f80c8f47947d98467b..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/audio/volume.spec.js +++ /dev/null @@ -1,25 +0,0 @@ -import { expect } from 'chai' - -import { toLinearVolumeScale, toLogarithmicVolumeScale } from '@/audio/volume' - -describe('store/auth', () => { - describe('toLinearVolumeScale', () => { - it('it should return real 0', () => { - expect(toLinearVolumeScale(0.0)).to.equal(0.0) - }) - - it('it should return full volume', () => { - expect(toLinearVolumeScale(1.0)).to.be.closeTo(1.0, 0.001) - }) - }) - - describe('toLogarithmicVolumeScale', () => { - it('it should return real 0', () => { - expect(toLogarithmicVolumeScale(0.0)).to.equal(0.0) - }) - - it('it should return full volume', () => { - expect(toLogarithmicVolumeScale(1.0)).to.be.closeTo(1.0, 0.001) - }) - }) -}) diff --git a/front/tests/unit/specs/components/common.spec.js b/front/tests/unit/specs/components/common.spec.js deleted file mode 100644 index c0ae3a65e666b14170c3be83966e80922b42370e..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/components/common.spec.js +++ /dev/null @@ -1,12 +0,0 @@ -import {expect} from 'chai' - -import Username from '@/components/common/Username.vue' - -import { render } from '../../utils' - -describe('Username', () => { - it('displays username', () => { - const vm = render(Username, {username: 'Hello'}) - expect(vm.$el.textContent).to.equal('Hello') - }) -}) diff --git a/front/tests/unit/specs/components/forms.spec.js b/front/tests/unit/specs/components/forms.spec.js deleted file mode 100644 index 8667ff782cdea3eac6d197234489d4ecf77882e3..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/components/forms.spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import { expect } from 'chai' -import PasswordInput from '@/components/forms/PasswordInput.vue' -import { shallowMount } from '@vue/test-utils' -const sinon = require('sinon') - -describe('PasswordInput', () => { - const password = 'password' - let sandbox - - beforeEach(function () { - sandbox = sinon.createSandbox() - }) - - afterEach(function () { - sandbox.restore() - }) - const wrapper = shallowMount(PasswordInput, { - mocks: { - $pgettext: () => 'dummy', - $store: { - commit: () => { } - }, - }, - propsData: { - fieldId: 'password', - value: password, - } - }) - wrapper.setProps({ value: password, copyButton: true }) - it('password input has passed value', () => { - const inputElement = wrapper.find('input') - expect(inputElement.element.value).to.equal(password) - }) - it('copy password function called', () => { - document.execCommand = jest.fn() - const spy = sandbox.spy(wrapper.vm, 'copyPassword') - sandbox.stub(PasswordInput.methods, '_copyStringToClipboard').callsFake() - const copyButton = wrapper.findAll('button').at(1) - copyButton.trigger('click') - sandbox.assert.calledOnce(spy) - }) -}) diff --git a/front/tests/unit/specs/filters/filters.spec.js b/front/tests/unit/specs/filters/filters.spec.js deleted file mode 100644 index 48a487dba2b5166921944ce5f8c4bfcf1838eaac..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/filters/filters.spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import {expect} from 'chai' -import moment from 'moment' -import {truncate, ago, capitalize, year, unique} from '@/filters' - -describe('filters', () => { - describe('truncate', () => { - it('leave strings as it if correct size', () => { - const input = 'Hello world' - let output = truncate(input, 100) - expect(output).to.equal(input) - }) - it('returns shorter string with character', () => { - const input = 'Hello world' - let output = truncate(input, 5) - expect(output).to.equal('Hello…') - }) - it('custom ellipsis', () => { - const input = 'Hello world' - let output = truncate(input, 5, ' pouet') - expect(output).to.equal('Hello pouet') - }) - }) - describe('ago', () => { - it('works', () => { - const input = new Date() - let output = ago(input) - let expected = moment(input).calendar(input, { - sameDay: 'LT', - nextDay: 'L', - nextWeek: 'L', - lastDay: 'L', - lastWeek: 'L', - sameElse: 'L' - }) - expect(output).to.equal(expected) - }) - }) - describe('year', () => { - it('works', () => { - const input = '2017-07-13' - let output = year(input) - expect(output).to.equal(2017) - }) - }) - describe('capitalize', () => { - it('works', () => { - const input = 'hello world' - let output = capitalize(input) - expect(output).to.deep.equal('Hello world') - }) - }) - describe('unique', () => { - it('works', () => { - const list = [{id: 1}, {id: 2}, {id: 3}, {id: 1}] - const dedupedList = unique(list, 'id') - expect(dedupedList).to.have.deep.members([{id: 1}, {id: 3}, {id: 2}]) - }) - }) -}) diff --git a/front/tests/unit/specs/search.spec.js b/front/tests/unit/specs/search.spec.js deleted file mode 100644 index 5cc551b20f4aca9cd8cd1dc99e227f1ab925d338..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/search.spec.js +++ /dev/null @@ -1,65 +0,0 @@ -import {expect} from 'chai' - -import {normalizeQuery, parseTokens, compileTokens} from '@/search' - -describe('search', () => { - it('normalizeQuery returns correct tokens', () => { - const input = 'this is a "search query" yeah' - let output = normalizeQuery(input) - expect(output).to.deep.equal(['this', 'is', 'a', 'search query', 'yeah']) - }) - it('parseTokens can extract fields and values from tokens', () => { - const input = ['unhandled', 'key:value', 'status:pending', 'title:"some title"', 'anotherunhandled'] - let output = parseTokens(input) - let expected = [ - { - 'field': null, - 'value': 'unhandled' - }, - { - 'field': 'key', - 'value': 'value' - }, - { - 'field': 'status', - 'value': 'pending', - }, - { - 'field': 'title', - 'value': 'some title' - }, - { - 'field': null, - 'value': 'anotherunhandled' - } - ] - expect(output).to.deep.equal(expected) - }) - it('compileTokens returns proper query string', () => { - let input = [ - { - 'field': null, - 'value': 'unhandled' - }, - { - 'field': 'key', - 'value': 'value' - }, - { - 'field': 'status', - 'value': 'pending', - }, - { - 'field': 'title', - 'value': 'some title' - }, - { - 'field': null, - 'value': 'anotherunhandled' - } - ] - const expected = 'unhandled key:value status:pending title:"some title" anotherunhandled' - let output = compileTokens(input) - expect(output).to.deep.equal(expected) - }) -}) diff --git a/front/tests/unit/specs/store/auth.spec.js b/front/tests/unit/specs/store/auth.spec.js deleted file mode 100644 index f2f188cd2d1371ef02f7b1151b10eadecd932e7b..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/store/auth.spec.js +++ /dev/null @@ -1,148 +0,0 @@ -var sinon = require('sinon') -import {expect} from 'chai' - -import moxios from 'moxios' -import store from '@/store/auth' - -import { testAction } from '../../utils' - -describe('store/auth', () => { - var sandbox - - beforeEach(function () { - sandbox = sinon.createSandbox() - 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', - profile: 'dummy', - availablePermissions: 'dummy' - } - store.mutations.authenticated(state, false) - expect(state.authenticated).to.equal(false) - expect(state.username).to.equal(null) - expect(state.profile).to.equal(null) - expect(state.availablePermissions).to.deep.equal({}) - }) - 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 = { oauth: {accessToken: 'helloworld' }} - expect(store.getters['header'](state)).to.equal('Bearer helloworld') - }) - }) - describe('actions', () => { - it('logout', () => { - testAction({ - action: store.actions.logout, - params: {state: {}}, - expectedMutations: [ - { type: 'auth/reset', payload: null, options: {root: true} }, - { type: 'favorites/reset', payload: null, options: {root: true} }, - { type: 'player/reset', payload: null, options: {root: true} }, - { type: 'playlists/reset', payload: null, options: {root: true} }, - { type: 'queue/reset', payload: null, options: {root: true} }, - { type: 'radios/reset', payload: null, options: {root: true} } - ] - }) - }) - it('login success', () => { - 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' } - ], - expectedActions: [ - { type: 'fetchProfile' } - ] - }) - }) - it('login error', () => { - 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() // eslint-disable-line no-undef - }) - }) - it('fetchProfile', () => { - const profile = { - username: 'bob', - permissions: { - admin: true - } - } - moxios.stubRequest('users/me/', { - status: 200, - response: profile - }) - testAction({ - action: store.actions.fetchProfile, - expectedMutations: [ - { type: 'authenticated', payload: true }, - { type: 'profile', payload: profile }, - { type: 'username', payload: profile.username }, - { type: 'permission', payload: {key: 'admin', status: true} } - ], - expectedActions: [ - { type: 'ui/initSettings', payload: { root: true } }, - { type: 'updateProfile', payload: profile }, - { type: 'ui/fetchUnreadNotifications', payload: null }, - { type: 'favorites/fetch', payload: null, options: {root: true} }, - { type: 'channels/fetchSubscriptions', payload: null, options: {root: true} }, - { type: 'libraries/fetchFollows', payload: null, options: {root: true} }, - { type: 'moderation/fetchContentFilters', payload: null, options: {root: true} }, - { type: 'playlists/fetchOwn', payload: null, options: {root: true} } - ] - }) - }) - }) -}) diff --git a/front/tests/unit/specs/store/favorites.spec.js b/front/tests/unit/specs/store/favorites.spec.js deleted file mode 100644 index 1c0f29a9de5ae70b7563a8add3dc4a81e79f025c..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/store/favorites.spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import {expect} from 'chai' - -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', () => { - testAction({ - action: store.actions.toggle, - payload: 1, - params: {getters: {isFavorite: () => false}}, - expectedActions: [ - { type: 'set', payload: {id: 1, value: true} } - ] - }) - }) - it('toggle true', () => { - testAction({ - action: store.actions.toggle, - payload: 1, - params: {getters: {isFavorite: () => true}}, - expectedActions: [ - { type: 'set', payload: {id: 1, value: false} } - ] - }) - }) - }) -}) diff --git a/front/tests/unit/specs/store/instance.spec.js b/front/tests/unit/specs/store/instance.spec.js deleted file mode 100644 index 5ae771c75f3b2e6efdb189f46fb3ff67c0a9de23..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/store/instance.spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import {expect} from 'chai' -var sinon = require('sinon') -import axios from 'axios' -import moxios from 'moxios' -import store from '@/store/instance' -import { testAction } from '../../utils' - -describe('store/instance', () => { - var sandbox - - beforeEach(function () { - sandbox = sinon.createSandbox() - moxios.install() - }) - afterEach(function () { - sandbox.restore() - moxios.uninstall() - axios.defaults.baseURL = null - }) - - describe('mutations', () => { - it('settings', () => { - const state = {settings: {users: {upload_quota: {value: 1}}}} - let settings = {users: {registration_enabled: {value: true}}} - store.mutations.settings(state, settings) - expect(state.settings).to.deep.equal({ - users: {upload_quota: {value: 1}, registration_enabled: {value: true}} - }) - }) - it('instanceUrl', () => { - const state = {instanceUrl: null, knownInstances: ['http://test2/', 'http://test/']} - store.mutations.instanceUrl(state, 'http://test') - expect(state).to.deep.equal({ - instanceUrl: 'http://test/', // trailing slash added - knownInstances: ['http://test/', 'http://test2/'] - }) - }) - }) - describe('actions', () => { - it('fetchSettings', () => { - moxios.stubRequest('instance/settings/', { - status: 200, - response: [ - { - section: 'users', - name: 'upload_quota', - value: 1 - }, - { - section: 'users', - name: 'registration_enabled', - value: false - } - ] - }) - testAction({ - action: store.actions.fetchSettings, - payload: null, - expectedMutations: [ - { - type: 'settings', - payload: { - users: { - upload_quota: { - section: 'users', - name: 'upload_quota', - value: 1 - }, - registration_enabled: { - section: 'users', - name: 'registration_enabled', - value: false - } - } - } - } - ] - }) - }) - }) -}) diff --git a/front/tests/unit/specs/store/player.spec.js b/front/tests/unit/specs/store/player.spec.js deleted file mode 100644 index b40642842612976bde41bc55b4676d97bc82e816..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/store/player.spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import {expect} from 'chai' - -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) - }) - it('increment error count', () => { - const state = { errorCount: 0 } - store.mutations.incrementErrorCount(state) - expect(state.errorCount).to.equal(1) - }) - it('reset error count', () => { - const state = { errorCount: 10 } - store.mutations.resetErrorCount(state) - expect(state.errorCount).to.equal(0) - }) - }) - describe('getters', () => { - it('durationFormatted', () => { - const state = { duration: 12.51 } - expect(store.getters['durationFormatted'](state)).to.equal('0:13') - }) - it('currentTimeFormatted', () => { - const state = { currentTime: 12.51 } - expect(store.getters['currentTimeFormatted'](state)).to.equal('0:13') - }) - it('progress', () => { - const state = { currentTime: 4, duration: 10 } - expect(store.getters['progress'](state)).to.equal(40) - }) - }) - describe('actions', () => { - it('incrementVolume', () => { - testAction({ - action: store.actions.incrementVolume, - payload: 0.2, - params: {state: {volume: 0.7}}, - expectedMutations: [ - { type: 'volume', payload: 0.7 + 0.2 } - ] - }) - }) - it('toggle playback false', () => { - testAction({ - action: store.actions.togglePlayback, - params: {state: {playing: false}}, - expectedMutations: [ - { type: 'playing', payload: true } - ] - }) - }) - it('toggle playback true', () => { - testAction({ - action: store.actions.togglePlayback, - params: {state: {playing: true}}, - expectedMutations: [ - { type: 'playing', payload: false } - ] - }) - }) - it('resume playback', () => { - testAction({ - action: store.actions.resumePlayback, - params: {state: {}}, - expectedMutations: [ - { type: 'playing', payload: true } - ] - }) - }) - it('pause playback', () => { - testAction({ - action: store.actions.pausePlayback, - expectedMutations: [ - { type: 'playing', payload: false } - ] - }) - }) - it('trackEnded', () => { - testAction({ - action: store.actions.trackEnded, - payload: {test: 'track'}, - params: {rootState: {queue: {currentIndex: 0, tracks: [1, 2]}}}, - expectedActions: [ - { type: 'queue/next', payload: null, options: {root: true} } - ] - }) - }) - it('trackEnded calls populateQueue if last', () => { - testAction({ - action: store.actions.trackEnded, - payload: {test: 'track'}, - params: {rootState: {queue: {currentIndex: 1, tracks: [1, 2]}}}, - expectedActions: [ - { type: 'radios/populateQueue', payload: null, options: {root: true} }, - { type: 'queue/next', payload: null, options: {root: true} } - ] - }) - }) - it('trackErrored', () => { - testAction({ - action: store.actions.trackErrored, - payload: {test: 'track'}, - params: {state: {errorCount: 0, maxConsecutiveErrors: 5}}, - expectedMutations: [ - { type: 'errored', payload: true }, - { type: 'incrementErrorCount' } - ], - expectedActions: [ - { type: 'queue/next', payload: null, options: {root: true} } - ] - }) - }) - it('updateProgress', () => { - testAction({ - action: store.actions.updateProgress, - payload: 1, - expectedMutations: [ - { type: 'currentTime', payload: 1 } - ] - }) - }) - it('mute', () => { - testAction({ - action: store.actions.mute, - params: {state: { volume: 0.7, tempVolume: 0}}, - expectedMutations: [ - { type: 'tempVolume', payload: 0.7 }, - { type: 'volume', payload: 0 }, - ] - }) - }) - it('unmute', () => { - testAction({ - action: store.actions.unmute, - params: {state: { volume: 0, tempVolume: 0.8}}, - expectedMutations: [ - { type: 'volume', payload: 0.8 }, - ] - }) - }) - }) -}) diff --git a/front/tests/unit/specs/store/playlists.spec.js b/front/tests/unit/specs/store/playlists.spec.js deleted file mode 100644 index 0fe0c0ae2e1332492580bf56893a14914cb8149a..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/store/playlists.spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import {expect} from 'chai' -var sinon = require('sinon') -import moxios from 'moxios' -import store from '@/store/playlists' - -import { testAction } from '../../utils' - -describe('store/playlists', () => { - var sandbox - - beforeEach(function () { - sandbox = sinon.createSandbox() - moxios.install() - }) - afterEach(function () { - sandbox.restore() - moxios.uninstall() - }) - - describe('mutations', () => { - it('set playlists', () => { - const state = { playlists: [] } - store.mutations.playlists(state, [{id: 1, name: 'test'}]) - expect(state.playlists).to.deep.equal([{id: 1, name: 'test'}]) - }) - }) - describe('actions', () => { - it('fetchOwn does nothing with no user', () => { - testAction({ - action: store.actions.fetchOwn, - payload: null, - params: {state: { playlists: [] }, rootState: {auth: {profile: {}}}}, - expectedMutations: [] - }) - }) - }) -}) diff --git a/front/tests/unit/specs/store/queue.spec.js b/front/tests/unit/specs/store/queue.spec.js deleted file mode 100644 index d7d98a17a89bc1737c6058a1311bc0f7bf79592f..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/store/queue.spec.js +++ /dev/null @@ -1,306 +0,0 @@ -var sinon = require('sinon') -import {expect} from 'chai' - -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.createSandbox() - }) - - 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) - }) - }) - describe('actions', () => { - it('append at end', () => { - testAction({ - action: store.actions.append, - payload: {track: 4}, - params: {state: {tracks: [1, 2, 3]}}, - expectedMutations: [ - { type: 'insert', payload: {track: 4, index: 3} } - ] - }) - }) - it('append at index', () => { - testAction({ - action: store.actions.append, - payload: {track: 2, index: 1}, - params: {state: {tracks: [1, 3]}}, - expectedMutations: [ - { type: 'insert', payload: {track: 2, index: 1} } - ] - }) - }) - it('appendMany', () => { - 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} }, - { type: 'append', payload: {track: tracks[1], index: 1} }, - ] - }) - }) - it('appendMany at index', () => { - 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} }, - { type: 'append', payload: {track: tracks[1], index: 2} }, - ] - }) - }) - it('cleanTrack after current', () => { - testAction({ - action: store.actions.cleanTrack, - payload: 3, - params: {state: {currentIndex: 2, tracks: [1, 2, 3, 4, 5]}}, - expectedMutations: [ - { type: 'splice', payload: {start: 3, size: 1} } - ] - }) - }) - it('cleanTrack before current', () => { - testAction({ - action: store.actions.cleanTrack, - payload: 1, - params: {state: {currentIndex: 2, tracks: []}}, - expectedMutations: [ - { type: 'splice', payload: {start: 1, size: 1} }, - { type: 'currentIndex', payload: 1 } - ] - }) - }) - it('cleanTrack current', () => { - testAction({ - action: store.actions.cleanTrack, - payload: 2, - params: {state: {currentIndex: 2, tracks: []}}, - expectedMutations: [ - { type: 'splice', payload: {start: 2, size: 1} }, - { type: 'currentIndex', payload: 2 } - ], - expectedActions: [ - { type: 'player/stop', payload: null, options: {root: true} } - ] - }) - }) - it('cleanTrack current is last', () => { - testAction({ - action: store.actions.cleanTrack, - payload: 5, - params: { state: { currentIndex: 5, tracks: [1, 2, 3, 4, 5] } }, - expectedMutations: [ - { type: 'splice', payload: { start: 5, size: 1 } }, - { type: 'currentIndex', payload: 4 } - ], - expectedActions: [ - { type: 'player/stop', payload: null, options: { root: true } } - ] - }) - }) - it('previous when at beginning', () => { - testAction({ - action: store.actions.previous, - params: {state: {currentIndex: 0}}, - expectedActions: [ - { type: 'currentIndex', payload: 0 } - ] - }) - }) - it('previous after less than 3 seconds of playback', () => { - testAction({ - action: store.actions.previous, - params: {state: {currentIndex: 1}, rootState: {player: {currentTime: 1}}}, - expectedActions: [ - { type: 'currentIndex', payload: 0 } - ] - }) - }) - it('previous after more than 3 seconds of playback', () => { - testAction({ - action: store.actions.previous, - params: {state: {currentIndex: 1}, rootState: {player: {currentTime: 3}}}, - expectedActions: [ - { type: 'currentIndex', payload: 1 } - ] - }) - }) - it('next on last track when looping on queue', () => { - testAction({ - action: store.actions.next, - params: {state: {tracks: [1, 2], currentIndex: 1}, rootState: {player: {looping: 2}}}, - expectedActions: [ - { type: 'currentIndex', payload: 0 } - ] - }) - }) - it('next track when last track', () => { - testAction({ - action: store.actions.next, - params: {state: {tracks: [1, 2], currentIndex: 1}, rootState: {player: {looping: 0}}}, - expectedMutations: [ - { type: 'ended', payload: true } - ] - }) - }) - it('next track when not last track', () => { - testAction({ - action: store.actions.next, - params: {state: {tracks: [1, 2], currentIndex: 0}, rootState: {player: {looping: 0}}}, - expectedActions: [ - { type: 'currentIndex', payload: 1 } - ] - }) - }) - it('currentIndex', () => { - 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: 'currentIndex', payload: 1 } - ] - }) - }) - it('currentIndex with radio and many tracks remaining', () => { - 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: 'currentIndex', payload: 1 } - ] - }) - }) - it('currentIndex with radio and less than two tracks remaining', () => { - 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: 'currentIndex', payload: 1 } - ], - expectedActions: [ - { type: 'radios/populateQueue', payload: null, options: {root: true} } - ] - }) - }) - it('clean', () => { - testAction({ - action: store.actions.clean, - expectedMutations: [ - { type: 'tracks', payload: [] }, - { type: 'ended', payload: true } - ], - expectedActions: [ - { type: 'radios/stop', payload: null, options: {root: true} }, - { type: 'player/stop', payload: null, options: {root: true} }, - { type: 'currentIndex', payload: -1 } - ] - }) - }) - it('shuffle', () => { - let _shuffle = sandbox.stub(_, 'shuffle') - let tracks = ['a', 'b', 'c', 'd', 'e'] - let shuffledTracks = ['a', 'b', 'e', 'd', 'c'] - _shuffle.returns(shuffledTracks) - testAction({ - action: store.actions.shuffle, - params: {state: {currentIndex: 1, tracks: tracks}}, - expectedMutations: [ - { type: 'tracks', payload: [] } - ], - expectedActions: [ - { type: 'appendMany', payload: {tracks: shuffledTracks} }, - { type: 'currentIndex', payload: {tracks: shuffledTracks} } - ] - }) - }) - }) -}) diff --git a/front/tests/unit/specs/store/radios.spec.js b/front/tests/unit/specs/store/radios.spec.js deleted file mode 100644 index a4d348d0f1ae0d256dbf0b45b442a10f36920e1c..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/store/radios.spec.js +++ /dev/null @@ -1,104 +0,0 @@ -var sinon = require('sinon') -import {expect} from 'chai' - -import moxios from 'moxios' -import store from '@/store/radios' -import { testAction } from '../../utils' - -describe('store/radios', () => { - var sandbox - - beforeEach(function () { - sandbox = sinon.createSandbox() - 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', () => { - 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', payload: true } - ] - }) - }) - it('stop', () => { - return testAction({ - action: store.actions.stop, - params: {state: {}}, - expectedMutations: [ - { type: 'current', payload: null }, - { type: 'running', payload: false } - ] - }) - }) - it('populateQueue', () => { - moxios.stubRequest('radios/tracks/', { - status: 201, - response: {track: {id: 1}} - }) - return testAction({ - action: store.actions.populateQueue, - params: { - state: {running: true, current: {session: 1}}, - rootState: {player: {errorCount: 0, maxConsecutiveErrors: 5}} - - }, - expectedActions: [ - { type: 'queue/append', payload: {track: {id: 1}}, options: {root: true} } - ] - }) - }) - it('populateQueue does nothing when not running', () => { - testAction({ - action: store.actions.populateQueue, - params: {state: {running: false}}, - expectedActions: [] - }) - }) - it('populateQueue does nothing when too much errors', () => { - return testAction({ - action: store.actions.populateQueue, - payload: {test: 'track'}, - params: { - rootState: {player: {errorCount: 5, maxConsecutiveErrors: 5}}, - state: {running: true} - }, - expectedActions: [] - }) - }) - }) -}) diff --git a/front/tests/unit/specs/utils.spec.js b/front/tests/unit/specs/utils.spec.js deleted file mode 100644 index 9fc9c36b7a57deb5622ff7231f7ccb8b2d4046b2..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/utils.spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import {expect} from 'chai' - -import {parseAPIErrors} from '@/utils' - -describe('utils', () => { - describe('parseAPIErrors', () => { - it('handles flat structure', () => { - const input = {"old_password": ["Invalid password"]} - let expected = ["Invalid password"] - let output = parseAPIErrors(input) - expect(output).to.deep.equal(expected) - }) - it('handles flat structure with multiple errors per field', () => { - const input = {"old_password": ["Invalid password", "Too short"]} - let expected = ["Invalid password", "Too short"] - let output = parseAPIErrors(input) - expect(output).to.deep.equal(expected) - }) - it('translate field name', () => { - const input = {"old_password": ["This field is required"]} - let expected = ["Old Password: This field is required"] - let output = parseAPIErrors(input) - expect(output).to.deep.equal(expected) - }) - it('handle nested fields', () => { - const input = {"summary": {"text": ["Ensure this field has no more than 5000 characters."]}} - let expected = ["Summary - Text: Ensure this field has no more than 5000 characters."] - let output = parseAPIErrors(input) - expect(output).to.deep.equal(expected) - }) - }) -}) diff --git a/front/tests/unit/specs/views/admin/library.spec.js b/front/tests/unit/specs/views/admin/library.spec.js deleted file mode 100644 index dbe65f2dd6707a3ed5b39b9b9907bab51f0117e4..0000000000000000000000000000000000000000 --- a/front/tests/unit/specs/views/admin/library.spec.js +++ /dev/null @@ -1,64 +0,0 @@ -const sinon = require('sinon') -import { expect } from 'chai' -import { shallowMount, createLocalVue, RouterLinkStub } from '@vue/test-utils' -import AlbumDetail from '@/views/admin/library/AlbumDetail.vue' -import GetTextPlugin from 'vue-gettext' - -import HumanDate from '@/components/common/HumanDate.vue' -import DangerousButton from '@/components/common/DangerousButton.vue' - -describe('views/admin/library', () => { - - let wrapper - let sandbox - beforeEach(() => { - sandbox = sinon.createSandbox() - }) - afterEach(() => { - sandbox.restore() - }) - describe('Album details', () => { - - it('displays default cover', async () => { - const album = { cover: null, artist: { id: 1 }, title: "dummy", id: 1, creation_date: "2020-01-01" } - const localVue = createLocalVue() - localVue.directive('title', (() => null)) - localVue.directive('dropdown', (() => null)) - localVue.use(GetTextPlugin, { translations: {} }) - localVue.filter('truncate', () => null) - localVue.filter('humanSize', () => null) - localVue.filter('ago', () => null) - localVue.filter('moment', () => null) - // overrides axios calls - sandbox.stub(AlbumDetail.methods, "fetchData").callsFake(() => null) - sandbox.stub(AlbumDetail.methods, "fetchStats").callsFake(() => null) - - wrapper = shallowMount(AlbumDetail, { - localVue, - data() { - return { - isLoading: false, - isLoadingStats: false, - object: album, - stats: [], - } - }, - mocks: { - $store: { - state: { auth: { profile: null }, ui: { lastDate: null } } - }, - }, - stubs: { - 'human-date': HumanDate, - 'dangerous-button': DangerousButton, - 'router-link': RouterLinkStub - }, - propsData: { - id: 1 - }, - computed: { labels: () => { return { statsWarning: null } } } - }) - expect(wrapper.find('img').attributes('src')).to.include("default-cover") - }) - }) -}) \ No newline at end of file diff --git a/front/tests/unit/utils.js b/front/tests/unit/utils.js deleted file mode 100644 index 642b3b5098b7f846f9a2ead9b7ab7e14f56f8127..0000000000000000000000000000000000000000 --- a/front/tests/unit/utils.js +++ /dev/null @@ -1,77 +0,0 @@ -// helper for testing action with expected mutations -import Vue from 'vue' -import {expect} from 'chai' - - -export const render = (Component, propsData) => { - const Constructor = Vue.extend(Component) - return new Constructor({ propsData: propsData }).$mount() -} - -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] - - expect(mutation.type).to.equal(type) - if (payload) { - expect(mutation.payload).to.deep.equal(payload) - } - - mutationsCount++ - if (isOver()) { - return - } - } - // mock dispatch - const dispatch = (type, payload, options) => { - const a = expectedActions[actionsCount] - if (!a) { - throw Error(`Unexecpted action ${type}`) - } - 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) - } - actionsCount++ - if (isOver()) { - return - } - } - - 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()) { - return - } - } - // call the action with mocked store and arguments - let promise = action({ commit, dispatch, ...params }, payload) - if (promise) { - promise.then(end) - return promise - } else { - return end() - } -} diff --git a/front/tsconfig.json b/front/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..758f322b5789d9476d7bdc2a2c969a4dba1343b7 --- /dev/null +++ b/front/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@vue/tsconfig/tsconfig.web.json", + "compilerOptions": { + "baseUrl": ".", + "sourceMap": true, + "noUnusedLocals": true, + "typeRoots": ["node_modules/@types"], + "types": [ + "vitest/globals", + "vite/client", + "vue/ref-macros", + "vue-gettext/types", + "vite-plugin-pwa/client" + ], + "paths": { + "?/*": ["test/*"], + "~/*": ["src/*"] + } + }, + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.vue", "vite.config.ts", "test/**/*.ts"] +} diff --git a/front/vite.config.js b/front/vite.config.js deleted file mode 100644 index fc6fb4c515e91e05e9ceef55a61cf24c8a91a481..0000000000000000000000000000000000000000 --- a/front/vite.config.js +++ /dev/null @@ -1,50 +0,0 @@ -// vite.config.js - -import { defineConfig } from 'vite' -import { createVuePlugin as vue } from 'vite-plugin-vue2' - -import path from 'path' - -const port = process.env.VUE_PORT ?? 8080 - -const hmr = { - port: process.env.HMR_PORT || (process.env.FUNKWHALE_PROTOCOL === 'https' ? 443 : port), - protocol: process.env.HMR_PROTOCOL || (process.env.FUNKWHALE_PROTOCOL === 'https' ? 'wss' : 'ws') -} - -if (process.env.GITPOD_WORKSPACE_URL) { - hmr.host = process.env.GITPOD_WORKSPACE_URL.replace('https://', `${process.env.HMR_PORT ?? process.env.VUE_PORT ?? 4000}-`) - hmr.clientPort = 443 - hmr.protocol = 'wss' - delete hmr.port -} - -// https://vitejs.dev/config/ -export default defineConfig({ - envPrefix: "VUE_", - plugins: [ - vue(), - { - name: 'fix-fomantic-ui-css', - transform (src, id) { - if (id.includes('fomantic-ui-css') && id.endsWith('.min.js')) { - return `import jQuery from 'jquery';${src}` - } - } - } - ], - server: { port, hmr }, - resolve: { - alias: { - '@': path.resolve(__dirname, './src') - } - }, - build: { - rollupOptions: { - input: { - main: path.resolve(__dirname, './index.html'), - embed: path.resolve(__dirname, './embed.html') - } - } - } -}) diff --git a/front/vite.config.ts b/front/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea184ac4d0109c2bd903fd5858c6002ff94042ed --- /dev/null +++ b/front/vite.config.ts @@ -0,0 +1,52 @@ +import { defineConfig } from 'vite' +import Vue from '@vitejs/plugin-vue' +import Inspector from 'vite-plugin-vue-inspector' +import { VitePWA } from 'vite-plugin-pwa' +import { resolve } from 'path' + +const port = +(process.env.VUE_PORT ?? 8080) + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => ({ + envPrefix: ['VUE_', 'FUNKWHALE_SENTRY_'], + plugins: [ + // https://github.com/vitejs/vite/tree/main/packages/plugin-vue + Vue(), + + // https://github.com/webfansplz/vite-plugin-vue-inspector + Inspector({ + toggleComboKey: 'alt-shift-d' + }), + + // https://github.com/antfu/vite-plugin-pwa + VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'serviceWorker.ts', + manifestFilename: 'manifest.json', + devOptions: { + enabled: true, + type: 'module', + navigateFallback: 'index.html' + } + }) + ], + server: { + port + }, + resolve: { + alias: { + '?': resolve(__dirname, './test'), + '~': resolve(__dirname, './src') + } + }, + test: { + environment: 'jsdom', + globals: true, + coverage: { + src: './src', + all: true, + reporter: ['text', 'cobertura'] + } + } +})) diff --git a/front/yarn.lock b/front/yarn.lock index 5907f792e7945b5e9797123c88247b2597900c85..3e288aa7892540d3bfa22dbe354ecfc5e595f96e 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -10,66 +10,54 @@ "@jridgewell/gen-mapping" "^0.1.0" "@jridgewell/trace-mapping" "^0.3.9" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.18.6": +"@apideck/better-ajv-errors@^0.3.1": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz#957d4c28e886a64a8141f7522783be65733ff097" + integrity sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA== + dependencies: + json-schema "^0.4.0" + jsonpointer "^5.0.0" + leven "^3.1.0" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== dependencies: "@babel/highlight" "^7.18.6" -"@babel/compat-data@^7.16.8", "@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" - integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== - -"@babel/core@7.17.12": - version "7.17.12" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.17.12.tgz#b4eb2d7ebc3449b062381644c93050db545b70ee" - integrity sha512-44ODe6O1IVz9s2oJE3rZ4trNNKTX9O7KpQpfAP4t8QII/zwrVRHL7i2pxhqtcY7tqMLrrKfMlBKnm1QlrRFs5w== - dependencies: - "@ampproject/remapping" "^2.1.0" - "@babel/code-frame" "^7.16.7" - "@babel/generator" "^7.17.12" - "@babel/helper-compilation-targets" "^7.17.10" - "@babel/helper-module-transforms" "^7.17.12" - "@babel/helpers" "^7.17.9" - "@babel/parser" "^7.17.12" - "@babel/template" "^7.16.7" - "@babel/traverse" "^7.17.12" - "@babel/types" "^7.17.12" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.1" - semver "^6.3.0" +"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8", "@babel/compat-data@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.0.tgz#2a592fd89bacb1fcde68de31bee4f2f2dacb0e86" + integrity sha512-y5rqgTTPTmaF5e2nVhOxw+Ur9HDJLsWb6U/KpgUzRZEdPfE6VOubXBKLdbcUTijzRptednSBDQbYZBOSqJxpJw== -"@babel/core@^7.1.0", "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.16.10", "@babel/core@^7.7.2", "@babel/core@^7.8.0": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.9.tgz#805461f967c77ff46c74ca0460ccf4fe933ddd59" - integrity sha512-1LIb1eL8APMy91/IMW+31ckrfBM4yCoLaVzoDhZUKSM4cu1L1nIidyxkCgzPAgrC5WEz36IPEr/eSeSF9pIn+g== +"@babel/core@^7.11.1", "@babel/core@^7.11.6", "@babel/core@^7.17.8": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.0.tgz#d2f5f4f2033c00de8096be3c9f45772563e150c3" + integrity sha512-reM4+U7B9ss148rh2n1Qs9ASS+w94irYXga7c2jaQv9RVzpS7Mv1a9rnYYwuDa45G+DkORt9g6An2k/V4d9LbQ== dependencies: "@ampproject/remapping" "^2.1.0" "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.9" - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-module-transforms" "^7.18.9" - "@babel/helpers" "^7.18.9" - "@babel/parser" "^7.18.9" - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/generator" "^7.19.0" + "@babel/helper-compilation-targets" "^7.19.0" + "@babel/helper-module-transforms" "^7.19.0" + "@babel/helpers" "^7.19.0" + "@babel/parser" "^7.19.0" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" json5 "^2.2.1" semver "^6.3.0" -"@babel/generator@^7.17.12", "@babel/generator@^7.18.9", "@babel/generator@^7.7.2": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.9.tgz#68337e9ea8044d6ddc690fb29acae39359cca0a5" - integrity sha512-wt5Naw6lJrL1/SGkipMiFxJjtyczUWTP38deiP1PO60HsBjDeKk08CGC3S8iVuvf0FmTdgKwU1KIXzSKL1G0Ug== +"@babel/generator@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.0.tgz#785596c06425e59334df2ccee63ab166b738419a" + integrity sha512-S1ahxf1gZ2dpoiFgA+ohK9DIpz50bJ0CWs7Zlzb54Z4sG8qmdIrGrVqmy1sAtTVRb+9CU6U8VqT9L0Zj7hxHVg== dependencies: - "@babel/types" "^7.18.9" + "@babel/types" "^7.19.0" "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" @@ -88,38 +76,38 @@ "@babel/helper-explode-assignable-expression" "^7.18.6" "@babel/types" "^7.18.9" -"@babel/helper-compilation-targets@^7.16.7", "@babel/helper-compilation-targets@^7.17.10", "@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.18.9.tgz#69e64f57b524cde3e5ff6cc5a9f4a387ee5563bf" - integrity sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg== +"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.0.tgz#537ec8339d53e806ed422f1e06c8f17d55b96bb0" + integrity sha512-Ai5bNWXIvwDvWM7njqsG3feMlL9hCVQsPYXodsZyLwshYkZVJt59Gftau4VrE8S9IT9asd2uSP1hG6wCNw+sXA== dependencies: - "@babel/compat-data" "^7.18.8" + "@babel/compat-data" "^7.19.0" "@babel/helper-validator-option" "^7.18.6" browserslist "^4.20.2" semver "^6.3.0" -"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.9.tgz#d802ee16a64a9e824fcbf0a2ffc92f19d58550ce" - integrity sha512-WvypNAYaVh23QcjpMR24CwZY2Nz6hqdOcFdPbNpV56hL5H6KiFheO7Xm1aPdlLQ7d5emYZX7VZwPp9x3z+2opw== +"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz#bfd6904620df4e46470bae4850d66be1054c404b" + integrity sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" "@babel/helper-member-expression-to-functions" "^7.18.9" "@babel/helper-optimise-call-expression" "^7.18.6" "@babel/helper-replace-supers" "^7.18.9" "@babel/helper-split-export-declaration" "^7.18.6" -"@babel/helper-create-regexp-features-plugin@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.18.6.tgz#3e35f4e04acbbf25f1b3534a657610a000543d3c" - integrity sha512-7LcpH1wnQLGrI+4v+nPp+zUvIkF9x0ddv1Hkdue10tg3gmRnLy97DXh4STiOf1qeIInyD69Qv5kKSZzKD8B/7A== +"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz#7976aca61c0984202baca73d84e2337a5424a41b" + integrity sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" regexpu-core "^5.1.0" -"@babel/helper-define-polyfill-provider@^0.3.1", "@babel/helper-define-polyfill-provider@^0.3.2": +"@babel/helper-define-polyfill-provider@^0.3.2": version "0.3.2" resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.2.tgz#bd10d0aca18e8ce012755395b05a79f45eca5073" integrity sha512-r9QJJ+uDWrd+94BSPcP6/de67ygLtvVy6cK4luE6MOuDsZIdoaPBnfSpbO/+LTifjPckbKXRuI9BB/Z2/y3iTg== @@ -131,7 +119,7 @@ resolve "^1.14.2" semver "^6.1.2" -"@babel/helper-environment-visitor@^7.18.6", "@babel/helper-environment-visitor@^7.18.9": +"@babel/helper-environment-visitor@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== @@ -143,13 +131,13 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-function-name@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.9.tgz#940e6084a55dee867d33b4e487da2676365e86b0" - integrity sha512-fJgWlZt7nxGksJS9a0XdSaI4XvpExnNIgRP+rVefWh5U7BL8pPuir6SJUmFKRfjWQ51OtWSzwOxhaH/EBWWc0A== +"@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" + integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w== dependencies: - "@babel/template" "^7.18.6" - "@babel/types" "^7.18.9" + "@babel/template" "^7.18.10" + "@babel/types" "^7.19.0" "@babel/helper-hoist-variables@^7.18.6": version "7.18.6" @@ -165,26 +153,26 @@ dependencies: "@babel/types" "^7.18.9" -"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.18.6": +"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== dependencies: "@babel/types" "^7.18.6" -"@babel/helper-module-transforms@^7.17.12", "@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.18.9.tgz#5a1079c005135ed627442df31a42887e80fcb712" - integrity sha512-KYNqY0ICwfv19b31XzvmI/mfcylOzbLtowkw+mfvGPAQ3kfCnMLYbED3YecL5tPd8nAYFQFAd6JHp2LxZk/J1g== +"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz#309b230f04e22c58c6a2c0c0c7e50b216d350c30" + integrity sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ== dependencies: "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-module-imports" "^7.18.6" "@babel/helper-simple-access" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" "@babel/helper-validator-identifier" "^7.18.6" - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" "@babel/helper-optimise-call-expression@^7.18.6": version "7.18.6" @@ -193,12 +181,12 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.17.12", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.18.9.tgz#4b8aea3b069d8cb8a72cdfe28ddf5ceca695ef2f" - integrity sha512-aBXPT3bmtLryXaoJLyYPXPlSD4p1ld9aYeR+sJNOZjJJGiOpb+fKfh3NkcCu7J54nUJwCERPBExCCpyCOHnu/w== +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz#4796bb14961521f0f8715990bee2fb6e51ce21bf" + integrity sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw== -"@babel/helper-remap-async-to-generator@^7.18.6": +"@babel/helper-remap-async-to-generator@^7.18.6", "@babel/helper-remap-async-to-generator@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz#997458a0e3357080e54e1d79ec347f8a8cd28519" integrity sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA== @@ -240,34 +228,39 @@ dependencies: "@babel/types" "^7.18.6" +"@babel/helper-string-parser@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" + integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== + "@babel/helper-validator-identifier@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== -"@babel/helper-validator-option@^7.16.7", "@babel/helper-validator-option@^7.18.6": +"@babel/helper-validator-option@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw== "@babel/helper-wrap-function@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.18.9.tgz#ae1feddc6ebbaa2fd79346b77821c3bd73a39646" - integrity sha512-cG2ru3TRAL6a60tfQflpEfs4ldiPwF6YW3zfJiRgmoFVIaC1vGnBBgatfec+ZUziPHkHSaXAuEck3Cdkf3eRpQ== + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz#89f18335cff1152373222f76a4b37799636ae8b1" + integrity sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg== dependencies: - "@babel/helper-function-name" "^7.18.9" - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" -"@babel/helpers@^7.17.9", "@babel/helpers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.18.9.tgz#4bef3b893f253a1eced04516824ede94dcfe7ff9" - integrity sha512-Jf5a+rbrLoR4eNdUmnFu8cN5eNJT6qdTdOg5IHIzq87WwyRw9PwguLFOWYgktN/60IP4fgDUawJvs7PjQIzELQ== +"@babel/helpers@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.0.tgz#f30534657faf246ae96551d88dd31e9d1fa1fc18" + integrity sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg== dependencies: - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" "@babel/highlight@^7.18.6": version "7.18.6" @@ -278,19 +271,19 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.10", "@babel/parser@^7.16.4", "@babel/parser@^7.17.12", "@babel/parser@^7.18.4", "@babel/parser@^7.18.6", "@babel/parser@^7.18.9", "@babel/parser@^7.6.0", "@babel/parser@^7.9.6": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.9.tgz#f2dde0c682ccc264a9a8595efd030a5cc8fd2539" - integrity sha512-9uJveS9eY9DJ0t64YbIBZICtJy8a5QrDEVdiLCG97fVLpDTpGX7t8mMSb6OWw6Lrnjqj4O8zwjELX3dhoMgiBg== +"@babel/parser@^7.16.4", "@babel/parser@^7.18.10", "@babel/parser@^7.19.0", "@babel/parser@^7.6.0", "@babel/parser@^7.9.6": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.0.tgz#497fcafb1d5b61376959c1c338745ef0577aa02c" + integrity sha512-74bEXKX2h+8rrfQUfsBfuZZHzsEs6Eql4pqy/T4Nn6Y9wNPggQOqD6z6pn5Bl8ZfysKouFZT/UXEH94ummEeQw== -"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.16.7": +"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2" integrity sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.16.7": +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz#a11af19aa373d68d561f08e0a57242350ed0ec50" integrity sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg== @@ -299,17 +292,17 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" "@babel/plugin-proposal-optional-chaining" "^7.18.9" -"@babel/plugin-proposal-async-generator-functions@^7.16.8": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.18.6.tgz#aedac81e6fc12bb643374656dd5f2605bf743d17" - integrity sha512-WAz4R9bvozx4qwf74M+sfqPMKfSqwM0phxPTR6iJIi8robgzXwkEgmeJG1gEKhm6sDqT/U9aV3lfcqybIpev8w== +"@babel/plugin-proposal-async-generator-functions@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.0.tgz#cf5740194f170467df20581712400487efc79ff1" + integrity sha512-nhEByMUTx3uZueJ/QkJuSlCfN4FGg+xy+vRsfGQGzSauq5ks2Deid2+05Q3KhfaUjvec1IGhw/Zm3cFm8JigTQ== dependencies: - "@babel/helper-environment-visitor" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-remap-async-to-generator" "^7.18.6" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-plugin-utils" "^7.19.0" + "@babel/helper-remap-async-to-generator" "^7.18.9" "@babel/plugin-syntax-async-generators" "^7.8.4" -"@babel/plugin-proposal-class-properties@^7.16.7": +"@babel/plugin-proposal-class-properties@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ== @@ -317,7 +310,7 @@ "@babel/helper-create-class-features-plugin" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-proposal-class-static-block@^7.16.7": +"@babel/plugin-proposal-class-static-block@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz#8aa81d403ab72d3962fc06c26e222dacfc9b9020" integrity sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw== @@ -326,18 +319,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-class-static-block" "^7.14.5" -"@babel/plugin-proposal-decorators@^7.16.7": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.18.9.tgz#d09d41ffc74af8499d2ac706ed0dbd5474711665" - integrity sha512-KD7zDNaD14CRpjQjVbV4EnH9lsKYlcpUrhZH37ei2IY+AlXrfAPy5pTmRUE4X6X1k8EsKXPraykxeaogqQvSGA== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-replace-supers" "^7.18.9" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/plugin-syntax-decorators" "^7.18.6" - -"@babel/plugin-proposal-dynamic-import@^7.16.7": +"@babel/plugin-proposal-dynamic-import@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz#72bcf8d408799f547d759298c3c27c7e7faa4d94" integrity sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw== @@ -345,7 +327,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-dynamic-import" "^7.8.3" -"@babel/plugin-proposal-export-namespace-from@^7.16.7": +"@babel/plugin-proposal-export-namespace-from@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz#5f7313ab348cdb19d590145f9247540e94761203" integrity sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA== @@ -353,7 +335,7 @@ "@babel/helper-plugin-utils" "^7.18.9" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" -"@babel/plugin-proposal-json-strings@^7.16.7": +"@babel/plugin-proposal-json-strings@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz#7e8788c1811c393aff762817e7dbf1ebd0c05f0b" integrity sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ== @@ -361,7 +343,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-json-strings" "^7.8.3" -"@babel/plugin-proposal-logical-assignment-operators@^7.16.7": +"@babel/plugin-proposal-logical-assignment-operators@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz#8148cbb350483bf6220af06fa6db3690e14b2e23" integrity sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q== @@ -369,7 +351,7 @@ "@babel/helper-plugin-utils" "^7.18.9" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" -"@babel/plugin-proposal-nullish-coalescing-operator@^7.16.7": +"@babel/plugin-proposal-nullish-coalescing-operator@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz#fdd940a99a740e577d6c753ab6fbb43fdb9467e1" integrity sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA== @@ -377,7 +359,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" -"@babel/plugin-proposal-numeric-separator@^7.16.7": +"@babel/plugin-proposal-numeric-separator@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz#899b14fbafe87f053d2c5ff05b36029c62e13c75" integrity sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q== @@ -385,7 +367,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-numeric-separator" "^7.10.4" -"@babel/plugin-proposal-object-rest-spread@^7.16.7": +"@babel/plugin-proposal-object-rest-spread@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz#f9434f6beb2c8cae9dfcf97d2a5941bbbf9ad4e7" integrity sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q== @@ -396,7 +378,7 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.3" "@babel/plugin-transform-parameters" "^7.18.8" -"@babel/plugin-proposal-optional-catch-binding@^7.16.7": +"@babel/plugin-proposal-optional-catch-binding@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz#f9400d0e6a3ea93ba9ef70b09e72dd6da638a2cb" integrity sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw== @@ -404,7 +386,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" -"@babel/plugin-proposal-optional-chaining@^7.16.7", "@babel/plugin-proposal-optional-chaining@^7.18.9": +"@babel/plugin-proposal-optional-chaining@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz#e8e8fe0723f2563960e4bf5e9690933691915993" integrity sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w== @@ -413,7 +395,7 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" "@babel/plugin-syntax-optional-chaining" "^7.8.3" -"@babel/plugin-proposal-private-methods@^7.16.11": +"@babel/plugin-proposal-private-methods@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz#5209de7d213457548a98436fa2882f52f4be6bea" integrity sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA== @@ -421,7 +403,7 @@ "@babel/helper-create-class-features-plugin" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-proposal-private-property-in-object@^7.16.7": +"@babel/plugin-proposal-private-property-in-object@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz#a64137b232f0aca3733a67eb1a144c192389c503" integrity sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw== @@ -431,7 +413,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-private-property-in-object" "^7.14.5" -"@babel/plugin-proposal-unicode-property-regex@^7.16.7", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": +"@babel/plugin-proposal-unicode-property-regex@^7.18.6", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz#af613d2cd5e643643b65cded64207b15c85cb78e" integrity sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w== @@ -446,14 +428,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-bigint@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" - integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-class-properties@^7.12.13", "@babel/plugin-syntax-class-properties@^7.8.3": +"@babel/plugin-syntax-class-properties@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== @@ -467,13 +442,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" -"@babel/plugin-syntax-decorators@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.18.6.tgz#2e45af22835d0b0f8665da2bfd4463649ce5dbc1" - integrity sha512-fqyLgjcxf/1yhyZ6A+yo1u9gJ7eleFQod2lkaUsF9DQ7sbbY3Ligym3L0+I2c0WmqNKDpoD9UTb1AKP3qRMOAQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-dynamic-import@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" @@ -488,7 +456,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.3" -"@babel/plugin-syntax-import-meta@^7.8.3": +"@babel/plugin-syntax-import-assertions@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz#cd6190500a4fa2fe31990a963ffab4b63e4505e4" + integrity sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-syntax-import-meta@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== @@ -502,14 +477,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.2.0": +"@babel/plugin-syntax-jsx@^7.0.0": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz#a8feef63b010150abd97f1649ec296e849943ca0" integrity sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== @@ -523,7 +498,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-numeric-separator@^7.10.4", "@babel/plugin-syntax-numeric-separator@^7.8.3": +"@babel/plugin-syntax-numeric-separator@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== @@ -558,28 +533,28 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" -"@babel/plugin-syntax-top-level-await@^7.14.5", "@babel/plugin-syntax-top-level-await@^7.8.3": +"@babel/plugin-syntax-top-level-await@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== dependencies: "@babel/helper-plugin-utils" "^7.14.5" -"@babel/plugin-syntax-typescript@^7.18.6", "@babel/plugin-syntax-typescript@^7.7.2": +"@babel/plugin-syntax-typescript@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz#1c09cd25795c7c2b8a4ba9ae49394576d4133285" integrity sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-arrow-functions@^7.16.7": +"@babel/plugin-transform-arrow-functions@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz#19063fcf8771ec7b31d742339dac62433d0611fe" integrity sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-async-to-generator@^7.16.8": +"@babel/plugin-transform-async-to-generator@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz#ccda3d1ab9d5ced5265fdb13f1882d5476c71615" integrity sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag== @@ -588,49 +563,50 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/helper-remap-async-to-generator" "^7.18.6" -"@babel/plugin-transform-block-scoped-functions@^7.16.7": +"@babel/plugin-transform-block-scoped-functions@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz#9187bf4ba302635b9d70d986ad70f038726216a8" integrity sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-block-scoping@^7.16.7": +"@babel/plugin-transform-block-scoping@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz#f9b7e018ac3f373c81452d6ada8bd5a18928926d" integrity sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw== dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-classes@^7.16.7": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.18.9.tgz#90818efc5b9746879b869d5ce83eb2aa48bbc3da" - integrity sha512-EkRQxsxoytpTlKJmSPYrsOMjCILacAjtSVkd4gChEe2kXjFCun3yohhW5I7plXJhCemM0gKsaGMcO8tinvCA5g== +"@babel/plugin-transform-classes@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz#0e61ec257fba409c41372175e7c1e606dc79bb20" + integrity sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" + "@babel/helper-compilation-targets" "^7.19.0" "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.9" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/helper-replace-supers" "^7.18.9" "@babel/helper-split-export-declaration" "^7.18.6" globals "^11.1.0" -"@babel/plugin-transform-computed-properties@^7.16.7": +"@babel/plugin-transform-computed-properties@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz#2357a8224d402dad623caf6259b611e56aec746e" integrity sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw== dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-destructuring@^7.16.7": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.9.tgz#68906549c021cb231bee1db21d3b5b095f8ee292" - integrity sha512-p5VCYNddPLkZTq4XymQIaIfZNJwT9YsjkPOhkVEqt6QIpQFZVM9IltqqYpOEkJoN1DPznmxUDyZ5CTZs/ZCuHA== +"@babel/plugin-transform-destructuring@^7.18.13": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz#9e03bc4a94475d62b7f4114938e6c5c33372cbf5" + integrity sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow== dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-dotall-regex@^7.16.7", "@babel/plugin-transform-dotall-regex@^7.4.4": +"@babel/plugin-transform-dotall-regex@^7.18.6", "@babel/plugin-transform-dotall-regex@^7.4.4": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz#b286b3e7aae6c7b861e45bed0a2fafd6b1a4fef8" integrity sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg== @@ -638,14 +614,14 @@ "@babel/helper-create-regexp-features-plugin" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-duplicate-keys@^7.16.7": +"@babel/plugin-transform-duplicate-keys@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz#687f15ee3cdad6d85191eb2a372c4528eaa0ae0e" integrity sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw== dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-exponentiation-operator@^7.16.7": +"@babel/plugin-transform-exponentiation-operator@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz#421c705f4521888c65e91fdd1af951bfefd4dacd" integrity sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw== @@ -653,14 +629,14 @@ "@babel/helper-builder-binary-assignment-operator-visitor" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-for-of@^7.16.7": +"@babel/plugin-transform-for-of@^7.18.8": version "7.18.8" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz#6ef8a50b244eb6a0bdbad0c7c61877e4e30097c1" integrity sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-function-name@^7.16.7": +"@babel/plugin-transform-function-name@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz#cc354f8234e62968946c61a46d6365440fc764e0" integrity sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ== @@ -669,21 +645,21 @@ "@babel/helper-function-name" "^7.18.9" "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-literals@^7.16.7": +"@babel/plugin-transform-literals@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz#72796fdbef80e56fba3c6a699d54f0de557444bc" integrity sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg== dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-member-expression-literals@^7.16.7": +"@babel/plugin-transform-member-expression-literals@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz#ac9fdc1a118620ac49b7e7a5d2dc177a1bfee88e" integrity sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-modules-amd@^7.16.7": +"@babel/plugin-transform-modules-amd@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz#8c91f8c5115d2202f277549848874027d7172d21" integrity sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg== @@ -692,7 +668,7 @@ "@babel/helper-plugin-utils" "^7.18.6" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-commonjs@^7.16.8": +"@babel/plugin-transform-modules-commonjs@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz#afd243afba166cca69892e24a8fd8c9f2ca87883" integrity sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q== @@ -702,18 +678,18 @@ "@babel/helper-simple-access" "^7.18.6" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-systemjs@^7.16.7": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.9.tgz#545df284a7ac6a05125e3e405e536c5853099a06" - integrity sha512-zY/VSIbbqtoRoJKo2cDTewL364jSlZGvn0LKOf9ntbfxOvjfmyrdtEEOAdswOswhZEb8UH3jDkCKHd1sPgsS0A== +"@babel/plugin-transform-modules-systemjs@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz#5f20b471284430f02d9c5059d9b9a16d4b085a1f" + integrity sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A== dependencies: "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-module-transforms" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" + "@babel/helper-module-transforms" "^7.19.0" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/helper-validator-identifier" "^7.18.6" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-umd@^7.16.7": +"@babel/plugin-transform-modules-umd@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz#81d3832d6034b75b54e62821ba58f28ed0aab4b9" integrity sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ== @@ -721,22 +697,22 @@ "@babel/helper-module-transforms" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-named-capturing-groups-regex@^7.16.8": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.18.6.tgz#c89bfbc7cc6805d692f3a49bc5fc1b630007246d" - integrity sha512-UmEOGF8XgaIqD74bC8g7iV3RYj8lMf0Bw7NJzvnS9qQhM4mg+1WHKotUIdjxgD2RGrgFLZZPCFPFj3P/kVDYhg== +"@babel/plugin-transform-named-capturing-groups-regex@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.0.tgz#58c52422e4f91a381727faed7d513c89d7f41ada" + integrity sha512-HDSuqOQzkU//kfGdiHBt71/hkDTApw4U/cMVgKgX7PqfB3LOaK+2GtCEsBu1dL9CkswDm0Gwehht1dCr421ULQ== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-create-regexp-features-plugin" "^7.19.0" + "@babel/helper-plugin-utils" "^7.19.0" -"@babel/plugin-transform-new-target@^7.16.7": +"@babel/plugin-transform-new-target@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz#d128f376ae200477f37c4ddfcc722a8a1b3246a8" integrity sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-object-super@^7.16.7": +"@babel/plugin-transform-object-super@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz#fb3c6ccdd15939b6ff7939944b51971ddc35912c" integrity sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA== @@ -744,21 +720,21 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/helper-replace-supers" "^7.18.6" -"@babel/plugin-transform-parameters@^7.16.7", "@babel/plugin-transform-parameters@^7.18.8": +"@babel/plugin-transform-parameters@^7.18.8": version "7.18.8" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz#ee9f1a0ce6d78af58d0956a9378ea3427cccb48a" integrity sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-property-literals@^7.16.7": +"@babel/plugin-transform-property-literals@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz#e22498903a483448e94e032e9bbb9c5ccbfc93a3" integrity sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-regenerator@^7.16.7": +"@babel/plugin-transform-regenerator@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz#585c66cb84d4b4bf72519a34cfce761b8676ca73" integrity sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ== @@ -766,55 +742,43 @@ "@babel/helper-plugin-utils" "^7.18.6" regenerator-transform "^0.15.0" -"@babel/plugin-transform-reserved-words@^7.16.7": +"@babel/plugin-transform-reserved-words@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz#b1abd8ebf8edaa5f7fe6bbb8d2133d23b6a6f76a" integrity sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-runtime@7.17.12": - version "7.17.12" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.17.12.tgz#5dc79735c4038c6f4fc0490f68f2798ce608cadd" - integrity sha512-xsl5MeGjWnmV6Ui9PfILM2+YRpa3GqLOrczPpXV3N2KCgQGU+sU8OfzuMbjkIdfvZEZIm+3y0V7w58sk0SGzlw== - dependencies: - "@babel/helper-module-imports" "^7.16.7" - "@babel/helper-plugin-utils" "^7.17.12" - babel-plugin-polyfill-corejs2 "^0.3.0" - babel-plugin-polyfill-corejs3 "^0.5.0" - babel-plugin-polyfill-regenerator "^0.3.0" - semver "^6.3.0" - -"@babel/plugin-transform-shorthand-properties@^7.16.7": +"@babel/plugin-transform-shorthand-properties@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz#6d6df7983d67b195289be24909e3f12a8f664dc9" integrity sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-spread@^7.16.7": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.18.9.tgz#6ea7a6297740f381c540ac56caf75b05b74fb664" - integrity sha512-39Q814wyoOPtIB/qGopNIL9xDChOE1pNU0ZY5dO0owhiVt/5kFm4li+/bBtwc7QotG0u5EPzqhZdjMtmqBqyQA== +"@babel/plugin-transform-spread@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz#dd60b4620c2fec806d60cfaae364ec2188d593b6" + integrity sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w== dependencies: - "@babel/helper-plugin-utils" "^7.18.9" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" -"@babel/plugin-transform-sticky-regex@^7.16.7": +"@babel/plugin-transform-sticky-regex@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz#c6706eb2b1524028e317720339583ad0f444adcc" integrity sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q== dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-template-literals@^7.16.7": +"@babel/plugin-transform-template-literals@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz#04ec6f10acdaa81846689d63fae117dd9c243a5e" integrity sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA== dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-typeof-symbol@^7.16.7": +"@babel/plugin-transform-typeof-symbol@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz#c8cea68263e45addcd6afc9091429f80925762c0" integrity sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw== @@ -822,22 +786,22 @@ "@babel/helper-plugin-utils" "^7.18.9" "@babel/plugin-transform-typescript@^7.16.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.18.8.tgz#303feb7a920e650f2213ef37b36bbf327e6fa5a0" - integrity sha512-p2xM8HI83UObjsZGofMV/EdYjamsDm6MoN3hXPYIT0+gxIoopE+B7rPYKAxfrz9K9PK7JafTTjqYC6qipLExYA== + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.0.tgz#50c3a68ec8efd5e040bde2cd764e8e16bc0cbeaf" + integrity sha512-DOOIywxPpkQHXijXv+s9MDAyZcLp12oYRl3CMWZ6u7TjSoCBq/KqHR/nNFR3+i2xqheZxoF0H2XyL7B6xeSRuA== dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-create-class-features-plugin" "^7.19.0" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/plugin-syntax-typescript" "^7.18.6" -"@babel/plugin-transform-unicode-escapes@^7.16.7": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.6.tgz#0d01fb7fb2243ae1c033f65f6e3b4be78db75f27" - integrity sha512-XNRwQUXYMP7VLuy54cr/KS/WeL3AZeORhrmeZ7iewgu+X2eBqmpaLI/hzqr9ZxCeUoq0ASK4GUzSM0BDhZkLFw== +"@babel/plugin-transform-unicode-escapes@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz#1ecfb0eda83d09bbcb77c09970c2dd55832aa246" + integrity sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ== dependencies: - "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-unicode-regex@^7.16.7": +"@babel/plugin-transform-unicode-regex@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz#194317225d8c201bbae103364ffe9e2cea36cdca" integrity sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA== @@ -845,37 +809,38 @@ "@babel/helper-create-regexp-features-plugin" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6" -"@babel/preset-env@7.16.11": - version "7.16.11" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.16.11.tgz#5dd88fd885fae36f88fd7c8342475c9f0abe2982" - integrity sha512-qcmWG8R7ZW6WBRPZK//y+E3Cli151B20W1Rv7ln27vuPaXU/8TKms6jFdiJtF7UDTxcrb7mZd88tAeK9LjdT8g== +"@babel/preset-env@^7.11.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.19.0.tgz#fd18caf499a67d6411b9ded68dc70d01ed1e5da7" + integrity sha512-1YUju1TAFuzjIQqNM9WsF4U6VbD/8t3wEAlw3LFYuuEr+ywqLRcSXxFKz4DCEj+sN94l/XTDiUXYRrsvMpz9WQ== dependencies: - "@babel/compat-data" "^7.16.8" - "@babel/helper-compilation-targets" "^7.16.7" - "@babel/helper-plugin-utils" "^7.16.7" - "@babel/helper-validator-option" "^7.16.7" - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.16.7" - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.16.7" - "@babel/plugin-proposal-async-generator-functions" "^7.16.8" - "@babel/plugin-proposal-class-properties" "^7.16.7" - "@babel/plugin-proposal-class-static-block" "^7.16.7" - "@babel/plugin-proposal-dynamic-import" "^7.16.7" - "@babel/plugin-proposal-export-namespace-from" "^7.16.7" - "@babel/plugin-proposal-json-strings" "^7.16.7" - "@babel/plugin-proposal-logical-assignment-operators" "^7.16.7" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.16.7" - "@babel/plugin-proposal-numeric-separator" "^7.16.7" - "@babel/plugin-proposal-object-rest-spread" "^7.16.7" - "@babel/plugin-proposal-optional-catch-binding" "^7.16.7" - "@babel/plugin-proposal-optional-chaining" "^7.16.7" - "@babel/plugin-proposal-private-methods" "^7.16.11" - "@babel/plugin-proposal-private-property-in-object" "^7.16.7" - "@babel/plugin-proposal-unicode-property-regex" "^7.16.7" + "@babel/compat-data" "^7.19.0" + "@babel/helper-compilation-targets" "^7.19.0" + "@babel/helper-plugin-utils" "^7.19.0" + "@babel/helper-validator-option" "^7.18.6" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.18.9" + "@babel/plugin-proposal-async-generator-functions" "^7.19.0" + "@babel/plugin-proposal-class-properties" "^7.18.6" + "@babel/plugin-proposal-class-static-block" "^7.18.6" + "@babel/plugin-proposal-dynamic-import" "^7.18.6" + "@babel/plugin-proposal-export-namespace-from" "^7.18.9" + "@babel/plugin-proposal-json-strings" "^7.18.6" + "@babel/plugin-proposal-logical-assignment-operators" "^7.18.9" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.6" + "@babel/plugin-proposal-numeric-separator" "^7.18.6" + "@babel/plugin-proposal-object-rest-spread" "^7.18.9" + "@babel/plugin-proposal-optional-catch-binding" "^7.18.6" + "@babel/plugin-proposal-optional-chaining" "^7.18.9" + "@babel/plugin-proposal-private-methods" "^7.18.6" + "@babel/plugin-proposal-private-property-in-object" "^7.18.6" + "@babel/plugin-proposal-unicode-property-regex" "^7.18.6" "@babel/plugin-syntax-async-generators" "^7.8.4" "@babel/plugin-syntax-class-properties" "^7.12.13" "@babel/plugin-syntax-class-static-block" "^7.14.5" "@babel/plugin-syntax-dynamic-import" "^7.8.3" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + "@babel/plugin-syntax-import-assertions" "^7.18.6" "@babel/plugin-syntax-json-strings" "^7.8.3" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" @@ -885,44 +850,44 @@ "@babel/plugin-syntax-optional-chaining" "^7.8.3" "@babel/plugin-syntax-private-property-in-object" "^7.14.5" "@babel/plugin-syntax-top-level-await" "^7.14.5" - "@babel/plugin-transform-arrow-functions" "^7.16.7" - "@babel/plugin-transform-async-to-generator" "^7.16.8" - "@babel/plugin-transform-block-scoped-functions" "^7.16.7" - "@babel/plugin-transform-block-scoping" "^7.16.7" - "@babel/plugin-transform-classes" "^7.16.7" - "@babel/plugin-transform-computed-properties" "^7.16.7" - "@babel/plugin-transform-destructuring" "^7.16.7" - "@babel/plugin-transform-dotall-regex" "^7.16.7" - "@babel/plugin-transform-duplicate-keys" "^7.16.7" - "@babel/plugin-transform-exponentiation-operator" "^7.16.7" - "@babel/plugin-transform-for-of" "^7.16.7" - "@babel/plugin-transform-function-name" "^7.16.7" - "@babel/plugin-transform-literals" "^7.16.7" - "@babel/plugin-transform-member-expression-literals" "^7.16.7" - "@babel/plugin-transform-modules-amd" "^7.16.7" - "@babel/plugin-transform-modules-commonjs" "^7.16.8" - "@babel/plugin-transform-modules-systemjs" "^7.16.7" - "@babel/plugin-transform-modules-umd" "^7.16.7" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.16.8" - "@babel/plugin-transform-new-target" "^7.16.7" - "@babel/plugin-transform-object-super" "^7.16.7" - "@babel/plugin-transform-parameters" "^7.16.7" - "@babel/plugin-transform-property-literals" "^7.16.7" - "@babel/plugin-transform-regenerator" "^7.16.7" - "@babel/plugin-transform-reserved-words" "^7.16.7" - "@babel/plugin-transform-shorthand-properties" "^7.16.7" - "@babel/plugin-transform-spread" "^7.16.7" - "@babel/plugin-transform-sticky-regex" "^7.16.7" - "@babel/plugin-transform-template-literals" "^7.16.7" - "@babel/plugin-transform-typeof-symbol" "^7.16.7" - "@babel/plugin-transform-unicode-escapes" "^7.16.7" - "@babel/plugin-transform-unicode-regex" "^7.16.7" + "@babel/plugin-transform-arrow-functions" "^7.18.6" + "@babel/plugin-transform-async-to-generator" "^7.18.6" + "@babel/plugin-transform-block-scoped-functions" "^7.18.6" + "@babel/plugin-transform-block-scoping" "^7.18.9" + "@babel/plugin-transform-classes" "^7.19.0" + "@babel/plugin-transform-computed-properties" "^7.18.9" + "@babel/plugin-transform-destructuring" "^7.18.13" + "@babel/plugin-transform-dotall-regex" "^7.18.6" + "@babel/plugin-transform-duplicate-keys" "^7.18.9" + "@babel/plugin-transform-exponentiation-operator" "^7.18.6" + "@babel/plugin-transform-for-of" "^7.18.8" + "@babel/plugin-transform-function-name" "^7.18.9" + "@babel/plugin-transform-literals" "^7.18.9" + "@babel/plugin-transform-member-expression-literals" "^7.18.6" + "@babel/plugin-transform-modules-amd" "^7.18.6" + "@babel/plugin-transform-modules-commonjs" "^7.18.6" + "@babel/plugin-transform-modules-systemjs" "^7.19.0" + "@babel/plugin-transform-modules-umd" "^7.18.6" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.19.0" + "@babel/plugin-transform-new-target" "^7.18.6" + "@babel/plugin-transform-object-super" "^7.18.6" + "@babel/plugin-transform-parameters" "^7.18.8" + "@babel/plugin-transform-property-literals" "^7.18.6" + "@babel/plugin-transform-regenerator" "^7.18.6" + "@babel/plugin-transform-reserved-words" "^7.18.6" + "@babel/plugin-transform-shorthand-properties" "^7.18.6" + "@babel/plugin-transform-spread" "^7.19.0" + "@babel/plugin-transform-sticky-regex" "^7.18.6" + "@babel/plugin-transform-template-literals" "^7.18.9" + "@babel/plugin-transform-typeof-symbol" "^7.18.9" + "@babel/plugin-transform-unicode-escapes" "^7.18.10" + "@babel/plugin-transform-unicode-regex" "^7.18.6" "@babel/preset-modules" "^0.1.5" - "@babel/types" "^7.16.8" - babel-plugin-polyfill-corejs2 "^0.3.0" - babel-plugin-polyfill-corejs3 "^0.5.0" - babel-plugin-polyfill-regenerator "^0.3.0" - core-js-compat "^3.20.2" + "@babel/types" "^7.19.0" + babel-plugin-polyfill-corejs2 "^0.3.2" + babel-plugin-polyfill-corejs3 "^0.5.3" + babel-plugin-polyfill-regenerator "^0.4.0" + core-js-compat "^3.22.1" semver "^6.3.0" "@babel/preset-modules@^0.1.5": @@ -936,43 +901,44 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/runtime@^7.8.4": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" - integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== +"@babel/runtime@^7.11.2", "@babel/runtime@^7.8.4": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259" + integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA== dependencies: regenerator-runtime "^0.13.4" -"@babel/template@^7.16.7", "@babel/template@^7.18.6", "@babel/template@^7.3.3": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31" - integrity sha512-JoDWzPe+wgBsTTgdnIma3iHNFC7YVJoPssVBDjiHfNlyt4YcunDtcDOUmfVDfCK5MfdsaIoX9PkijPhjH3nYUw== +"@babel/template@^7.0.0", "@babel/template@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" + integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA== dependencies: "@babel/code-frame" "^7.18.6" - "@babel/parser" "^7.18.6" - "@babel/types" "^7.18.6" + "@babel/parser" "^7.18.10" + "@babel/types" "^7.18.10" -"@babel/traverse@^7.17.12", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.9.tgz#deeff3e8f1bad9786874cb2feda7a2d77a904f98" - integrity sha512-LcPAnujXGwBgv3/WHv01pHtb2tihcyW1XuL9wd7jqh1Z8AQkTd+QVjMrMijrln0T7ED3UXLIy36P9Ao7W75rYg== +"@babel/traverse@^7.0.0", "@babel/traverse@^7.18.9", "@babel/traverse@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.0.tgz#eb9c561c7360005c592cc645abafe0c3c4548eed" + integrity sha512-4pKpFRDh+utd2mbRC8JLnlsMUii3PMHjpL6a0SZ4NMZy7YFP9aXORxEhdMVOc9CpWtDF09IkciQLEhK7Ml7gRA== dependencies: "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.9" + "@babel/generator" "^7.19.0" "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" "@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/parser" "^7.19.0" + "@babel/types" "^7.19.0" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.16.8", "@babel/types@^7.17.12", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.6.1", "@babel/types@^7.9.6": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.9.tgz#7148d64ba133d8d73a41b3172ac4b83a1452205f" - integrity sha512-WwMLAg2MvJmt/rKEVQBBhIVffMmnilX4oe0sRe7iPOHIGsqpruFHHdrfj4O1CMMtgMtCU4oPafZjDPCRgO57Wg== +"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.4.4", "@babel/types@^7.6.1", "@babel/types@^7.9.6": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.0.tgz#75f21d73d73dc0351f3368d28db73465f4814600" + integrity sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA== dependencies: + "@babel/helper-string-parser" "^7.18.10" "@babel/helper-validator-identifier" "^7.18.6" to-fast-properties "^2.0.0" @@ -981,14 +947,24 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@eslint/eslintrc@^1.2.1": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" - integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw== +"@esbuild/linux-loong64@0.14.54": + version "0.14.54" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028" + integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw== + +"@esbuild/linux-loong64@0.15.7": + version "0.15.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.7.tgz#1ec4af4a16c554cbd402cc557ccdd874e3f7be53" + integrity sha512-IKznSJOsVUuyt7cDzzSZyqBEcZe+7WlBqTVXiF1OXP/4Nm387ToaXZ0fyLwI1iBlI/bzpxVq411QE2/Bt2XWWw== + +"@eslint/eslintrc@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.2.tgz#58b69582f3b7271d8fa67fe5251767a5b38ea356" + integrity sha512-AXYd23w1S/bv3fTs3Lz0vjiYemS08jWkI3hYyS9I1ry+0f+Yjs1wm+sU0BS8qDOPrBIkp4qHYC16I8uVtpLajQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.3.2" + espree "^9.4.0" globals "^13.15.0" ignore "^5.2.0" import-fresh "^3.2.1" @@ -996,205 +972,35 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@humanwhocodes/config-array@^0.9.2": - version "0.9.5" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" - integrity sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw== +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== dependencies: "@humanwhocodes/object-schema" "^1.2.1" debug "^4.1.1" minimatch "^3.0.4" +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== -"@istanbuljs/load-nyc-config@^1.0.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" - integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== - dependencies: - camelcase "^5.3.1" - find-up "^4.1.0" - get-package-type "^0.1.0" - js-yaml "^3.13.1" - resolve-from "^5.0.0" - -"@istanbuljs/schema@^0.1.2": +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-27.5.1.tgz#260fe7239602fe5130a94f1aa386eff54b014bba" - integrity sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg== - dependencies: - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - jest-message-util "^27.5.1" - jest-util "^27.5.1" - slash "^3.0.0" - -"@jest/core@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-27.5.1.tgz#267ac5f704e09dc52de2922cbf3af9edcd64b626" - integrity sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ== - dependencies: - "@jest/console" "^27.5.1" - "@jest/reporters" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - emittery "^0.8.1" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-changed-files "^27.5.1" - jest-config "^27.5.1" - jest-haste-map "^27.5.1" - jest-message-util "^27.5.1" - jest-regex-util "^27.5.1" - jest-resolve "^27.5.1" - jest-resolve-dependencies "^27.5.1" - jest-runner "^27.5.1" - jest-runtime "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - jest-validate "^27.5.1" - jest-watcher "^27.5.1" - micromatch "^4.0.4" - rimraf "^3.0.0" - slash "^3.0.0" - strip-ansi "^6.0.0" - -"@jest/environment@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-27.5.1.tgz#d7425820511fe7158abbecc010140c3fd3be9c74" - integrity sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA== - dependencies: - "@jest/fake-timers" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - jest-mock "^27.5.1" - -"@jest/fake-timers@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74" - integrity sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ== - dependencies: - "@jest/types" "^27.5.1" - "@sinonjs/fake-timers" "^8.0.1" - "@types/node" "*" - jest-message-util "^27.5.1" - jest-mock "^27.5.1" - jest-util "^27.5.1" - -"@jest/globals@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-27.5.1.tgz#7ac06ce57ab966566c7963431cef458434601b2b" - integrity sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/types" "^27.5.1" - expect "^27.5.1" - -"@jest/reporters@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-27.5.1.tgz#ceda7be96170b03c923c37987b64015812ffec04" - integrity sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw== - dependencies: - "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - collect-v8-coverage "^1.0.0" - exit "^0.1.2" - glob "^7.1.2" - graceful-fs "^4.2.9" - istanbul-lib-coverage "^3.0.0" - istanbul-lib-instrument "^5.1.0" - istanbul-lib-report "^3.0.0" - istanbul-lib-source-maps "^4.0.0" - istanbul-reports "^3.1.3" - jest-haste-map "^27.5.1" - jest-resolve "^27.5.1" - jest-util "^27.5.1" - jest-worker "^27.5.1" - slash "^3.0.0" - source-map "^0.6.0" - string-length "^4.0.1" - terminal-link "^2.0.0" - v8-to-istanbul "^8.1.0" - -"@jest/source-map@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" - integrity sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg== - dependencies: - callsites "^3.0.0" - graceful-fs "^4.2.9" - source-map "^0.6.0" - -"@jest/test-result@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-27.5.1.tgz#56a6585fa80f7cdab72b8c5fc2e871d03832f5bb" - integrity sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag== - dependencies: - "@jest/console" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/istanbul-lib-coverage" "^2.0.0" - collect-v8-coverage "^1.0.0" - -"@jest/test-sequencer@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz#4057e0e9cea4439e544c6353c6affe58d095745b" - integrity sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ== - dependencies: - "@jest/test-result" "^27.5.1" - graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-runtime "^27.5.1" - -"@jest/transform@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-27.5.1.tgz#6c3501dcc00c4c08915f292a600ece5ecfe1f409" - integrity sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw== - dependencies: - "@babel/core" "^7.1.0" - "@jest/types" "^27.5.1" - babel-plugin-istanbul "^6.1.1" - chalk "^4.0.0" - convert-source-map "^1.4.0" - fast-json-stable-stringify "^2.0.0" - graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-regex-util "^27.5.1" - jest-util "^27.5.1" - micromatch "^4.0.4" - pirates "^4.0.4" - slash "^3.0.0" - source-map "^0.6.1" - write-file-atomic "^3.0.0" - -"@jest/types@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.5.1.tgz#3c79ec4a8ba61c170bf937bcf9e98a9df175ec80" - integrity sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^16.0.0" - chalk "^4.0.0" - "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -1203,7 +1009,7 @@ "@jridgewell/set-array" "^1.0.0" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/gen-mapping@^0.3.2": +"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": version "0.3.2" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== @@ -1222,27 +1028,148 @@ resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== +"@jridgewell/source-map@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" + integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" + "@jridgewell/sourcemap-codec@^1.4.10": version "1.4.14" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== -"@jridgewell/trace-mapping@^0.3.9": - version "0.3.14" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed" - integrity sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ== +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.9": + version "0.3.15" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774" + integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g== dependencies: "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@rollup/pluginutils@^4.1.1": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" - integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ== +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== dependencies: - estree-walker "^2.0.1" + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@rollup/plugin-babel@^5.2.0": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" + integrity sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q== + dependencies: + "@babel/helper-module-imports" "^7.10.4" + "@rollup/pluginutils" "^3.1.0" + +"@rollup/plugin-node-resolve@^11.2.1": + version "11.2.1" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz#82aa59397a29cd4e13248b106e6a4a1880362a60" + integrity sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + "@types/resolve" "1.17.1" + builtin-modules "^3.1.0" + deepmerge "^4.2.2" + is-module "^1.0.0" + resolve "^1.19.0" + +"@rollup/plugin-replace@^2.4.1": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz#a2d539314fbc77c244858faa523012825068510a" + integrity sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + magic-string "^0.25.7" + +"@rollup/pluginutils@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" + integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== + dependencies: + "@types/estree" "0.0.39" + estree-walker "^1.0.1" picomatch "^2.2.2" +"@sentry/browser@7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.12.1.tgz#2be6fa5c2529a2a75abac4d00aca786362302a1a" + integrity sha512-pgyL65CrGFLe8sKcEG8KXAuVTE8zkAsyTlv/AuME06cSdxzO/memPK/r3BI6EM7WupIdga+V5tQUldeT1kgHNA== + dependencies: + "@sentry/core" "7.12.1" + "@sentry/types" "7.12.1" + "@sentry/utils" "7.12.1" + tslib "^1.9.3" + +"@sentry/core@7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.12.1.tgz#a22f1c530ed528a699ed204c36eb5fc8d308103d" + integrity sha512-DFHbzHFjukhlkRZ5xzfebx0IBzblW43kmfnalBBq7xEMscUvnhsYnlvL9Y20tuPZ/PrTcq4JAHbFluAvw6M0QQ== + dependencies: + "@sentry/hub" "7.12.1" + "@sentry/types" "7.12.1" + "@sentry/utils" "7.12.1" + tslib "^1.9.3" + +"@sentry/hub@7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.12.1.tgz#dffad40cd2b8f44df2d5f20a89df87879cbbf1c3" + integrity sha512-KLVnVqXf+CRmXNy9/T8K2/js7QvOQ94xtgP5KnWJbu2rl+JhxnIGiBRF51lPXFIatt7zWwB9qNdMS8lVsvLMGQ== + dependencies: + "@sentry/types" "7.12.1" + "@sentry/utils" "7.12.1" + tslib "^1.9.3" + +"@sentry/tracing@7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.12.1.tgz#9f92985f152054ac90b6ec83a33c44e8084a008e" + integrity sha512-WnweIt//IqkEkJSjA8DtnIeCdItYIqJSxNQ6qK+r546/ufxRYFBck2fbmM0oKZJVg2evbwhadrBTIUzYkqNj4A== + dependencies: + "@sentry/hub" "7.12.1" + "@sentry/types" "7.12.1" + "@sentry/utils" "7.12.1" + tslib "^1.9.3" + +"@sentry/types@7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.12.1.tgz#eff76d938f9effc62a2ec76cd5c3f04de37f5c15" + integrity sha512-VGZs39SZgMcCGv7H0VyFy1LEFGsnFZH590JUopmz6nG63EpeYQ2xzhIoPNAiLKbyUvBEwukn+faCg3u3MGqhgQ== + +"@sentry/utils@7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.12.1.tgz#fcf80fdc332d0bd288e21b13efc7a2f0d604f75a" + integrity sha512-Dh8B13pC0u8uLM/zf+oZngyg808c6BDEO94F7H+h3IciCVVd92A0cOQwLGAEdf8srnJgpZJNAlSC8lFDhbFHzQ== + dependencies: + "@sentry/types" "7.12.1" + tslib "^1.9.3" + +"@sentry/vue@7.12.1": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@sentry/vue/-/vue-7.12.1.tgz#cb8a93384be40e3389333547fbe443f8a2615fa4" + integrity sha512-p8Z1CrjVgHBK+Udb/X+bl5MTs3faGMMwZlcTtcMG0ZIY54V1GkvAsGBn3EFoe0yGCv6UFiuS90CxTfh0XtZavg== + dependencies: + "@sentry/browser" "7.12.1" + "@sentry/core" "7.12.1" + "@sentry/types" "7.12.1" + "@sentry/utils" "7.12.1" + tslib "^1.9.3" + "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -1257,13 +1184,6 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@sinonjs/fake-timers@^8.0.1": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz#3fdc2b6cb58935b21bfb8d1625eb1300484316e7" - integrity sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg== - dependencies: - "@sinonjs/commons" "^1.7.0" - "@sinonjs/samsam@^6.1.1": version "6.1.1" resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1" @@ -1274,324 +1194,803 @@ type-detect "^4.0.8" "@sinonjs/text-encoding@^0.7.1": - version "0.7.1" - resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" - integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== - -"@tootallnate/once@1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" - integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== + version "0.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== -"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": - version "7.1.19" - resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" - integrity sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw== +"@surma/rollup-plugin-off-main-thread@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053" + integrity sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ== dependencies: - "@babel/parser" "^7.1.0" - "@babel/types" "^7.0.0" - "@types/babel__generator" "*" - "@types/babel__template" "*" - "@types/babel__traverse" "*" + ejs "^3.1.6" + json5 "^2.2.0" + magic-string "^0.25.0" + string.prototype.matchall "^4.0.6" -"@types/babel__generator@*": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.4.tgz#1f20ce4c5b1990b37900b63f050182d28c2439b7" - integrity sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg== - dependencies: - "@babel/types" "^7.0.0" +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== -"@types/babel__template@*": - version "7.4.1" - resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.1.tgz#3d1a48fd9d6c0edfd56f2ff578daed48f36c8969" - integrity sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g== +"@types/chai-subset@^1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94" + integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw== dependencies: - "@babel/parser" "^7.1.0" - "@babel/types" "^7.0.0" + "@types/chai" "*" + +"@types/chai@*", "@types/chai@^4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07" + integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g== + +"@types/cookie@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" + integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== + +"@types/diff@5.0.2": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@types/diff/-/diff-5.0.2.tgz#dd565e0086ccf8bc6522c6ebafd8a3125c91c12b" + integrity sha512-uw8eYMIReOwstQ0QKF0sICefSy8cNO/v7gOTiIy9SbwuHyEecJUm7qlgueOO5S1udZ5I/irVydHVwMchgzbKTg== -"@types/babel__traverse@*", "@types/babel__traverse@^7.0.4", "@types/babel__traverse@^7.0.6": - version "7.17.1" - resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.17.1.tgz#1a0e73e8c28c7e832656db372b779bfd2ef37314" - integrity sha512-kVzjari1s2YVi77D3w1yuvohV2idweYXMCDzqBiVNN63TcDWrIlTVOYpqVrvbbyOE/IyzBoTKF0fdnLPEORFxA== +"@types/dompurify@2.3.4": + version "2.3.4" + resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.4.tgz#94e997e30338ea24d4c8d08beca91ce4dd17a1b4" + integrity sha512-EXzDatIb5EspL2eb/xPGmaC8pePcTHrkDCONjeisusLFrVfl38Pjea/R0YJGu3k9ZQadSvMqW0WXPI2hEo2Ajg== dependencies: - "@babel/types" "^7.3.0" + "@types/trusted-types" "*" -"@types/graceful-fs@^4.1.2": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" - integrity sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw== +"@types/estree@0.0.39": + version "0.0.39" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" + integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== + +"@types/glob@5 - 7": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" + integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA== dependencies: + "@types/minimatch" "*" "@types/node" "*" -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": +"@types/howler@2.2.7": + version "2.2.7" + resolved "https://registry.yarnpkg.com/@types/howler/-/howler-2.2.7.tgz#5acfbed57f9e1d99b8dabe1b824729e1c1ea1fae" + integrity sha512-PEZldwZqJJw1PWRTpupyC7ajVTZA8aHd8nB/Y0n6zRZi5u8ktYDntsHj13ltEiBRqWwF06pASxBEvCTxniG8eA== + +"@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g== -"@types/istanbul-lib-report@*": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" - integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== +"@types/jquery@*", "@types/jquery@3.5.14": + version "3.5.14" + resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.14.tgz#ac8e11ee591e94d4d58da602cb3a5a8320dee577" + integrity sha512-X1gtMRMbziVQkErhTQmSe2jFwwENA/Zr+PprCkF63vFq+Yt5PZ4AlKqgmeNlwgn7dhsXEK888eIW2520EpC+xg== dependencies: - "@types/istanbul-lib-coverage" "*" + "@types/sizzle" "*" -"@types/istanbul-reports@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff" - integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw== - dependencies: - "@types/istanbul-lib-report" "*" +"@types/json-schema@^7.0.9": + version "7.0.11" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" + integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/node@*": - version "18.6.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.1.tgz#828e4785ccca13f44e2fb6852ae0ef11e3e20ba5" - integrity sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg== +"@types/lodash-es@4.17.6": + version "4.17.6" + resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.6.tgz#c2ed4c8320ffa6f11b43eb89e9eaeec65966a0a0" + integrity sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg== + dependencies: + "@types/lodash" "*" -"@types/prettier@^2.1.5": - version "2.6.3" - resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.6.3.tgz#68ada76827b0010d0db071f739314fa429943d0a" - integrity sha512-ymZk3LEC/fsut+/Q5qejp6R9O1rMxz3XaRHDV6kX8MrGAhOSPqVARbDi+EZvInBpw+BnCX3TD240byVkOfQsHg== +"@types/lodash@*": + version "4.14.184" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.184.tgz#23f96cd2a21a28e106dc24d825d4aa966de7a9fe" + integrity sha512-RoZphVtHbxPZizt4IcILciSWiC6dcn+eZ8oX9IWEYfDMcocdd42f7NPI6fQj+6zI8y4E0L7gu2pcZKLGTRaV9Q== -"@types/stack-utils@^2.0.0": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" - integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/minimatch@*": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" + integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== -"@types/strip-bom@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" - integrity sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ== +"@types/moxios@0.4.15": + version "0.4.15" + resolved "https://registry.yarnpkg.com/@types/moxios/-/moxios-0.4.15.tgz#d8b774f360ba652801b5807e0833f6ff30a17770" + integrity sha512-eHD7i0/Uu7pFGzS4uIed2InJLj5H6xOOsqPjGtRyvyC/jnzRt6q6Xtnm2PQlkcqKHjRybEqjw71dcPnzfDouhw== + dependencies: + axios ">=0.13.0" -"@types/strip-json-comments@0.0.30": - version "0.0.30" - resolved "https://registry.yarnpkg.com/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz#9aa30c04db212a9a0649d6ae6fd50accc40748a1" - integrity sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ== +"@types/node@*": + version "18.7.15" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.15.tgz#20ae1ec80c57ee844b469f968a1cd511d4088b29" + integrity sha512-XnjpaI8Bgc3eBag2Aw4t2Uj/49lLBSStHWfqKvIuXD7FIrZyMLWp8KuAFHAqxMZYTF9l08N1ctUn9YNybZJVmQ== -"@types/yargs-parser@*": - version "21.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" - integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== +"@types/parse-json@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" + integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== -"@types/yargs@^16.0.0": - version "16.0.4" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.4.tgz#26aad98dd2c2a38e421086ea9ad42b9e51642977" - integrity sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw== - dependencies: - "@types/yargs-parser" "*" +"@types/parse5@^5": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" + integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== -"@vue/babel-helper-vue-jsx-merge-props@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.2.1.tgz#31624a7a505fb14da1d58023725a4c5f270e6a81" - integrity sha512-QOi5OW45e2R20VygMSNhyQHvpdUwQZqGPc748JLGCYEy+yp8fNFNdbNIGAgZmi9e+2JHPd6i6idRuqivyicIkA== +"@types/qs@6.9.7": + version "6.9.7" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" + integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== -"@vue/babel-plugin-transform-vue-jsx@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.2.1.tgz#646046c652c2f0242727f34519d917b064041ed7" - integrity sha512-HJuqwACYehQwh1fNT8f4kyzqlNMpBuUK4rSiSES5D4QsYncv5fxFsLyrxFPG2ksO7t5WP+Vgix6tt6yKClwPzA== +"@types/resolve@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" + integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw== dependencies: - "@babel/helper-module-imports" "^7.0.0" - "@babel/plugin-syntax-jsx" "^7.2.0" - "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" - html-tags "^2.0.0" - lodash.kebabcase "^4.1.1" - svg-tags "^1.0.0" + "@types/node" "*" -"@vue/babel-preset-jsx@^1.2.4": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@vue/babel-preset-jsx/-/babel-preset-jsx-1.3.1.tgz#10789417a17680d9855bd96fd9894d9b51fd979b" - integrity sha512-ml+nqcSKp8uAqFZLNc7OWLMzR7xDBsUfkomF98DtiIBlLqlq4jCQoLINARhgqRIyKdB+mk/94NWpIb4pL6D3xw== - dependencies: - "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" - "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" - "@vue/babel-sugar-composition-api-inject-h" "^1.3.0" - "@vue/babel-sugar-composition-api-render-instance" "^1.3.0" - "@vue/babel-sugar-functional-vue" "^1.2.2" - "@vue/babel-sugar-inject-h" "^1.2.2" - "@vue/babel-sugar-v-model" "^1.3.0" - "@vue/babel-sugar-v-on" "^1.3.0" - -"@vue/babel-sugar-composition-api-inject-h@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-inject-h/-/babel-sugar-composition-api-inject-h-1.3.0.tgz#1402f34cea217c7117fb66fdcbd94e1c370cd9c0" - integrity sha512-pIDOutEpqbURdVw7xhgxmuDW8Tl+lTgzJZC5jdlUu0lY2+izT9kz3Umd/Tbu0U5cpCJ2Yhu87BZFBzWpS0Xemg== +"@types/semantic-ui-accordion@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-accordion/-/semantic-ui-accordion-2.2.2.tgz#2f3b9d60a981d9eddc3705619079defaacc3aee0" + integrity sha512-XClXI/20W7iFLQ7eyslZswbdv3A4qWEnFz8JvOylGatCW7biTLVhMBPcN0b17TZ1GeV4V/l3ctmvTEBCHvg8CA== dependencies: - "@babel/plugin-syntax-jsx" "^7.2.0" + "@types/jquery" "*" -"@vue/babel-sugar-composition-api-render-instance@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-render-instance/-/babel-sugar-composition-api-render-instance-1.3.0.tgz#3039d3d9eca09e56d41a56a03d73a146211c18a5" - integrity sha512-NYNnU2r7wkJLMV5p9Zj4pswmCs037O/N2+/Fs6SyX7aRFzXJRP1/2CZh5cIwQxWQajHXuCUd5mTb7DxoBVWyTg== +"@types/semantic-ui-api@*": + version "2.2.4" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-api/-/semantic-ui-api-2.2.4.tgz#ea9cad381b38b77f95fa3bf679d7c5273c7b20dd" + integrity sha512-6IvCjZDJ0TVb1EtnNFQPNyVmOZnLGPEKyEAs9G0FF3XuAyyOdtfD4NGHJ0kknnX1FD+++ye0EuSVFDutbwEFWw== dependencies: - "@babel/plugin-syntax-jsx" "^7.2.0" + "@types/jquery" "*" -"@vue/babel-sugar-functional-vue@^1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vue/babel-sugar-functional-vue/-/babel-sugar-functional-vue-1.2.2.tgz#267a9ac8d787c96edbf03ce3f392c49da9bd2658" - integrity sha512-JvbgGn1bjCLByIAU1VOoepHQ1vFsroSA/QkzdiSs657V79q6OwEWLCQtQnEXD/rLTA8rRit4rMOhFpbjRFm82w== +"@types/semantic-ui-checkbox@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-checkbox/-/semantic-ui-checkbox-2.2.2.tgz#bb19b20d503103a724c9dc79f71132edb092a362" + integrity sha512-ZTAy3yNwOAaoznxsFoR33XopJnyyzAGrLeDpn7hVTkUYm7wgGsgOpRfG2psdM3fIOCkZkkE9IEAp1pLGBifL1g== dependencies: - "@babel/plugin-syntax-jsx" "^7.2.0" + "@types/jquery" "*" -"@vue/babel-sugar-inject-h@^1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vue/babel-sugar-inject-h/-/babel-sugar-inject-h-1.2.2.tgz#d738d3c893367ec8491dcbb669b000919293e3aa" - integrity sha512-y8vTo00oRkzQTgufeotjCLPAvlhnpSkcHFEp60+LJUwygGcd5Chrpn5480AQp/thrxVm8m2ifAk0LyFel9oCnw== +"@types/semantic-ui-dimmer@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-dimmer/-/semantic-ui-dimmer-2.2.2.tgz#667185161c31d046a51a6d71097d2909829e1423" + integrity sha512-wK7da/70UJ9AU7Ju2MeOO9sjRPrhU6jf+VvHiTwlaCGm7+ALiJThd88D1iB6ODDQpm+ebjIbQkvAmDSkMpmKlg== dependencies: - "@babel/plugin-syntax-jsx" "^7.2.0" + "@types/jquery" "*" -"@vue/babel-sugar-v-model@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-model/-/babel-sugar-v-model-1.3.0.tgz#e4da7ae27a74c473b1abba060260ecaa8cb6e46b" - integrity sha512-zcsabmdX48JmxTObn3xmrvvdbEy8oo63DphVyA3WRYGp4SEvJRpu/IvZCVPl/dXLuob2xO/QRuncqPgHvZPzpA== - dependencies: - "@babel/plugin-syntax-jsx" "^7.2.0" - "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" - "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" - camelcase "^5.0.0" - html-tags "^2.0.0" - svg-tags "^1.0.0" +"@types/semantic-ui-dropdown@*": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-dropdown/-/semantic-ui-dropdown-2.2.3.tgz#fffafea2a26dcb85fefc74a23d6a764f21a75b61" + integrity sha512-y2ZIiEWvFFyLu7+yqNV550U9hs3sfqP7ajLxHEWlGjxGS4NsJmy9In7/UcxnpJB+JkanC4JkyogEN6wRlbqvhw== + dependencies: + "@types/jquery" "*" + "@types/semantic-ui-api" "*" -"@vue/babel-sugar-v-on@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-on/-/babel-sugar-v-on-1.3.0.tgz#d35756f8720e527a3b1867e21c3c248cde47ca87" - integrity sha512-8VZgrS0G5bh7+Prj7oJkzg9GvhSPnuW5YT6MNaVAEy4uwxRLJ8GqHenaStfllChTao4XZ3EZkNtHB4Xbr/ePdA== +"@types/semantic-ui-embed@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-embed/-/semantic-ui-embed-2.2.2.tgz#5b851bc966c06812550410348da7f8ea866e0c02" + integrity sha512-5sW99BtK2SIBs9/sSM/0vMr6tphPyPXHyClhFX1tJi0L5ZH0wEmf6XcBRZgROe3ueHYVaJ0Pt/zwPQ5SMW0xDg== dependencies: - "@babel/plugin-syntax-jsx" "^7.2.0" - "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" - camelcase "^5.0.0" + "@types/jquery" "*" -"@vue/compiler-core@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.37.tgz#b3c42e04c0e0f2c496ff1784e543fbefe91e215a" - integrity sha512-81KhEjo7YAOh0vQJoSmAD68wLfYqJvoiD4ulyedzF+OEk/bk6/hx3fTNVfuzugIIaTrOx4PGx6pAiBRe5e9Zmg== +"@types/semantic-ui-form@*": + version "2.2.6" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-form/-/semantic-ui-form-2.2.6.tgz#767c364c92fb3977f221b8c72e094e0928d4a024" + integrity sha512-khj3o6w2TWN9Bh7yZhsosUfAPMBRP/rD3DiApZrPUaioCHf0VrDtYuiqJXTd/Qt7cZW2w+NgZ1riV/3leXx8iA== dependencies: - "@babel/parser" "^7.16.4" - "@vue/shared" "3.2.37" - estree-walker "^2.0.2" - source-map "^0.6.1" + "@types/jquery" "*" -"@vue/compiler-dom@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.37.tgz#10d2427a789e7c707c872da9d678c82a0c6582b5" - integrity sha512-yxJLH167fucHKxaqXpYk7x8z7mMEnXOw3G2q62FTkmsvNxu4FQSu5+3UMb+L7fjKa26DEzhrmCxAgFLLIzVfqQ== +"@types/semantic-ui-modal@*": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-modal/-/semantic-ui-modal-2.2.3.tgz#482e8ca3323b22e7247ff92249a52a8bc2fccc6d" + integrity sha512-Th48BFk1pd4kluFjCUDKn7Aml3xoLdFFK8wFQRz6UsOMdvsXx2OrNkubhjqc79tcBzONC7NszSW2ImslPPHNCg== dependencies: - "@vue/compiler-core" "3.2.37" - "@vue/shared" "3.2.37" + "@types/jquery" "*" + "@types/semantic-ui-dimmer" "*" -"@vue/compiler-sfc@2.7.8": - version "2.7.8" - resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-2.7.8.tgz#731aadd6beafdb9c72fd8614ce189ac6cee87612" - integrity sha512-2DK4YWKfgLnW9VDR9gnju1gcYRk3flKj8UNsms7fsRmFcg35slVTZEkqwBtX+wJBXaamFfn6NxSsZh3h12Ix/Q== +"@types/semantic-ui-nag@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-nag/-/semantic-ui-nag-2.2.2.tgz#93e9cc410aa17b273ef73a705666815a7c2a09a6" + integrity sha512-gqjSFmMLw8vtPa6/Rv/mFBK1mdqaUbLkhUA4CsTDhkibUqnNqpvI/d1XFFLdC/ULu9v7UloMTCndSGKao+q5oA== dependencies: - "@babel/parser" "^7.18.4" - postcss "^8.4.14" - source-map "^0.6.1" + "@types/jquery" "*" -"@vue/compiler-sfc@^3.0.0": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.37.tgz#3103af3da2f40286edcd85ea495dcb35bc7f5ff4" - integrity sha512-+7i/2+9LYlpqDv+KTtWhOZH+pa8/HnX/905MdVmAcI/mPQOBwkHHIzrsEsucyOIZQYMkXUiTkmZq5am/NyXKkg== +"@types/semantic-ui-popup@*": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-popup/-/semantic-ui-popup-2.2.3.tgz#cdf391d0e16ffbfdc34c7fee85e85d20a9323c5a" + integrity sha512-tw7FXUTAs+GEU939RBpOCVq9H8vYpsr8uYvJC0RUxXCYXCUHsgYzgIIklKoD+xPvUCf34MHnMBwrrTCMJosxGg== dependencies: - "@babel/parser" "^7.16.4" - "@vue/compiler-core" "3.2.37" - "@vue/compiler-dom" "3.2.37" - "@vue/compiler-ssr" "3.2.37" - "@vue/reactivity-transform" "3.2.37" - "@vue/shared" "3.2.37" - estree-walker "^2.0.2" - magic-string "^0.25.7" - postcss "^8.1.10" - source-map "^0.6.1" + "@types/jquery" "*" -"@vue/compiler-ssr@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.37.tgz#4899d19f3a5fafd61524a9d1aee8eb0505313cff" - integrity sha512-7mQJD7HdXxQjktmsWp/J67lThEIcxLemz1Vb5I6rYJHR5vI+lON3nPGOH3ubmbvYGt8xEUaAr1j7/tIFWiEOqw== +"@types/semantic-ui-progress@*": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-progress/-/semantic-ui-progress-2.2.3.tgz#2ad3e2b69c5c5927aa4c11634cdf0a04ab53b36f" + integrity sha512-gv0i4+/uVbUJnuTzNv2oEqJ8CMQPeAR6K+s2zm1r4waH+8ZQ0SKM1DZ4t3w4gEMxhXqZVlLlyIhfY1SmUI0GuQ== dependencies: - "@vue/compiler-dom" "3.2.37" - "@vue/shared" "3.2.37" + "@types/jquery" "*" -"@vue/component-compiler-utils@^3.2.2": - version "3.3.0" - resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-3.3.0.tgz#f9f5fb53464b0c37b2c8d2f3fbfe44df60f61dc9" - integrity sha512-97sfH2mYNU+2PzGrmK2haqffDpVASuib9/w2/noxiFi31Z54hW+q3izKQXXQZSNhtiUpAI36uSuYepeBe4wpHQ== - dependencies: - consolidate "^0.15.1" - hash-sum "^1.0.2" - lru-cache "^4.1.2" - merge-source-map "^1.1.0" - postcss "^7.0.36" - postcss-selector-parser "^6.0.2" - source-map "~0.6.1" - vue-template-es2015-compiler "^1.9.0" - optionalDependencies: - prettier "^1.18.2 || ^2.0.0" +"@types/semantic-ui-rating@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-rating/-/semantic-ui-rating-2.2.2.tgz#d35807b61ebc6b4f2a977b430fbd862fa5f5b5a7" + integrity sha512-9497T8bEnkadWtQDl5Hno9lviZ2bJjx5rKd/Gfq6PWZ1/4/71LrYdH1DSr+sHYJ5HkaSA0b7GFVCTxi9pEdd6Q== + dependencies: + "@types/jquery" "*" -"@vue/composition-api@1.4.9": - version "1.4.9" - resolved "https://registry.yarnpkg.com/@vue/composition-api/-/composition-api-1.4.9.tgz#6fa65284f545887b52d421f23b4fa1c41bc0ad4b" - integrity sha512-l6YOeg5LEXmfPqyxAnBaCv1FMRw0OGKJ4m6nOWRm6ngt5TuHcj5ZoBRN+LXh3J0u6Ur3C4VA+RiKT+M0eItr/g== +"@types/semantic-ui-search@*": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-search/-/semantic-ui-search-2.2.3.tgz#7da1a62ed116a1aa0baee2a9c66196b16f643e13" + integrity sha512-JVrrW9uakXTudNm1MGrkRpirL2vm8NCVtrPyH6zIbBNqi08UPeHY8yxjnFpTPv5sMKBGGhkSn9cYrGz6Cweg2Q== + dependencies: + "@types/jquery" "*" + "@types/semantic-ui-api" "*" -"@vue/reactivity-transform@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.37.tgz#0caa47c4344df4ae59f5a05dde2a8758829f8eca" - integrity sha512-IWopkKEb+8qpu/1eMKVeXrK0NLw9HicGviJzhJDEyfxTR9e1WtpnnbYkJWurX6WwoFP0sz10xQg8yL8lgskAZg== +"@types/semantic-ui-shape@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-shape/-/semantic-ui-shape-2.2.2.tgz#ce3bd95e4ec1127380343910c4766a4f070c1336" + integrity sha512-bXaeheuuDY3rAmA5QQRAA0fzMiEkhRgZts5i7w/d1XlMHCVNeHIIAbhTurl3bPwTlbr0NI7T3ZmxH0EKRVdIEg== dependencies: - "@babel/parser" "^7.16.4" - "@vue/compiler-core" "3.2.37" - "@vue/shared" "3.2.37" - estree-walker "^2.0.2" - magic-string "^0.25.7" + "@types/jquery" "*" -"@vue/shared@3.2.37": +"@types/semantic-ui-sidebar@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-sidebar/-/semantic-ui-sidebar-2.2.2.tgz#792464d6e381354c8c1e935635c9010b1366f4a9" + integrity sha512-fm/whmNiyTzQwduc4maV9XjdwVc4pVlkhX5vippW9ukCCkVGY8qBgQKHFYhAHPhe7sCsGIuS+Vpr83t8X7Fg8w== + dependencies: + "@types/jquery" "*" + +"@types/semantic-ui-site@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-site/-/semantic-ui-site-2.2.2.tgz#25a358ca572aa0e811f2f912ab6872ef897c8bbe" + integrity sha512-XxwUxqpBLAlPpO7OqAYIdBRsZTmKLXvSzBLczms3JshnoChEZbxtKRYxSxgK93Y4XYCfKnpXQXEF6RIw5FF/mA== + dependencies: + "@types/jquery" "*" + +"@types/semantic-ui-sticky@*": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-sticky/-/semantic-ui-sticky-2.2.3.tgz#1317d2eac9b42d8088f8f0f3228684efa0ce6a84" + integrity sha512-HOhd+W75u9Hk0owQXUGdDKpvVhKl/207hueZqTTREZPTmxALAHbl6bHKxnvcJqRerhOFdObQcCFZGL5DEXRtcA== + dependencies: + "@types/jquery" "*" + +"@types/semantic-ui-tab@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-tab/-/semantic-ui-tab-2.2.2.tgz#4bae0c8ac1e970cd93fc31cf99f546e29ac9a069" + integrity sha512-o7a2TJAxjh7pVqRzpQmJd7hTcaDv/tAYJh2Aez5mYiRrFylhzwIrJAcXhSwVRVInPZkc8MDlBPg8Xm+QJ98rLw== + dependencies: + "@types/jquery" "*" + "@types/semantic-ui-api" "*" + +"@types/semantic-ui-transition@*": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-transition/-/semantic-ui-transition-2.2.2.tgz#e03d9eec32c962a5c862112f75a53879c3628406" + integrity sha512-wZJICf3qCr+68zPvzTKC9nQJ3mneW+K/K9Y2KphxujWgMCkOQEetDNb5Dbt9YZe92L0SnaPaDgp1KVaKAortdw== + dependencies: + "@types/jquery" "*" + +"@types/semantic-ui-visibility@*": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/semantic-ui-visibility/-/semantic-ui-visibility-2.2.3.tgz#254a126b502159e194fcbc9b8561e14c4ba215a1" + integrity sha512-4vfXjZHJhif8Rw4WQ691Zx2Y0vqdNF2D0AYT7ltQH1/mL/fCqEwTLndl3qvgCbxpniGbTnYRajqx1dk8+Ji/HQ== + dependencies: + "@types/jquery" "*" + +"@types/semantic-ui@2.2.7": + version "2.2.7" + resolved "https://registry.yarnpkg.com/@types/semantic-ui/-/semantic-ui-2.2.7.tgz#4ae4242004aac11a21133d4f338e868b92605270" + integrity sha512-Uj6rby2GnuVyO7pj8vgUFsv5eaxb0ktpfasYcB/vXnSAeJ4cRjIOvxka+EoPjw3tPCY4/WlxRss8hsh7kRWzQg== + dependencies: + "@types/jquery" "*" + "@types/semantic-ui-accordion" "*" + "@types/semantic-ui-api" "*" + "@types/semantic-ui-checkbox" "*" + "@types/semantic-ui-dimmer" "*" + "@types/semantic-ui-dropdown" "*" + "@types/semantic-ui-embed" "*" + "@types/semantic-ui-form" "*" + "@types/semantic-ui-modal" "*" + "@types/semantic-ui-nag" "*" + "@types/semantic-ui-popup" "*" + "@types/semantic-ui-progress" "*" + "@types/semantic-ui-rating" "*" + "@types/semantic-ui-search" "*" + "@types/semantic-ui-shape" "*" + "@types/semantic-ui-sidebar" "*" + "@types/semantic-ui-site" "*" + "@types/semantic-ui-sticky" "*" + "@types/semantic-ui-tab" "*" + "@types/semantic-ui-transition" "*" + "@types/semantic-ui-visibility" "*" + +"@types/showdown@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.0.tgz#3e800eca8573848cac4e5555f4377ba3a0e7b1f2" + integrity sha512-70xBJoLv+oXjB5PhtA8vo7erjLDp9/qqI63SRHm4REKrwuPOLs8HhXwlZJBJaB4kC18cCZ1UUZ6Fb/PLFW4TCA== + +"@types/sizzle@*": + version "2.3.3" + resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef" + integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ== + +"@types/trusted-types@*", "@types/trusted-types@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" + integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg== + +"@types/vue-virtual-scroller@npm:@earltp/vue-virtual-scroller": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@earltp/vue-virtual-scroller/-/vue-virtual-scroller-1.0.1.tgz#75116ef9b091457a654d92ff0688e991b3cd9e8a" + integrity sha512-7UsmP2JALnkfWlheuWRDywuBUTLJcVPE86X5ogA3djUmYFybE6qximgQ7OgyJnrKLteWR7+1Cp0GUXHhdDKaDQ== + +"@types/web-bluetooth@^0.0.15": + version "0.0.15" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.15.tgz#d60330046a6ed8a13b4a53df3813c44942ebdf72" + integrity sha512-w7hEHXnPMEZ+4nGKl/KDRVpxkwYxYExuHOYXyzIzCDzEZ9ZCGMAewulr9IqJu2LR4N37fcnb1XVeuZ09qgOxhA== + +"@typescript-eslint/eslint-plugin@5.36.2", "@typescript-eslint/eslint-plugin@^5.0.0": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz#6df092a20e0f9ec748b27f293a12cb39d0c1fe4d" + integrity sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw== + dependencies: + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/type-utils" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + functional-red-black-tree "^1.0.1" + ignore "^5.2.0" + regexpp "^3.2.0" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/parser@^5.0.0": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.36.2.tgz#3ddf323d3ac85a25295a55fcb9c7a49ab4680ddd" + integrity sha512-qS/Kb0yzy8sR0idFspI9Z6+t7mqk/oRjnAYfewG+VN73opAUvmYL3oPIMmgOX6CnQS6gmVIXGshlb5RY/R22pA== + dependencies: + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" + integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + +"@typescript-eslint/type-utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz#752373f4babf05e993adf2cd543a763632826391" + integrity sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw== + dependencies: + "@typescript-eslint/typescript-estree" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + tsutils "^3.21.0" + +"@typescript-eslint/types@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" + integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== + +"@typescript-eslint/typescript-estree@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" + integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.2.tgz#b01a76f0ab244404c7aefc340c5015d5ce6da74c" + integrity sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg== + dependencies: + "@types/json-schema" "^7.0.9" + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" + eslint-scope "^5.1.1" + eslint-utils "^3.0.0" + +"@typescript-eslint/visitor-keys@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" + integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== + dependencies: + "@typescript-eslint/types" "5.36.2" + eslint-visitor-keys "^3.3.0" + +"@vitejs/plugin-vue@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-3.0.3.tgz#7e3e401ccb30b4380d2279d9849281413f1791ef" + integrity sha512-U4zNBlz9mg+TA+i+5QPc3N5lQvdUXENZLO2h0Wdzp56gI1MWhqJOv+6R+d4kOzoaSSq6TnGPBdZAXKOe4lXy6g== + +"@vitest/coverage-c8@0.23.2": + version "0.23.2" + resolved "https://registry.yarnpkg.com/@vitest/coverage-c8/-/coverage-c8-0.23.2.tgz#48bf6ea81a7ce57abf539361d5a6762e87c36448" + integrity sha512-VWT6zGj9iXEZCimnRLAUhf3siYVGIG9VryyMoo7B8zMOd+bnAbH8/7PhqvmjedVwa9wh61nkxqgG7/3Y/mzofQ== + dependencies: + c8 "^7.12.0" + vitest "0.23.2" + +"@volar/code-gen@0.40.5": + version "0.40.5" + resolved "https://registry.yarnpkg.com/@volar/code-gen/-/code-gen-0.40.5.tgz#e469e98c128bcd702b1b288f7fb1377246da418d" + integrity sha512-M3D/2pmvjyGYalmldcyvTqVXhUnDxMYA2HtThmdQ8pVsTW4BVVzqrjnJAvHKNfM/zU0XA+fzIh1tfJ4Cssoe5w== + dependencies: + "@volar/source-map" "0.40.5" + +"@volar/source-map@0.40.5": + version "0.40.5" + resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-0.40.5.tgz#65189bcc7a517e19d5956b38297619604bcc6eee" + integrity sha512-HNO+svbNHXmJtDs82muusI1ErWnMpmNPDpz0Hmex5XDEa+q3NlWFXPMAxCflg294fkCfdOizyCxXYqh3UKz3VA== + dependencies: + "@vue/reactivity" "3.2.37" + +"@volar/typescript-faster@0.40.5": + version "0.40.5" + resolved "https://registry.yarnpkg.com/@volar/typescript-faster/-/typescript-faster-0.40.5.tgz#55ad4d694bd1d90a3552642c3ec79356625d07b5" + integrity sha512-DfIVkQawbesz+8ghbYS1NnlIQjfMIDsSJY/bumIJk98M/tM/2Fykhiil3GDfTr6ju/fAqDpfF8VF8XpYDWPc7w== + dependencies: + semver "^7.3.7" + +"@volar/vue-language-core@0.40.5": + version "0.40.5" + resolved "https://registry.yarnpkg.com/@volar/vue-language-core/-/vue-language-core-0.40.5.tgz#62a04f41f9b0f091bc64bce2c1eccbe0d227bb06" + integrity sha512-4EpMQdLaORWg3EBZtgqzgkNRm1+qvyvCyLbnWHT2SPkUuHObPcbyA3giXbOEnPB0pnQpr2nVTv+tRLWvksXdyA== + dependencies: + "@volar/code-gen" "0.40.5" + "@volar/source-map" "0.40.5" + "@vue/compiler-core" "^3.2.37" + "@vue/compiler-dom" "^3.2.37" + "@vue/compiler-sfc" "^3.2.37" + "@vue/reactivity" "^3.2.37" + "@vue/shared" "^3.2.37" + +"@volar/vue-typescript@0.40.5": + version "0.40.5" + resolved "https://registry.yarnpkg.com/@volar/vue-typescript/-/vue-typescript-0.40.5.tgz#e15f22880eee49b664ed8b15301eb8441c7a24ec" + integrity sha512-6OHJ87e8A3Z0xuWmBg72X5aYbW/EatogB168827j/OL371eiy3dExqa5y2+eJuWYjDiRJOmlHzhUElN+OEQRZQ== + dependencies: + "@volar/code-gen" "0.40.5" + "@volar/typescript-faster" "0.40.5" + "@volar/vue-language-core" "0.40.5" + +"@vue/babel-helper-vue-transform-on@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.0.2.tgz#9b9c691cd06fc855221a2475c3cc831d774bc7dc" + integrity sha512-hz4R8tS5jMn8lDq6iD+yWL6XNB699pGIVLk7WSJnn1dbpjaazsjZQkieJoRX6gW5zpYSCFqQ7jUquPNY65tQYA== + +"@vue/babel-plugin-jsx@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.1.1.tgz#0c5bac27880d23f89894cd036a37b55ef61ddfc1" + integrity sha512-j2uVfZjnB5+zkcbc/zsOc0fSNGCMMjaEXP52wdwdIfn0qjFfEYpYZBFKFg+HHnQeJCVrjOeO0YxgaL7DMrym9w== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.0.0" + "@babel/template" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + "@vue/babel-helper-vue-transform-on" "^1.0.2" + camelcase "^6.0.0" + html-tags "^3.1.0" + svg-tags "^1.0.0" + +"@vue/compiler-core@3.2.38", "@vue/compiler-core@^3.2.37": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.38.tgz#0a2a7bffd2280ac19a96baf5301838a2cf1964d7" + integrity sha512-/FsvnSu7Z+lkd/8KXMa4yYNUiqQrI22135gfsQYVGuh5tqEgOB0XqrUdb/KnCLa5+TmQLPwvyUnKMyCpu+SX3Q== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/shared" "3.2.38" + estree-walker "^2.0.2" + source-map "^0.6.1" + +"@vue/compiler-core@3.2.39": + version "3.2.39" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.39.tgz#0d77e635f4bdb918326669155a2dc977c053943e" + integrity sha512-mf/36OWXqWn0wsC40nwRRGheR/qoID+lZXbIuLnr4/AngM0ov8Xvv8GHunC0rKRIkh60bTqydlqTeBo49rlbqw== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/shared" "3.2.39" + estree-walker "^2.0.2" + source-map "^0.6.1" + +"@vue/compiler-core@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.40.tgz#c785501f09536748121e937fb87605bbb1ada8e5" + integrity sha512-2Dc3Stk0J/VyQ4OUr2yEC53kU28614lZS+bnrCbFSAIftBJ40g/2yQzf4mPBiFuqguMB7hyHaujdgZAQ67kZYA== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/shared" "3.2.40" + estree-walker "^2.0.2" + source-map "^0.6.1" + +"@vue/compiler-dom@3.2.38", "@vue/compiler-dom@^3.2.31", "@vue/compiler-dom@^3.2.37": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.38.tgz#53d04ed0c0c62d1ef259bf82f9b28100a880b6fd" + integrity sha512-zqX4FgUbw56kzHlgYuEEJR8mefFiiyR3u96498+zWPsLeh1WKvgIReoNE+U7gG8bCUdvsrJ0JRmev0Ky6n2O0g== + dependencies: + "@vue/compiler-core" "3.2.38" + "@vue/shared" "3.2.38" + +"@vue/compiler-dom@3.2.39": + version "3.2.39" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.39.tgz#bd69d35c1a48fe2cea4ab9e96d2a3a735d146fdf" + integrity sha512-HMFI25Be1C8vLEEv1hgEO1dWwG9QQ8LTTPmCkblVJY/O3OvWx6r1+zsox5mKPMGvqYEZa6l8j+xgOfUspgo7hw== + dependencies: + "@vue/compiler-core" "3.2.39" + "@vue/shared" "3.2.39" + +"@vue/compiler-dom@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.40.tgz#c225418773774db536174d30d3f25ba42a33e7e4" + integrity sha512-OZCNyYVC2LQJy4H7h0o28rtk+4v+HMQygRTpmibGoG9wZyomQiS5otU7qo3Wlq5UfHDw2RFwxb9BJgKjVpjrQw== + dependencies: + "@vue/compiler-core" "3.2.40" + "@vue/shared" "3.2.40" + +"@vue/compiler-sfc@3.2.39": + version "3.2.39" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.39.tgz#8fe29990f672805b7c5a2ecfa5b05e681c862ea2" + integrity sha512-fqAQgFs1/BxTUZkd0Vakn3teKUt//J3c420BgnYgEOoVdTwYpBTSXCMJ88GOBCylmUBbtquGPli9tVs7LzsWIA== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.39" + "@vue/compiler-dom" "3.2.39" + "@vue/compiler-ssr" "3.2.39" + "@vue/reactivity-transform" "3.2.39" + "@vue/shared" "3.2.39" + estree-walker "^2.0.2" + magic-string "^0.25.7" + postcss "^8.1.10" + source-map "^0.6.1" + +"@vue/compiler-sfc@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.40.tgz#61823283efc84d25d9d2989458f305d32a2ed141" + integrity sha512-tzqwniIN1fu1PDHC3CpqY/dPCfN/RN1thpBC+g69kJcrl7mbGiHKNwbA6kJ3XKKy8R6JLKqcpVugqN4HkeBFFg== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.40" + "@vue/compiler-dom" "3.2.40" + "@vue/compiler-ssr" "3.2.40" + "@vue/reactivity-transform" "3.2.40" + "@vue/shared" "3.2.40" + estree-walker "^2.0.2" + magic-string "^0.25.7" + postcss "^8.1.10" + source-map "^0.6.1" + +"@vue/compiler-sfc@^3.0.0", "@vue/compiler-sfc@^3.2.37": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.38.tgz#9e763019471a535eb1fceeaac9d4d18a83f0940f" + integrity sha512-KZjrW32KloMYtTcHAFuw3CqsyWc5X6seb8KbkANSWt3Cz9p2qA8c1GJpSkksFP9ABb6an0FLCFl46ZFXx3kKpg== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.38" + "@vue/compiler-dom" "3.2.38" + "@vue/compiler-ssr" "3.2.38" + "@vue/reactivity-transform" "3.2.38" + "@vue/shared" "3.2.38" + estree-walker "^2.0.2" + magic-string "^0.25.7" + postcss "^8.1.10" + source-map "^0.6.1" + +"@vue/compiler-ssr@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.38.tgz#933b23bf99e667e5078eefc6ba94cb95fd765dfe" + integrity sha512-bm9jOeyv1H3UskNm4S6IfueKjUNFmi2kRweFIGnqaGkkRePjwEcfCVqyS3roe7HvF4ugsEkhf4+kIvDhip6XzQ== + dependencies: + "@vue/compiler-dom" "3.2.38" + "@vue/shared" "3.2.38" + +"@vue/compiler-ssr@3.2.39": + version "3.2.39" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.39.tgz#4f3bfb535cb98b764bee45e078700e03ccc60633" + integrity sha512-EoGCJ6lincKOZGW+0Ky4WOKsSmqL7hp1ZYgen8M7u/mlvvEQUaO9tKKOy7K43M9U2aA3tPv0TuYYQFrEbK2eFQ== + dependencies: + "@vue/compiler-dom" "3.2.39" + "@vue/shared" "3.2.39" + +"@vue/compiler-ssr@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.40.tgz#67df95a096c63e9ec4b50b84cc6f05816793629c" + integrity sha512-80cQcgasKjrPPuKcxwuCx7feq+wC6oFl5YaKSee9pV3DNq+6fmCVwEEC3vvkf/E2aI76rIJSOYHsWSEIxK74oQ== + dependencies: + "@vue/compiler-dom" "3.2.40" + "@vue/shared" "3.2.40" + +"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.1.4": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.2.1.tgz#6f2948ff002ec46df01420dfeff91de16c5b4092" + integrity sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ== + +"@vue/eslint-config-standard@8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@vue/eslint-config-standard/-/eslint-config-standard-8.0.1.tgz#d6e5f87bf99d142fc7adaaa5c32bc8d6af626ae3" + integrity sha512-+FsTb8kOf2GSbXXTwbigRBRRur/byMbwL6Ijii2JoXW4hsLB4arl9lbgV54OUOV5o20INLHDmBVONO16rP/a1g== + dependencies: + eslint-config-standard "^17.0.0" + eslint-import-resolver-custom-alias "^1.3.0" + eslint-import-resolver-node "^0.3.6" + eslint-plugin-import "^2.26.0" + eslint-plugin-n "^15.2.4" + eslint-plugin-promise "^6.0.0" + +"@vue/eslint-config-typescript@11.0.1": + version "11.0.1" + resolved "https://registry.yarnpkg.com/@vue/eslint-config-typescript/-/eslint-config-typescript-11.0.1.tgz#d79b3656aecea844ec9875bc93155163f684dde7" + integrity sha512-0U+nL0nA7ahnGPk3rTN49x76miUwuQtQPQNWOFvAcjg6nFJkIkA8qbGNtXwsuHtwBwRtWpHhShL3zK07v+632w== + dependencies: + "@typescript-eslint/eslint-plugin" "^5.0.0" + "@typescript-eslint/parser" "^5.0.0" + vue-eslint-parser "^9.0.0" + +"@vue/reactivity-transform@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.38.tgz#a856c217b2ead99eefb6fddb1d61119b2cb67984" + integrity sha512-3SD3Jmi1yXrDwiNJqQ6fs1x61WsDLqVk4NyKVz78mkaIRh6d3IqtRnptgRfXn+Fzf+m6B1KxBYWq1APj6h4qeA== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.38" + "@vue/shared" "3.2.38" + estree-walker "^2.0.2" + magic-string "^0.25.7" + +"@vue/reactivity-transform@3.2.39": + version "3.2.39" + resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.39.tgz#da6ae6c8fd77791b9ae21976720d116591e1c4aa" + integrity sha512-HGuWu864zStiWs9wBC6JYOP1E00UjMdDWIG5W+FpUx28hV3uz9ODOKVNm/vdOy/Pvzg8+OcANxAVC85WFBbl3A== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.39" + "@vue/shared" "3.2.39" + estree-walker "^2.0.2" + magic-string "^0.25.7" + +"@vue/reactivity-transform@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.40.tgz#dc24b9074b26f0d9dd2034c6349f5bb2a51c86ac" + integrity sha512-HQUCVwEaacq6fGEsg2NUuGKIhUveMCjOk8jGHqLXPI2w6zFoPrlQhwWEaINTv5kkZDXKEnCijAp+4gNEHG03yw== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.40" + "@vue/shared" "3.2.40" + estree-walker "^2.0.2" + magic-string "^0.25.7" + +"@vue/reactivity@3.2.37": + version "3.2.37" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.37.tgz#5bc3847ac58828e2b78526e08219e0a1089f8848" + integrity sha512-/7WRafBOshOc6m3F7plwzPeCu/RCVv9uMpOwa/5PiY1Zz+WLVRWiy0MYKwmg19KBdGtFWsmZ4cD+LOdVPcs52A== + dependencies: + "@vue/shared" "3.2.37" + +"@vue/reactivity@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.40.tgz#ae65496f5b364e4e481c426f391568ed7d133cca" + integrity sha512-N9qgGLlZmtUBMHF9xDT4EkD9RdXde1Xbveb+niWMXuHVWQP5BzgRmE3SFyUBBcyayG4y1lhoz+lphGRRxxK4RA== + dependencies: + "@vue/shared" "3.2.40" + +"@vue/reactivity@^3.2.37": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.38.tgz#d576fdcea98eefb96a1f1ad456e289263e87292e" + integrity sha512-6L4myYcH9HG2M25co7/BSo0skKFHpAN8PhkNPM4xRVkyGl1K5M3Jx4rp5bsYhvYze2K4+l+pioN4e6ZwFLUVtw== + dependencies: + "@vue/shared" "3.2.38" + +"@vue/runtime-core@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.40.tgz#e814358bf1b0ff6d4a6b4f8f62d9f341964fb275" + integrity sha512-U1+rWf0H8xK8aBUZhnrN97yoZfHbjgw/bGUzfgKPJl69/mXDuSg8CbdBYBn6VVQdR947vWneQBFzdhasyzMUKg== + dependencies: + "@vue/reactivity" "3.2.40" + "@vue/shared" "3.2.40" + +"@vue/runtime-dom@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.40.tgz#975119feac5ab703aa9bbbf37c9cc966602c8eab" + integrity sha512-AO2HMQ+0s2+MCec8hXAhxMgWhFhOPJ/CyRXnmTJ6XIOnJFLrH5Iq3TNwvVcODGR295jy77I6dWPj+wvFoSYaww== + dependencies: + "@vue/runtime-core" "3.2.40" + "@vue/shared" "3.2.40" + csstype "^2.6.8" + +"@vue/server-renderer@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.2.40.tgz#55eaac31f7105c3907e1895129bf4efb6b0ce393" + integrity sha512-gtUcpRwrXOJPJ4qyBpU3EyxQa4EkV8I4f8VrDePcGCPe4O/hd0BPS7v9OgjIQob6Ap8VDz9G+mGTKazE45/95w== + dependencies: + "@vue/compiler-ssr" "3.2.40" + "@vue/shared" "3.2.40" + +"@vue/shared@3.2.37": version "3.2.37" resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.37.tgz#8e6adc3f2759af52f0e85863dfb0b711ecc5c702" integrity sha512-4rSJemR2NQIo9Klm1vabqWjD8rs/ZaJSzMxkMNeJS6lHiUjjUeYFbooN19NgFjztubEKh3WlZUeOLVdbbUWHsw== -"@vue/test-utils@1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.3.0.tgz#d563decdcd9c68a7bca151d4179a2bfd6d5c3e15" - integrity sha512-Xk2Xiyj2k5dFb8eYUKkcN9PzqZSppTlx7LaQWBbdA8tqh3jHr/KHX2/YLhNFc/xwDrgeLybqd+4ZCPJSGPIqeA== +"@vue/shared@3.2.38", "@vue/shared@^3.2.37": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.38.tgz#e823f0cb2e85b6bf43430c0d6811b1441c300f3c" + integrity sha512-dTyhTIRmGXBjxJE+skC8tTWCGLCVc4wQgRRLt8+O9p5ewBAjoBwtCAkLPrtToSr1xltoe3st21Pv953aOZ7alg== + +"@vue/shared@3.2.39": + version "3.2.39" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.39.tgz#302df167559a1a5156da162d8cc6760cef67f8e3" + integrity sha512-D3dl2ZB9qE6mTuWPk9RlhDeP1dgNRUKC3NJxji74A4yL8M2MwlhLKUC/49WHjrNzSPug58fWx/yFbaTzGAQSBw== + +"@vue/shared@3.2.40": + version "3.2.40" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.40.tgz#e57799da2a930b975321981fcee3d1e90ed257ae" + integrity sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ== + +"@vue/test-utils@2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.2.tgz#0b5edd683366153d5bc5a91edc62f292118710eb" + integrity sha512-E2P4oXSaWDqTZNbmKZFVLrNN/siVN78YkEqs7pHryWerrlZR9bBFLWdJwRoguX45Ru6HxIflzKl4vQvwRMwm5g== + +"@vue/tsconfig@0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.1.3.tgz#4a61dbd29783d01ddab504276dcf0c2b6988654f" + integrity sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg== + +"@vueuse/core@9.1.1": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-9.1.1.tgz#a5c09c33ccee58cfd53bc3ec2d5a0d304155529e" + integrity sha512-QfuaNWRDMQcCUwXylCyYhPC3ScS9Tiiz4J0chdwr3vOemBwRToSywq8MP+ZegKYFnbETzRY8G/5zC+ca30wrRQ== dependencies: - dom-event-types "^1.0.0" - lodash "^4.17.15" - pretty "^2.0.0" + "@types/web-bluetooth" "^0.0.15" + "@vueuse/metadata" "9.1.1" + "@vueuse/shared" "9.1.1" + vue-demi "*" -"@vueuse/core@8.2.5": - version "8.2.5" - resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-8.2.5.tgz#ca6a59091ecf16e6739c53f3d857b11967a5eb06" - integrity sha512-5prZAA1Ji2ltwNUnzreu6WIXYqHYP/9U2BiY5mD/650VYLpVcwVlYznJDFcLCmEWI3o3Vd34oS1FUf+6Mh68GQ== +"@vueuse/integrations@9.1.1": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@vueuse/integrations/-/integrations-9.1.1.tgz#2397f6e53a33ef984c7a3625fc2cc14daa780a87" + integrity sha512-RVRio67rogVIV8e1Uov93JuxrE7UXhRm2B1pzdTAd+/oBxo4E3WaoXo/naI3kW1quvlOUv+g1/6goyGQoIGSow== dependencies: - "@vueuse/metadata" "8.2.5" - "@vueuse/shared" "8.2.5" + "@vueuse/core" "9.1.1" + "@vueuse/shared" "9.1.1" vue-demi "*" -"@vueuse/metadata@8.2.5": - version "8.2.5" - resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-8.2.5.tgz#51c7d95e04284ea378a5242a2e88b77494e2c117" - integrity sha512-Lk9plJjh9cIdiRdcj16dau+2LANxIdFCiTgdfzwYXbflxq0QnMBeOD2qHgKDE7fuVrtPcVWj8VSuZEx1HRfNQA== +"@vueuse/metadata@9.1.1": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.1.1.tgz#b3fe4b97e62096f7566cd8eb107c503998b2c9a6" + integrity sha512-XZ2KtSW+85LLHB/IdGILPAtbIVHasPsAW7aqz3BRMzJdAQWRiM/FGa1OKBwLbXtUw/AmjKYFlZJo7eOFIBXRog== + +"@vueuse/router@9.1.1": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@vueuse/router/-/router-9.1.1.tgz#356946b97e2499d96d8e9b5cfced5f778edd63c9" + integrity sha512-1HE09QYoHEUF2vWJqGEV1GgoFy6ti7gxzahiN9o/GJpyWM11koQd03BhP4RjVbUx3ua2wTYNSmaCKvLJGCnNGg== + dependencies: + "@vueuse/shared" "9.1.1" + vue-demi "*" -"@vueuse/shared@8.2.5": - version "8.2.5" - resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-8.2.5.tgz#1ae200a240c4b8d42d41723b64d8f917aa57ff16" - integrity sha512-lNWo+7sk6JCuOj4AiYM+6HZ6fq4xAuVq1sVckMQKgfCJZpZRe4i8es+ZULO5bYTKP+VrOCtqrLR2GzEfrbr3YQ== +"@vueuse/shared@9.1.1": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-9.1.1.tgz#811f47629e281a19013ae6dcdf11ed3e1e91e023" + integrity sha512-c+IfcOYmHiHqoEa3ED1Tbpue5GHmoUmTp8PtO4YbczthtY155Rt6DmWhjxMLXBF1Bcidagxljmp/7xtAzEHXLw== dependencies: vue-demi "*" -abab@^2.0.3, abab@^2.0.5: +abab@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - acorn-class-fields@^0.3.7: version "0.3.7" resolved "https://registry.yarnpkg.com/acorn-class-fields/-/acorn-class-fields-0.3.7.tgz#a35122f3cc6ad2bb33b1857e79215677fcfdd720" @@ -1607,7 +2006,7 @@ acorn-globals@^6.0.0: acorn "^7.1.1" acorn-walk "^7.1.1" -acorn-jsx@^5.2.0, acorn-jsx@^5.3.2: +acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== @@ -1655,7 +2054,7 @@ acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.2.4, acorn@^8.7.1: +acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0: version "8.8.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== @@ -1677,28 +2076,21 @@ ajv@^6.10.0, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-escapes@^4.2.1: - version "4.3.2" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" - integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== +ajv@^8.6.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" + integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== dependencies: - type-fest "^0.21.3" - -ansi-regex@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - integrity sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA== + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - integrity sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA== - ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -1713,12 +2105,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" - integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== - -anymatch@^3.0.3, anymatch@~3.1.2: +anymatch@~3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== @@ -1726,18 +2113,16 @@ anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +array-back@^3.0.1, array-back@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0" + integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q== + array-includes@^3.1.4: version "3.1.5" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.5.tgz#2c320010db8d31031fd2a5f6b3bbd4b1aad31bdb" @@ -1749,6 +2134,11 @@ array-includes@^3.1.4: get-intrinsic "^1.1.1" is-string "^1.0.7" +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + array.prototype.flat@^1.2.5: version "1.3.0" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz#0b0c1567bf57b38b56b4c97b8aa72ab45e4adc7b" @@ -1774,6 +2164,11 @@ assertion-error@^1.1.0: resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== +async@^3.2.3: + version "3.2.4" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" + integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1784,69 +2179,18 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== -atob@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" - integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== - -autoprefixer@10.4.7: - version "10.4.7" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.7.tgz#1db8d195f41a52ca5069b7593be167618edbbedf" - integrity sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA== - dependencies: - browserslist "^4.20.3" - caniuse-lite "^1.0.30001335" - fraction.js "^4.2.0" - normalize-range "^0.1.2" - picocolors "^1.0.0" - postcss-value-parser "^4.2.0" - -axios-auth-refresh@3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/axios-auth-refresh/-/axios-auth-refresh-3.2.2.tgz#e3ef505932d33318c74c2ffce04c0fdc9767ffe0" - integrity sha512-Ocu8leYhuhNejH/RBC0tWpUuv0cOplvdwltIQuYmkhihbZ8AqR0qPIZs+fV5V3JL6L5IM4Y5rjHgXyQWozjM7g== - -axios@0.26.1: - version "0.26.1" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9" - integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA== - dependencies: - follow-redirects "^1.14.8" +axios-auth-refresh@3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/axios-auth-refresh/-/axios-auth-refresh-3.3.3.tgz#f8c2fd0ca3adf89168dfb0caff10f076499ea482" + integrity sha512-2IbDhJ/h6ddNBBnnzn1VFK/qx17pE9aVqiafB8rx5LVHsJ1HtFpUGkbXY7PzTG+8P9HJWcyA3fNZl9BikSuilg== -babel-code-frame@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" - integrity sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g== +axios@0.27.2, axios@>=0.13.0: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== dependencies: - chalk "^1.1.3" - esutils "^2.0.2" - js-tokens "^3.0.2" - -babel-core@7.0.0-bridge.0: - version "7.0.0-bridge.0" - resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece" - integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg== - -babel-jest@27.5.1, babel-jest@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-27.5.1.tgz#a1bf8d61928edfefd21da27eb86a695bfd691444" - integrity sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg== - dependencies: - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/babel__core" "^7.1.14" - babel-plugin-istanbul "^6.1.1" - babel-preset-jest "^27.5.1" - chalk "^4.0.0" - graceful-fs "^4.2.9" - slash "^3.0.0" - -babel-messages@^6.23.0: - version "6.23.0" - resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e" - integrity sha512-Bl3ZiA+LjqaMtNYopA9TYE9HP1tQ+E5dLxE0XrAzcIJeK2UqF0/EaqXwBn9esd4UmTfEab+P+UYQ1GnioFIb/w== - dependencies: - babel-runtime "^6.22.0" + follow-redirects "^1.14.9" + form-data "^4.0.0" babel-plugin-dynamic-import-node@^2.3.3: version "2.3.3" @@ -1855,28 +2199,7 @@ babel-plugin-dynamic-import-node@^2.3.3: dependencies: object.assign "^4.1.0" -babel-plugin-istanbul@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" - integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@istanbuljs/load-nyc-config" "^1.0.0" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-instrument "^5.0.4" - test-exclude "^6.0.0" - -babel-plugin-jest-hoist@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz#9be98ecf28c331eb9f5df9c72d6f89deb8181c2e" - integrity sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ== - dependencies: - "@babel/template" "^7.3.3" - "@babel/types" "^7.3.3" - "@types/babel__core" "^7.0.0" - "@types/babel__traverse" "^7.0.6" - -babel-plugin-polyfill-corejs2@^0.3.0: +babel-plugin-polyfill-corejs2@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.2.tgz#e4c31d4c89b56f3cf85b92558954c66b54bd972d" integrity sha512-LPnodUl3lS0/4wN3Rb+m+UK8s7lj2jcLRrjho4gLw+OJs+I4bvGXshINesY5xx/apM+biTnQ9reDI8yj+0M5+Q== @@ -1885,7 +2208,7 @@ babel-plugin-polyfill-corejs2@^0.3.0: "@babel/helper-define-polyfill-provider" "^0.3.2" semver "^6.1.1" -babel-plugin-polyfill-corejs3@^0.5.0: +babel-plugin-polyfill-corejs3@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.3.tgz#d7e09c9a899079d71a8b670c6181af56ec19c5c7" integrity sha512-zKsXDh0XjnrUEW0mxIHLfjBfnXSMr5Q/goMe/fxpQnLm07mcOZiIZHBNWCMx60HmdvjxfXcalac0tfFg0wqxyw== @@ -1893,100 +2216,12 @@ babel-plugin-polyfill-corejs3@^0.5.0: "@babel/helper-define-polyfill-provider" "^0.3.2" core-js-compat "^3.21.0" -babel-plugin-polyfill-regenerator@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.3.1.tgz#2c0678ea47c75c8cc2fbb1852278d8fb68233990" - integrity sha512-Y2B06tvgHYt1x0yz17jGkGeeMr5FeKUu+ASJ+N6nB5lQ8Dapfg42i0OVrf8PNGJ3zKL4A23snMi1IRwrqqND7A== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.1" - -babel-plugin-transform-es2015-modules-commonjs@^6.26.0: - version "6.26.2" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz#58a793863a9e7ca870bdc5a881117ffac27db6f3" - integrity sha512-CV9ROOHEdrjcwhIaJNBGMBCodN+1cfkwtM1SbUHmvyy35KGT7fohbpOxkE2uLz1o6odKK2Ck/tz47z+VqQfi9Q== - dependencies: - babel-plugin-transform-strict-mode "^6.24.1" - babel-runtime "^6.26.0" - babel-template "^6.26.0" - babel-types "^6.26.0" - -babel-plugin-transform-strict-mode@^6.24.1: - version "6.24.1" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758" - integrity sha512-j3KtSpjyLSJxNoCDrhwiJad8kw0gJ9REGj8/CqL0HeRyLnvUNYV9zcqluL6QJSXh3nfsLEmSLvwRfGzrgR96Pw== - dependencies: - babel-runtime "^6.22.0" - babel-types "^6.24.1" - -babel-preset-current-node-syntax@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" - integrity sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== +babel-plugin-polyfill-regenerator@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.0.tgz#8f51809b6d5883e07e71548d75966ff7635527fe" + integrity sha512-RW1cnryiADFeHmfLS+WW/G431p1PsW5qdRdz0SDRi7TKcUgc7Oh/uXkT7MZ/+tGsT1BkczEAmD5XjUyJ5SWDTw== dependencies: - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/plugin-syntax-bigint" "^7.8.3" - "@babel/plugin-syntax-class-properties" "^7.8.3" - "@babel/plugin-syntax-import-meta" "^7.8.3" - "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.8.3" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-top-level-await" "^7.8.3" - -babel-preset-jest@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz#91f10f58034cb7989cb4f962b69fa6eef6a6bc81" - integrity sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag== - dependencies: - babel-plugin-jest-hoist "^27.5.1" - babel-preset-current-node-syntax "^1.0.0" - -babel-runtime@^6.22.0, babel-runtime@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" - integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g== - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.11.0" - -babel-template@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02" - integrity sha512-PCOcLFW7/eazGUKIoqH97sO9A2UYMahsn/yRQ7uOk37iutwjq7ODtcTNF+iFDSHNfkctqsLRjLP7URnOx0T1fg== - dependencies: - babel-runtime "^6.26.0" - babel-traverse "^6.26.0" - babel-types "^6.26.0" - babylon "^6.18.0" - lodash "^4.17.4" - -babel-traverse@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee" - integrity sha512-iSxeXx7apsjCHe9c7n8VtRXGzI2Bk1rBSOJgCCjfyXb6v1aCqE1KSEpq/8SXuVN8Ka/Rh1WDTF0MDzkvTA4MIA== - dependencies: - babel-code-frame "^6.26.0" - babel-messages "^6.23.0" - babel-runtime "^6.26.0" - babel-types "^6.26.0" - babylon "^6.18.0" - debug "^2.6.8" - globals "^9.18.0" - invariant "^2.2.2" - lodash "^4.17.4" - -babel-types@^6.24.1, babel-types@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" - integrity sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g== - dependencies: - babel-runtime "^6.26.0" - esutils "^2.0.2" - lodash "^4.17.4" - to-fast-properties "^1.0.3" + "@babel/helper-define-polyfill-provider" "^0.3.2" babel-walk@3.0.0-canary-5: version "3.0.0-canary-5" @@ -1995,11 +2230,6 @@ babel-walk@3.0.0-canary-5: dependencies: "@babel/types" "^7.9.6" -babylon@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" - integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== - balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -2010,18 +2240,6 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -bindings@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" - integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== - dependencies: - file-uri-to-path "1.0.0" - -bluebird@^3.1.1, bluebird@^3.7.2: - version "3.7.2" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" - integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== - boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -2035,6 +2253,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" @@ -2047,33 +2272,63 @@ browser-process-hrtime@^1.0.0: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== -browserslist@^4.20.2, browserslist@^4.20.3, browserslist@^4.21.2: - version "4.21.2" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.2.tgz#59a400757465535954946a400b841ed37e2b4ecf" - integrity sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA== +browserslist@^4.20.2, browserslist@^4.21.3: + version "4.21.3" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" + integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ== dependencies: - caniuse-lite "^1.0.30001366" - electron-to-chromium "^1.4.188" + caniuse-lite "^1.0.30001370" + electron-to-chromium "^1.4.202" node-releases "^2.0.6" - update-browserslist-db "^1.0.4" - -bser@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" - integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== - dependencies: - node-int64 "^0.4.0" + update-browserslist-db "^1.0.5" buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +builtin-modules@^3.1.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" + integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== + +builtins@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/builtins/-/builtins-4.1.0.tgz#1edd016dd91ce771a1ed6fc3b2b71fb918953250" + integrity sha512-1bPRZQtmKaO6h7qV1YHXNtr6nCK28k0Zo95KM4dXfILcZZwoHJBN1m3lfLv9LPkcOZlrSr+J1bzMaZFO98Yq0w== + dependencies: + semver "^7.0.0" + +builtins@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9" + integrity sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ== + dependencies: + semver "^7.0.0" + buntis@0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/buntis/-/buntis-0.2.1.tgz#a043aabc7d64f2243bfaa53e34e999c2dd790e82" integrity sha512-5wszfQlsqJmZrfxpPkO5yQcEoBAmfUYlXxXU/IM6PhPZ8DMnMMJQ9rvAHfe5WZmnB6E1IoJYylFfTaf1e2FJbQ== +c8@^7.12.0: + version "7.12.0" + resolved "https://registry.yarnpkg.com/c8/-/c8-7.12.0.tgz#402db1c1af4af5249153535d1c84ad70c5c96b14" + integrity sha512-CtgQrHOkyxr5koX1wEUmN/5cfDa2ckbHRA4Gy5LAL0zaCFtVWJS5++n+w4/sr2GWGerBxgTjpKeDclk/Qk6W/A== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@istanbuljs/schema" "^0.1.3" + find-up "^5.0.0" + foreground-child "^2.0.0" + istanbul-lib-coverage "^3.2.0" + istanbul-lib-report "^3.0.0" + istanbul-reports "^3.1.4" + rimraf "^3.0.2" + test-exclude "^6.0.0" + v8-to-istanbul "^9.0.0" + yargs "^16.2.0" + yargs-parser "^20.2.9" + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -2087,22 +2342,17 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camelcase@^5.0.0, camelcase@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -camelcase@^6.2.0: +camelcase@^6.0.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001335, caniuse-lite@^1.0.30001366: - version "1.0.30001370" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001370.tgz#0a30d4f20d38b9e108cc5ae7cc62df9fe66cd5ba" - integrity sha512-3PDmaP56wz/qz7G508xzjx8C+MC2qEm4SYhSEzC9IBROo+dGXFWRuaXkWti0A9tuI00g+toiriVqxtWMgl350g== +caniuse-lite@^1.0.30001370: + version "1.0.30001390" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001390.tgz#158a43011e7068ef7fc73590e9fd91a7cece5e7f" + integrity sha512-sS4CaUM+/+vqQUlCvCJ2WtDlV81aWtHhqeEVkLokVJJa3ViN4zDxAGfq9R8i1m90uGHxo99cy10Od+lvn3hf0g== -chai@4.3.6: +chai@^4.3.6: version "4.3.6" resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.6.tgz#ffe4ba2d9fa9d6680cc0b370adae709ec9011e9c" integrity sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q== @@ -2115,18 +2365,15 @@ chai@4.3.6: pathval "^1.1.1" type-detect "^4.0.5" -chalk@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A== +chalk@4.1.2, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" + ansi-styles "^4.1.0" + supports-color "^7.1.0" -chalk@^2.0.0, chalk@^2.1.0: +chalk@^2.0.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -2135,19 +2382,6 @@ chalk@^2.0.0, chalk@^2.1.0: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -char-regex@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" - integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== - character-parser@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/character-parser/-/character-parser-2.2.0.tgz#c7ce28f36d4bcd9744e5ffc2c5fcde1c73261fc0" @@ -2200,25 +2434,6 @@ cheerio@^1.0.0-rc.3: optionalDependencies: fsevents "~2.3.2" -ci-info@^3.2.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.2.tgz#6d2967ffa407466481c6c90b6e16b3098f080128" - integrity sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg== - -cjs-module-lexer@^1.0.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" - integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== - -cliui@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" - integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^6.2.0" - cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -2228,21 +2443,6 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" -clone@2.x: - version "2.1.2" - resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" - integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== - -collect-v8-coverage@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" - integrity sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg== - color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -2274,7 +2474,17 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^2.19.0: +command-line-args@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e" + integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg== + dependencies: + array-back "^3.1.0" + find-replace "^3.0.0" + lodash.camelcase "^4.3.0" + typical "^4.0.0" + +commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -2284,42 +2494,16 @@ commander@^9.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-9.4.0.tgz#bc4a40918fefe52e22450c111ecd6b7acce6f11c" integrity sha512-sRPT+umqkz90UA8M1yqYfnHlZA7fF6nSphDtxeywPZ49ysjxDQybzk13CL+mXekDRG92skbcqCLVovuCusNmFw== +common-tags@^1.8.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" + integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -condense-newlines@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/condense-newlines/-/condense-newlines-0.2.1.tgz#3de985553139475d32502c83b02f60684d24c55f" - integrity sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg== - dependencies: - extend-shallow "^2.0.1" - is-whitespace "^0.3.0" - kind-of "^3.0.2" - -config-chain@^1.1.13: - version "1.1.13" - resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" - integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== - dependencies: - ini "^1.3.4" - proto-list "~1.2.1" - -consolidate@^0.15.1: - version "0.15.1" - resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.15.1.tgz#21ab043235c71a07d45d9aad98593b0dba56bab7" - integrity sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw== - dependencies: - bluebird "^3.1.1" - -consolidate@^0.16.0: - version "0.16.0" - resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.16.0.tgz#a11864768930f2f19431660a65906668f5fbdc16" - integrity sha512-Nhl1wzCslqXYTJVDyJCu3ODohy9OfBMB5uD2BiBTzd7w+QY0lBzafkR8y8755yMYHAaMD4NuzbAw03/xzfw+eQ== - dependencies: - bluebird "^3.7.2" - constantinople@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/constantinople/-/constantinople-4.0.1.tgz#0def113fa0e4dc8de83331a5cf79c8b325213151" @@ -2328,32 +2512,38 @@ constantinople@^4.0.1: "@babel/parser" "^7.6.0" "@babel/types" "^7.6.1" -convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: +convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== dependencies: safe-buffer "~5.1.1" -core-js-compat@^3.20.2, core-js-compat@^3.21.0: - version "3.24.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.24.0.tgz#885958fac38bf3f4464a90f2663b4620f6aee6e3" - integrity sha512-F+2E63X3ff/nj8uIrf8Rf24UDGIz7p838+xjEp+Bx3y8OWXj+VTPPZNCtdqovPaS9o7Tka5mCH01Zn5vOd6UQg== +cookie@^0.4.0: + version "0.4.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== + +core-js-compat@^3.21.0, core-js-compat@^3.22.1: + version "3.25.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.25.0.tgz#489affbfbf9cb3fa56192fe2dd9ebaee985a66c5" + integrity sha512-extKQM0g8/3GjFx9US12FAgx8KJawB7RCQ5y8ipYLbmfzEzmFRWdDjIlxDx82g7ygcNG85qMVUSRyABouELdow== dependencies: - browserslist "^4.21.2" + browserslist "^4.21.3" semver "7.0.0" -core-js@^2.4.0: - version "2.6.12" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" - integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== - -core-js@^3.20.0: - version "3.24.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.24.0.tgz#4928d4e99c593a234eb1a1f9abd3122b04d3ac57" - integrity sha512-IeOyT8A6iK37Ep4kZDD423mpi6JfPRoPUdQwEWYiGolvn4o6j2diaRzNfDfpTdu3a5qMbrGUzKUpYpRY8jXCkQ== +cosmiconfig@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d" + integrity sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.2.1" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.10.0" -cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -2362,6 +2552,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-random-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" + integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== + css-select@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" @@ -2373,30 +2568,25 @@ css-select@^5.1.0: domutils "^3.0.1" nth-check "^2.0.1" +css-selector-parser@^1.3: + version "1.4.1" + resolved "https://registry.yarnpkg.com/css-selector-parser/-/css-selector-parser-1.4.1.tgz#03f9cb8a81c3e5ab2c51684557d5aaf6d2569759" + integrity sha512-HYPSb7y/Z7BNDCOrakL4raGO2zltZkbeXyAd6Tg9obzix6QhzxCotdBl6VT0Dv4vZfJGVz3WL/xaEI9Ly3ul0g== + css-what@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== -css@^2.1.0: - version "2.2.4" - resolved "https://registry.yarnpkg.com/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929" - integrity sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw== - dependencies: - inherits "^2.0.3" - source-map "^0.6.1" - source-map-resolve "^0.5.2" - urix "^0.1.0" - cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== -cssom@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" - integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== cssom@~0.3.6: version "0.3.8" @@ -2410,46 +2600,28 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" -csstype@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" - integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== - -custom-event-polyfill@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz#9bc993ddda937c1a30ccd335614c6c58c4f87aee" - integrity sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w== - -data-urls@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" - integrity sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ== - dependencies: - abab "^2.0.3" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.0.0" +csstype@^2.6.8: + version "2.6.20" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.20.tgz#9229c65ea0b260cf4d3d997cb06288e36a8d6dda" + integrity sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA== -de-indent@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" - integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== - -deasync@^0.1.15: - version "0.1.27" - resolved "https://registry.yarnpkg.com/deasync/-/deasync-0.1.27.tgz#2a669a68d2d43bf8effa5a7efe7d8e1f1e447216" - integrity sha512-aCt6M9Ilkvs8TKIchmibUpNe/QSp9UNQL6YkvVraAce/SFFZCvYw3lQevl6MlUDn8Xr4QD4wYTerWH22yn+ODQ== +data-urls@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" + integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ== dependencies: - bindings "^1.5.0" - node-addon-api "^1.7.1" + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== dependencies: ms "2.1.2" -debug@^2.6.8, debug@^2.6.9: +debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -2463,25 +2635,10 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -decamelize@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== - -decimal.js@^10.2.1: - version "10.3.1" - resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" - integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== - -decode-uri-component@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" - integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og== - -dedent@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" - integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA== +decimal.js@^10.3.1: + version "10.4.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.0.tgz#97a7448873b01e92e5ff9117d89a7bca8e63e0fe" + integrity sha512-Nv6ENEzyPQ6AItkGwLE2PGKinZZ9g59vSh2BeH6NqPu0OTKZ5ruJsVqh/orbAnqXc9pBbgXAIrc2EyaCj8NpGg== deep-eql@^3.0.1: version "3.0.1" @@ -2513,26 +2670,18 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -detect-newline@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" - integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== - -diff-sequences@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" - integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== - -diff@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" - integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== - -diff@^5.0.0: +diff@5.1.0, diff@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -2552,20 +2701,6 @@ doctypes@^1.1.0: resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" integrity sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ== -dom-event-types@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/dom-event-types/-/dom-event-types-1.1.0.tgz#120c1f92ddea7758db1ccee0a100a33c39f4701b" - integrity sha512-jNCX+uNJ3v38BKvPbpki6j5ItVlnSqVV6vDWGS6rExzCMjsc39frLjm1n91o6YaKK6AZl0wLloItW6C6mr61BQ== - -dom-serializer@^1.0.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" - integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.0" - entities "^2.0.0" - dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" @@ -2575,24 +2710,17 @@ dom-serializer@^2.0.0: domhandler "^5.0.2" entities "^4.2.0" -domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: +domelementtype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== -domexception@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" - integrity sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg== - dependencies: - webidl-conversions "^5.0.0" - -domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.2.2: - version "4.3.1" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" - integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== +domexception@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" + integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw== dependencies: - domelementtype "^2.2.0" + webidl-conversions "^7.0.0" domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3: version "5.0.3" @@ -2601,14 +2729,10 @@ domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -domutils@^2.5.2, domutils@^2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" - integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== - dependencies: - dom-serializer "^1.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" +dompurify@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.0.tgz#c9c88390f024c2823332615c9e20a453cf3825dd" + integrity sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA== domutils@^3.0.1: version "3.0.1" @@ -2638,45 +2762,27 @@ easygettext@2.17.0: "@vue/compiler-sfc" "^3.0.0" pug "^3.0.2" -editorconfig@^0.15.3: - version "0.15.3" - resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" - integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g== +ejs@^3.1.6: + version "3.1.8" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.8.tgz#758d32910c78047585c7ef1f92f9ee041c1c190b" + integrity sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ== dependencies: - commander "^2.19.0" - lru-cache "^4.1.5" - semver "^5.6.0" - sigmund "^1.0.1" - -electron-to-chromium@^1.4.188: - version "1.4.200" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.200.tgz#6e4c5266106688965b4ea7caa11f0dd315586854" - integrity sha512-nPyI7oHc8T64oSqRXrAt99gNMpk0SAgPHw/o+hkNKyb5+bcdnFtZcSO9FUJES5cVkVZvo8u4qiZ1gQILl8UXsA== + jake "^10.8.5" -emittery@^0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.8.1.tgz#bb23cc86d03b30aa75a7f734819dee2e1ba70860" - integrity sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg== +electron-to-chromium@^1.4.202: + version "1.4.242" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.242.tgz#51284820b0e6f6ce6c60d3945a3c4f9e4bd88f5f" + integrity sha512-nPdgMWtjjWGCtreW/2adkrB2jyHjClo9PtVhR6rW+oxa4E4Wom642Tn+5LslHP3XPL5MCpkn5/UEY60EXylNeQ== emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== -entities@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" - integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== - -entities@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4" - integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q== - -entities@^4.2.0, entities@^4.3.0: - version "4.3.1" - resolved "https://registry.yarnpkg.com/entities/-/entities-4.3.1.tgz#c34062a94c865c322f9d67b4384e4169bcede6a4" - integrity sha512-o4q/dYJlmyjP2zfnaWDUC6A3BQFmVTX+tZPezK7k0GLSU9QYCauscf5Y+qcEPzKL+EixVouYDgLQK5H9GrLpkg== +entities@^4.2.0, entities@^4.3.0, entities@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" + integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== error-ex@^1.3.1: version "1.3.2" @@ -2686,15 +2792,15 @@ error-ex@^1.3.1: is-arrayish "^0.2.1" es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5: - version "1.20.1" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814" - integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA== + version "1.20.2" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.2.tgz#8495a07bc56d342a3b8ea3ab01bd986700c2ccb3" + integrity sha512-XxXQuVNrySBNlEkTYJoDNFe5+s2yIOpzq80sUHEdPdQr0S5nTLz4ZPPPswNIpKseDDUS5yghX1gfLIHQZ1iNuQ== dependencies: call-bind "^1.0.2" es-to-primitive "^1.2.1" function-bind "^1.1.1" function.prototype.name "^1.1.5" - get-intrinsic "^1.1.1" + get-intrinsic "^1.1.2" get-symbol-description "^1.0.0" has "^1.0.3" has-property-descriptors "^1.0.0" @@ -2706,9 +2812,9 @@ es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19 is-shared-array-buffer "^1.0.2" is-string "^1.0.7" is-weakref "^1.0.2" - object-inspect "^1.12.0" + object-inspect "^1.12.2" object-keys "^1.1.1" - object.assign "^4.1.2" + object.assign "^4.1.4" regexp.prototype.flags "^1.4.3" string.prototype.trimend "^1.0.5" string.prototype.trimstart "^1.0.5" @@ -2730,147 +2836,282 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -esbuild-android-64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.50.tgz#a46fc80fa2007690e647680d837483a750a3097f" - integrity sha512-H7iUEm7gUJHzidsBlFPGF6FTExazcgXL/46xxLo6i6bMtPim6ZmXyTccS8yOMpy6HAC6dPZ/JCQqrkkin69n6Q== - -esbuild-android-arm64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.50.tgz#bdda7851fa7f5f770d6ff0ad593a8945d3a0fcdd" - integrity sha512-NFaoqEwa+OYfoYVpQWDMdKII7wZZkAjtJFo1WdnBeCYlYikvUhTnf2aPwPu5qEAw/ie1NYK0yn3cafwP+kP+OQ== - -esbuild-darwin-64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.50.tgz#f0535435f9760766f30db14a991ee5ca94c022a4" - integrity sha512-gDQsCvGnZiJv9cfdO48QqxkRV8oKAXgR2CGp7TdIpccwFdJMHf8hyIJhMW/05b/HJjET/26Us27Jx91BFfEVSA== - -esbuild-darwin-arm64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.50.tgz#76a41a40e8947a15ae62970e9ed2853883c4b16c" - integrity sha512-36nNs5OjKIb/Q50Sgp8+rYW/PqirRiFN0NFc9hEvgPzNJxeJedktXwzfJSln4EcRFRh5Vz4IlqFRScp+aiBBzA== - -esbuild-freebsd-64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.50.tgz#2ed6633c17ed42c20a1bd68e82c4bbc75ea4fb57" - integrity sha512-/1pHHCUem8e/R86/uR+4v5diI2CtBdiWKiqGuPa9b/0x3Nwdh5AOH7lj+8823C6uX1e0ufwkSLkS+aFZiBCWxA== - -esbuild-freebsd-arm64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.50.tgz#cb115f4cdafe9cdbe58875ba482fccc54d32aa43" - integrity sha512-iKwUVMQztnPZe5pUYHdMkRc9aSpvoV1mkuHlCoPtxZA3V+Kg/ptpzkcSY+fKd0kuom+l6Rc93k0UPVkP7xoqrw== - -esbuild-linux-32@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.50.tgz#fe2b724994dcf1d4e48dc4832ff008ad7d00bcfd" - integrity sha512-sWUwvf3uz7dFOpLzYuih+WQ7dRycrBWHCdoXJ4I4XdMxEHCECd8b7a9N9u7FzT6XR2gHPk9EzvchQUtiEMRwqw== - -esbuild-linux-64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.50.tgz#7851ab5151df9501a2187bd4909c594ad232b623" - integrity sha512-u0PQxPhaeI629t4Y3EEcQ0wmWG+tC/LpP2K7yDFvwuPq0jSQ8SIN+ARNYfRjGW15O2we3XJvklbGV0wRuUCPig== - -esbuild-linux-arm64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.50.tgz#76a76afef484a0512f1fbbcc762edd705dee8892" - integrity sha512-ZyfoNgsTftD7Rp5S7La5auomKdNeB3Ck+kSKXC4pp96VnHyYGjHHXWIlcbH8i+efRn9brszo1/Thl1qn8RqmhQ== - -esbuild-linux-arm@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.50.tgz#6d7a8c0712091b0c3a668dd5d8b5c924adbaeb12" - integrity sha512-VALZq13bhmFJYFE/mLEb+9A0w5vo8z+YDVOWeaf9vOTrSC31RohRIwtxXBnVJ7YKLYfEMzcgFYf+OFln3Y0cWg== - -esbuild-linux-mips64le@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.50.tgz#43426909c1884c5dc6b40765673a08a7ec1d2064" - integrity sha512-ygo31Vxn/WrmjKCHkBoutOlFG5yM9J2UhzHb0oWD9O61dGg+Hzjz9hjf5cmM7FBhAzdpOdEWHIrVOg2YAi6rTw== - -esbuild-linux-ppc64le@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.50.tgz#c754ea3da1dd180c6e9b6b508dc18ce983d92b11" - integrity sha512-xWCKU5UaiTUT6Wz/O7GKP9KWdfbsb7vhfgQzRfX4ahh5NZV4ozZ4+SdzYG8WxetsLy84UzLX3Pi++xpVn1OkFQ== - -esbuild-linux-riscv64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.50.tgz#f3b2dd3c4c2b91bf191d3b98a9819c8aa6f5ad7f" - integrity sha512-0+dsneSEihZTopoO9B6Z6K4j3uI7EdxBP7YSF5rTwUgCID+wHD3vM1gGT0m+pjCW+NOacU9kH/WE9N686FHAJg== - -esbuild-linux-s390x@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.50.tgz#3dfbc4578b2a81995caabb79df2b628ea86a5390" - integrity sha512-tVjqcu8o0P9H4StwbIhL1sQYm5mWATlodKB6dpEZFkcyTI8kfIGWiWcrGmkNGH2i1kBUOsdlBafPxR3nzp3TDA== - -esbuild-netbsd-64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.50.tgz#17dbf51eaa48d983e794b588d195415410ef8c85" - integrity sha512-0R/glfqAQ2q6MHDf7YJw/TulibugjizBxyPvZIcorH0Mb7vSimdHy0XF5uCba5CKt+r4wjax1mvO9lZ4jiAhEg== - -esbuild-openbsd-64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.50.tgz#cf6b1a50c8cf67b0725aaa4bce9773976168c50e" - integrity sha512-7PAtmrR5mDOFubXIkuxYQ4bdNS6XCK8AIIHUiZxq1kL8cFIH5731jPcXQ4JNy/wbj1C9sZ8rzD8BIM80Tqk29w== - -esbuild-sunos-64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.50.tgz#f705ae0dd914c3b45dc43319c4f532216c3d841f" - integrity sha512-gBxNY/wyptvD7PkHIYcq7se6SQEXcSC8Y7mE0FJB+CGgssEWf6vBPfTTZ2b6BWKnmaP6P6qb7s/KRIV5T2PxsQ== - -esbuild-windows-32@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.50.tgz#6364905a99c1e6c1e2fe7bfccebd958131b1cd6c" - integrity sha512-MOOe6J9cqe/iW1qbIVYSAqzJFh0p2LBLhVUIWdMVnNUNjvg2/4QNX4oT4IzgDeldU+Bym9/Tn6+DxvUHJXL5Zw== - -esbuild-windows-64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.50.tgz#56603cb6367e30d14098deb77de6aa18d76dd89b" - integrity sha512-r/qE5Ex3w1jjGv/JlpPoWB365ldkppUlnizhMxJgojp907ZF1PgLTuW207kgzZcSCXyquL9qJkMsY+MRtaZ5yQ== - -esbuild-windows-arm64@0.14.50: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.50.tgz#e7ddde6a97194051a5a4ac05f4f5900e922a7ea5" - integrity sha512-EMS4lQnsIe12ZyAinOINx7eq2mjpDdhGZZWDwPZE/yUTN9cnc2Ze/xUTYIAyaJqrqQda3LnDpADKpvLvol6ENQ== - -esbuild@^0.14.14: - version "0.14.50" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.50.tgz#7a665392c8df94bf6e1ae1e999966a5ee62c6cbc" - integrity sha512-SbC3k35Ih2IC6trhbMYW7hYeGdjPKf9atTKwBUHqMCYFZZ9z8zhuvfnZihsnJypl74FjiAKjBRqFkBkAd0rS/w== +esbuild-android-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be" + integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ== + +esbuild-android-64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.7.tgz#a521604d8c4c6befc7affedc897df8ccde189bea" + integrity sha512-p7rCvdsldhxQr3YHxptf1Jcd86dlhvc3EQmQJaZzzuAxefO9PvcI0GLOa5nCWem1AJ8iMRu9w0r5TG8pHmbi9w== + +esbuild-android-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771" + integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg== + +esbuild-android-arm64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.7.tgz#307b81f1088bf1e81dfe5f3d1d63a2d2a2e3e68e" + integrity sha512-L775l9ynJT7rVqRM5vo+9w5g2ysbOCfsdLV4CWanTZ1k/9Jb3IYlQ06VCI1edhcosTYJRECQFJa3eAvkx72eyQ== + +esbuild-darwin-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25" + integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug== + +esbuild-darwin-64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.7.tgz#270117b0c4ec6bcbc5cf3a297a7d11954f007e11" + integrity sha512-KGPt3r1c9ww009t2xLB6Vk0YyNOXh7hbjZ3EecHoVDxgtbUlYstMPDaReimKe6eOEfyY4hBEEeTvKwPsiH5WZg== + +esbuild-darwin-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73" + integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw== + +esbuild-darwin-arm64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.7.tgz#97851eacd11dacb7719713602e3319e16202fc77" + integrity sha512-kBIHvtVqbSGajN88lYMnR3aIleH3ABZLLFLxwL2stiuIGAjGlQW741NxVTpUHQXUmPzxi6POqc9npkXa8AcSZQ== + +esbuild-freebsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d" + integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg== + +esbuild-freebsd-64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.7.tgz#1de15ffaf5ae916aa925800aa6d02579960dd8c4" + integrity sha512-hESZB91qDLV5MEwNxzMxPfbjAhOmtfsr9Wnuci7pY6TtEh4UDuevmGmkUIjX/b+e/k4tcNBMf7SRQ2mdNuK/HQ== + +esbuild-freebsd-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48" + integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q== + +esbuild-freebsd-arm64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.7.tgz#0f160dbf5c9a31a1d8dd87acbbcb1a04b7031594" + integrity sha512-dLFR0ChH5t+b3J8w0fVKGvtwSLWCv7GYT2Y2jFGulF1L5HftQLzVGN+6pi1SivuiVSmTh28FwUhi9PwQicXI6Q== + +esbuild-linux-32@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5" + integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw== + +esbuild-linux-32@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.15.7.tgz#422eb853370a5e40bdce8b39525380de11ccadec" + integrity sha512-v3gT/LsONGUZcjbt2swrMjwxo32NJzk+7sAgtxhGx1+ZmOFaTRXBAi1PPfgpeo/J//Un2jIKm/I+qqeo4caJvg== + +esbuild-linux-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652" + integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg== + +esbuild-linux-64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.15.7.tgz#f89c468453bb3194b14f19dc32e0b99612e81d2b" + integrity sha512-LxXEfLAKwOVmm1yecpMmWERBshl+Kv5YJ/1KnyAr6HRHFW8cxOEsEfisD3sVl/RvHyW//lhYUVSuy9jGEfIRAQ== + +esbuild-linux-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b" + integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig== + +esbuild-linux-arm64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.7.tgz#68a79d6eb5e032efb9168a0f340ccfd33d6350a1" + integrity sha512-P3cfhudpzWDkglutWgXcT2S7Ft7o2e3YDMrP1n0z2dlbUZghUkKCyaWw0zhp4KxEEzt/E7lmrtRu/pGWnwb9vw== + +esbuild-linux-arm@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59" + integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw== + +esbuild-linux-arm@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.7.tgz#2b7c784d0b3339878013dfa82bf5eaf82c7ce7d3" + integrity sha512-JKgAHtMR5f75wJTeuNQbyznZZa+pjiUHV7sRZp42UNdyXC6TiUYMW/8z8yIBAr2Fpad8hM1royZKQisqPABPvQ== + +esbuild-linux-mips64le@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34" + integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw== + +esbuild-linux-mips64le@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.7.tgz#bb8330a50b14aa84673816cb63cc6c8b9beb62cc" + integrity sha512-T7XKuxl0VpeFLCJXub6U+iybiqh0kM/bWOTb4qcPyDDwNVhLUiPcGdG2/0S7F93czUZOKP57YiLV8YQewgLHKw== + +esbuild-linux-ppc64le@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e" + integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ== + +esbuild-linux-ppc64le@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.7.tgz#52544e7fa992811eb996674090d0bc41f067a14b" + integrity sha512-6mGuC19WpFN7NYbecMIJjeQgvDb5aMuvyk0PDYBJrqAEMkTwg3Z98kEKuCm6THHRnrgsdr7bp4SruSAxEM4eJw== + +esbuild-linux-riscv64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8" + integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg== + +esbuild-linux-riscv64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.7.tgz#a43ae60697992b957e454cbb622f7ee5297e8159" + integrity sha512-uUJsezbswAYo/X7OU/P+PuL/EI9WzxsEQXDekfwpQ23uGiooxqoLFAPmXPcRAt941vjlY9jtITEEikWMBr+F/g== + +esbuild-linux-s390x@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6" + integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA== + +esbuild-linux-s390x@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.7.tgz#8c76a125dd10a84c166294d77416caaf5e1c7b64" + integrity sha512-+tO+xOyTNMc34rXlSxK7aCwJgvQyffqEM5MMdNDEeMU3ss0S6wKvbBOQfgd5jRPblfwJ6b+bKiz0g5nABpY0QQ== + +esbuild-netbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81" + integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w== + +esbuild-netbsd-64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.7.tgz#19b2e75449d7d9c32b5d8a222bac2f1e0c3b08fd" + integrity sha512-yVc4Wz+Pu3cP5hzm5kIygNPrjar/v5WCSoRmIjCPWfBVJkZNb5brEGKUlf+0Y759D48BCWa0WHrWXaNy0DULTQ== + +esbuild-node-loader@^0.6.5: + version "0.6.5" + resolved "https://registry.yarnpkg.com/esbuild-node-loader/-/esbuild-node-loader-0.6.5.tgz#c0aad436d01542150a8297b99dab71aa82add818" + integrity sha512-uPP+dllWm38cFvDysdocutN3lfe5pTIbddAHp1ENyLzpHYqE2r+3Wo+pfg9X3p8DFWwzIisft5YkeBIthIcixw== + dependencies: + esbuild ">=0.13.12" + +esbuild-openbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b" + integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw== + +esbuild-openbsd-64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.7.tgz#1357b2bf72fd037d9150e751420a1fe4c8618ad7" + integrity sha512-GsimbwC4FSR4lN3wf8XmTQ+r8/0YSQo21rWDL0XFFhLHKlzEA4SsT1Tl8bPYu00IU6UWSJ+b3fG/8SB69rcuEQ== + +esbuild-register@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/esbuild-register/-/esbuild-register-3.3.3.tgz#5bd80025c80caf77e6484ced5cc77233b1d39688" + integrity sha512-eFHOkutgIMJY5gc8LUp/7c+LLlDqzNi9T6AwCZ2WKKl3HmT+5ef3ZRyPPxDOynInML0fgaC50yszPKfPnjC0NQ== + +esbuild-sunos-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da" + integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw== + +esbuild-sunos-64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.7.tgz#87ab2c604592a9c3c763e72969da0d72bcde91d2" + integrity sha512-8CDI1aL/ts0mDGbWzjEOGKXnU7p3rDzggHSBtVryQzkSOsjCHRVe0iFYUuhczlxU1R3LN/E7HgUO4NXzGGP/Ag== + +esbuild-windows-32@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31" + integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w== + +esbuild-windows-32@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.15.7.tgz#c81e688c0457665a8d463a669e5bf60870323e99" + integrity sha512-cOnKXUEPS8EGCzRSFa1x6NQjGhGsFlVgjhqGEbLTPsA7x4RRYiy2RKoArNUU4iR2vHmzqS5Gr84MEumO/wxYKA== + +esbuild-windows-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4" + integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ== + +esbuild-windows-64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.15.7.tgz#2421d1ae34b0561a9d6767346b381961266c4eff" + integrity sha512-7MI08Ec2sTIDv+zH6StNBKO+2hGUYIT42GmFyW6MBBWWtJhTcQLinKS6ldIN1d52MXIbiJ6nXyCJ+LpL4jBm3Q== + +esbuild-windows-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982" + integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg== + +esbuild-windows-arm64@0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.7.tgz#7d5e9e060a7b454cb2f57f84a3f3c23c8f30b7d2" + integrity sha512-R06nmqBlWjKHddhRJYlqDd3Fabx9LFdKcjoOy08YLimwmsswlFBJV4rXzZCxz/b7ZJXvrZgj8DDv1ewE9+StMw== + +esbuild@>=0.13.0, esbuild@>=0.13.12, esbuild@^0.15.6: + version "0.15.7" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.7.tgz#8a1f1aff58671a3199dd24df95314122fc1ddee8" + integrity sha512-7V8tzllIbAQV1M4QoE52ImKu8hT/NLGlGXkiDsbEU5PS6K8Mn09ZnYoS+dcmHxOS9CRsV4IRAMdT3I67IyUNXw== + optionalDependencies: + "@esbuild/linux-loong64" "0.15.7" + esbuild-android-64 "0.15.7" + esbuild-android-arm64 "0.15.7" + esbuild-darwin-64 "0.15.7" + esbuild-darwin-arm64 "0.15.7" + esbuild-freebsd-64 "0.15.7" + esbuild-freebsd-arm64 "0.15.7" + esbuild-linux-32 "0.15.7" + esbuild-linux-64 "0.15.7" + esbuild-linux-arm "0.15.7" + esbuild-linux-arm64 "0.15.7" + esbuild-linux-mips64le "0.15.7" + esbuild-linux-ppc64le "0.15.7" + esbuild-linux-riscv64 "0.15.7" + esbuild-linux-s390x "0.15.7" + esbuild-netbsd-64 "0.15.7" + esbuild-openbsd-64 "0.15.7" + esbuild-sunos-64 "0.15.7" + esbuild-windows-32 "0.15.7" + esbuild-windows-64 "0.15.7" + esbuild-windows-arm64 "0.15.7" + +esbuild@^0.14.47: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2" + integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA== optionalDependencies: - esbuild-android-64 "0.14.50" - esbuild-android-arm64 "0.14.50" - esbuild-darwin-64 "0.14.50" - esbuild-darwin-arm64 "0.14.50" - esbuild-freebsd-64 "0.14.50" - esbuild-freebsd-arm64 "0.14.50" - esbuild-linux-32 "0.14.50" - esbuild-linux-64 "0.14.50" - esbuild-linux-arm "0.14.50" - esbuild-linux-arm64 "0.14.50" - esbuild-linux-mips64le "0.14.50" - esbuild-linux-ppc64le "0.14.50" - esbuild-linux-riscv64 "0.14.50" - esbuild-linux-s390x "0.14.50" - esbuild-netbsd-64 "0.14.50" - esbuild-openbsd-64 "0.14.50" - esbuild-sunos-64 "0.14.50" - esbuild-windows-32 "0.14.50" - esbuild-windows-64 "0.14.50" - esbuild-windows-arm64 "0.14.50" + "@esbuild/linux-loong64" "0.14.54" + esbuild-android-64 "0.14.54" + esbuild-android-arm64 "0.14.54" + esbuild-darwin-64 "0.14.54" + esbuild-darwin-arm64 "0.14.54" + esbuild-freebsd-64 "0.14.54" + esbuild-freebsd-arm64 "0.14.54" + esbuild-linux-32 "0.14.54" + esbuild-linux-64 "0.14.54" + esbuild-linux-arm "0.14.54" + esbuild-linux-arm64 "0.14.54" + esbuild-linux-mips64le "0.14.54" + esbuild-linux-ppc64le "0.14.54" + esbuild-linux-riscv64 "0.14.54" + esbuild-linux-s390x "0.14.54" + esbuild-netbsd-64 "0.14.54" + esbuild-openbsd-64 "0.14.54" + esbuild-sunos-64 "0.14.54" + esbuild-windows-32 "0.14.54" + esbuild-windows-64 "0.14.54" + esbuild-windows-arm64 "0.14.54" escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: +escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== -escape-string-regexp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" - integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== - escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" @@ -2888,10 +3129,18 @@ escodegen@^2.0.0: optionalDependencies: source-map "~0.6.1" -eslint-config-standard@16.0.3: - version "16.0.3" - resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-16.0.3.tgz#6c8761e544e96c531ff92642eeb87842b8488516" - integrity sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg== +eslint-config-standard@17.0.0, eslint-config-standard@^17.0.0: + version "17.0.0" + resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-17.0.0.tgz#fd5b6cf1dcf6ba8d29f200c461de2e19069888cf" + integrity sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg== + +eslint-import-resolver-custom-alias@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-custom-alias/-/eslint-import-resolver-custom-alias-1.3.0.tgz#361858d18103edd19ac69284b95e276e91a3cf7c" + integrity sha512-9rrpduF6/SZHFXrJgjeA+edJek6xulplYfo/UJvLPrY38O9UY00rAq76dHRnZ289yftc5NIfx3THi0IILRQ3dg== + dependencies: + glob-parent "^5.1.0" + resolve "^1.3.0" eslint-import-resolver-node@^0.3.6: version "0.3.6" @@ -2901,13 +3150,12 @@ eslint-import-resolver-node@^0.3.6: debug "^3.2.7" resolve "^1.20.0" -eslint-module-utils@^2.7.2: - version "2.7.3" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz#ad7e3a10552fdd0642e1e55292781bd6e34876ee" - integrity sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ== +eslint-module-utils@^2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz#4f3e41116aaf13a20792261e61d3a2e7e0583974" + integrity sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA== dependencies: debug "^3.2.7" - find-up "^2.1.0" eslint-plugin-es@^3.0.0: version "3.0.1" @@ -2917,31 +3165,53 @@ eslint-plugin-es@^3.0.0: eslint-utils "^2.0.0" regexpp "^3.0.0" -eslint-plugin-html@6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.2.0.tgz#715bc00b50bbd0d996e28f953c289a5ebec69d43" - integrity sha512-vi3NW0E8AJombTvt8beMwkL1R/fdRWl4QSNRNMhVQKWm36/X0KF0unGNAY4mqUF06mnwVWZcIcerrCnfn9025g== +eslint-plugin-es@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-4.1.0.tgz#f0822f0c18a535a97c3e714e89f88586a7641ec9" + integrity sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ== + dependencies: + eslint-utils "^2.0.0" + regexpp "^3.0.0" + +eslint-plugin-html@7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-7.1.0.tgz#aec2a3772b40ccf51a5be4f972f07600539d3b3e" + integrity sha512-fNLRraV/e6j8e3XYOC9xgND4j+U7b1Rq+OygMlLcMg+wI/IpVbF+ubQa3R78EjKB9njT6TQOlcK5rFKBVVtdfg== dependencies: - htmlparser2 "^7.1.2" + htmlparser2 "^8.0.1" -eslint-plugin-import@2.25.4: - version "2.25.4" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz#322f3f916a4e9e991ac7af32032c25ce313209f1" - integrity sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA== +eslint-plugin-import@2.26.0, eslint-plugin-import@^2.26.0: + version "2.26.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b" + integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA== dependencies: array-includes "^3.1.4" array.prototype.flat "^1.2.5" debug "^2.6.9" doctrine "^2.1.0" eslint-import-resolver-node "^0.3.6" - eslint-module-utils "^2.7.2" + eslint-module-utils "^2.7.3" has "^1.0.3" - is-core-module "^2.8.0" + is-core-module "^2.8.1" is-glob "^4.0.3" - minimatch "^3.0.4" + minimatch "^3.1.2" object.values "^1.1.5" - resolve "^1.20.0" - tsconfig-paths "^3.12.0" + resolve "^1.22.0" + tsconfig-paths "^3.14.1" + +eslint-plugin-n@15.2.5, eslint-plugin-n@^15.2.4: + version "15.2.5" + resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-15.2.5.tgz#aa7ff8d45bb8bf2df8ea3b7d3774ae570cb794b8" + integrity sha512-8+BYsqiyZfpu6NXmdLOXVUfk8IocpCjpd8nMRRH0A9ulrcemhb2VI9RSJMEy5udx++A/YcVPD11zT8hpFq368g== + dependencies: + builtins "^5.0.1" + eslint-plugin-es "^4.1.0" + eslint-utils "^3.0.0" + ignore "^5.1.1" + is-core-module "^2.10.0" + minimatch "^3.1.2" + resolve "^1.22.1" + semver "^7.3.7" eslint-plugin-node@11.1.0: version "11.1.0" @@ -2955,20 +3225,23 @@ eslint-plugin-node@11.1.0: resolve "^1.10.1" semver "^6.1.0" -eslint-plugin-promise@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-6.0.0.tgz#017652c07c9816413a41e11c30adc42c3d55ff18" - integrity sha512-7GPezalm5Bfi/E22PnQxDWH2iW9GTvAlUNTztemeHb6c1BniSyoeTrM87JkC0wYdi6aQrZX9p2qEiAno8aTcbw== +eslint-plugin-promise@6.0.1, eslint-plugin-promise@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-6.0.1.tgz#a8cddf96a67c4059bdabf4d724a29572188ae423" + integrity sha512-uM4Tgo5u3UWQiroOyDEsYcVMOo7re3zmno0IZmB5auxoaQNIceAbXEkSt8RNrKtaYehARHG06pYK6K1JhtP0Zw== -eslint-plugin-vue@7.20.0: - version "7.20.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-7.20.0.tgz#98c21885a6bfdf0713c3a92957a5afeaaeed9253" - integrity sha512-oVNDqzBC9h3GO+NTgWeLMhhGigy6/bQaQbHS+0z7C4YEu/qK/yxHvca/2PTZtGNPsCrHwOTgKMrwu02A9iPBmw== +eslint-plugin-vue@9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.4.0.tgz#31c2d9002b5bb437b351a5feffdf37c4397e5cb9" + integrity sha512-Nzz2QIJ8FG+rtJaqT/7/ru5ie2XgT9KCudkbN0y3uFYhQ41nuHEaboLAiqwMcK006hZPQv/rVMRhUIwEGhIvfQ== dependencies: - eslint-utils "^2.1.0" + eslint-utils "^3.0.0" natural-compare "^1.4.0" - semver "^6.3.0" - vue-eslint-parser "^7.10.0" + nth-check "^2.0.1" + postcss-selector-parser "^6.0.9" + semver "^7.3.5" + vue-eslint-parser "^9.0.1" + xml-name-validator "^4.0.0" eslint-scope@^5.1.1: version "5.1.1" @@ -2986,7 +3259,7 @@ eslint-scope@^7.1.1: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-utils@^2.0.0, eslint-utils@^2.1.0: +eslint-utils@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== @@ -3015,13 +3288,15 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.11.0: - version "8.11.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.11.0.tgz#88b91cfba1356fc10bb9eb592958457dfe09fb37" - integrity sha512-/KRpd9mIRg2raGxHRGwW9ZywYNAClZrHjdueHcrVDuO3a6bj83eoTirCCk0M0yPwOjWYKHwRVRid+xK4F/GHgA== +eslint@8.23.1: + version "8.23.1" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.1.tgz#cfd7b3f7fdd07db8d16b4ac0516a29c8d8dca5dc" + integrity sha512-w7C1IXCc6fNqjpuYd0yPlcTKKmHlHHktRkzmBPZ+7cvNBQuiNjx0xaMTjAJGCafJhQkrFJooREv0CtrVzmHwqg== dependencies: - "@eslint/eslintrc" "^1.2.1" - "@humanwhocodes/config-array" "^0.9.2" + "@eslint/eslintrc" "^1.3.2" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -3031,50 +3306,54 @@ eslint@8.11.0: eslint-scope "^7.1.1" eslint-utils "^3.0.0" eslint-visitor-keys "^3.3.0" - espree "^9.3.1" + espree "^9.4.0" esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" - functional-red-black-tree "^1.0.1" + find-up "^5.0.0" glob-parent "^6.0.1" - globals "^13.6.0" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" ignore "^5.2.0" import-fresh "^3.0.0" imurmurhash "^0.1.4" is-glob "^4.0.0" + js-sdsl "^4.1.4" js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" lodash.merge "^4.6.2" - minimatch "^3.0.4" + minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.1" regexpp "^3.2.0" strip-ansi "^6.0.1" strip-json-comments "^3.1.0" text-table "^0.2.0" - v8-compile-cache "^2.0.3" -espree@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" - integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw== +esno@^0.14.1: + version "0.14.1" + resolved "https://registry.yarnpkg.com/esno/-/esno-0.14.1.tgz#b7557b3c70eda5ae0c3f0daa07739b8337526610" + integrity sha512-yDFYw6dGUjCT1qKsdG7WOc/RzIh/qwxUEVZ+ohCltaxBxEFMNqeqbQL9xjRl6Yvdwrfc5OCjUA9JbFmuu/8BKg== dependencies: - acorn "^7.1.1" - acorn-jsx "^5.2.0" - eslint-visitor-keys "^1.1.0" + cross-spawn "^7.0.3" + esbuild ">=0.13.0" + esbuild-node-loader "^0.6.5" + esbuild-register "^3.3.2" + import-meta-resolve "^1.1.1" -espree@^9.3.1, espree@^9.3.2: - version "9.3.2" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.2.tgz#f58f77bd334731182801ced3380a8cc859091596" - integrity sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA== +espree@^9.3.1, espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== dependencies: - acorn "^8.7.1" + acorn "^8.8.0" acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" -esprima@^4.0.0, esprima@^4.0.1: +esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== @@ -3103,6 +3382,11 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" + integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== + estree-walker@^2.0.1, estree-walker@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" @@ -3113,56 +3397,23 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -execa@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== - -expect@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/expect/-/expect-27.5.1.tgz#83ce59f1e5bdf5f9d2b94b61d2050db48f3fef74" - integrity sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw== - dependencies: - "@jest/types" "^27.5.1" - jest-get-type "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - -extend-shallow@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" - integrity sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug== - dependencies: - is-extendable "^0.1.0" - -extract-from-css@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/extract-from-css/-/extract-from-css-0.4.4.tgz#1ea7df2e7c7c6eb9922fa08e8adaea486f6f8f92" - integrity sha512-41qWGBdtKp9U7sgBxAQ7vonYqSXzgW/SiAYzq4tdWSVhAShvpVCH1nyvPQgjse6EdgbW7Y7ERdT3674/lKr65A== - dependencies: - css "^2.1.0" - fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-json-stable-stringify@^2.0.0: +fast-glob@^3.2.11, fast-glob@^3.2.9: + version "3.2.11" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" + integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -3172,12 +3423,12 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== -fb-watchman@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" - integrity sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg== +fastq@^1.6.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" + integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw== dependencies: - bser "2.1.1" + reusify "^1.0.4" file-entry-cache@^6.0.1: version "6.0.1" @@ -3186,10 +3437,12 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-uri-to-path@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" - integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== +filelist@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" + integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== + dependencies: + minimatch "^5.0.1" fill-range@^7.0.1: version "7.0.1" @@ -3198,27 +3451,19 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -find-babel-config@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/find-babel-config/-/find-babel-config-1.2.0.tgz#a9b7b317eb5b9860cda9d54740a8c8337a2283a2" - integrity sha512-jB2CHJeqy6a820ssiqwrKMeyC6nNdmrcgkKWJWmpoxpE8RKciYJXCcXRq1h2AzCo5I5BJeN2tkGEO3hLTuePRA== - dependencies: - json5 "^0.5.1" - path-exists "^3.0.0" - -find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ== +find-replace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" + integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ== dependencies: - locate-path "^2.0.0" + array-back "^3.0.1" -find-up@^4.0.0, find-up@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== dependencies: - locate-path "^5.0.0" + locate-path "^6.0.0" path-exists "^4.0.0" flat-cache@^3.0.4: @@ -3230,32 +3475,32 @@ flat-cache@^3.0.4: rimraf "^3.0.2" flatted@^3.1.0: - version "3.2.6" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.6.tgz#022e9218c637f9f3fc9c35ab9c9193f05add60b2" - integrity sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ== + version "3.2.7" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" + integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== -flow-parser@^0.183.0: - version "0.183.0" - resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.183.0.tgz#69bcd35608ef179c619df0036c2b61d0f84665ae" - integrity sha512-2e/aIZIM7iJpHCBxpqdXetYYoO3YQEJzA7M8v5bhWhXCu+lIfkeSfOWycWW0rhlnJyjMftbmwn6B2eenKeGlag== +flow-parser@^0.186.0: + version "0.186.0" + resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.186.0.tgz#ef6f4c7a3d8eb29fdd96e1d1f651b7ccb210f8e9" + integrity sha512-QaPJczRxNc/yvp3pawws439VZ/vHGq+i1/mZ3bEdSaRy8scPgZgiWklSB6jN7y5NR9sfgL4GGIiBcMXTj3Opqg== flow-remove-types@^2.135.0: - version "2.183.0" - resolved "https://registry.yarnpkg.com/flow-remove-types/-/flow-remove-types-2.183.0.tgz#3c1f8d247e8fa34e207a0a246dcba8065ebaf07d" - integrity sha512-hWaRTfRYX4Cm4CHEjRATmxg01Pe+KPNkGx/O3L1XLCQPh3v/W1QiqV7vB1B884KImdfzpXgz5gL4sRf2k808rw== + version "2.186.0" + resolved "https://registry.yarnpkg.com/flow-remove-types/-/flow-remove-types-2.186.0.tgz#cea279219dab6b9c459f3214b6c5ff1413ec0dda" + integrity sha512-TFeKuYZg82UieW7QQbrWvP5YkvAhZx0kAsgpCbyRDHFHOkftnwd8RE0yloQy3gtMC3abCg5Fh+8W1qVrmTKZlw== dependencies: - flow-parser "^0.183.0" + flow-parser "^0.186.0" pirates "^3.0.2" vlq "^0.2.1" -focus-trap@6.7.3: - version "6.7.3" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.7.3.tgz#b5dc195b49c90001f08a63134471d1e6dd381ddd" - integrity sha512-8xCEKndV4KrseGhFKKKmczVA14yx1/hnmFICPOjcFjToxCJYj/NHH43tPc3YE/PLnLRNZoFug0EcWkGQde/miQ== +focus-trap@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-7.0.0.tgz#4e2cd9aab5b673e2cbad945c52bd232f6160c2cb" + integrity sha512-uT4Bl8TwU+5vVAx/DHil/1eVS54k9unqhK/vGy2KSh7esPmqgC0koAB9J2sJ+vtj8+vmiFyGk2unLkhNLQaxoA== dependencies: - tabbable "^5.2.1" + tabbable "^6.0.0" -follow-redirects@^1.14.8: +follow-redirects@^1.14.9: version "1.15.1" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== @@ -3267,21 +3512,24 @@ fomantic-ui-css@2.8.8: dependencies: jquery "^3.4.0" -form-data@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" - integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== +foreground-child@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" + integrity sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^3.0.2" + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" mime-types "^2.1.12" -fraction.js@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" - integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA== - -fs-extra@^9.1.0: +fs-extra@^9.0.1: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -3296,7 +3544,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@~2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -3331,7 +3579,7 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -get-caller-file@^2.0.1, get-caller-file@^2.0.5: +get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== @@ -3341,7 +3589,7 @@ get-func-name@^2.0.0: resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" integrity sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig== -get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: +get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1, get-intrinsic@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.2.tgz#336975123e05ad0b7ba41f152ee4aadbea6cf598" integrity sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA== @@ -3350,15 +3598,10 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.3" -get-package-type@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" - integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== - -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +get-own-enumerable-property-symbols@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" + integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== get-symbol-description@^1.0.0: version "1.0.0" @@ -3368,13 +3611,25 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" -glob-all@3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/glob-all/-/glob-all-3.3.0.tgz#2019896fbaeb37bc451809cf0cb1e5d2b3e345b2" - integrity sha512-30gCh9beSb+YSAh0vsoIlBRm4bSlyMa+5nayax1EJhjwYrCohX0aDxcxvWVe3heOrJikbHgRs75Af6kPLcumew== +gettext-extractor@^3.5.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/gettext-extractor/-/gettext-extractor-3.5.4.tgz#bd36c65b4d26014ffd925f9ac7b4738d6893d6b2" + integrity sha512-iK4tSnteSw+pFMts43OP8hUnsOklbkxz3ytWqru7dPf8Ec3uzTYv1aw70ojAvKItmofpj1ibfY7sZWsdSN6zIw== + dependencies: + "@types/glob" "5 - 7" + "@types/parse5" "^5" + css-selector-parser "^1.3" + glob "5 - 7" + parse5 "5 - 6" + pofile "1.0.x" + typescript "2 - 4" + +glob-parent@^5.1.0, glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: - glob "^7.1.2" - yargs "^15.3.1" + is-glob "^4.0.1" glob-parent@^6.0.1: version "6.0.2" @@ -3383,14 +3638,7 @@ glob-parent@^6.0.1: dependencies: is-glob "^4.0.3" -glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: +"glob@5 - 7", glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -3407,29 +3655,34 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.15.0, globals@^13.6.0: +globals@^13.15.0: version "13.17.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.17.0.tgz#902eb1e680a41da93945adbdcb5a9f361ba69bd4" integrity sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw== dependencies: type-fest "^0.20.2" -globals@^9.18.0: - version "9.18.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" - integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ== +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" -graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.9: +graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.10" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== -has-ansi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" - integrity sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg== - dependencies: - ansi-regex "^2.0.0" +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" @@ -3453,7 +3706,7 @@ has-property-descriptors@^1.0.0: dependencies: get-intrinsic "^1.1.1" -has-symbols@^1.0.1, has-symbols@^1.0.2, has-symbols@^1.0.3: +has-symbols@^1.0.2, has-symbols@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== @@ -3472,62 +3725,27 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" -hash-sum@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04" - integrity sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA== - -hash-sum@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-2.0.0.tgz#81d01bb5de8ea4a214ad5d6ead1b523460b0b45a" - integrity sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg== - -he@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" - integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== - howler@2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/howler/-/howler-2.2.3.tgz#a2eff9b08b586798e7a2ee17a602a90df28715da" integrity sha512-QM0FFkw0LRX1PR8pNzJVAY25JhIWvbKMBFM4gqk+QdV+kPXOhleWGCB6AiAF/goGjIHK2e/nIElplvjQwhr0jg== -html-encoding-sniffer@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" - integrity sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ== +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== dependencies: - whatwg-encoding "^1.0.5" + whatwg-encoding "^2.0.0" html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== -html-tags@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-2.0.0.tgz#10b30a386085f43cede353cc8fa7cb0deeea668b" - integrity sha512-+Il6N8cCo2wB/Vd3gqy/8TZhTD3QvcVeQLCnZiGkGCH3JP28IgGAY41giccp2W4R3jfyJPAP318FQTa1yU7K7g== - -htmlparser2@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" - integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.0.0" - domutils "^2.5.2" - entities "^2.0.0" - -htmlparser2@^7.1.2: - version "7.2.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-7.2.0.tgz#8817cdea38bbc324392a90b1990908e81a65f5a5" - integrity sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.2" - domutils "^2.8.0" - entities "^3.0.1" +html-tags@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.2.0.tgz#dbb3518d20b726524e4dd43de397eb0a95726961" + integrity sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg== htmlparser2@^8.0.1: version "8.0.1" @@ -3539,16 +3757,16 @@ htmlparser2@^8.0.1: domutils "^3.0.1" entities "^4.3.0" -http-proxy-agent@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" - integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== dependencies: - "@tootallnate/once" "1" + "@tootallnate/once" "2" agent-base "6" debug "4" -https-proxy-agent@^5.0.0: +https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== @@ -3556,17 +3774,17 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - -iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== dependencies: - safer-buffer ">= 2.1.2 < 3" + safer-buffer ">= 2.1.2 < 3.0.0" + +idb@^7.0.1: + version "7.0.2" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.0.2.tgz#7a067e20dd16539938e456814b7d714ba8db3892" + integrity sha512-jjKrT1EnyZewQ/gCBb/eyiYrhGzws2FeY92Yx8qT9S9GeQAmo4JFVIiWRIfKW/6Ob9A+UDAOW9j9jn58fy2HIg== ignore@^5.1.1, ignore@^5.2.0: version "5.2.0" @@ -3586,13 +3804,12 @@ import-fresh@^3.0.0, import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-local@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" - integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== +import-meta-resolve@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/import-meta-resolve/-/import-meta-resolve-1.1.1.tgz#244fd542fd1fae73550d4f8b3cde3bba1d7b2b18" + integrity sha512-JiTuIvVyPaUg11eTrNDx5bgQ/yMKMZffc7YSjvQeSMXy58DO2SQ8BtAf3xteZvmzvjYh14wnqNjL8XVeDy2o9A== dependencies: - pkg-dir "^4.2.0" - resolve-cwd "^3.0.0" + builtins "^4.0.0" imurmurhash@^0.1.4: version "0.1.4" @@ -3607,16 +3824,11 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3: +inherits@2: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@^1.3.4: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - internal-slot@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" @@ -3626,13 +3838,6 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" -invariant@^2.2.2: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -3660,20 +3865,15 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-buffer@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" - integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== - is-callable@^1.1.4, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== -is-core-module@^2.8.0, is-core-module@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" - integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== +is-core-module@^2.10.0, is-core-module@^2.8.1, is-core-module@^2.9.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" + integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== dependencies: has "^1.0.3" @@ -3692,11 +3892,6 @@ is-expression@^4.0.0: acorn "^7.1.1" object-assign "^4.1.1" -is-extendable@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== - is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -3707,11 +3902,6 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-generator-fn@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" - integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== - is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -3719,6 +3909,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" + integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== + is-negative-zero@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" @@ -3736,10 +3931,10 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-plain-object@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" - integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== +is-obj@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + integrity sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg== is-potential-custom-element-name@^1.0.1: version "1.0.1" @@ -3759,6 +3954,11 @@ is-regex@^1.0.3, is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + integrity sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA== + is-shared-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" @@ -3785,11 +3985,6 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" -is-typedarray@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== - is-weakref@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" @@ -3797,11 +3992,6 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" -is-whitespace@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-whitespace/-/is-whitespace-0.3.0.tgz#1639ecb1be036aec69a54cbb401cfbed7114ab7f" - integrity sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg== - isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" @@ -3817,17 +4007,6 @@ istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== -istanbul-lib-instrument@^5.0.4, istanbul-lib-instrument@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz#31d18bdd127f825dd02ea7bfdfd906f8ab840e9f" - integrity sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A== - dependencies: - "@babel/core" "^7.12.3" - "@babel/parser" "^7.14.7" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-coverage "^3.2.0" - semver "^6.3.0" - istanbul-lib-report@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6" @@ -3837,16 +4016,7 @@ istanbul-lib-report@^3.0.0: make-dir "^3.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== - dependencies: - debug "^4.1.1" - istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" - -istanbul-reports@^3.1.3: +istanbul-reports@^3.1.4: version "3.1.5" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.5.tgz#cc9a6ab25cb25659810e4785ed9d9fb742578bae" integrity sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w== @@ -3854,445 +4024,50 @@ istanbul-reports@^3.1.3: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -jest-changed-files@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-27.5.1.tgz#a348aed00ec9bf671cc58a66fcbe7c3dfd6a68f5" - integrity sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw== - dependencies: - "@jest/types" "^27.5.1" - execa "^5.0.0" - throat "^6.0.1" - -jest-circus@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-27.5.1.tgz#37a5a4459b7bf4406e53d637b49d22c65d125ecc" - integrity sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - co "^4.6.0" - dedent "^0.7.0" - expect "^27.5.1" - is-generator-fn "^2.0.0" - jest-each "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - jest-runtime "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - pretty-format "^27.5.1" - slash "^3.0.0" - stack-utils "^2.0.3" - throat "^6.0.1" - -jest-cli@27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-27.5.1.tgz#278794a6e6458ea8029547e6c6cbf673bd30b145" - integrity sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw== - dependencies: - "@jest/core" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" - chalk "^4.0.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - import-local "^3.0.2" - jest-config "^27.5.1" - jest-util "^27.5.1" - jest-validate "^27.5.1" - prompts "^2.0.1" - yargs "^16.2.0" - -jest-config@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-27.5.1.tgz#5c387de33dca3f99ad6357ddeccd91bf3a0e4a41" - integrity sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA== - dependencies: - "@babel/core" "^7.8.0" - "@jest/test-sequencer" "^27.5.1" - "@jest/types" "^27.5.1" - babel-jest "^27.5.1" - chalk "^4.0.0" - ci-info "^3.2.0" - deepmerge "^4.2.2" - glob "^7.1.1" - graceful-fs "^4.2.9" - jest-circus "^27.5.1" - jest-environment-jsdom "^27.5.1" - jest-environment-node "^27.5.1" - jest-get-type "^27.5.1" - jest-jasmine2 "^27.5.1" - jest-regex-util "^27.5.1" - jest-resolve "^27.5.1" - jest-runner "^27.5.1" - jest-util "^27.5.1" - jest-validate "^27.5.1" - micromatch "^4.0.4" - parse-json "^5.2.0" - pretty-format "^27.5.1" - slash "^3.0.0" - strip-json-comments "^3.1.1" - -jest-diff@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" - integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw== - dependencies: - chalk "^4.0.0" - diff-sequences "^27.5.1" - jest-get-type "^27.5.1" - pretty-format "^27.5.1" - -jest-docblock@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" - integrity sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ== - dependencies: - detect-newline "^3.0.0" - -jest-each@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-27.5.1.tgz#5bc87016f45ed9507fed6e4702a5b468a5b2c44e" - integrity sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ== - dependencies: - "@jest/types" "^27.5.1" - chalk "^4.0.0" - jest-get-type "^27.5.1" - jest-util "^27.5.1" - pretty-format "^27.5.1" - -jest-environment-jsdom@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz#ea9ccd1fc610209655a77898f86b2b559516a546" - integrity sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/fake-timers" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - jest-mock "^27.5.1" - jest-util "^27.5.1" - jsdom "^16.6.0" - -jest-environment-node@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-27.5.1.tgz#dedc2cfe52fab6b8f5714b4808aefa85357a365e" - integrity sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/fake-timers" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - jest-mock "^27.5.1" - jest-util "^27.5.1" - -jest-get-type@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" - integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== - -jest-haste-map@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-27.5.1.tgz#9fd8bd7e7b4fa502d9c6164c5640512b4e811e7f" - integrity sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng== - dependencies: - "@jest/types" "^27.5.1" - "@types/graceful-fs" "^4.1.2" - "@types/node" "*" - anymatch "^3.0.3" - fb-watchman "^2.0.0" - graceful-fs "^4.2.9" - jest-regex-util "^27.5.1" - jest-serializer "^27.5.1" - jest-util "^27.5.1" - jest-worker "^27.5.1" - micromatch "^4.0.4" - walker "^1.0.7" - optionalDependencies: - fsevents "^2.3.2" - -jest-jasmine2@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz#a037b0034ef49a9f3d71c4375a796f3b230d1ac4" - integrity sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/source-map" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - co "^4.6.0" - expect "^27.5.1" - is-generator-fn "^2.0.0" - jest-each "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - jest-runtime "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - pretty-format "^27.5.1" - throat "^6.0.1" - -jest-leak-detector@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz#6ec9d54c3579dd6e3e66d70e3498adf80fde3fb8" - integrity sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ== - dependencies: - jest-get-type "^27.5.1" - pretty-format "^27.5.1" - -jest-matcher-utils@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab" - integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw== - dependencies: - chalk "^4.0.0" - jest-diff "^27.5.1" - jest-get-type "^27.5.1" - pretty-format "^27.5.1" - -jest-message-util@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-27.5.1.tgz#bdda72806da10d9ed6425e12afff38cd1458b6cf" - integrity sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g== - dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^27.5.1" - "@types/stack-utils" "^2.0.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^27.5.1" - slash "^3.0.0" - stack-utils "^2.0.3" - -jest-mock@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" - integrity sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og== - dependencies: - "@jest/types" "^27.5.1" - "@types/node" "*" - -jest-pnp-resolver@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" - integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== - -jest-regex-util@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-27.5.1.tgz#4da143f7e9fd1e542d4aa69617b38e4a78365b95" - integrity sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg== - -jest-resolve-dependencies@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz#d811ecc8305e731cc86dd79741ee98fed06f1da8" - integrity sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg== - dependencies: - "@jest/types" "^27.5.1" - jest-regex-util "^27.5.1" - jest-snapshot "^27.5.1" - -jest-resolve@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-27.5.1.tgz#a2f1c5a0796ec18fe9eb1536ac3814c23617b384" - integrity sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw== +jake@^10.8.5: + version "10.8.5" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46" + integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw== dependencies: - "@jest/types" "^27.5.1" - chalk "^4.0.0" - graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-pnp-resolver "^1.2.2" - jest-util "^27.5.1" - jest-validate "^27.5.1" - resolve "^1.20.0" - resolve.exports "^1.1.0" - slash "^3.0.0" - -jest-runner@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-27.5.1.tgz#071b27c1fa30d90540805c5645a0ec167c7b62e5" - integrity sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ== - dependencies: - "@jest/console" "^27.5.1" - "@jest/environment" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - emittery "^0.8.1" - graceful-fs "^4.2.9" - jest-docblock "^27.5.1" - jest-environment-jsdom "^27.5.1" - jest-environment-node "^27.5.1" - jest-haste-map "^27.5.1" - jest-leak-detector "^27.5.1" - jest-message-util "^27.5.1" - jest-resolve "^27.5.1" - jest-runtime "^27.5.1" - jest-util "^27.5.1" - jest-worker "^27.5.1" - source-map-support "^0.5.6" - throat "^6.0.1" - -jest-runtime@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-27.5.1.tgz#4896003d7a334f7e8e4a53ba93fb9bcd3db0a1af" - integrity sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/fake-timers" "^27.5.1" - "@jest/globals" "^27.5.1" - "@jest/source-map" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - chalk "^4.0.0" - cjs-module-lexer "^1.0.0" - collect-v8-coverage "^1.0.0" - execa "^5.0.0" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-message-util "^27.5.1" - jest-mock "^27.5.1" - jest-regex-util "^27.5.1" - jest-resolve "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - slash "^3.0.0" - strip-bom "^4.0.0" - -jest-serializer@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-27.5.1.tgz#81438410a30ea66fd57ff730835123dea1fb1f64" - integrity sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w== - dependencies: - "@types/node" "*" - graceful-fs "^4.2.9" - -jest-snapshot@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-27.5.1.tgz#b668d50d23d38054a51b42c4039cab59ae6eb6a1" - integrity sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA== - dependencies: - "@babel/core" "^7.7.2" - "@babel/generator" "^7.7.2" - "@babel/plugin-syntax-typescript" "^7.7.2" - "@babel/traverse" "^7.7.2" - "@babel/types" "^7.0.0" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/babel__traverse" "^7.0.4" - "@types/prettier" "^2.1.5" - babel-preset-current-node-syntax "^1.0.0" - chalk "^4.0.0" - expect "^27.5.1" - graceful-fs "^4.2.9" - jest-diff "^27.5.1" - jest-get-type "^27.5.1" - jest-haste-map "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - jest-util "^27.5.1" - natural-compare "^1.4.0" - pretty-format "^27.5.1" - semver "^7.3.2" - -jest-util@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-27.5.1.tgz#3ba9771e8e31a0b85da48fe0b0891fb86c01c2f9" - integrity sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw== - dependencies: - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - -jest-validate@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" - integrity sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ== - dependencies: - "@jest/types" "^27.5.1" - camelcase "^6.2.0" - chalk "^4.0.0" - jest-get-type "^27.5.1" - leven "^3.1.0" - pretty-format "^27.5.1" - -jest-watcher@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-27.5.1.tgz#71bd85fb9bde3a2c2ec4dc353437971c43c642a2" - integrity sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw== - dependencies: - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - jest-util "^27.5.1" - string-length "^4.0.1" + async "^3.2.3" + chalk "^4.0.2" + filelist "^1.0.1" + minimatch "^3.0.4" -jest-worker@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" - integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== +jest-worker@^26.2.1: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" + integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== dependencies: "@types/node" "*" merge-stream "^2.0.0" - supports-color "^8.0.0" + supports-color "^7.0.0" jquery@^3.4.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" - integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw== - -js-beautify@^1.6.12, js-beautify@^1.6.14: - version "1.14.4" - resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.14.4.tgz#187d600a835f84de67a6d09ceaf3f199b7284c82" - integrity sha512-+b4A9c3glceZEmxyIbxDOYB0ZJdReLvyU1077RqKsO4dZx9FUHjTOJn8VHwpg33QoucIykOiYbh7MfqBOghnrA== - dependencies: - config-chain "^1.1.13" - editorconfig "^0.15.3" - glob "^7.1.3" - nopt "^5.0.0" + version "3.6.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.1.tgz#fab0408f8b45fc19f956205773b62b292c147a16" + integrity sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw== js-logger@1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/js-logger/-/js-logger-1.6.1.tgz#8f09671b515e4a6f31dced8fdb8923432e2c60af" integrity sha512-yTgMCPXVjhmg28CuUH8CKjU+cIKL/G+zTu4Fn4lQxs8mRFH/03QTNvEFngcxfg/gRDiQAOoyCKmMTOm9ayOzXA== +js-sdsl@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.4.tgz#78793c90f80e8430b7d8dc94515b6c77d98a26a6" + integrity sha512-Y2/yD55y5jteOAmY50JbUZYwk3CP3wnLPEZnlR1w9oKhITrBEtAxwuWKebFf8hMrPMgbYwFoWK/lH2sBkErELw== + js-stringify@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/js-stringify/-/js-stringify-1.0.2.tgz#1736fddfd9724f28a3682adc6230ae7e4e9679db" integrity sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g== -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: +js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-tokens@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" - integrity sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg== - -js-yaml@^3.13.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" @@ -4300,38 +4075,38 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -jsdom@^16.6.0: - version "16.7.0" - resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" - integrity sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw== +jsdom@20.0.0: + version "20.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.0.tgz#882825ac9cc5e5bbee704ba16143e1fa78361ebf" + integrity sha512-x4a6CKCgx00uCmP+QakBDFXwjAJ69IkkIWHmtmjd3wvXPcdOS44hfX2vqkOQrVrq8l9DhNNADZRXaCEWvgXtVA== dependencies: - abab "^2.0.5" - acorn "^8.2.4" + abab "^2.0.6" + acorn "^8.7.1" acorn-globals "^6.0.0" - cssom "^0.4.4" + cssom "^0.5.0" cssstyle "^2.3.0" - data-urls "^2.0.0" - decimal.js "^10.2.1" - domexception "^2.0.1" + data-urls "^3.0.2" + decimal.js "^10.3.1" + domexception "^4.0.0" escodegen "^2.0.0" - form-data "^3.0.0" - html-encoding-sniffer "^2.0.1" - http-proxy-agent "^4.0.1" - https-proxy-agent "^5.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" is-potential-custom-element-name "^1.0.1" nwsapi "^2.2.0" - parse5 "6.0.1" - saxes "^5.0.1" + parse5 "^7.0.0" + saxes "^6.0.0" symbol-tree "^3.2.4" tough-cookie "^4.0.0" w3c-hr-time "^1.0.2" - w3c-xmlserializer "^2.0.0" - webidl-conversions "^6.1.0" - whatwg-encoding "^1.0.5" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.5.0" - ws "^7.4.6" - xml-name-validator "^3.0.0" + w3c-xmlserializer "^3.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + ws "^8.8.0" + xml-name-validator "^4.0.0" jsesc@^2.5.1: version "2.5.2" @@ -4353,16 +4128,21 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-schema@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== -json5@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" - integrity sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw== - json5@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" @@ -4370,7 +4150,7 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.2.1: +json5@^2.2.0, json5@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== @@ -4384,6 +4164,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonpointer@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" + integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ== + jstransformer@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3" @@ -4397,17 +4182,10 @@ just-extend@^4.0.2: resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== -kind-of@^3.0.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== - dependencies: - is-buffer "^1.1.5" - -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +kolorist@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.5.1.tgz#c3d66dc4fabde4f6b7faa6efda84c00491f9e52b" + integrity sha512-lxpCM3HTvquGxKGzHeknB/sUjuVoUElLlfYnXZT73K8geR9jQbroGlSCFBax9/0mpGoD3kzcMLnOlGQPJJNyqQ== leven@^3.1.0: version "3.1.0" @@ -4435,25 +4213,27 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -loadjs@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/loadjs/-/loadjs-4.2.0.tgz#2a0336376397a6a43edf98c9ec3229ddd5abb6f6" - integrity sha512-AgQGZisAlTPbTEzrHPb6q+NYBMD+DP9uvGSIjSUM5uG+0jG15cb8axWpxuOIqrmQjn6scaaH8JwloiP27b2KXA== +local-pkg@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.2.tgz#13107310b77e74a0e513147a131a2ba288176c2f" + integrity sha512-mlERgSPrbxU3BP4qBqAvvwlgW4MTg78iwJdGGnv7kibKjWcJksrG3t6LB5lXI93wXRDvG4NpUgJFmTG4T6rdrg== -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA== +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" + p-locate "^5.0.0" -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" +lodash-es@4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== lodash.debounce@^4.0.8: version "4.0.8" @@ -4465,28 +4245,21 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== -lodash.kebabcase@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" - integrity sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g== - lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@4.17.21, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.7.0: +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== + +lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -loose-envify@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - loupe@^2.3.1: version "2.3.4" resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.4.tgz#7e0b9bffc76f148f9be769cb1321d3dcf3cb25f3" @@ -4494,14 +4267,6 @@ loupe@^2.3.1: dependencies: get-func-name "^2.0.0" -lru-cache@^4.1.2, lru-cache@^4.1.5: - version "4.1.5" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" - integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== - dependencies: - pseudomap "^1.0.2" - yallist "^2.1.2" - lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -4509,13 +4274,20 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -magic-string@^0.25.7: +magic-string@^0.25.0, magic-string@^0.25.7: version "0.25.9" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== dependencies: sourcemap-codec "^1.4.8" +magic-string@^0.26.1: + version "0.26.3" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.26.3.tgz#25840b875140f7b4785ab06bddc384270b7dd452" + integrity sha512-u1Po0NDyFcwdg2nzHT88wSK0+Rih0N1M+Ph1Sp08k8yvFFU3KR72wryS7e1qMPJypt99WB7fIFVCA92mQrMjrg== + dependencies: + sourcemap-codec "^1.4.8" + make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -4523,25 +4295,16 @@ make-dir@^3.0.0: dependencies: semver "^6.0.0" -makeerror@1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" - integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== - dependencies: - tmpl "1.0.5" - -merge-source-map@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646" - integrity sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw== - dependencies: - source-map "^0.6.1" - merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + micromatch@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" @@ -4562,11 +4325,6 @@ mime-types@^2.1.12: dependencies: mime-db "1.52.0" -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -4574,15 +4332,27 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1: + version "5.1.0" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7" + integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== -moment@2.29.3: - version "2.29.3" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.3.tgz#edd47411c322413999f7a5940d526de183c031f3" - integrity sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw== +mitt@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-2.1.0.tgz#f740577c23176c6205b121b2973514eade1b2230" + integrity sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg== + +moment@2.29.4: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== moxios@0.4.0: version "0.4.0" @@ -4625,24 +4395,6 @@ nise@^5.1.1: just-extend "^4.0.2" path-to-regexp "^1.7.0" -node-addon-api@^1.7.1: - version "1.7.2" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d" - integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg== - -node-cache@^4.1.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-4.2.1.tgz#efd8474dee4edec4138cdded580f5516500f7334" - integrity sha512-BOb67bWg2dTyax5kdef5WfU3X8xu4wPg+zHzkvls0Q/QpYycIFRLEEIdAx9Wma43DxG6Qzn4illdZoYseKWa4A== - dependencies: - clone "2.x" - lodash "^4.17.15" - -node-int64@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" - integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== - node-modules-regexp@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" @@ -4653,30 +4405,11 @@ node-releases@^2.0.6: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== -nopt@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" - integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== - dependencies: - abbrev "1" - normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -normalize-range@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" - integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== - -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - nth-check@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" @@ -4685,16 +4418,16 @@ nth-check@^2.0.1: boolbase "^1.0.0" nwsapi@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.1.tgz#10a9f268fbf4c461249ebcfe38e359aa36e2577c" - integrity sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg== + version "2.2.2" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0" + integrity sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw== object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -object-inspect@^1.12.0, object-inspect@^1.9.0: +object-inspect@^1.12.2, object-inspect@^1.9.0: version "1.12.2" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== @@ -4704,14 +4437,14 @@ object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.0, object.assign@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" - integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== +object.assign@^4.1.0, object.assign@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" + integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== dependencies: - call-bind "^1.0.0" - define-properties "^1.1.3" - has-symbols "^1.0.1" + call-bind "^1.0.2" + define-properties "^1.1.4" + has-symbols "^1.0.3" object-keys "^1.1.1" object.values@^1.1.5: @@ -4730,13 +4463,6 @@ once@^1.3.0: dependencies: wrappy "1" -onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -4761,43 +4487,19 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg== +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== dependencies: - p-limit "^1.1.0" + yocto-queue "^0.1.0" -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== dependencies: - p-limit "^2.2.0" - -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww== - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + p-limit "^3.0.2" parent-module@^1.0.0: version "1.0.1" @@ -4806,7 +4508,7 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-json@^5.2.0: +parse-json@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== @@ -4816,10 +4518,12 @@ parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse-srcset@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" - integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== +parse5-htmlparser2-tree-adapter@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" + integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== + dependencies: + parse5 "^6.0.1" parse5-htmlparser2-tree-adapter@^7.0.0: version "7.0.0" @@ -4829,22 +4533,17 @@ parse5-htmlparser2-tree-adapter@^7.0.0: domhandler "^5.0.2" parse5 "^7.0.0" -parse5@6.0.1: +"parse5@5 - 6", parse5@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== parse5@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.0.0.tgz#51f74a5257f5fcc536389e8c2d0b3802e1bfa91a" - integrity sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g== + version "7.1.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.1.tgz#4649f940ccfb95d8754f37f73078ea20afe0c746" + integrity sha512-kwpuwzB+px5WUg9pyK0IcK/shltJN5/OVhQagxhCQNtT9Y9QRZqNY2e1cmbu/paRh5LMnz/oVTVLBpjFmMZhSg== dependencies: - entities "^4.3.0" - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== + entities "^4.4.0" path-exists@^4.0.0: version "4.0.0" @@ -4856,7 +4555,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== -path-key@^3.0.0, path-key@^3.1.0: +path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== @@ -4873,22 +4572,22 @@ path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + pathval@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== -picocolors@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" - integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA== - picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -4900,35 +4599,17 @@ pirates@^3.0.2: dependencies: node-modules-regexp "^1.0.0" -pirates@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" - integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== - -pkg-dir@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - -plyr@3.6.12, "plyr@github:sampotts/plyr#develop": - version "3.6.12" - resolved "https://registry.yarnpkg.com/plyr/-/plyr-3.6.12.tgz#e64481f163f9188d783447aedbf424fefd2ee92f" - integrity sha512-42WhYpMS/FEyX2unSEvhYtj1RvJgWvOsjZQFDongOQHA4eVzsyr7b06bzVpinMAOVC9e5H7RCbK+6CCAFIl2VQ== - dependencies: - core-js "^3.20.0" - custom-event-polyfill "^1.0.7" - loadjs "^4.2.0" - rangetouch "^2.0.1" - url-polyfill "^1.1.12" +pofile@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.0.11.tgz#35aff58c17491d127a07336d5522ebc9df57c954" + integrity sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg== -pofile@^1.1.0: +pofile@^1.1.0, pofile@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.1.3.tgz#e2c0d4052b9829f171b888bfb35c87791dbea297" integrity sha512-sk96pUvpNwDV6PLrnhr68Uu1S5NohsxqLKz0GuracgrDo40BdF/r1RhHnjakUk6Q4Z0OKIybOQ7GevLKGN1iYw== -postcss-selector-parser@^6.0.2: +postcss-selector-parser@^6.0.9: version "6.0.10" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== @@ -4936,23 +4617,10 @@ postcss-selector-parser@^6.0.2: cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss-value-parser@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" - integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== - -postcss@^7.0.36: - version "7.0.39" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.39.tgz#9624375d965630e2e1f2c02a935c82a59cb48309" - integrity sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA== - dependencies: - picocolors "^0.2.1" - source-map "^0.6.1" - -postcss@^8.1.10, postcss@^8.3.11, postcss@^8.4.14, postcss@^8.4.6: - version "8.4.14" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" - integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== +postcss@^8.1.10, postcss@^8.4.16: + version "8.4.16" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.16.tgz#33a1d675fac39941f5f445db0de4db2b6e01d43c" + integrity sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ== dependencies: nanoid "^3.3.4" picocolors "^1.0.0" @@ -4968,28 +4636,15 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -"prettier@^1.18.2 || ^2.0.0", prettier@^2.4.1: - version "2.7.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" - integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== - -pretty-format@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" - integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== - dependencies: - ansi-regex "^5.0.1" - ansi-styles "^5.0.0" - react-is "^17.0.1" +pretty-bytes@^5.3.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" + integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== -pretty@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pretty/-/pretty-2.0.0.tgz#adbc7960b7bbfe289a557dc5f737619a220d06a5" - integrity sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w== - dependencies: - condense-newlines "^0.2.1" - extend-shallow "^2.0.1" - js-beautify "^1.6.12" +pretty-bytes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.0.0.tgz#928be2ad1f51a2e336add8ba764739f9776a8140" + integrity sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg== promise@^7.0.1: version "7.3.1" @@ -4998,24 +4653,6 @@ promise@^7.0.1: dependencies: asap "~2.0.3" -prompts@^2.0.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== - dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" - -proto-list@~1.2.1: - version "1.2.4" - resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" - integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== - -pseudomap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ== - psl@^1.1.33: version "1.9.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" @@ -5129,27 +4766,29 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -qs@6.10.5: - version "6.10.5" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.5.tgz#974715920a80ff6a262264acd2c7e6c2a53282b4" - integrity sha512-O5RlPh0VFtR78y79rgcgKK4wbAI0C5zGVLztOIdpWX6ep368q5Hv6XRxDvXuZ9q3C6v+e3n8UfZZJw7IIG27eQ== +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== dependencies: side-channel "^1.0.4" -querystring@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd" - integrity sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== -rangetouch@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/rangetouch/-/rangetouch-2.0.1.tgz#c01105110fd3afca2adcb1a580692837d883cb70" - integrity sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA== +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -react-is@^17.0.1: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" - integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" readdirp@~3.6.0: version "3.6.0" @@ -5170,11 +4809,6 @@ regenerate@^1.4.2: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== -regenerator-runtime@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" - integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== - regenerator-runtime@^0.13.4: version "0.13.9" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" @@ -5187,7 +4821,7 @@ regenerator-transform@^0.15.0: dependencies: "@babel/runtime" "^7.8.4" -regexp.prototype.flags@^1.4.3: +regexp.prototype.flags@^1.4.1, regexp.prototype.flags@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== @@ -5213,11 +4847,6 @@ regexpu-core@^5.1.0: unicode-match-property-ecmascript "^2.0.0" unicode-match-property-value-ecmascript "^2.0.0" -register-service-worker@1.7.2: - version "1.7.2" - resolved "https://registry.yarnpkg.com/register-service-worker/-/register-service-worker-1.7.2.tgz#6516983e1ef790a98c4225af1216bc80941a4bd2" - integrity sha512-CiD3ZSanZqcMPRhtfct5K9f7i3OLCcBBWsJjLh1gW9RO/nS94sVzY59iS+fgYBOBqaBpf4EzfqUF3j9IG+xo8A== - regjsgen@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.6.0.tgz#83414c5354afd7d6627b16af5f10f41c4e71808d" @@ -5235,39 +4864,22 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== -require-main-filename@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" - integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== -resolve-cwd@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" - integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== - dependencies: - resolve-from "^5.0.0" +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - -resolve-url@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" - integrity sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg== - -resolve.exports@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.0.tgz#5ce842b94b05146c0e03076985d1d0e7e48c90c9" - integrity sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ== - -resolve@^1.10.1, resolve@^1.14.2, resolve@^1.15.1, resolve@^1.20.0, resolve@^1.22.0: +resolve@^1.10.1, resolve@^1.14.2, resolve@^1.15.1, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.3.0: version "1.22.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== @@ -5276,55 +4888,84 @@ resolve@^1.10.1, resolve@^1.14.2, resolve@^1.15.1, resolve@^1.20.0, resolve@^1.2 path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -rimraf@^3.0.0, rimraf@^3.0.2: +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== dependencies: glob "^7.1.3" -rollup@^2.58.0, rollup@^2.59.0: - version "2.77.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.77.0.tgz#749eaa5ac09b6baa52acc076bc46613eddfd53f4" - integrity sha512-vL8xjY4yOQEw79DvyXLijhnhh+R/O9zpF/LEgkCebZFtb6ELeN9H3/2T0r8+mp+fFTBHZ5qGpOpW2ela2zRt3g== +rollup-plugin-terser@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d" + integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ== + dependencies: + "@babel/code-frame" "^7.10.4" + jest-worker "^26.2.1" + serialize-javascript "^4.0.0" + terser "^5.0.0" + +"rollup@>=2.75.6 <2.77.0 || ~2.77.0": + version "2.77.3" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.77.3.tgz#8f00418d3a2740036e15deb653bed1a90ee0cc12" + integrity sha512-/qxNTG7FbmefJWoeeYJFbHehJ2HNWnjkAFRKzWN/45eNBBF/r8lo992CwcJXEzyVxs5FmfId+vTSTQDb+bxA+g== + optionalDependencies: + fsevents "~2.3.2" + +rollup@^2.43.1, rollup@^2.75.7: + version "2.79.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.0.tgz#9177992c9f09eb58c5e56cbfa641607a12b57ce2" + integrity sha512-x4KsrCgwQ7ZJPcFA/SUu6QVcYlO7uRLfLAy0DSA4NS2eG8japdbpM50ToH7z4iObodRYOJ0soneF0iaQRJ6zhA== + optionalDependencies: + fsevents "~2.3.2" + +rollup@~2.78.0: + version "2.78.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.78.1.tgz#52fe3934d9c83cb4f7c4cb5fb75d88591be8648f" + integrity sha512-VeeCgtGi4P+o9hIg+xz4qQpRl6R401LWEXBmxYKOV4zlF82lyhgh2hTZnheFUbANE8l2A41F458iwj2vEYaXJg== optionalDependencies: fsevents "~2.3.2" +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-buffer@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -"safer-buffer@>= 2.1.2 < 3": +"safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sanitize-html@2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.7.0.tgz#e106205b468aca932e2f9baf241f24660d34e279" - integrity sha512-jfQelabOn5voO7FAfnQF7v+jsA6z9zC/O4ec0z3E35XPEtHYJT/OdUziVWlKW4irCr2kXaQAyXTXDHWAibg1tA== - dependencies: - deepmerge "^4.2.2" - escape-string-regexp "^4.0.0" - htmlparser2 "^6.0.0" - is-plain-object "^5.0.0" - parse-srcset "^1.0.2" - postcss "^8.3.11" - -sass@1.49.11: - version "1.49.11" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.49.11.tgz#1ffeb77faeed8b806a2a1e021d7c9fd3fc322cb7" - integrity sha512-wvS/geXgHUGs6A/4ud5BFIWKO1nKd7wYIGimDk4q4GFkJicILActpv9ueMT4eRGSsp1BdKHuw1WwAHXbhsJELQ== +sass@1.54.9: + version "1.54.9" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.9.tgz#b05f14ed572869218d1a76961de60cd647221762" + integrity sha512-xb1hjASzEH+0L0WI9oFjqhRi51t/gagWnxLiwUNMltA0Ab6jIDkAacgKiGYKM9Jhy109osM7woEEai6SXeJo5Q== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" source-map-js ">=0.6.2 <2.0.0" -saxes@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" - integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== dependencies: xmlchars "^2.2.0" @@ -5333,27 +4974,24 @@ semver@7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.3.2: +semver@^7.0.0, semver@^7.3.5, semver@^7.3.6, semver@^7.3.7: version "7.3.7" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== dependencies: lru-cache "^6.0.0" -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== +serialize-javascript@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + dependencies: + randombytes "^2.1.0" shebang-command@^2.0.0: version "2.0.0" @@ -5367,10 +5005,15 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -showdown@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/showdown/-/showdown-2.0.3.tgz#1ec96dd19637fe4617b160f7b50280120964640b" - integrity sha512-jHytkv5c5YFTAOYIIaTT1zLL/aC+7C1FiP0CIGQozhHnnFSbor1oYkaNqWFL6CpB3zJNPPSxJrAlsHgzN14knQ== +shell-quote@^1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123" + integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw== + +showdown@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/showdown/-/showdown-2.1.0.tgz#1251f5ed8f773f0c0c7bfc8e6fd23581f9e545c5" + integrity sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ== dependencies: commander "^9.0.0" @@ -5388,20 +5031,15 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" -sigmund@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" - integrity sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g== - -signal-exit@^3.0.2, signal-exit@^3.0.3: +signal-exit@^3.0.2: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -sinon@13.0.2: - version "13.0.2" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a" - integrity sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA== +sinon@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-14.0.0.tgz#203731c116d3a2d58dc4e3cbe1f443ba9382a031" + integrity sha512-ugA6BFmE+WrJdh0owRZHToLd32Uw3Lxq6E6LtNRU+xTVBefx632h03Q7apXWRsRdZAJ41LB8aUfn2+O4jsDNMw== dependencies: "@sinonjs/commons" "^1.8.3" "@sinonjs/fake-timers" "^9.1.2" @@ -5410,38 +5048,22 @@ sinon@13.0.2: nise "^5.1.1" supports-color "^7.2.0" -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -sortablejs@1.10.2: - version "1.10.2" - resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.2.tgz#6e40364d913f98b85a14f6678f92b5c1221f5290" - integrity sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A== +sortablejs@1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.14.0.tgz#6d2e17ccbdb25f464734df621d4f35d4ab35b3d8" + integrity sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w== "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== -source-map-resolve@^0.5.2: - version "0.5.3" - resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" - integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== - dependencies: - atob "^2.1.2" - decode-uri-component "^0.2.0" - resolve-url "^0.2.1" - source-map-url "^0.4.0" - urix "^0.1.0" - -source-map-support@^0.5.6: +source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -5449,52 +5071,24 @@ source-map-support@^0.5.6: buffer-from "^1.0.0" source-map "^0.6.0" -source-map-url@^0.4.0: - version "0.4.1" - resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56" - integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== - -source-map@^0.5.6: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== - source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@^0.7.3: - version "0.7.4" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" - integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== +source-map@^0.8.0-beta.0: + version "0.8.0-beta.0" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.8.0-beta.0.tgz#d4c1bb42c3f7ee925f005927ba10709e0d1d1f11" + integrity sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA== + dependencies: + whatwg-url "^7.0.0" sourcemap-codec@^1.4.8: version "1.4.8" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== - -stack-utils@^2.0.3: - version "2.0.5" - resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.5.tgz#d25265fca995154659dbbfba3b49254778d2fdd5" - integrity sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA== - dependencies: - escape-string-regexp "^2.0.0" - -string-length@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" - integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== - dependencies: - char-regex "^1.0.2" - strip-ansi "^6.0.0" - -string-width@^4.1.0, string-width@^4.2.0: +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5503,6 +5097,20 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string.prototype.matchall@^4.0.6: + version "4.0.7" + resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz#8e6ecb0d8a1fb1fda470d81acecb2dba057a481d" + integrity sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.1" + get-intrinsic "^1.1.1" + has-symbols "^1.0.3" + internal-slot "^1.0.3" + regexp.prototype.flags "^1.4.1" + side-channel "^1.0.4" + string.prototype.trimend@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz#914a65baaab25fbdd4ee291ca7dde57e869cb8d0" @@ -5521,12 +5129,14 @@ string.prototype.trimstart@^1.0.5: define-properties "^1.1.4" es-abstract "^1.19.5" -strip-ansi@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg== +stringify-object@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" + integrity sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw== dependencies: - ansi-regex "^2.0.0" + get-own-enumerable-property-symbols "^3.0.0" + is-obj "^1.0.1" + is-regexp "^1.0.0" strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" @@ -5540,30 +5150,22 @@ strip-bom@^3.0.0: resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== -strip-bom@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" - integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== - -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - -strip-json-comments@^2.0.0: +strip-comments@^2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + resolved "https://registry.yarnpkg.com/strip-comments/-/strip-comments-2.0.1.tgz#4ad11c3fbcac177a67a40ac224ca339ca1c1ba9b" + integrity sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw== strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - integrity sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g== +strip-literal@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-0.4.1.tgz#fb9316ad35071c4229ed67aad1a0467e5a9b0c1a" + integrity sha512-z+F/xmDM8GOdvA5UoZXFxEnxdvMOZ+XEBIwjfLfc8hMSuHpGxjXAUCfuEo+t1GOHSb8+qgI/IBRpxXVMaABYWA== + dependencies: + acorn "^8.8.0" supports-color@^5.3.0: version "5.5.0" @@ -5579,21 +5181,6 @@ supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: dependencies: has-flag "^4.0.0" -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-hyperlinks@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz#4f77b42488765891774b70c79babd87f9bd594bb" - integrity sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ== - dependencies: - has-flag "^4.0.0" - supports-color "^7.0.0" - supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -5609,18 +5196,35 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== -tabbable@^5.2.1: - version "5.3.3" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" - integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== +tabbable@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.0.0.tgz#7f95ea69134e9335979092ba63866fe67b521b01" + integrity sha512-SxhZErfHc3Yozz/HLAl/iPOxuIj8AtUw13NRewVOjFW7vbsqT1f3PuiHrPQbUkRcLNEgAedAv2DnjLtzynJXiw== -terminal-link@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" - integrity sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ== +temp-dir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" + integrity sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg== + +tempy@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tempy/-/tempy-0.6.0.tgz#65e2c35abc06f1124a97f387b08303442bde59f3" + integrity sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw== + dependencies: + is-stream "^2.0.0" + temp-dir "^2.0.0" + type-fest "^0.16.0" + unique-string "^2.0.0" + +terser@^5.0.0: + version "5.15.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.0.tgz#e16967894eeba6e1091509ec83f0c60e179f2425" + integrity sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA== dependencies: - ansi-escapes "^4.2.1" - supports-hyperlinks "^2.0.0" + "@jridgewell/source-map" "^0.3.2" + acorn "^8.5.0" + commander "^2.20.0" + source-map-support "~0.5.20" test-exclude@^6.0.0: version "6.0.0" @@ -5641,20 +5245,25 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -throat@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375" - integrity sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w== +tinybench@^2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.1.5.tgz#6864341415ff0f912ed160cfd90b7f833ece674c" + integrity sha512-ak+PZZEuH3mw6CCFOgf5S90YH0MARnZNhxjhjguAmoJimEMAJuNip/rJRd6/wyylHItomVpKTzZk9zrhTrQCoQ== -tmpl@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" - integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== +tinypool@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.2.4.tgz#4d2598c4689d1a2ce267ddf3360a9c6b3925a20c" + integrity sha512-Vs3rhkUH6Qq1t5bqtb816oT+HeJTXfwt2cbPH17sWHIYKTotQIFPk3tf2fgqRrVyMDVOc1EnPgzIxfIulXVzwQ== -to-fast-properties@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" - integrity sha512-lxrWP8ejsq+7E3nNjwYmUBMAgjMTZoTI+sdBOpvNyijeDLa29LUn9QaoXAHv4+Z578hbmHHJKZknzxVtvo77og== +tinypool@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.3.0.tgz#c405d8b743509fc28ea4ca358433190be654f819" + integrity sha512-NX5KeqHOBZU6Bc0xj9Vr5Szbb1j8tUHIeD18s41aDJaPeC5QTdEhK0SpdpUrZlj2nv5cctNcSjaKNanXlfcVEQ== + +tinyspy@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-1.0.2.tgz#6da0b3918bfd56170fb3cd3a2b5ef832ee1dff0d" + integrity sha512-bSGlgwLBYf7PnUsQ6WOc6SJ3pGOcd+d8AA6EUnLDDM0kWEstC1JIlSZA3UNliDXhd9ABoS7hiRBDCu+XP/sf1Q== to-fast-properties@^2.0.0: version "2.0.0" @@ -5674,22 +5283,37 @@ token-stream@1.0.0: integrity sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg== tough-cookie@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" - integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + version "4.1.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.2.tgz#e53e84b85f24e0b65dd526f46628db6c85f6b874" + integrity sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ== dependencies: psl "^1.1.33" punycode "^2.1.1" - universalify "^0.1.2" + universalify "^0.2.0" + url-parse "^1.5.3" -tr46@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" - integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA== + dependencies: + punycode "^2.1.0" + +tr46@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" + integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== dependencies: punycode "^2.1.1" -tsconfig-paths@^3.12.0: +transliteration@2.3.5: + version "2.3.5" + resolved "https://registry.yarnpkg.com/transliteration/-/transliteration-2.3.5.tgz#8f92309575f69e4a8a525dab4ff705ebcf961c45" + integrity sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw== + dependencies: + yargs "^17.5.1" + +tsconfig-paths@^3.14.1: version "3.14.1" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ== @@ -5699,15 +5323,22 @@ tsconfig-paths@^3.12.0: minimist "^1.2.6" strip-bom "^3.0.0" -tsconfig@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-7.0.0.tgz#84538875a4dc216e5c4a5432b3a4dec3d54e91b7" - integrity sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw== +tslib@^1.8.1, tslib@^1.9.3: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +tslib@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + +tsutils@^3.21.0: + version "3.21.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== dependencies: - "@types/strip-bom" "^3.0.0" - "@types/strip-json-comments" "0.0.30" - strip-bom "^3.0.0" - strip-json-comments "^2.0.0" + tslib "^1.8.1" type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" @@ -5728,22 +5359,30 @@ type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.16.0.tgz#3240b891a78b0deae910dbeb86553e552a148860" + integrity sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg== + type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== -type-fest@^0.21.3: - version "0.21.3" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" - integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +"typescript@2 - 4": + version "4.8.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790" + integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw== -typedarray-to-buffer@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" - integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== - dependencies: - is-typedarray "^1.0.0" +typescript@4.8.3: + version "4.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.3.tgz#d59344522c4bc464a65a730ac695007fdb66dd88" + integrity sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig== + +typical@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4" + integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw== unbox-primitive@^1.0.2: version "1.0.2" @@ -5778,20 +5417,40 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8" integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ== -universalify@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +unique-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" + integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== + dependencies: + crypto-random-string "^2.0.0" + +universal-cookie@4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-4.0.4.tgz#06e8b3625bf9af049569ef97109b4bb226ad798d" + integrity sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw== + dependencies: + "@types/cookie" "^0.3.3" + cookie "^0.4.0" + +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== universalify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== -update-browserslist-db@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.5.tgz#be06a5eedd62f107b7c19eb5bcefb194411abf38" - integrity sha512-dteFFpCyvuDdr9S/ff1ISkKt/9YZxKjI9WlRR99c180GaztJtRa/fn18FdxGVKVsnPY7/a/FDN68mcvUmP4U7Q== +upath@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" + integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== + +update-browserslist-db@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.7.tgz#16279639cff1d0f800b14792de43d97df2d11b7d" + integrity sha512-iN/XYesmZ2RmmWAiI4Z5rq0YqSiv0brj9Ce9CfhNE4xIW2h+MFxcgkxIzZ+ShkFPUkjU3gQ+3oypadD3RAMtrg== dependencies: escalade "^3.1.1" picocolors "^1.0.0" @@ -5803,73 +5462,117 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -urix@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" - integrity sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg== - -url-polyfill@^1.1.12: - version "1.1.12" - resolved "https://registry.yarnpkg.com/url-polyfill/-/url-polyfill-1.1.12.tgz#6cdaa17f6b022841b3aec0bf8dbd87ac0cd33331" - integrity sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A== +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -v8-compile-cache@^2.0.3: - version "2.3.0" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" - integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +utility-types@3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b" + integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== -v8-to-istanbul@^8.1.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed" - integrity sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w== +v8-to-istanbul@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz#b6f994b0b5d4ef255e17a0d17dc444a9f5132fa4" + integrity sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w== dependencies: + "@jridgewell/trace-mapping" "^0.3.12" "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" - source-map "^0.7.3" -vite-plugin-vue2@1.9.3: - version "1.9.3" - resolved "https://registry.yarnpkg.com/vite-plugin-vue2/-/vite-plugin-vue2-1.9.3.tgz#a73363e70d7fe6e420a52890ca650d3d270245f5" - integrity sha512-0KhHSEeht0VHJtt4Z2cJ9bWBq4dP3HoXpapqAHV+f+cUa6KywYdOd+z6sSGLpuGjN8F9YinrFIo8dfVmMOpc8Q== +vite-plugin-pwa@0.12.4: + version "0.12.4" + resolved "https://registry.yarnpkg.com/vite-plugin-pwa/-/vite-plugin-pwa-0.12.4.tgz#9e921518a0a340e4e6ae287a2cf345c4ca56f92d" + integrity sha512-ONHS2OcMMWdOOIPG8JBet2BTssZC1FSjY34f9t5byM3H6RKgu5qbv6p809OlfYkg8bFi58oEhNDxDB5UE4vkfQ== + dependencies: + debug "^4.3.4" + fast-glob "^3.2.11" + pretty-bytes "^6.0.0" + rollup "^2.75.7" + workbox-build "^6.5.3" + workbox-window "^6.5.3" + +vite-plugin-vue-inspector@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-1.1.1.tgz#6b8f1d36164e7850f9e86419e66702576afdc6d1" + integrity sha512-M3NJwXIUHHITSy6GqmnijrfxgM4JtMtVErYAdNf2H/cf9trAe7LzPBEJK+pH5M1h+9xr2JdU1pz5o8lNrW9UDQ== dependencies: - "@babel/core" "^7.16.10" - "@babel/parser" "^7.16.10" - "@babel/plugin-proposal-class-properties" "^7.16.7" - "@babel/plugin-proposal-decorators" "^7.16.7" + "@babel/core" "^7.17.8" + "@babel/plugin-syntax-import-meta" "^7.10.4" "@babel/plugin-transform-typescript" "^7.16.8" - "@rollup/pluginutils" "^4.1.1" - "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" - "@vue/babel-preset-jsx" "^1.2.4" - "@vue/component-compiler-utils" "^3.2.2" - consolidate "^0.16.0" - debug "^4.3.2" - fs-extra "^9.1.0" - hash-sum "^2.0.0" - magic-string "^0.25.7" - prettier "^2.4.1" - querystring "^0.2.1" - rollup "^2.58.0" - slash "^3.0.0" - source-map "^0.7.3" - vue-template-es2015-compiler "^1.9.1" + "@vue/babel-plugin-jsx" "^1.1.1" + "@vue/compiler-dom" "^3.2.31" + chalk "4.1.2" + esno "^0.14.1" + kolorist "^1.5.1" + magic-string "^0.26.1" + shell-quote "^1.7.3" + +vite@3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/vite/-/vite-3.0.9.tgz#45fac22c2a5290a970f23d66c1aef56a04be8a30" + integrity sha512-waYABTM+G6DBTCpYAxvevpG50UOlZuynR0ckTK5PawNVt7ebX6X7wNXHaGIO6wYYFXSM7/WcuFuO2QzhBB6aMw== + dependencies: + esbuild "^0.14.47" + postcss "^8.4.16" + resolve "^1.22.1" + rollup ">=2.75.6 <2.77.0 || ~2.77.0" + optionalDependencies: + fsevents "~2.3.2" -vite@2.8.6: - version "2.8.6" - resolved "https://registry.yarnpkg.com/vite/-/vite-2.8.6.tgz#32d50e23c99ca31b26b8ccdc78b1d72d4d7323d3" - integrity sha512-e4H0QpludOVKkmOsRyqQ7LTcMUDF3mcgyNU4lmi0B5JUbe0ZxeBBl8VoZ8Y6Rfn9eFKYtdXNPcYK97ZwH+K2ug== +"vite@^2.9.12 || ^3.0.0-0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/vite/-/vite-3.1.0.tgz#3138b279072941d57e76bcf7f66f272fc6a17fe2" + integrity sha512-YBg3dUicDpDWFCGttmvMbVyS9ydjntwEjwXRj2KBFwSB8SxmGcudo1yb8FW5+M/G86aS8x828ujnzUVdsLjs9g== dependencies: - esbuild "^0.14.14" - postcss "^8.4.6" - resolve "^1.22.0" - rollup "^2.59.0" + esbuild "^0.15.6" + postcss "^8.4.16" + resolve "^1.22.1" + rollup "~2.78.0" optionalDependencies: fsevents "~2.3.2" +vitest@0.22.1: + version "0.22.1" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.22.1.tgz#3122e6024bf782ee9aca53034017af7adb009c32" + integrity sha512-+x28YTnSLth4KbXg7MCzoDAzPJlJex7YgiZbUh6YLp0/4PqVZ7q7/zyfdL0OaPtKTpNiQFPpMC8Y2MSzk8F7dw== + dependencies: + "@types/chai" "^4.3.3" + "@types/chai-subset" "^1.3.3" + "@types/node" "*" + chai "^4.3.6" + debug "^4.3.4" + local-pkg "^0.4.2" + tinypool "^0.2.4" + tinyspy "^1.0.2" + vite "^2.9.12 || ^3.0.0-0" + +vitest@0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.23.2.tgz#f978de0f2ada1b7c5ff8dc479ce75b976957ff19" + integrity sha512-kTBKp3ROPDkYC+x2zWt4znkDtnT08W1FQ6ngRFuqxpBGNuNVS+eWZKfffr8y2JGvEzZ9EzMAOcNaiqMj/FZqMw== + dependencies: + "@types/chai" "^4.3.3" + "@types/chai-subset" "^1.3.3" + "@types/node" "*" + chai "^4.3.6" + debug "^4.3.4" + local-pkg "^0.4.2" + strip-literal "^0.4.0" + tinybench "^2.1.5" + tinypool "^0.3.0" + tinyspy "^1.0.2" + vite "^2.9.12 || ^3.0.0-0" + vlq@^0.2.1: version "0.2.3" resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" @@ -5881,100 +5584,111 @@ void-elements@^3.1.0: integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== vue-demi@*: - version "0.13.5" - resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.5.tgz#d5eddbc9eaefb89ce5995269d1fa6b0486312092" - integrity sha512-tO3K2bML3AwiHmVHeKCq6HLef2st4zBXIV5aEkoJl6HZ+gJWxWv2O8wLH8qrA3SX3lDoTDHNghLX1xZg83MXvw== + version "0.13.11" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99" + integrity sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A== -vue-eslint-parser@^7.10.0: - version "7.11.0" - resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.11.0.tgz#214b5dea961007fcffb2ee65b8912307628d0daf" - integrity sha512-qh3VhDLeh773wjgNTl7ss0VejY9bMMa0GoDG2fQVyDzRFdiU3L7fw74tWZDHNQXdZqxO3EveQroa9ct39D2nqg== +vue-demi@^0.12.5: + version "0.12.5" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.12.5.tgz#8eeed566a7d86eb090209a11723f887d28aeb2d1" + integrity sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q== + +vue-eslint-parser@^9.0.0, vue-eslint-parser@^9.0.1: + version "9.0.3" + resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.0.3.tgz#0c17a89e0932cc94fa6a79f0726697e13bfe3c96" + integrity sha512-yL+ZDb+9T0ELG4VIFo/2anAOz8SvBdlqEnQnvJ3M7Scq56DvtjY0VY88bByRZB0D4J0u8olBcfrXTVONXsh4og== dependencies: - debug "^4.1.1" - eslint-scope "^5.1.1" - eslint-visitor-keys "^1.1.0" - espree "^6.2.1" + debug "^4.3.4" + eslint-scope "^7.1.1" + eslint-visitor-keys "^3.3.0" + espree "^9.3.1" esquery "^1.4.0" lodash "^4.17.21" - semver "^6.3.0" + semver "^7.3.6" vue-gettext@2.1.12: version "2.1.12" resolved "https://registry.yarnpkg.com/vue-gettext/-/vue-gettext-2.1.12.tgz#444d3220149b17fa4c7caeded3f12d439b698f33" integrity sha512-7Kw36xtKvARp8ZafQGPK9WR6EM+dhFUikR5f0+etSkiHuvUM3yf1HsRDLYoLLdJ0AMaXxKwgekumzvCk6KX8rA== -vue-jest@3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/vue-jest/-/vue-jest-3.0.7.tgz#a6d29758a5cb4d750f5d1242212be39be4296a33" - integrity sha512-PIOxFM+wsBMry26ZpfBvUQ/DGH2hvp5khDQ1n51g3bN0TwFwTy4J85XVfxTRMukqHji/GnAoGUnlZ5Ao73K62w== - dependencies: - babel-plugin-transform-es2015-modules-commonjs "^6.26.0" - chalk "^2.1.0" - deasync "^0.1.15" - extract-from-css "^0.4.4" - find-babel-config "^1.1.0" - js-beautify "^1.6.14" - node-cache "^4.1.1" - object-assign "^4.1.1" - source-map "^0.5.6" - tsconfig "^7.0.0" - vue-template-es2015-compiler "^1.6.0" +vue-observe-visibility@^2.0.0-alpha.1: + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz#1e4eda7b12562161d58984b7e0dea676d83bdb13" + integrity sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g== -vue-lazyload@1.3.4: - version "1.3.4" - resolved "https://registry.yarnpkg.com/vue-lazyload/-/vue-lazyload-1.3.4.tgz#2988998f6bc1a2027268f5b0cffa7a7e30e6ccb4" - integrity sha512-K0frbPQJuvFHVpdl/ov5CqCR/CHWeLGs8E8V1d/09DIETqBjeGhC1fLMmwUy3Go2Yd/VX610AZ7Mdn4B54592Q== +vue-resize@^2.0.0-alpha.1: + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz#43eeb79e74febe932b9b20c5c57e0ebc14e2df3a" + integrity sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg== -vue-plyr@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/vue-plyr/-/vue-plyr-7.0.0.tgz#938257e2a4def4582b5803a16087371a8e96209e" - integrity sha512-NvbO/ZzV1IxlBQQbQlon5Sk8hKuGAj3k4k0XVdi7gM4oSqu8mZMhJ3WM3FfAtNfV790jbLnb8P3dHYqaBqIv6g== +vue-router@4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.1.5.tgz#256f597e3f5a281a23352a6193aa6e342c8d9f9a" + integrity sha512-IsvoF5D2GQ/EGTs/Th4NQms9gd2NSqV+yylxIyp/OYp8xOwxmU8Kj/74E9DTSYAyH5LX7idVUngN3JSj1X4xcQ== dependencies: - plyr "github:sampotts/plyr#develop" - vue "^2.6.12" + "@vue/devtools-api" "^6.1.4" -vue-router@3.5.4: - version "3.5.4" - resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.5.4.tgz#c453c0b36bc75554de066fefc3f2a9c3212aca70" - integrity sha512-x+/DLAJZv2mcQ7glH2oV9ze8uPwcI+H+GgTgTmb5I55bCgY3+vXWIsqbYUzbBSZnwFHEJku4eoaH/x98veyymQ== - -vue-template-compiler@2.6.14: - version "2.6.14" - resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz#a2f0e7d985670d42c9c9ee0d044fed7690f4f763" - integrity sha512-ODQS1SyMbjKoO1JBJZojSw6FE4qnh9rIpUZn2EUT86FKizx9uH5z6uXiIrm4/Nb/gwxTi/o17ZDEGWAXHvtC7g== +vue-tsc@0.40.5: + version "0.40.5" + resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-0.40.5.tgz#c7c6eef789bed2454626c2862614474216cb52cf" + integrity sha512-rQPyyqrQiDzc8a3YpAYUb27VQbU+NDewOJ7GJBiEIEcaSugMjDc6dtJijTHK32t8IgGPl6ybsJ9LIU9sZvixLA== dependencies: - de-indent "^1.0.2" - he "^1.1.0" + "@volar/vue-language-core" "0.40.5" + "@volar/vue-typescript" "0.40.5" -vue-template-es2015-compiler@^1.6.0, vue-template-es2015-compiler@^1.9.0, vue-template-es2015-compiler@^1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" - integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== +vue-upload-component@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/vue-upload-component/-/vue-upload-component-3.1.2.tgz#9ba050f408781b008a16cb8bfaadbcf07eab68fa" + integrity sha512-z7/98xhNbLecTf09ZyJp0QKH6RMoVH3ojBrWYonPPHQ6qodQf0qVV81+vnB94cFVWpR/mb+F+dMN2p04K14ouw== -vue-upload-component@2.8.22: - version "2.8.22" - resolved "https://registry.yarnpkg.com/vue-upload-component/-/vue-upload-component-2.8.22.tgz#7a1573149a4afa5ca6e8c7e0bc70533925fe26b7" - integrity sha512-AJpETqiZrgqs8bwJQpWTFrRg3i6s7cUodRRZVnb1f94Jvpd0YYfzGY4zluBqPmssNSkUaYu7EteXaK8aW17Osw== +vue-virtual-scroller@2.0.0-alpha.1: + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-alpha.1.tgz#5b5410105b8e60ca57bbd5f2faf5ad1d8108d046" + integrity sha512-Mn5w3Qe06t7c3Imm2RHD43RACab1CCWplpdgzq+/FWJcpQtcGKd5vDep8i+nIwFtzFLsWAqEK0RzM7KrfAcBng== + dependencies: + mitt "^2.1.0" + vue-observe-visibility "^2.0.0-alpha.1" + vue-resize "^2.0.0-alpha.1" -vue@2.6.14: - version "2.6.14" - resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235" - integrity sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ== +vue3-gettext@2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/vue3-gettext/-/vue3-gettext-2.3.4.tgz#f7c64604a20638f49910b9616bdb6269423e0a2b" + integrity sha512-X+dibsUhiSRz2LW5de6NkDl+GNZoo9JMGmlJ+wuzez/UJYvJ6GKofOeoUxbtwOk2vPY+/wesmyyzhkMoR3Z/AA== + dependencies: + chalk "^4.1.2" + command-line-args "^5.2.1" + cosmiconfig "^7.0.1" + gettext-extractor "^3.5.4" + glob "^7.2.0" + parse5 "^6.0.1" + parse5-htmlparser2-tree-adapter "^6.0.1" + pofile "^1.1.3" + tslib "^2.4.0" + +vue3-lazyload@0.3.6: + version "0.3.6" + resolved "https://registry.yarnpkg.com/vue3-lazyload/-/vue3-lazyload-0.3.6.tgz#87c509e7f0139ea5e00eb76c368519905ad19678" + integrity sha512-UcVnEN9JzxeFBa7nNAPWKXHTtvVAzWYhBSvRU+Gmx9MdTGLWKwjZiNSyB1Os25jr9HaFHWY0DaU8uugXkGu9Gw== + dependencies: + vue-demi "^0.12.5" -vue@^2.6.12: - version "2.7.8" - resolved "https://registry.yarnpkg.com/vue/-/vue-2.7.8.tgz#34e06553137611d8cecc4b0cd78b7a59f80b1299" - integrity sha512-ncwlZx5qOcn754bCu5/tS/IWPhXHopfit79cx+uIlLMyt3vCMGcXai5yCG5y+I6cDmEj4ukRYyZail9FTQh7lQ== +vue@3.2.40: + version "3.2.40" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.40.tgz#23f387f6f9b3a0767938db6751e4fb5900f0ee34" + integrity sha512-1mGHulzUbl2Nk3pfvI5aXYYyJUs1nm4kyvuz38u4xlQkLUn1i2R7nDbI4TufECmY8v1qNBHYy62bCaM+3cHP2A== dependencies: - "@vue/compiler-sfc" "2.7.8" - csstype "^3.1.0" + "@vue/compiler-dom" "3.2.40" + "@vue/compiler-sfc" "3.2.40" + "@vue/runtime-dom" "3.2.40" + "@vue/server-renderer" "3.2.40" + "@vue/shared" "3.2.40" -vuedraggable@2.24.3: - version "2.24.3" - resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.24.3.tgz#43c93849b746a24ce503e123d5b259c701ba0d19" - integrity sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g== +vuedraggable@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-4.1.0.tgz#edece68adb8a4d9e06accff9dfc9040e66852270" + integrity sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww== dependencies: - sortablejs "1.10.2" + sortablejs "1.14.0" vuex-persistedstate@4.1.0: version "4.1.0" @@ -5989,10 +5703,12 @@ vuex-router-sync@5.0.0: resolved "https://registry.yarnpkg.com/vuex-router-sync/-/vuex-router-sync-5.0.0.tgz#1a225c17a1dd9e2f74af0a1b2c62072e9492b305" integrity sha512-Mry2sO4kiAG64714X1CFpTA/shUH1DmkZ26DFDtwoM/yyx6OtMrc+MxrU+7vvbNLO9LSpgwkiJ8W+rlmRtsM+w== -vuex@3.6.2: - version "3.6.2" - resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71" - integrity sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw== +vuex@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/vuex/-/vuex-4.0.2.tgz#f896dbd5bf2a0e963f00c67e9b610de749ccacc9" + integrity sha512-M6r8uxELjZIK8kTKDGgZTYX/ahzblnzC4isU1tpmEuOIIKmV+TRdc+H4s8ds2NuZ7wpUTdGRzJRtoj+lI+pc0Q== + dependencies: + "@vue/devtools-api" "^6.0.0-beta.11" w3c-hr-time@^1.0.2: version "1.0.2" @@ -6001,50 +5717,51 @@ w3c-hr-time@^1.0.2: dependencies: browser-process-hrtime "^1.0.0" -w3c-xmlserializer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a" - integrity sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA== - dependencies: - xml-name-validator "^3.0.0" - -walker@^1.0.7: - version "1.0.8" - resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" - integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== +w3c-xmlserializer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz#06cdc3eefb7e4d0b20a560a5a3aeb0d2d9a65923" + integrity sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg== dependencies: - makeerror "1.0.12" + xml-name-validator "^4.0.0" -webidl-conversions@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" - integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== -webidl-conversions@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" - integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== -whatwg-encoding@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" - integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== dependencies: - iconv-lite "0.4.24" + iconv-lite "0.6.3" -whatwg-mimetype@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" - integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + +whatwg-url@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" + integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== + dependencies: + tr46 "^3.0.0" + webidl-conversions "^7.0.0" -whatwg-url@^8.0.0, whatwg-url@^8.5.0: - version "8.7.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" - integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== +whatwg-url@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" + integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== dependencies: - lodash "^4.7.0" - tr46 "^2.1.0" - webidl-conversions "^6.1.0" + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" which-boxed-primitive@^1.0.2: version "1.0.2" @@ -6057,11 +5774,6 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q== - which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -6084,14 +5796,163 @@ word-wrap@^1.2.3, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -wrap-ansi@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" - integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" +workbox-background-sync@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz#3141afba3cc8aa2ae14c24d0f6811374ba8ff6a9" + integrity sha512-0r4INQZMyPky/lj4Ou98qxcThrETucOde+7mRGJl13MPJugQNKeZQOdIJe/1AchOP23cTqHcN/YVpD6r8E6I8g== + dependencies: + idb "^7.0.1" + workbox-core "6.5.4" + +workbox-broadcast-update@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-6.5.4.tgz#8441cff5417cd41f384ba7633ca960a7ffe40f66" + integrity sha512-I/lBERoH1u3zyBosnpPEtcAVe5lwykx9Yg1k6f8/BGEPGaMMgZrwVrqL1uA9QZ1NGGFoyE6t9i7lBjOlDhFEEw== + dependencies: + workbox-core "6.5.4" + +workbox-build@^6.5.3: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-6.5.4.tgz#7d06d31eb28a878817e1c991c05c5b93409f0389" + integrity sha512-kgRevLXEYvUW9WS4XoziYqZ8Q9j/2ziJYEtTrjdz5/L/cTUa2XfyMP2i7c3p34lgqJ03+mTiz13SdFef2POwbA== + dependencies: + "@apideck/better-ajv-errors" "^0.3.1" + "@babel/core" "^7.11.1" + "@babel/preset-env" "^7.11.0" + "@babel/runtime" "^7.11.2" + "@rollup/plugin-babel" "^5.2.0" + "@rollup/plugin-node-resolve" "^11.2.1" + "@rollup/plugin-replace" "^2.4.1" + "@surma/rollup-plugin-off-main-thread" "^2.2.3" + ajv "^8.6.0" + common-tags "^1.8.0" + fast-json-stable-stringify "^2.1.0" + fs-extra "^9.0.1" + glob "^7.1.6" + lodash "^4.17.20" + pretty-bytes "^5.3.0" + rollup "^2.43.1" + rollup-plugin-terser "^7.0.0" + source-map "^0.8.0-beta.0" + stringify-object "^3.3.0" + strip-comments "^2.0.1" + tempy "^0.6.0" + upath "^1.2.0" + workbox-background-sync "6.5.4" + workbox-broadcast-update "6.5.4" + workbox-cacheable-response "6.5.4" + workbox-core "6.5.4" + workbox-expiration "6.5.4" + workbox-google-analytics "6.5.4" + workbox-navigation-preload "6.5.4" + workbox-precaching "6.5.4" + workbox-range-requests "6.5.4" + workbox-recipes "6.5.4" + workbox-routing "6.5.4" + workbox-strategies "6.5.4" + workbox-streams "6.5.4" + workbox-sw "6.5.4" + workbox-window "6.5.4" + +workbox-cacheable-response@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-6.5.4.tgz#a5c6ec0c6e2b6f037379198d4ef07d098f7cf137" + integrity sha512-DCR9uD0Fqj8oB2TSWQEm1hbFs/85hXXoayVwFKLVuIuxwJaihBsLsp4y7J9bvZbqtPJ1KlCkmYVGQKrBU4KAug== + dependencies: + workbox-core "6.5.4" + +workbox-core@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-6.5.4.tgz#df48bf44cd58bb1d1726c49b883fb1dffa24c9ba" + integrity sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q== + +workbox-expiration@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.5.4.tgz#501056f81e87e1d296c76570bb483ce5e29b4539" + integrity sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ== + dependencies: + idb "^7.0.1" + workbox-core "6.5.4" + +workbox-google-analytics@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-6.5.4.tgz#c74327f80dfa4c1954cbba93cd7ea640fe7ece7d" + integrity sha512-8AU1WuaXsD49249Wq0B2zn4a/vvFfHkpcFfqAFHNHwln3jK9QUYmzdkKXGIZl9wyKNP+RRX30vcgcyWMcZ9VAg== + dependencies: + workbox-background-sync "6.5.4" + workbox-core "6.5.4" + workbox-routing "6.5.4" + workbox-strategies "6.5.4" + +workbox-navigation-preload@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-6.5.4.tgz#ede56dd5f6fc9e860a7e45b2c1a8f87c1c793212" + integrity sha512-IIwf80eO3cr8h6XSQJF+Hxj26rg2RPFVUmJLUlM0+A2GzB4HFbQyKkrgD5y2d84g2IbJzP4B4j5dPBRzamHrng== + dependencies: + workbox-core "6.5.4" + +workbox-precaching@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.5.4.tgz#740e3561df92c6726ab5f7471e6aac89582cab72" + integrity sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg== + dependencies: + workbox-core "6.5.4" + workbox-routing "6.5.4" + workbox-strategies "6.5.4" + +workbox-range-requests@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz#86b3d482e090433dab38d36ae031b2bb0bd74399" + integrity sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg== + dependencies: + workbox-core "6.5.4" + +workbox-recipes@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-6.5.4.tgz#cca809ee63b98b158b2702dcfb741b5cc3e24acb" + integrity sha512-QZNO8Ez708NNwzLNEXTG4QYSKQ1ochzEtRLGaq+mr2PyoEIC1xFW7MrWxrONUxBFOByksds9Z4//lKAX8tHyUA== + dependencies: + workbox-cacheable-response "6.5.4" + workbox-core "6.5.4" + workbox-expiration "6.5.4" + workbox-precaching "6.5.4" + workbox-routing "6.5.4" + workbox-strategies "6.5.4" + +workbox-routing@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-6.5.4.tgz#6a7fbbd23f4ac801038d9a0298bc907ee26fe3da" + integrity sha512-apQswLsbrrOsBUWtr9Lf80F+P1sHnQdYodRo32SjiByYi36IDyL2r7BH1lJtFX8fwNHDa1QOVY74WKLLS6o5Pg== + dependencies: + workbox-core "6.5.4" + +workbox-strategies@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.5.4.tgz#4edda035b3c010fc7f6152918370699334cd204d" + integrity sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw== + dependencies: + workbox-core "6.5.4" + +workbox-streams@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.5.4.tgz#1cb3c168a6101df7b5269d0353c19e36668d7d69" + integrity sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg== + dependencies: + workbox-core "6.5.4" + workbox-routing "6.5.4" + +workbox-sw@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-6.5.4.tgz#d93e9c67924dd153a61367a4656ff4d2ae2ed736" + integrity sha512-vo2RQo7DILVRoH5LjGqw3nphavEjK4Qk+FenXeUsknKn14eCNedHOXWbmnvP4ipKhlE35pvJ4yl4YYf6YsJArA== + +workbox-window@6.5.4, workbox-window@^6.5.3: + version "6.5.4" + resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-6.5.4.tgz#d991bc0a94dff3c2dbb6b84558cff155ca878e91" + integrity sha512-HnLZJDwYBE+hpG25AQBO8RUWBJRaCsI9ksQJEp3aCOFCaG5kqaToAYXFRAHxzRluM2cQbGzdQF5rjKPWPA1fug== + dependencies: + "@types/trusted-types" "^2.0.2" + workbox-core "6.5.4" wrap-ansi@^7.0.0: version "7.0.0" @@ -6107,80 +5968,45 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -write-file-atomic@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" - integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== - dependencies: - imurmurhash "^0.1.4" - is-typedarray "^1.0.0" - signal-exit "^3.0.2" - typedarray-to-buffer "^3.1.5" - -ws@^7.4.6: - version "7.5.9" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" - integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== +ws@^8.8.0: + version "8.8.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.1.tgz#5dbad0feb7ade8ecc99b830c1d77c913d4955ff0" + integrity sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA== -xml-name-validator@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" - integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -y18n@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" - integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== - y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== -yallist@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - integrity sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A== - yallist@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@^18.1.2: - version "18.1.3" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" - integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" +yaml@^1.10.0: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== -yargs-parser@^20.2.2: +yargs-parser@^20.2.2, yargs-parser@^20.2.9: version "20.2.9" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== -yargs@^15.3.1: - version "15.4.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" - integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== - dependencies: - cliui "^6.0.0" - decamelize "^1.2.0" - find-up "^4.1.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^4.2.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^18.1.2" +yargs-parser@^21.0.0: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== yargs@^16.2.0: version "16.2.0" @@ -6194,3 +6020,21 @@ yargs@^16.2.0: string-width "^4.2.0" y18n "^5.0.5" yargs-parser "^20.2.2" + +yargs@^17.5.1: + version "17.5.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.5.1.tgz#e109900cab6fcb7fd44b1d8249166feb0b36e58e" + integrity sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.0.0" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/pyproject.toml b/pyproject.toml index d0e6839be12aab5f4f878b85549fd280ea91737b..045fc186718d8c7409722511c7da11445359bef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,11 @@ name = "Enhancements" showcontent = true + [[tool.towncrier.type]] + directory = "refactoring" + name = "Refactoring" + showcontent = true + [[tool.towncrier.type]] directory = "bugfix" name = "Bugfixes"