kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Merge branch 'feat/2091-improve-visuals' into 'develop'
Draft: III-6 Improve Visuals & Layout Closes #2081, #2090, #2091, #2367, and #2401 See merge request funkwhale/funkwhale!2805merge-requests/2805/merge
commit
38e29690dd
|
@ -1,3 +1,5 @@
|
|||
COMPOSE_BAKE=true
|
||||
|
||||
# api + celeryworker
|
||||
DEBUG=True
|
||||
DEFAULT_FROM_EMAIL=hello@funkwhale.test
|
||||
|
|
|
@ -34,6 +34,8 @@ pip-log.txt
|
|||
.tox
|
||||
nosetests.xml
|
||||
htmlcov
|
||||
coverage.xml
|
||||
report.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
@ -75,11 +77,13 @@ api/staticfiles
|
|||
api/static
|
||||
api/.pytest_cache
|
||||
api/celerybeat-*
|
||||
|
||||
# Front
|
||||
oldfront/node_modules/
|
||||
front/static/translations
|
||||
front/node_modules/
|
||||
front/dist/
|
||||
front/dev-dist/
|
||||
front/npm-debug.log*
|
||||
front/yarn-debug.log*
|
||||
front/yarn-error.log*
|
||||
|
@ -88,7 +92,16 @@ front/tests/e2e/reports
|
|||
front/test_results.xml
|
||||
front/coverage/
|
||||
front/selenium-debug.log
|
||||
|
||||
# Vitepress
|
||||
front/ui-docs/.vitepress/.vite
|
||||
front/ui-docs/.vitepress/cache
|
||||
front/ui-docs/.vitepress/dist
|
||||
front/ui-docs/public
|
||||
|
||||
# Docs
|
||||
docs/_build
|
||||
|
||||
#Tauri
|
||||
front/tauri/gen
|
||||
|
||||
|
@ -116,8 +129,14 @@ tsconfig.tsbuildinfo
|
|||
flake.nix
|
||||
flake.lock
|
||||
|
||||
# Vscode
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Zed
|
||||
.zed/
|
||||
|
||||
# Node version (asdf)
|
||||
.tool-versions
|
||||
|
||||
# Lychee link checker
|
||||
.lycheecache
|
||||
|
|
|
@ -8,50 +8,56 @@ include:
|
|||
file: /templates/ssh-agent.yml
|
||||
|
||||
variables:
|
||||
PYTHONDONTWRITEBYTECODE: "true"
|
||||
PYTHONDONTWRITEBYTECODE: 'true'
|
||||
|
||||
PIP_CACHE_DIR: $CI_PROJECT_DIR/.cache/pip
|
||||
YARN_CACHE_FOLDER: $CI_PROJECT_DIR/.cache/yarn
|
||||
POETRY_VIRTUALENVS_IN_PROJECT: "true"
|
||||
POETRY_VIRTUALENVS_IN_PROJECT: 'true'
|
||||
|
||||
.shared_variables:
|
||||
# Keep the git files permissions during job setup
|
||||
keep_git_files_permissions: &keep_git_files_permissions
|
||||
GIT_STRATEGY: clone
|
||||
GIT_DEPTH: "5"
|
||||
FF_DISABLE_UMASK_FOR_DOCKER_EXECUTOR: "true"
|
||||
GIT_DEPTH: '5'
|
||||
FF_DISABLE_UMASK_FOR_DOCKER_EXECUTOR: 'true'
|
||||
|
||||
.shared_caches:
|
||||
# Cache for front related jobs
|
||||
front_cache: &front_cache
|
||||
- key: front-yarn
|
||||
paths: [$YARN_CACHE_FOLDER]
|
||||
- key:
|
||||
prefix: front-node_modules
|
||||
files: [front/yarn.lock]
|
||||
paths: [front/node_modules]
|
||||
- key:
|
||||
prefix: front-lint
|
||||
files:
|
||||
- front/.eslintcache
|
||||
- front/tsconfig.tsbuildinfo
|
||||
yarn_cache: &yarn_cache
|
||||
key: front-yarn-$CI_COMMIT_REF_SLUG
|
||||
paths: [$YARN_CACHE_FOLDER]
|
||||
node_cache: &node_cache
|
||||
key:
|
||||
prefix: front-node_modules-$CI_COMMIT_REF_SLUG
|
||||
files: [front/yarn.lock]
|
||||
paths: [front/node_modules]
|
||||
lint_cache: &lint_cache
|
||||
key:
|
||||
prefix: front-lint-$CI_COMMIT_REF_SLUG
|
||||
files:
|
||||
- front/.eslintcache
|
||||
- front/tsconfig.tsbuildinfo
|
||||
cypress_cache: &cypress_cache
|
||||
key: cypress-cache-$CI_COMMIT_REF_SLUG
|
||||
paths:
|
||||
- /root/.cache/Cypress
|
||||
|
||||
# Cache for api related jobs
|
||||
# Include the python version to prevent loosing caches in the test matrix
|
||||
api_cache: &api_cache
|
||||
- key: api-pip-$PYTHON_VERSION
|
||||
- key: api-pip-$CI_COMMIT_REF_SLUG
|
||||
paths: [$PIP_CACHE_DIR]
|
||||
- key:
|
||||
prefix: api-venv-$PYTHON_VERSION
|
||||
prefix: api-venv-$CI_COMMIT_REF_SLUG
|
||||
files: [api/poetry.lock]
|
||||
paths: [api/.venv]
|
||||
|
||||
# Cache for docs related jobs
|
||||
docs_cache: &docs_cache
|
||||
- key: docs-pip
|
||||
- key: docs-pip-$CI_COMMIT_REF_SLUG
|
||||
paths: [$PIP_CACHE_DIR]
|
||||
- key:
|
||||
prefix: docs-venv
|
||||
prefix: docs-venv-$CI_COMMIT_REF_SLUG
|
||||
files: [docs/poetry.lock]
|
||||
paths: [docs/.venv]
|
||||
|
||||
|
@ -97,7 +103,10 @@ review_front:
|
|||
environment:
|
||||
name: review/front/$CI_COMMIT_REF_NAME
|
||||
url: http://$CI_PROJECT_NAMESPACE.pages.funkwhale.audio/-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/front-review/index.html
|
||||
cache: *front_cache
|
||||
cache:
|
||||
- *yarn_cache
|
||||
- *node_cache
|
||||
- *lint_cache
|
||||
before_script:
|
||||
- mkdir front-review
|
||||
- cd front
|
||||
|
@ -143,6 +152,7 @@ find_broken_links:
|
|||
lychee
|
||||
--cache
|
||||
--no-progress
|
||||
--include-fragments
|
||||
--exclude-all-private
|
||||
--exclude 'demo\.funkwhale\.audio'
|
||||
--exclude 'nginx\.com'
|
||||
|
@ -191,13 +201,15 @@ lint_front:
|
|||
- changes: [front/**/*]
|
||||
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:18
|
||||
cache: *front_cache
|
||||
cache:
|
||||
- *yarn_cache
|
||||
- *node_cache
|
||||
- *lint_cache
|
||||
before_script:
|
||||
- cd front
|
||||
- yarn install --frozen-lockfile
|
||||
script:
|
||||
- yarn lint --max-warnings 0
|
||||
- yarn lint:tsc
|
||||
- yarn lint
|
||||
|
||||
test_scripts:
|
||||
stage: test
|
||||
|
@ -232,7 +244,7 @@ test_api:
|
|||
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:$PYTHON_VERSION
|
||||
parallel:
|
||||
matrix:
|
||||
- PYTHON_VERSION: ["3.10", "3.11", "3.12", "3.13"]
|
||||
- PYTHON_VERSION: ['3.10', '3.11', '3.12', '3.13']
|
||||
services:
|
||||
- name: postgres:15-alpine
|
||||
command:
|
||||
|
@ -242,11 +254,11 @@ test_api:
|
|||
- name: redis:7-alpine
|
||||
cache: *api_cache
|
||||
variables:
|
||||
DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
FUNKWHALE_URL: "https://funkwhale.ci"
|
||||
DATABASE_URL: 'postgresql://postgres@postgres/postgres'
|
||||
FUNKWHALE_URL: 'https://funkwhale.ci'
|
||||
DJANGO_SETTINGS_MODULE: config.settings.local
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
CACHE_URL: "redis://redis:6379/0"
|
||||
CACHE_URL: 'redis://redis:6379/0'
|
||||
before_script:
|
||||
- cd api
|
||||
- make install
|
||||
|
@ -277,7 +289,10 @@ test_front:
|
|||
- changes: [front/**/*]
|
||||
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:18
|
||||
cache: *front_cache
|
||||
cache:
|
||||
- *yarn_cache
|
||||
- *node_cache
|
||||
- *lint_cache
|
||||
before_script:
|
||||
- cd front
|
||||
- yarn install --frozen-lockfile
|
||||
|
@ -316,12 +331,13 @@ test_integration:
|
|||
|
||||
image:
|
||||
name: cypress/included:13.6.4
|
||||
entrypoint: [""]
|
||||
entrypoint: ['']
|
||||
cache:
|
||||
- *front_cache
|
||||
- key:
|
||||
paths:
|
||||
- /root/.cache/Cypress
|
||||
- *yarn_cache
|
||||
- *node_cache
|
||||
- *lint_cache
|
||||
- *cypress_cache
|
||||
|
||||
before_script:
|
||||
- cd front
|
||||
- yarn install
|
||||
|
@ -345,12 +361,12 @@ build_api_schema:
|
|||
- redis:7-alpine
|
||||
cache: *api_cache
|
||||
variables:
|
||||
DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
FUNKWHALE_URL: "https://funkwhale.ci"
|
||||
DATABASE_URL: 'postgresql://postgres@postgres/postgres'
|
||||
FUNKWHALE_URL: 'https://funkwhale.ci'
|
||||
DJANGO_SETTINGS_MODULE: config.settings.local
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
CACHE_URL: "redis://redis:6379/0"
|
||||
API_TYPE: "v1"
|
||||
CACHE_URL: 'redis://redis:6379/0'
|
||||
API_TYPE: 'v1'
|
||||
before_script:
|
||||
- cd api
|
||||
- make install
|
||||
|
@ -403,7 +419,10 @@ build_front:
|
|||
variables:
|
||||
<<: *keep_git_files_permissions
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
cache: *front_cache
|
||||
cache:
|
||||
- *yarn_cache
|
||||
- *node_cache
|
||||
- *lint_cache
|
||||
before_script:
|
||||
- cd front
|
||||
- yarn install --frozen-lockfile
|
||||
|
@ -513,7 +532,7 @@ docker:
|
|||
<<: *keep_git_files_permissions
|
||||
DOCKER_HOST: tcp://docker:2375/
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
DOCKER_TLS_CERTDIR: ''
|
||||
BUILDKIT_PROGRESS: plain
|
||||
|
||||
DOCKER_CACHE_IMAGE: $CI_REGISTRY/funkwhale/funkwhale/cache
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
version: "3"
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
- "POSTGRES_HOST_AUTH_METHOD=trust"
|
||||
- 'POSTGRES_HOST_AUTH_METHOD=trust'
|
||||
volumes:
|
||||
- "../data/postgres:/var/lib/postgresql/data"
|
||||
- '../data/postgres:/var/lib/postgresql/data'
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- "../data/redis:/data"
|
||||
- '../data/redis:/data'
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
|
@ -26,14 +26,14 @@ services:
|
|||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
environment:
|
||||
- "NGINX_MAX_BODY_SIZE=100M"
|
||||
- "FUNKWHALE_API_IP=host.docker.internal"
|
||||
- "FUNKWHALE_API_HOST=host.docker.internal"
|
||||
- "FUNKWHALE_API_PORT=5000"
|
||||
- "FUNKWHALE_FRONT_IP=host.docker.internal"
|
||||
- "FUNKWHALE_FRONT_PORT=8080"
|
||||
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-host.docker.internal}"
|
||||
- "FUNKWHALE_PROTOCOL=https"
|
||||
- 'NGINX_MAX_BODY_SIZE=100M'
|
||||
- 'FUNKWHALE_API_IP=host.docker.internal'
|
||||
- 'FUNKWHALE_API_HOST=host.docker.internal'
|
||||
- 'FUNKWHALE_API_PORT=5000'
|
||||
- 'FUNKWHALE_FRONT_IP=host.docker.internal'
|
||||
- 'FUNKWHALE_FRONT_PORT=8080'
|
||||
- 'FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-host.docker.internal}'
|
||||
- 'FUNKWHALE_PROTOCOL=https'
|
||||
volumes:
|
||||
- ../data/media:/workspace/funkwhale/data/media:ro
|
||||
- ../data/music:/music:ro
|
||||
|
|
|
@ -6,7 +6,8 @@ repos:
|
|||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
exclude: "api/funkwhale_api/common/schema.yml"
|
||||
exclude: 'api/funkwhale_api/common/schema.yml'
|
||||
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-shebang-scripts-are-executable
|
||||
|
@ -63,7 +64,7 @@ repos:
|
|||
hooks:
|
||||
- id: prettier
|
||||
files: \.(md|yml|yaml|json)$
|
||||
exclude: "api/funkwhale_api/common/schema.yml"
|
||||
exclude: 'api/funkwhale_api/common/schema.yml'
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.2.6
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"embeddedLanguageFormatting": "off",
|
||||
"htmlWhitespaceSensitivity": "strict",
|
||||
"printWidth": 160,
|
||||
"semi": false,
|
||||
"singleAttributePerLine": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"singleAttributePerLine": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.json",
|
||||
"options": {
|
||||
"parser": "json",
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1398,6 +1398,7 @@ VERSATILEIMAGEFIELD_RENDITION_KEY_SETS = {
|
|||
],
|
||||
"attachment_square": [
|
||||
("original", "url"),
|
||||
("small_square_crop", "crop__50x50"),
|
||||
("medium_square_crop", "crop__200x200"),
|
||||
("large_square_crop", "crop__600x600"),
|
||||
],
|
||||
|
|
|
@ -263,6 +263,7 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
attributed_to = federation_serializers.APIActorSerializer()
|
||||
rss_url = serializers.CharField(source="get_rss_url")
|
||||
url = serializers.SerializerMethodField()
|
||||
subscriptions_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Channel
|
||||
|
@ -276,6 +277,7 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
"rss_url",
|
||||
"url",
|
||||
"downloads_count",
|
||||
"subscriptions_count",
|
||||
]
|
||||
|
||||
def to_representation(self, obj):
|
||||
|
@ -284,6 +286,7 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
data["subscriptions_count"] = self.get_subscriptions_count(obj)
|
||||
return data
|
||||
|
||||
@extend_schema_field(OpenApiTypes.INT)
|
||||
def get_subscriptions_count(self, obj) -> int:
|
||||
return obj.actor.received_follows.exclude(approved=False).count()
|
||||
|
||||
|
|
|
@ -2,10 +2,12 @@ from django import http
|
|||
from django.db import transaction
|
||||
from django.db.models import Count, Prefetch, Q, Sum
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer
|
||||
from rest_framework import decorators, exceptions, mixins
|
||||
from rest_framework import permissions as rest_permissions
|
||||
from rest_framework import response, viewsets
|
||||
from rest_framework import response
|
||||
from rest_framework import serializers as rest_serializers
|
||||
from rest_framework import viewsets
|
||||
|
||||
from funkwhale_api.common import locales, permissions, preferences
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
|
@ -210,6 +212,32 @@ class ChannelViewSet(
|
|||
data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads)
|
||||
return response.Response(data, status=200)
|
||||
|
||||
@extend_schema(
|
||||
responses=inline_serializer(
|
||||
name="MetedataChoicesSerializer",
|
||||
fields={
|
||||
"language": rest_serializers.ListField(
|
||||
child=inline_serializer(
|
||||
name="LanguageItem",
|
||||
fields={
|
||||
"value": rest_serializers.CharField(),
|
||||
"label": rest_serializers.CharField(),
|
||||
},
|
||||
)
|
||||
),
|
||||
"itunes_category": rest_serializers.ListField(
|
||||
child=inline_serializer(
|
||||
name="iTunesCategoryItem",
|
||||
fields={
|
||||
"value": rest_serializers.CharField(),
|
||||
"label": rest_serializers.CharField(),
|
||||
"children": rest_serializers.CharField(),
|
||||
},
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
@decorators.action(
|
||||
methods=["get"],
|
||||
detail=False,
|
||||
|
|
|
@ -257,6 +257,13 @@ class Attachment(models.Model):
|
|||
proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid})
|
||||
return federation_utils.full_url(proxy_url + "?next=original")
|
||||
|
||||
@property
|
||||
def download_url_small_square_crop(self):
|
||||
if self.file:
|
||||
return utils.media_url(self.file.crop["50x50"].url)
|
||||
proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid})
|
||||
return federation_utils.full_url(proxy_url + "?next=small_square_crop")
|
||||
|
||||
@property
|
||||
def download_url_medium_square_crop(self):
|
||||
if self.file:
|
||||
|
|
|
@ -2152,7 +2152,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Channel'
|
||||
$ref: '#/components/schemas/MetedataChoices'
|
||||
description: ''
|
||||
/api/v1/channels/rss-subscribe/:
|
||||
post:
|
||||
|
@ -9291,16 +9291,25 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedUploadForOwnerRequest'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadBulkUpdateRequest'
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedUploadForOwnerRequest'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadBulkUpdateRequest'
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedUploadForOwnerRequest'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadBulkUpdateRequest'
|
||||
application/activity+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedUploadForOwnerRequest'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadBulkUpdateRequest'
|
||||
required: true
|
||||
security:
|
||||
- oauth2: []
|
||||
- ApplicationToken: []
|
||||
|
@ -11653,7 +11662,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Channel'
|
||||
$ref: '#/components/schemas/MetedataChoices'
|
||||
description: ''
|
||||
/api/v2/channels/rss-subscribe/:
|
||||
post:
|
||||
|
@ -12927,7 +12936,7 @@ paths:
|
|||
description: ''
|
||||
/api/v2/instance/nodeinfo/2.1/:
|
||||
get:
|
||||
operationId: getNodeInfo20_2
|
||||
operationId: getNodeInfo21
|
||||
tags:
|
||||
- instance
|
||||
responses:
|
||||
|
@ -12935,7 +12944,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NodeInfo20'
|
||||
$ref: '#/components/schemas/NodeInfo21'
|
||||
description: ''
|
||||
/api/v2/instance/settings/:
|
||||
get:
|
||||
|
@ -18957,16 +18966,25 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedUploadForOwnerRequest'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadBulkUpdateRequest'
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedUploadForOwnerRequest'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadBulkUpdateRequest'
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedUploadForOwnerRequest'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadBulkUpdateRequest'
|
||||
application/activity+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedUploadForOwnerRequest'
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadBulkUpdateRequest'
|
||||
required: true
|
||||
security:
|
||||
- oauth2: []
|
||||
- ApplicationToken: []
|
||||
|
@ -20270,12 +20288,16 @@ components:
|
|||
downloads_count:
|
||||
type: integer
|
||||
readOnly: true
|
||||
subscriptions_count:
|
||||
type: integer
|
||||
readOnly: true
|
||||
required:
|
||||
- actor
|
||||
- artist
|
||||
- attributed_to
|
||||
- downloads_count
|
||||
- rss_url
|
||||
- subscriptions_count
|
||||
- url
|
||||
ChannelCreate:
|
||||
type: object
|
||||
|
@ -20926,6 +20948,16 @@ components:
|
|||
required:
|
||||
- channel
|
||||
- uuid
|
||||
LanguageItem:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
required:
|
||||
- label
|
||||
- value
|
||||
Library:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -23124,6 +23156,124 @@ components:
|
|||
- shortDescription
|
||||
- supportedUploadExtensions
|
||||
- terms
|
||||
Metadata21:
|
||||
type: object
|
||||
properties:
|
||||
actorId:
|
||||
type: string
|
||||
private:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
shortDescription:
|
||||
type: string
|
||||
readOnly: true
|
||||
longDescription:
|
||||
type: string
|
||||
readOnly: true
|
||||
contactEmail:
|
||||
type: string
|
||||
readOnly: true
|
||||
nodeName:
|
||||
type: string
|
||||
readOnly: true
|
||||
banner:
|
||||
type: string
|
||||
readOnly: true
|
||||
defaultUploadQuota:
|
||||
type: integer
|
||||
readOnly: true
|
||||
supportedUploadExtensions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
allowList:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AllowListStat'
|
||||
readOnly: true
|
||||
funkwhaleSupportMessageEnabled:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
instanceSupportMessage:
|
||||
type: string
|
||||
readOnly: true
|
||||
usage:
|
||||
$ref: '#/components/schemas/MetadataUsage'
|
||||
languages:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
location:
|
||||
type: string
|
||||
content:
|
||||
$ref: '#/components/schemas/MetadataContent'
|
||||
features:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
codeOfConduct:
|
||||
type: string
|
||||
readOnly: true
|
||||
required:
|
||||
- actorId
|
||||
- allowList
|
||||
- banner
|
||||
- codeOfConduct
|
||||
- contactEmail
|
||||
- content
|
||||
- defaultUploadQuota
|
||||
- features
|
||||
- funkwhaleSupportMessageEnabled
|
||||
- instanceSupportMessage
|
||||
- languages
|
||||
- location
|
||||
- longDescription
|
||||
- nodeName
|
||||
- private
|
||||
- shortDescription
|
||||
- supportedUploadExtensions
|
||||
MetadataContent:
|
||||
type: object
|
||||
properties:
|
||||
local:
|
||||
$ref: '#/components/schemas/MetadataContentLocal'
|
||||
topMusicCategories:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MetadataContentCategory'
|
||||
topPodcastCategories:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MetadataContentCategory'
|
||||
required:
|
||||
- local
|
||||
- topMusicCategories
|
||||
- topPodcastCategories
|
||||
MetadataContentCategory:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
required:
|
||||
- count
|
||||
- name
|
||||
MetadataContentLocal:
|
||||
type: object
|
||||
properties:
|
||||
artists:
|
||||
type: integer
|
||||
releases:
|
||||
type: integer
|
||||
recordings:
|
||||
type: integer
|
||||
hoursOfContent:
|
||||
type: integer
|
||||
required:
|
||||
- artists
|
||||
- hoursOfContent
|
||||
- recordings
|
||||
- releases
|
||||
MetadataUsage:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -23146,6 +23296,20 @@ components:
|
|||
readOnly: true
|
||||
required:
|
||||
- tracks
|
||||
MetedataChoices:
|
||||
type: object
|
||||
properties:
|
||||
language:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LanguageItem'
|
||||
itunes_category:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/iTunesCategoryItem'
|
||||
required:
|
||||
- itunes_category
|
||||
- language
|
||||
ModerationTarget:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -23248,6 +23412,42 @@ components:
|
|||
- software
|
||||
- usage
|
||||
- version
|
||||
NodeInfo21:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
readOnly: true
|
||||
software:
|
||||
$ref: '#/components/schemas/SoftwareSerializer_v2'
|
||||
protocols:
|
||||
type: array
|
||||
items: {}
|
||||
readOnly: true
|
||||
services:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Services'
|
||||
default:
|
||||
inbound: []
|
||||
outbound: []
|
||||
openRegistrations:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
usage:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Usage'
|
||||
readOnly: true
|
||||
metadata:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Metadata21'
|
||||
readOnly: true
|
||||
required:
|
||||
- metadata
|
||||
- openRegistrations
|
||||
- protocols
|
||||
- software
|
||||
- usage
|
||||
- version
|
||||
NodeInfoLibrary:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -24425,6 +24625,10 @@ components:
|
|||
maxLength: 100
|
||||
privacy_level:
|
||||
$ref: '#/components/schemas/PrivacyLevelEnum'
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 5000
|
||||
PatchedRadioRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -24455,6 +24659,8 @@ components:
|
|||
allOf:
|
||||
- $ref: '#/components/schemas/ImportStatusEnum'
|
||||
default: pending
|
||||
privacy_level:
|
||||
$ref: '#/components/schemas/LibraryPrivacyLevelEnum'
|
||||
import_metadata: {}
|
||||
import_reference:
|
||||
type: string
|
||||
|
@ -24546,6 +24752,10 @@ components:
|
|||
is_playable:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 5000
|
||||
required:
|
||||
- actor
|
||||
- album_covers
|
||||
|
@ -24576,6 +24786,10 @@ components:
|
|||
maxLength: 100
|
||||
privacy_level:
|
||||
$ref: '#/components/schemas/PrivacyLevelEnum'
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 5000
|
||||
required:
|
||||
- name
|
||||
PlaylistTrack:
|
||||
|
@ -25056,6 +25270,25 @@ components:
|
|||
required:
|
||||
- name
|
||||
- version
|
||||
SoftwareSerializer_v2:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
readOnly: true
|
||||
version:
|
||||
type: string
|
||||
repository:
|
||||
type: string
|
||||
readOnly: true
|
||||
homepage:
|
||||
type: string
|
||||
readOnly: true
|
||||
required:
|
||||
- homepage
|
||||
- name
|
||||
- repository
|
||||
- version
|
||||
SpaManifest:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -25494,6 +25727,17 @@ components:
|
|||
- mimetype
|
||||
- size
|
||||
- uuid
|
||||
UploadBulkUpdateRequest:
|
||||
type: object
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
privacy_level:
|
||||
$ref: '#/components/schemas/LibraryPrivacyLevelEnum'
|
||||
required:
|
||||
- privacy_level
|
||||
- uuid
|
||||
UploadForOwner:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -25540,6 +25784,8 @@ components:
|
|||
allOf:
|
||||
- $ref: '#/components/schemas/ImportStatusEnum'
|
||||
default: pending
|
||||
privacy_level:
|
||||
$ref: '#/components/schemas/LibraryPrivacyLevelEnum'
|
||||
import_details:
|
||||
readOnly: true
|
||||
import_metadata: {}
|
||||
|
@ -25580,6 +25826,8 @@ components:
|
|||
allOf:
|
||||
- $ref: '#/components/schemas/ImportStatusEnum'
|
||||
default: pending
|
||||
privacy_level:
|
||||
$ref: '#/components/schemas/LibraryPrivacyLevelEnum'
|
||||
import_metadata: {}
|
||||
import_reference:
|
||||
type: string
|
||||
|
@ -25845,6 +26093,19 @@ components:
|
|||
minLength: 1
|
||||
required:
|
||||
- key
|
||||
iTunesCategoryItem:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
children:
|
||||
type: string
|
||||
required:
|
||||
- children
|
||||
- label
|
||||
- value
|
||||
securitySchemes:
|
||||
ApplicationToken:
|
||||
type: http
|
||||
|
|
|
@ -308,6 +308,7 @@ class AttachmentSerializer(serializers.Serializer):
|
|||
urls = {}
|
||||
urls["source"] = o.url
|
||||
urls["original"] = o.download_url_original
|
||||
urls["small_square_crop"] = o.download_url_small_square_crop
|
||||
urls["medium_square_crop"] = o.download_url_medium_square_crop
|
||||
urls["large_square_crop"] = o.download_url_large_square_crop
|
||||
return urls
|
||||
|
|
|
@ -176,7 +176,12 @@ class AttachmentViewSet(
|
|||
return r
|
||||
|
||||
size = request.GET.get("next", "original").lower()
|
||||
if size not in ["original", "medium_square_crop", "large_square_crop"]:
|
||||
if size not in [
|
||||
"original",
|
||||
"small_square_crop",
|
||||
"medium_square_crop",
|
||||
"large_square_crop",
|
||||
]:
|
||||
size = "original"
|
||||
|
||||
try:
|
||||
|
|
|
@ -126,7 +126,7 @@ class NodeInfo21(NodeInfo20):
|
|||
serializer_class = serializers.NodeInfo21Serializer
|
||||
|
||||
@extend_schema(
|
||||
responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20"
|
||||
responses=serializers.NodeInfo21Serializer, operation_id="getNodeInfo21"
|
||||
)
|
||||
def get(self, request):
|
||||
pref = preferences.all()
|
||||
|
|
|
@ -129,7 +129,7 @@ class Format(types.MultipleChoicePreference):
|
|||
("aac", "aac"),
|
||||
("mp3", "mp3"),
|
||||
]
|
||||
help_text = "Witch audio format to allow"
|
||||
help_text = "Which audio format to allow"
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
|
|
|
@ -372,6 +372,9 @@ class UploadSerializer(serializers.ModelSerializer):
|
|||
required=False,
|
||||
filters=lambda context: {"actor": context["user"].actor},
|
||||
)
|
||||
privacy_level = serializers.ChoiceField(
|
||||
choices=models.LIBRARY_PRIVACY_LEVEL_CHOICES, required=False
|
||||
)
|
||||
channel = common_serializers.RelatedField(
|
||||
"uuid",
|
||||
ChannelSerializer(),
|
||||
|
@ -395,6 +398,7 @@ class UploadSerializer(serializers.ModelSerializer):
|
|||
"size",
|
||||
"import_date",
|
||||
"import_status",
|
||||
"privacy_level",
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
|
@ -495,6 +499,7 @@ class UploadForOwnerSerializer(UploadSerializer):
|
|||
r = super().to_representation(obj)
|
||||
if "audio_file" in r:
|
||||
del r["audio_file"]
|
||||
r["privacy_level"] = obj.library.privacy_level
|
||||
return r
|
||||
|
||||
def validate(self, validated_data):
|
||||
|
|
|
@ -798,6 +798,9 @@ class UploadViewSet(
|
|||
cover_data["content"] = base64.b64encode(cover_data["content"])
|
||||
return Response(payload, status=200)
|
||||
|
||||
@extend_schema(
|
||||
request=serializers.UploadBulkUpdateSerializer(many=True),
|
||||
)
|
||||
@action(detail=False, methods=["patch"])
|
||||
def bulk_update(self, request, *args, **kwargs):
|
||||
"""
|
||||
|
@ -811,7 +814,9 @@ class UploadViewSet(
|
|||
models.Upload.objects.bulk_update(serializer.validated_data, ["library"])
|
||||
|
||||
return Response(
|
||||
serializers.UploadForOwnerSerializer(serializer.validated_data).data,
|
||||
serializers.UploadForOwnerSerializer(
|
||||
serializer.validated_data, many=True
|
||||
).data,
|
||||
status=200,
|
||||
)
|
||||
|
||||
|
|
|
@ -49,6 +49,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
|
|||
"duration",
|
||||
"is_playable",
|
||||
"actor",
|
||||
"description",
|
||||
)
|
||||
read_only_fields = ["id", "modification_date", "creation_date"]
|
||||
|
||||
|
|
|
@ -111,6 +111,9 @@ class GetArtistInfo2Serializer(serializers.Serializer):
|
|||
if artist.mbid:
|
||||
payload["musicBrainzId"] = TagValue(artist.mbid)
|
||||
if artist.attachment_cover:
|
||||
payload["smallImageUrl"] = TagValue(
|
||||
artist.attachment_cover.download_url_small_square_crop
|
||||
)
|
||||
payload["mediumImageUrl"] = TagValue(
|
||||
artist.attachment_cover.download_url_medium_square_crop
|
||||
)
|
||||
|
|
|
@ -230,6 +230,7 @@ def test_channel_serializer_representation(factories, to_api_date):
|
|||
"rss_url": channel.get_rss_url(),
|
||||
"url": channel.actor.url,
|
||||
"downloads_count": 12,
|
||||
"subscriptions_count": 0,
|
||||
}
|
||||
expected["artist"]["description"] = common_serializers.ContentSerializer(
|
||||
content
|
||||
|
@ -254,6 +255,7 @@ def test_channel_serializer_external_representation(factories, to_api_date):
|
|||
"rss_url": channel.get_rss_url(),
|
||||
"url": channel.actor.url,
|
||||
"downloads_count": 0,
|
||||
"subscriptions_count": 0,
|
||||
}
|
||||
expected["artist"]["description"] = common_serializers.ContentSerializer(
|
||||
content
|
||||
|
|
|
@ -195,6 +195,9 @@ def test_attachment_serializer_existing_file(factories, to_api_date):
|
|||
"urls": {
|
||||
"source": attachment.url,
|
||||
"original": federation_utils.full_url(attachment.file.url),
|
||||
"small_square_crop": federation_utils.full_url(
|
||||
attachment.file.crop["50x50"].url
|
||||
),
|
||||
"medium_square_crop": federation_utils.full_url(
|
||||
attachment.file.crop["200x200"].url
|
||||
),
|
||||
|
@ -225,6 +228,9 @@ def test_attachment_serializer_remote_file(factories, to_api_date):
|
|||
"urls": {
|
||||
"source": attachment.url,
|
||||
"original": federation_utils.full_url(proxy_url + "?next=original"),
|
||||
"small_square_crop": federation_utils.full_url(
|
||||
proxy_url + "?next=small_square_crop"
|
||||
),
|
||||
"medium_square_crop": federation_utils.full_url(
|
||||
proxy_url + "?next=medium_square_crop"
|
||||
),
|
||||
|
|
|
@ -169,6 +169,7 @@ def test_upload_owner_serializer(factories, to_api_date):
|
|||
"import_details": {"hello": "world"},
|
||||
"source": "upload://test",
|
||||
"import_reference": "ref",
|
||||
"privacy_level": upload.library.privacy_level,
|
||||
}
|
||||
serializer = serializers.UploadForOwnerSerializer(upload)
|
||||
assert serializer.data == expected
|
||||
|
|
|
@ -85,6 +85,7 @@ def test_playlist_serializer(factories, to_api_date):
|
|||
"duration": 0,
|
||||
"tracks_count": 0,
|
||||
"album_covers": [],
|
||||
"description": playlist.description,
|
||||
}
|
||||
serializer = serializers.PlaylistSerializer(playlist)
|
||||
|
||||
|
|
|
@ -156,6 +156,9 @@ def test_get_artist_info_2_serializer(factories):
|
|||
|
||||
expected = {
|
||||
"musicBrainzId": artist.mbid,
|
||||
"smallImageUrl": renderers.TagValue(
|
||||
artist.attachment_cover.download_url_small_square_crop
|
||||
),
|
||||
"mediumImageUrl": renderers.TagValue(
|
||||
artist.attachment_cover.download_url_medium_square_crop
|
||||
),
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Improve mobile design (#2090)
|
|
@ -0,0 +1 @@
|
|||
Improve visuals & layout (#2091)
|
|
@ -5,3 +5,4 @@ networks:
|
|||
include:
|
||||
- path: compose/docs.sphinx.yml
|
||||
- path: compose/docs.openapi.yml
|
||||
- path: compose/docs.ui.yml
|
||||
|
|
49
compose.yml
49
compose.yml
|
@ -13,17 +13,17 @@ x-django: &django
|
|||
|
||||
- EXTERNAL_REQUESTS_VERIFY_SSL
|
||||
|
||||
- "FORCE_HTTPS_URLS=${FORCE_HTTPS_URLS:-False}"
|
||||
- 'FORCE_HTTPS_URLS=${FORCE_HTTPS_URLS:-False}'
|
||||
- FUNKWHALE_PROTOCOL
|
||||
- "FUNKWHALE_HOSTNAME=${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}"
|
||||
- 'FUNKWHALE_HOSTNAME=${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}'
|
||||
|
||||
- DATABASE_URL
|
||||
- CACHE_URL
|
||||
- EMAIL_CONFIG
|
||||
- TYPESENSE_API_KEY
|
||||
|
||||
- "STATIC_URL=${FUNKWHALE_PROTOCOL}://${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}/static/"
|
||||
- "MEDIA_URL=${FUNKWHALE_PROTOCOL}://${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}/media/"
|
||||
- 'STATIC_URL=${FUNKWHALE_PROTOCOL}://${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}/static/'
|
||||
- 'MEDIA_URL=${FUNKWHALE_PROTOCOL}://${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}/media/'
|
||||
|
||||
- STATIC_ROOT
|
||||
- MEDIA_ROOT
|
||||
|
@ -31,7 +31,7 @@ x-django: &django
|
|||
- FUNKWHALE_SPA_HTML_ROOT
|
||||
- LDAP_ENABLED
|
||||
- BROWSABLE_API_ENABLED
|
||||
- "MUSIC_DIRECTORY_PATH=${MUSIC_DIRECTORY_PATH:-/music}"
|
||||
- 'MUSIC_DIRECTORY_PATH=${MUSIC_DIRECTORY_PATH:-/music}'
|
||||
|
||||
- C_FORCE_ROOT
|
||||
- PYTHONDONTWRITEBYTECODE
|
||||
|
@ -46,17 +46,14 @@ services:
|
|||
context: ./front
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "${VUE_PORT:-8080}:${VUE_PORT:-8080}"
|
||||
- '${VUE_PORT:-8080}:${VUE_PORT:-8080}'
|
||||
environment:
|
||||
- HOST
|
||||
- VUE_PORT
|
||||
volumes:
|
||||
- "./front:/app"
|
||||
- "/app/node_modules"
|
||||
- "./po:/po"
|
||||
- './front:/app'
|
||||
- '/app/node_modules'
|
||||
networks:
|
||||
- internal
|
||||
command: "yarn dev --host"
|
||||
|
||||
api:
|
||||
extends:
|
||||
|
@ -75,8 +72,8 @@ services:
|
|||
file: ./compose/app.nginx.yml
|
||||
service: nginx
|
||||
environment:
|
||||
- "MUSIC_DIRECTORY_PATH=${MUSIC_DIRECTORY_PATH:-/music}"
|
||||
- "FUNKWHALE_HOSTNAME=${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}"
|
||||
- 'MUSIC_DIRECTORY_PATH=${MUSIC_DIRECTORY_PATH:-/music}'
|
||||
- 'FUNKWHALE_HOSTNAME=${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}'
|
||||
|
||||
- FUNKWHALE_PROTOCOL
|
||||
|
||||
|
@ -89,34 +86,34 @@ services:
|
|||
- NGINX_MAX_BODY_SIZE
|
||||
|
||||
- STATIC_ROOT
|
||||
- "MEDIA_ROOT=${MEDIA_ROOT:-/data/media}"
|
||||
- 'MEDIA_ROOT=${MEDIA_ROOT:-/data/media}'
|
||||
networks:
|
||||
- web
|
||||
- internal
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- 'traefik.enable=true'
|
||||
|
||||
- "traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-web.rule=Host(`${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}`)"
|
||||
- "traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-web.entrypoints=web"
|
||||
- 'traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-web.rule=Host(`${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}`)'
|
||||
- 'traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-web.entrypoints=web'
|
||||
|
||||
- "traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-webs.rule=Host(`${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}`)"
|
||||
- "traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-webs.entrypoints=webs"
|
||||
- 'traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-webs.rule=Host(`${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}`)'
|
||||
- 'traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-webs.entrypoints=webs'
|
||||
|
||||
- "traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-webs.tls=true"
|
||||
- "traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-webs.tls.domains[0].main=${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}"
|
||||
- 'traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-webs.tls=true'
|
||||
- 'traefik.http.routers.test-funkwhale-${COMPOSE_PROJECT_NAME:-funkwhale}-webs.tls.domains[0].main=${COMPOSE_PROJECT_NAME:-funkwhale}.${FUNKWHALE_DOMAIN}'
|
||||
|
||||
postgres:
|
||||
image: "postgres:${POSTGRES_VERSION:-15}-alpine"
|
||||
image: 'postgres:${POSTGRES_VERSION:-15}-alpine'
|
||||
environment:
|
||||
- POSTGRES_HOST_AUTH_METHOD
|
||||
command: postgres ${POSTGRES_ARGS:-}
|
||||
volumes:
|
||||
- "./.state/${COMPOSE_PROJECT_NAME:-funkwhale}/postgres:/var/lib/postgresql/data"
|
||||
- './.state/${COMPOSE_PROJECT_NAME:-funkwhale}/postgres:/var/lib/postgresql/data'
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
@ -124,11 +121,11 @@ services:
|
|||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- "./.state/${COMPOSE_PROJECT_NAME:-funkwhale}/redis:/data"
|
||||
- './.state/${COMPOSE_PROJECT_NAME:-funkwhale}/redis:/data'
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
@ -3,11 +3,11 @@ x-django: &django
|
|||
volumes:
|
||||
- ../api:/app
|
||||
- ../.env:/app/.env
|
||||
- "${MUSIC_DIRECTORY_SERVE_PATH:-../.state/music}:/music:ro"
|
||||
- "../.state/plugins:/srv/funkwhale/plugins"
|
||||
- "../.state/staticfiles:/staticfiles"
|
||||
- "../.state/media:/protected/media"
|
||||
- "../.state/${COMPOSE_PROJECT_NAME:-funkwhale}/media:/data/media"
|
||||
- '${MUSIC_DIRECTORY_SERVE_PATH:-../.state/music}:/music:ro'
|
||||
- '../.state/plugins:/srv/funkwhale/plugins'
|
||||
- '../.state/staticfiles:/staticfiles'
|
||||
- '../.state/media:/protected/media'
|
||||
- '../.state/${COMPOSE_PROJECT_NAME:-funkwhale}/media:/data/media'
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
@ -23,11 +23,7 @@ services:
|
|||
context: ../api
|
||||
dockerfile: Dockerfile.debian
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'docker compose logs api | grep -q "Uvicorn running on" || exit 0',
|
||||
]
|
||||
test: ['CMD-SHELL', 'docker compose logs api | grep -q "Uvicorn running on" || exit 0']
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
@ -5,7 +5,7 @@ services:
|
|||
- api
|
||||
- front
|
||||
volumes:
|
||||
- "${MUSIC_DIRECTORY_SERVE_PATH:-../.state/music}:${MUSIC_DIRECTORY_PATH:-/music}:ro"
|
||||
- '${MUSIC_DIRECTORY_SERVE_PATH:-../.state/music}:${MUSIC_DIRECTORY_PATH:-/music}:ro'
|
||||
|
||||
- ./etc/nginx/conf.dev:/etc/nginx/templates/default.conf.template:ro
|
||||
- ../deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro
|
||||
|
|
|
@ -2,18 +2,18 @@ services:
|
|||
openapi:
|
||||
image: swaggerapi/swagger-ui
|
||||
environment:
|
||||
- "URL=/openapi.yml"
|
||||
- 'URL=/openapi.yml'
|
||||
ports:
|
||||
- "8002:8080"
|
||||
- '8002:8080'
|
||||
volumes:
|
||||
- "../docs/specs/nodeinfo21/schema.yml:/usr/share/nginx/html/openapi.yml"
|
||||
- '../docs/specs/nodeinfo21/schema.yml:/usr/share/nginx/html/openapi.yml'
|
||||
# - "../docs/api:/usr/share/nginx/html/api"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.test-funkwhale-openapi-web.rule=Host(`openapi.funkwhale.test`)"
|
||||
- "traefik.http.routers.test-funkwhale-openapi-web.entrypoints=web"
|
||||
- "traefik.http.services.test-funkwhale-openapi.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.test-funkwhale-openapi-webs.rule=Host(`openapi.funkwhale.test`)"
|
||||
- "traefik.http.routers.test-funkwhale-openapi-webs.entrypoints=webs"
|
||||
- "traefik.http.routers.test-funkwhale-openapi-webs.tls=true"
|
||||
networks: ["web"]
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.test-funkwhale-openapi-web.rule=Host(`openapi.funkwhale.test`)'
|
||||
- 'traefik.http.routers.test-funkwhale-openapi-web.entrypoints=web'
|
||||
- 'traefik.http.services.test-funkwhale-openapi.loadbalancer.server.port=8080'
|
||||
- 'traefik.http.routers.test-funkwhale-openapi-webs.rule=Host(`openapi.funkwhale.test`)'
|
||||
- 'traefik.http.routers.test-funkwhale-openapi-webs.entrypoints=webs'
|
||||
- 'traefik.http.routers.test-funkwhale-openapi-webs.tls=true'
|
||||
networks: ['web']
|
||||
|
|
|
@ -4,16 +4,16 @@ services:
|
|||
context: ../
|
||||
dockerfile: docs/Dockerfile
|
||||
init: true
|
||||
ports: ["8001:8001"]
|
||||
ports: ['8001:8001']
|
||||
command: sh -c 'cd /src/docs && make dev'
|
||||
volumes:
|
||||
- ../docs:/src/docs
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.test-funkwhale-docs-web.rule=Host(`docs.funkwhale.test`)"
|
||||
- "traefik.http.routers.test-funkwhale-docs-web.entrypoints=web"
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.test-funkwhale-docs-web.rule=Host(`docs.funkwhale.test`)'
|
||||
- 'traefik.http.routers.test-funkwhale-docs-web.entrypoints=web'
|
||||
|
||||
- "traefik.http.routers.test-funkwhale-docs-webs.rule=Host(`docs.funkwhale.test`)"
|
||||
- "traefik.http.routers.test-funkwhale-docs-webs.entrypoints=webs"
|
||||
- "traefik.http.routers.test-funkwhale-docs-webs.tls=true"
|
||||
networks: ["web"]
|
||||
- 'traefik.http.routers.test-funkwhale-docs-webs.rule=Host(`docs.funkwhale.test`)'
|
||||
- 'traefik.http.routers.test-funkwhale-docs-webs.entrypoints=webs'
|
||||
- 'traefik.http.routers.test-funkwhale-docs-webs.tls=true'
|
||||
networks: ['web']
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
ui:
|
||||
build:
|
||||
context: ../front
|
||||
dockerfile: Dockerfile.dev
|
||||
command: yarn dev:docs --host 0.0.0.0
|
||||
expose: ['5173']
|
||||
ports:
|
||||
- '8003:5173'
|
||||
volumes:
|
||||
- '../front:/app'
|
||||
- '/app/node_modules'
|
||||
networks: ['web']
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.test-funkwhale-ui-web.rule=Host(`ui.funkwhale.test`)'
|
||||
- 'traefik.http.routers.test-funkwhale-ui-web.entrypoints=web'
|
||||
- 'traefik.http.services.test-funkwhale-ui.loadbalancer.server.port=5173'
|
||||
- 'traefik.http.routers.test-funkwhale-ui-webs.rule=Host(`ui.funkwhale.test`)'
|
||||
- 'traefik.http.routers.test-funkwhale-ui-webs.entrypoints=webs'
|
||||
- 'traefik.http.routers.test-funkwhale-ui-webs.tls=true'
|
|
@ -3,7 +3,7 @@ http:
|
|||
test-funkwhale-mailpit:
|
||||
loadbalancer:
|
||||
servers:
|
||||
- url: "http://172.17.0.1:8025"
|
||||
- url: 'http://172.17.0.1:8025'
|
||||
passhostheader: true
|
||||
routers:
|
||||
test-funkwhale-mailpit-web:
|
||||
|
|
|
@ -13,8 +13,8 @@ api:
|
|||
|
||||
entryPoints:
|
||||
traefik:
|
||||
address: "172.17.0.1:8008"
|
||||
address: '172.17.0.1:8008'
|
||||
web:
|
||||
address: "172.17.0.1:80"
|
||||
address: '172.17.0.1:80'
|
||||
webs:
|
||||
address: "172.17.0.1:443"
|
||||
address: '172.17.0.1:443'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
x-busybox: &busybox
|
||||
init: true
|
||||
image: "busybox"
|
||||
image: 'busybox'
|
||||
network_mode: bridge
|
||||
dns: 172.17.0.1
|
||||
dns_search: funkwhale.test
|
||||
|
@ -11,18 +11,18 @@ networks:
|
|||
|
||||
services:
|
||||
whoami:
|
||||
image: "traefik/whoami"
|
||||
image: 'traefik/whoami'
|
||||
networks:
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.whoami-web.rule=Host(`whoami.funkwhale.test`)"
|
||||
- "traefik.http.routers.whoami-web.entrypoints=web"
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.whoami-web.rule=Host(`whoami.funkwhale.test`)'
|
||||
- 'traefik.http.routers.whoami-web.entrypoints=web'
|
||||
|
||||
- "traefik.http.routers.whoami-webs.rule=Host(`whoami.funkwhale.test`)"
|
||||
- "traefik.http.routers.whoami-webs.entrypoints=webs"
|
||||
- "traefik.http.routers.whoami-webs.tls=true"
|
||||
- "traefik.http.routers.whoami.tls.domains[0].main=whoami.funkwhale.test"
|
||||
- 'traefik.http.routers.whoami-webs.rule=Host(`whoami.funkwhale.test`)'
|
||||
- 'traefik.http.routers.whoami-webs.entrypoints=webs'
|
||||
- 'traefik.http.routers.whoami-webs.tls=true'
|
||||
- 'traefik.http.routers.whoami.tls.domains[0].main=whoami.funkwhale.test'
|
||||
|
||||
shell:
|
||||
<<: *busybox
|
||||
|
|
|
@ -9,5 +9,5 @@ services:
|
|||
MP_SMTP_BIND_ADDR: 172.17.0.1:1025
|
||||
MP_MAX_MESSAGES: 5000
|
||||
MP_DATABASE: /data/mailpit.db
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: "true"
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 'true'
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 'true'
|
||||
|
|
|
@ -3,13 +3,13 @@ services:
|
|||
image: minio/minio
|
||||
command: server /data
|
||||
volumes:
|
||||
- "../.state/${COMPOSE_PROJECT_NAME:-funkwhale}/minio:/data"
|
||||
- '../.state/${COMPOSE_PROJECT_NAME:-funkwhale}/minio:/data'
|
||||
environment:
|
||||
- "MINIO_ACCESS_KEY=${AWS_ACCESS_KEY_ID:-access_key}"
|
||||
- "MINIO_SECRET_KEY=${AWS_SECRET_ACCESS_KEY:-secret_key}"
|
||||
- "MINIO_HTTP_TRACE: /dev/stdout"
|
||||
- 'MINIO_ACCESS_KEY=${AWS_ACCESS_KEY_ID:-access_key}'
|
||||
- 'MINIO_SECRET_KEY=${AWS_SECRET_ACCESS_KEY:-secret_key}'
|
||||
- 'MINIO_HTTP_TRACE: /dev/stdout'
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- '9000:9000'
|
||||
networks:
|
||||
- web
|
||||
- internal
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
x-verify: &verify
|
||||
init: true
|
||||
image: "busybox"
|
||||
image: 'busybox'
|
||||
network_mode: bridge
|
||||
dns: 172.17.0.1
|
||||
dns_search: funkwhale.test
|
||||
|
@ -12,4 +12,4 @@ services:
|
|||
|
||||
verify-internal-connectivity:
|
||||
<<: *verify
|
||||
command: "ping -c 1 ${COMPOSE_PROJECT_NAME:-funkwhale}.funkwhale.test"
|
||||
command: 'ping -c 1 ${COMPOSE_PROJECT_NAME:-funkwhale}.funkwhale.test'
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
version: "3"
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- "POSTGRES_HOST_AUTH_METHOD=trust"
|
||||
- 'POSTGRES_HOST_AUTH_METHOD=trust'
|
||||
image: postgres:15-alpine
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
|
@ -41,8 +41,8 @@ services:
|
|||
environment:
|
||||
- C_FORCE_ROOT=true
|
||||
volumes:
|
||||
- "${MUSIC_DIRECTORY_SERVE_PATH-/srv/funkwhale/data/music}:${MUSIC_DIRECTORY_PATH-/music}:ro"
|
||||
- "${MEDIA_ROOT}:${MEDIA_ROOT}"
|
||||
- '${MUSIC_DIRECTORY_SERVE_PATH-/srv/funkwhale/data/music}:${MUSIC_DIRECTORY_PATH-/music}:ro'
|
||||
- '${MEDIA_ROOT}:${MEDIA_ROOT}'
|
||||
|
||||
celerybeat:
|
||||
restart: unless-stopped
|
||||
|
@ -65,9 +65,9 @@ services:
|
|||
- redis
|
||||
env_file: .env
|
||||
volumes:
|
||||
- "${MUSIC_DIRECTORY_SERVE_PATH-/srv/funkwhale/data/music}:${MUSIC_DIRECTORY_PATH-/music}:ro"
|
||||
- "${MEDIA_ROOT}:${MEDIA_ROOT}"
|
||||
- "${STATIC_ROOT}:${STATIC_ROOT}"
|
||||
- '${MUSIC_DIRECTORY_SERVE_PATH-/srv/funkwhale/data/music}:${MUSIC_DIRECTORY_PATH-/music}:ro'
|
||||
- '${MEDIA_ROOT}:${MEDIA_ROOT}'
|
||||
- '${STATIC_ROOT}:${STATIC_ROOT}'
|
||||
|
||||
front:
|
||||
restart: unless-stopped
|
||||
|
@ -78,7 +78,7 @@ services:
|
|||
- .env
|
||||
environment:
|
||||
# Override those variables in your .env file if needed
|
||||
- "NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE-100M}"
|
||||
- 'NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE-100M}'
|
||||
volumes:
|
||||
# Uncomment if you want to use your previous nginx config, please let us
|
||||
# know what special configuration you need, so we can support it with out
|
||||
|
@ -86,12 +86,12 @@ services:
|
|||
#- "./nginx/funkwhale.template:/etc/nginx/templates/default.conf.template:ro"
|
||||
#- "./nginx/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro"
|
||||
|
||||
- "${MUSIC_DIRECTORY_SERVE_PATH-/srv/funkwhale/data/music}:${MUSIC_DIRECTORY_PATH-/music}:ro"
|
||||
- "${MEDIA_ROOT}:${MEDIA_ROOT}:ro"
|
||||
- "${STATIC_ROOT}:/usr/share/nginx/html/staticfiles:ro"
|
||||
- '${MUSIC_DIRECTORY_SERVE_PATH-/srv/funkwhale/data/music}:${MUSIC_DIRECTORY_PATH-/music}:ro'
|
||||
- '${MEDIA_ROOT}:${MEDIA_ROOT}:ro'
|
||||
- '${STATIC_ROOT}:/usr/share/nginx/html/staticfiles:ro'
|
||||
ports:
|
||||
# override those variables in your .env file if needed
|
||||
- "${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT}:80"
|
||||
- '${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT}:80'
|
||||
|
||||
typesense:
|
||||
restart: unless-stopped
|
||||
|
|
|
@ -129,8 +129,13 @@ def test_downgrade_not_superuser_skips_email(factories, mocker):
|
|||
mocked_notify.assert_not_called()
|
||||
```
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
(runtests)=
|
||||
## Run tests
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
You can run all tests in the pytest suite with the following command:
|
||||
|
||||
```sh
|
||||
|
|
|
@ -47,8 +47,8 @@ const labels = computed(() => ({
|
|||
:::{tab-item} Template
|
||||
|
||||
```html
|
||||
<h2>{{ $t('components.About.header.funkwhale') }}</h2>
|
||||
<button>{{ $t('components.About.button.cancel') }}</button>
|
||||
<h2>{{ t('components.About.header.funkwhale') }}</h2>
|
||||
<button>{{ t('components.About.button.cancel') }}</button>
|
||||
```
|
||||
|
||||
:::
|
||||
|
@ -84,11 +84,11 @@ Some strings change depending on whether they are plural or not. You can create
|
|||
v-if="object.artist?.content_category === 'podcast'"
|
||||
class="meta ellipsis"
|
||||
>
|
||||
{{ $t('components.audio.ChannelCard.meta.episodes', {episode_count:
|
||||
{{ t('components.audio.ChannelCard.meta.episodes', {episode_count:
|
||||
object.artist.tracks_count}) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.audio.ChannelCard.meta.tracks', {tracks_count:
|
||||
{{ t('components.audio.ChannelCard.meta.tracks', {tracks_count:
|
||||
object.artist?.tracks_count}) }}
|
||||
</span>
|
||||
<tags-list
|
||||
|
|
|
@ -2,25 +2,72 @@
|
|||
|
||||
The Funkwhale frontend is a {abbr}`SPA (Single Page Application)` written in [Typescript](https://typescriptlang.org) and [Vue.js](https://vuejs.org).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Network errors (405 and 404) in the console
|
||||
|
||||
If you are using Google Chrome, you may have to disable the network cache:
|
||||
|
||||
- Go to the Dev Tools
|
||||
- Select the Network tab
|
||||
- In the toolbar under the Network tab, activate the checkmark "Disable Cache"
|
||||
|
||||
### Edits don't appear when I check them in the browser
|
||||
|
||||
Reload the page with `Ctrl+Shift+R` (Mac: `Cmd+Shift+R`)
|
||||
|
||||
Make sure you have no add-ons in your browser that mess with the DOM. The best way to check is to open a private window/tab with `Ctrl/Cmd+Shift+P` (Firefox)
|
||||
|
||||
## Styles
|
||||
|
||||
We currently use [Fomantic UI](https://fomantic-ui.com) as our UI framework. We customize this with our own SCSS files located in `front/src/styles/_main.scss`.
|
||||
<--! TODO: Mermaid diagrams -->
|
||||
|
||||
We apply changes to the Fomantic CSS files before we import them:
|
||||
### UI styles
|
||||
|
||||
1. We replace hardcoded color values with CSS variables to make themin easier. For example: `color: orange` is replaced by `color: var(--vibrant-color)`
|
||||
2. We remove unused values from the CSS files to keep the size down
|
||||
```mermaid
|
||||
graph TD
|
||||
/node_modules/bootstrap-icons/font/bootstrap-icons.css --> src/styles/font.scss
|
||||
src/styles/base/generic.scss --> src/styles/base/index.scss
|
||||
src/styles/base/index.scss --> src/styles/funkwhale.scss
|
||||
src/styles/font.scss --> src/styles/funkwhale.scss
|
||||
src/styles/colors.scss --> src/styles/funkwhale.scss
|
||||
src/styles/funkwhale.scss --> src/main.ts
|
||||
```
|
||||
|
||||
These changes are applied when you run `yarn install` through a `postinstall` hook. If you want to modify these changes, check the `front/scripts/fix-fomantic-css.py` script.
|
||||
### App styles
|
||||
|
||||
We plan to replace Fomantic with our own UI framework in the near future. Check our [Penpot](https://design.funkwhale.audio) to see what we've got planned.
|
||||
```mermaid
|
||||
graph TD
|
||||
_css_vars --> /themes/_...
|
||||
vendor/_media.scss --> _main.scss
|
||||
/globals/_... --> _main.scss
|
||||
/components/_... --> _main.scss
|
||||
/pages/_... --> _main.scss
|
||||
/themes/_... --> _main.scss
|
||||
_vars.scss --> /themes/_...
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
Our [component library](https://ui.funkwhale.audio) contains reusable Vue components that you can add to the Funkwhale frontend. If you want to add a new component, check out [the repository](https://dev.funkwhale.audio/funkwhale/vui).
|
||||
Start the **UI Live Docs** (vitepress) and follow the link:
|
||||
|
||||
```sh
|
||||
yarn dev:docs
|
||||
```
|
||||
|
||||
- Example: [Button component in the live docs](http://localhost:5173/components/ui/button)
|
||||
|
||||
- Find more details about the UI library and [how to contributein the live docs](http://localhost:5173/contributing)
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
(testing)=
|
||||
## Testing
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
### Unit tests
|
||||
|
||||
The Funkwhale frontend contains some tests to catch errors before changes go live. The coverage is still fairly low, so we welcome any contributions.
|
||||
|
||||
To run the test suite, run the following command:
|
||||
|
@ -34,3 +81,7 @@ To run tests as you make changes, launch the test suite with the `-w` flag:
|
|||
```sh
|
||||
docker compose run --rm front yarn test:unit -w
|
||||
```
|
||||
|
||||
### End-to-end testing and User testing
|
||||
|
||||
In addition, there are End-to-end tests (cyprus), and we are planning to do User tests in 2025.
|
||||
|
|
|
@ -19,6 +19,12 @@ maxdepth: 1
|
|||
objects
|
||||
```
|
||||
|
||||
## How to use federation in the user interface
|
||||
|
||||
If you have the fediverse handle of a user ('xx@xx.xx'), displayed in their profile, you can paste this address in the global search field. Funkwhale will then clone the profile onto your instance and you will be able to see their profile locally and follow their activities. You are not automatically subscribed to their channels or have access to their public libraries. We still have to implement this in a [viable way] (https://dev.funkwhale.audio/funkwhale/funkwhale/-/issues/2422)
|
||||
|
||||
The same works for rss feeds that are outside of the fediverse. Just enter the RSS feed URI into the search field and you can add it as a podcast or music channel.
|
||||
|
||||
## Technologies and standards
|
||||
|
||||
Funkwhale's federation is built on top of the following technologies:
|
||||
|
|
|
@ -264,8 +264,7 @@ You can create local data to mimic a live environment.
|
|||
Add some fake data to populate the database. The following command creates 25 artists with random albums, tracks, and metadata.
|
||||
|
||||
```sh
|
||||
command="from funkwhale_api.music import fake_data; fake_data.create_data()"
|
||||
echo $command | docker compose run --rm -T api funkwhale-manage shell -i python
|
||||
docker compose exec -T api funkwhale-manage shell -i python <<< "from funkwhale_api.music import fake_data; fake_data.create_data(super_user_name='YOURNAMEHERE')"
|
||||
```
|
||||
|
||||
This will launch a development funkwhale instance with a super user having `COMPOSE_PROJECT_NAME` as username and `funkwhale` as password. Libraries, listenings and music data will be associated with the superuser :
|
||||
|
@ -416,11 +415,43 @@ To build the documentation locally run:
|
|||
docker compose -f compose.docs.yml up -d
|
||||
```
|
||||
|
||||
The documentation is then accessible at [https://docs.funkwhale.test](https://docs.funkwhale.test). The OpenAPI schema is available at [https://openapi.funkwhale.test](https://openapi.funkwhale.test).
|
||||
The documentation is then accessible at [https://docs.funkwhale.test](https://docs.funkwhale.test). The OpenAPI schema is available at [https://openapi.funkwhale.test](https://openapi.funkwhale.test). The UI component library is available at [https://ui.funkwhale.test](https://ui.funkwhale.test).
|
||||
|
||||
Fallback ports are available for the documentation at
|
||||
[http://localhost:8001/](http://localhost:8001/) and for the OpenAPI schema at
|
||||
[http://localhost:8002/](http://localhost:8002/).
|
||||
[http://localhost:8001/](http://localhost:8001/), for the OpenAPI schema at
|
||||
[http://localhost:8002/](http://localhost:8002/) and for the UI component library at [http://localhost:8003/](http://localhost:8003/).
|
||||
|
||||
Maintain their life cycle with similar commands to those used to
|
||||
[set up auxiliary services (point 2.)](#set-up-auxiliary-services).
|
||||
|
||||
## Running the test suites
|
||||
|
||||
Run the App test suite:
|
||||
|
||||
```sh
|
||||
docker compose run --rm front yarn test
|
||||
```
|
||||
|
||||
Run the App tests with coverage:
|
||||
|
||||
```sh
|
||||
docker compose run --rm front yarn test:unit
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
Please also see the [Testing](<#testing>) in the App contributing guidelines.
|
||||
|
||||
Run the API test suite:
|
||||
|
||||
```sh
|
||||
docker compose run --rm api pytest
|
||||
```
|
||||
|
||||
Run a single test:
|
||||
|
||||
```sh
|
||||
docker compose run --rm api pytest tests/music/test_models.py
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
Please also see [Run tests](<#runtests>) in the API contributing guidelines.
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Issue
|
||||
|
||||
We now have playlist, use complained about library not being clearly defined.
|
||||
We now have playlists for sorting music and sharing, users complained about library not being clearly defined.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
|
@ -14,7 +14,7 @@ A new endpoint to move upload from one library to another : `PATCH` on `api/v2/u
|
|||
|
||||
New `description` field on playlist, to inherit from the `description` field of Library
|
||||
|
||||
Library Follows will be transformed to user follow.
|
||||
Library Follows will be transformed to user follows.
|
||||
The schedule_scan function of the library still exist and allow federation of audio content
|
||||
|
||||
During user creation, built-in libraries are generated automatically by `create_user_libraries`
|
||||
|
|
|
@ -1,633 +0,0 @@
|
|||
openapi: "3.0.3"
|
||||
info:
|
||||
description: "Interactive documentation for [Funkwhale](https://funkwhale.audio) API."
|
||||
version: "2.0.0"
|
||||
title: "Funkwhale API"
|
||||
|
||||
servers:
|
||||
- url: "https://demo.funkwhale.audio"
|
||||
description: "Demo server"
|
||||
- url: "https://open.audio"
|
||||
description: "Real server with real content"
|
||||
- url: "https://{domain}"
|
||||
description: "Custom server"
|
||||
variables:
|
||||
domain:
|
||||
default: yourdomain
|
||||
description: "Your Funkwhale Domain"
|
||||
protocol:
|
||||
enum:
|
||||
- "http"
|
||||
- "https"
|
||||
default: "https"
|
||||
tags:
|
||||
- name: Instance
|
||||
description: Information about the server
|
||||
- name: Content
|
||||
description: Information about content on the server
|
||||
paths:
|
||||
/api/v2/instance/nodeinfo/2.1:
|
||||
get:
|
||||
tags:
|
||||
- Instance
|
||||
summary: Retrieve nodeinfo data
|
||||
description: Retrieve details about a Funkwhale server using the Nodeinfo standard
|
||||
operationId: getNodeinfo
|
||||
responses:
|
||||
"200":
|
||||
description: Successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Nodeinfo"
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Nodeinfo"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
/api/v2/tags/podcasts:
|
||||
get:
|
||||
tags:
|
||||
- Content
|
||||
summary: Retrieve podcast categories
|
||||
description: Retrieve a list of podcast categories and the number of uploads tagged with those categories
|
||||
operationId: getTagsPodcasts
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
required: false
|
||||
description: A free text field to filter category names
|
||||
schema:
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
description: The number of the result page you want to return
|
||||
schema:
|
||||
type: number
|
||||
- name: page_size
|
||||
in: query
|
||||
required: false
|
||||
description: The number of results to return on each page. Defaults to 50.
|
||||
schema:
|
||||
type: number
|
||||
- name: ordering
|
||||
in: query
|
||||
required: false
|
||||
description: |
|
||||
The order in which results are presented. Preface with `-` to return items in descending order.
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- "name"
|
||||
- "creation_date"
|
||||
- "tagged_items"
|
||||
- "-name"
|
||||
- "-creation_date"
|
||||
- "-tagged_items"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Categories"
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Categories"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
/api/v2/tags/podcasts/{category}:
|
||||
get:
|
||||
tags:
|
||||
- Content
|
||||
summary: Retrieve podcast categories
|
||||
description: Retrieve a list of podcast categories and the number of uploads tagged with those categories
|
||||
operationId: getTagPodcasts
|
||||
parameters:
|
||||
- name: category
|
||||
in: path
|
||||
required: true
|
||||
description: The category you want to return information about
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Category"
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Category"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
/api/v2/tags/music:
|
||||
get:
|
||||
tags:
|
||||
- Content
|
||||
summary: Retrieve music genres
|
||||
description: Retrieve a list of music genres and the number of uploads tagged with those categories
|
||||
operationId: getTagsMusic
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
required: false
|
||||
description: A free text field to filter genre names
|
||||
schema:
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
description: The number of the result page you want to return
|
||||
schema:
|
||||
type: number
|
||||
- name: page_size
|
||||
in: query
|
||||
required: false
|
||||
description: The number of results to return on each page. Defaults to 50.
|
||||
schema:
|
||||
type: number
|
||||
- name: ordering
|
||||
in: query
|
||||
required: false
|
||||
description: |
|
||||
The order in which results are presented. Preface with `-` to return items in descending order.
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- "name"
|
||||
- "creation_date"
|
||||
- "tagged_items"
|
||||
- "-name"
|
||||
- "-creation_date"
|
||||
- "-tagged_items"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Genres"
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Genres"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
/api/v2/tags/music/{genre}:
|
||||
get:
|
||||
tags:
|
||||
- Content
|
||||
summary: Retrieve podcast categories
|
||||
description: Retrieve a list of podcast categories and the number of uploads tagged with those categories
|
||||
operationId: getTagMusic
|
||||
parameters:
|
||||
- name: genre
|
||||
in: path
|
||||
required: true
|
||||
description: The genre you want to return information about
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Genre"
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Genre"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
components:
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
code: 401
|
||||
message: User not authorized
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
example:
|
||||
code: 401
|
||||
message: User not authorized
|
||||
schemas:
|
||||
Categories:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: number
|
||||
next:
|
||||
type: string
|
||||
format: url
|
||||
previous:
|
||||
type: string
|
||||
format: url
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Category"
|
||||
example:
|
||||
total: 5
|
||||
next: https://demo.funkwhale.audio/api/v2/categories?page=2&page_size=2&q=crime
|
||||
previous: null
|
||||
results:
|
||||
- category: "True Crime"
|
||||
created_date: "2020-01-01T00:00:00.000Z"
|
||||
tagged_items: 5
|
||||
results_page: "https://demo.funkwhale.audio/library/categories/True%20Crime"
|
||||
- category: "True Stories"
|
||||
created_date: "2023-12-15T23:32:52.000Z"
|
||||
tagged_items: 200
|
||||
results_page: "https://demo.funkwhale.audio/library/categories/True%20Stories"
|
||||
Category:
|
||||
type: object
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
created_date:
|
||||
type: string
|
||||
format: date-time
|
||||
tagged_items:
|
||||
type: number
|
||||
results_page:
|
||||
type: string
|
||||
format: url
|
||||
example:
|
||||
category: "True Crime"
|
||||
created_date: "2020-01-01T00:00:00.000Z"
|
||||
tagged_items: 5
|
||||
results_page: "https://demo.funkwhale.audio/library/categories/True%20Crime"
|
||||
Genres:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: number
|
||||
next:
|
||||
type: string
|
||||
format: url
|
||||
previous:
|
||||
type: string
|
||||
format: url
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Genre"
|
||||
example:
|
||||
total: 5
|
||||
next: https://demo.funkwhale.audio/api/v2/categories?page=2&page_size=2&q=rock
|
||||
previous: null
|
||||
results:
|
||||
- genre: "Acoustic Rock"
|
||||
created_date: "2020-01-01T00:00:00.000Z"
|
||||
tagged_items: 5
|
||||
results_page: "https://demo.funkwhale.audio/library/categories/Acoustic%20Rock"
|
||||
- genre: "Surf Rock"
|
||||
created_date: "2023-12-15T23:32:52.000Z"
|
||||
tagged_items: 200
|
||||
results_page: "https://demo.funkwhale.audio/library/categories/Surf%20Rock"
|
||||
Genre:
|
||||
type: object
|
||||
properties:
|
||||
genre:
|
||||
type: string
|
||||
created_date:
|
||||
type: string
|
||||
format: date-time
|
||||
tagged_items:
|
||||
type: number
|
||||
results_page:
|
||||
type: string
|
||||
format: url
|
||||
example:
|
||||
genre: "Acoustic Rock"
|
||||
created_date: "2020-01-01T00:00:00.000Z"
|
||||
tagged_items: 5
|
||||
results_page: "https://demo.funkwhale.audio/library/categories/Acoustic%20Rock"
|
||||
Nodeinfo:
|
||||
type: object
|
||||
required:
|
||||
- version
|
||||
- software
|
||||
- protocols
|
||||
- services
|
||||
- openRegistrations
|
||||
- usage
|
||||
- metadata
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
enum:
|
||||
- "2.1"
|
||||
software:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- version
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
enum:
|
||||
- "Funkwhale"
|
||||
version:
|
||||
type: string
|
||||
example: "1.4.0"
|
||||
repository:
|
||||
type: string
|
||||
format: url
|
||||
enum:
|
||||
- "https://dev.funkwhale.audio/funkwhale/funkwhale"
|
||||
homepage:
|
||||
type: string
|
||||
format: url
|
||||
enum:
|
||||
- "https://funkwhale.audio"
|
||||
protocols:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- "activitypub"
|
||||
- "buddycloud"
|
||||
- "dfrn"
|
||||
- "diaspora"
|
||||
- "libertree"
|
||||
- "ostatus"
|
||||
- "pumpio"
|
||||
- "tent"
|
||||
- "xmpp"
|
||||
- "zot"
|
||||
example:
|
||||
- "activitypub"
|
||||
services:
|
||||
type: object
|
||||
required:
|
||||
- inbound
|
||||
- outbound
|
||||
properties:
|
||||
inbound:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- "atom1.0"
|
||||
- "gnusocial"
|
||||
- "imap"
|
||||
- "pnut"
|
||||
- "pop3"
|
||||
- "pumpio"
|
||||
- "rss2.0"
|
||||
- "twitter"
|
||||
outbound:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- "atom1.0"
|
||||
- "blogger"
|
||||
- "buddycloud"
|
||||
- "diaspora"
|
||||
- "dreamwidth"
|
||||
- "drupal"
|
||||
- "facebook"
|
||||
- "friendica"
|
||||
- "gnusocial"
|
||||
- "google"
|
||||
- "insanejournal"
|
||||
- "libertree"
|
||||
- "linkedin"
|
||||
- "livejournal"
|
||||
- "mediagoblin"
|
||||
- "myspace"
|
||||
- "pinterest"
|
||||
- "pnut"
|
||||
- "posterous"
|
||||
- "pumpio"
|
||||
- "redmatrix"
|
||||
- "rss2.0"
|
||||
- "smtp"
|
||||
- "tent"
|
||||
- "tumblr"
|
||||
- "twitter"
|
||||
- "wordpress"
|
||||
- "xmpp"
|
||||
openRegistrations:
|
||||
type: boolean
|
||||
usage:
|
||||
type: object
|
||||
required:
|
||||
- users
|
||||
properties:
|
||||
users:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
activeHalfYear:
|
||||
type: integer
|
||||
minimum: 0
|
||||
activeMonth:
|
||||
type: integer
|
||||
minimum: 0
|
||||
localPosts:
|
||||
type: integer
|
||||
minimum: 0
|
||||
localComments:
|
||||
type: integer
|
||||
minimum: 0
|
||||
metadata:
|
||||
type: object
|
||||
properties:
|
||||
actorId:
|
||||
type: string
|
||||
format: url
|
||||
private:
|
||||
type: boolean
|
||||
shortDescription:
|
||||
type: string
|
||||
longDescription:
|
||||
type: string
|
||||
contactEmail:
|
||||
type: string
|
||||
format: email
|
||||
nodeName:
|
||||
type: string
|
||||
banner:
|
||||
type: string
|
||||
format: url
|
||||
nullable: true
|
||||
defaultUploadQuota:
|
||||
type: integer
|
||||
supportedUploadExtensions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
allowList:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
domains:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
funkwhaleSupportMessageEnabled:
|
||||
type: boolean
|
||||
instanceSupportMessage:
|
||||
type: string
|
||||
languages:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
location:
|
||||
type: string
|
||||
codeOfConduct:
|
||||
type: string
|
||||
format: url
|
||||
content:
|
||||
type: object
|
||||
properties:
|
||||
local:
|
||||
type: object
|
||||
properties:
|
||||
artists:
|
||||
type: number
|
||||
releases:
|
||||
type: number
|
||||
recordings:
|
||||
type: number
|
||||
hoursOfContent:
|
||||
type: number
|
||||
example:
|
||||
artists: 1000
|
||||
releases: 10000
|
||||
recordings: 150000
|
||||
hoursOfContent: 7500
|
||||
topMusicCategories:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
example:
|
||||
- name: "rock"
|
||||
count: 1256
|
||||
- name: "jazz"
|
||||
count: 604
|
||||
- name: "classical"
|
||||
count: 308
|
||||
topPodcastCategories:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
example:
|
||||
- name: "comedy"
|
||||
count: 12
|
||||
- name: "politics"
|
||||
count: 4
|
||||
- name: "nature"
|
||||
count: 1
|
||||
federation:
|
||||
type: object
|
||||
properties:
|
||||
followedInstances:
|
||||
type: integer
|
||||
followingInstances:
|
||||
type: integer
|
||||
usage:
|
||||
type: object
|
||||
properties:
|
||||
listenings:
|
||||
type: integer
|
||||
minimum: 0
|
||||
downloads:
|
||||
type: integer
|
||||
minimum: 0
|
||||
favorites:
|
||||
type: object
|
||||
properties:
|
||||
tracks:
|
||||
type: integer
|
||||
minimum: 0
|
||||
features:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- "channels"
|
||||
- "podcasts"
|
||||
- "collections"
|
||||
- "audiobooks"
|
||||
- "federation"
|
||||
- "anonymousCanListen"
|
||||
- "onlyMbidTaggedContent"
|
||||
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
securitySchemes:
|
||||
oauth2:
|
||||
type: oauth2
|
||||
description: This API uses OAuth 2 with the Authorization Code flow. You can register an app using the /oauth/apps/ endpoint.
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: /authorize
|
||||
tokenUrl: /api/v1/oauth/token/
|
||||
refreshUrl: /api/v1/oauth/token/
|
||||
scopes:
|
||||
"read": "Read-only access to all user data"
|
||||
"write": "Write-only access on all user data"
|
||||
"read:edits": "Read-only access to edits"
|
||||
"write:edits": "Write-only access to edits"
|
||||
"read:favorites": "Read-only access to favorites"
|
||||
"write:favorites": "Write-only access to favorits"
|
||||
"read:filters": "Read-only to to content filters"
|
||||
"write:filters": "Write-only access to content-filters"
|
||||
"read:follows": "Read-only to follows"
|
||||
"write:follows": "Write-only access to follows"
|
||||
"read:libraries": "Read-only access to library and uploads"
|
||||
"write:libraries": "Write-only access to libraries"
|
||||
"read:listenings": "Read-only access to listening history"
|
||||
"write:listenings": "Write-only access to listening history"
|
||||
"read:notifications": "Read-only access to notifications"
|
||||
"write:notifications": "Write-only access to notifications"
|
||||
"read:playlists": "Read-only access to playlists"
|
||||
"write:playlists": "Write-only access to playlists"
|
||||
"read:profile": "Read-only access to profile data"
|
||||
"write:profile": "Write-only access to profile data"
|
||||
"read:radios": "Read-only access to radios"
|
||||
"write:radios": "Write-only access to radios"
|
||||
"read:reports": "Read-only access to reports"
|
||||
"write:reports": "Write-only access to reports"
|
||||
"read:security": "Read-only access security settings"
|
||||
"write:security": "write-only access security settings"
|
||||
|
||||
security:
|
||||
- oauth2: []
|
|
@ -0,0 +1 @@
|
|||
../../../api/funkwhale_api/common/schema.yml
|
|
@ -6,8 +6,7 @@ module.exports = {
|
|||
extends: [
|
||||
'plugin:@intlify/vue-i18n/recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'@vue/typescript/recommended',
|
||||
'@vue/standard'
|
||||
'@vue/typescript/recommended'
|
||||
],
|
||||
globals: {
|
||||
SharedArrayBuffer: 'readonly',
|
||||
|
@ -20,8 +19,14 @@ module.exports = {
|
|||
ecmaVersion: 2020
|
||||
},
|
||||
plugins: [
|
||||
'html',
|
||||
'vue'
|
||||
],
|
||||
ignorePatterns: [
|
||||
'src/locales/*.json',
|
||||
'dist/',
|
||||
'stats.html'
|
||||
],
|
||||
rules: {
|
||||
// NOTE: Nicer for the eye
|
||||
'operator-linebreak': ['error', 'before'],
|
||||
|
@ -55,7 +60,10 @@ module.exports = {
|
|||
'@typescript-eslint/no-this-alias': 'off',
|
||||
|
||||
// TODO (wvffle): Remove after API Client
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
|
||||
// Configure TypeScript style
|
||||
'comma-dangle': ['error', 'never']
|
||||
},
|
||||
overrides: [
|
||||
{
|
|
@ -1,14 +1,19 @@
|
|||
FROM node:18-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
# needed to compile translations
|
||||
RUN apk add --no-cache jq bash coreutils python3
|
||||
RUN apk add --no-cache jq bash coreutils git python3
|
||||
|
||||
EXPOSE 8080
|
||||
WORKDIR /app/
|
||||
COPY scripts/ ./scripts/
|
||||
|
||||
# Create node_modules directory to prevent it from being hidden by volume mounts
|
||||
RUN mkdir -p node_modules
|
||||
|
||||
ADD scripts/ ./scripts/
|
||||
ADD package.json yarn.lock ./
|
||||
RUN yarn install
|
||||
RUN yarn
|
||||
|
||||
COPY . .
|
||||
VOLUME /app
|
||||
VOLUME /app/node_modules
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["yarn", "serve"]
|
||||
CMD ["yarn", "dev", "--host"]
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
# Funkwhale Frontend
|
||||
|
||||
Please follow the instructions in [Set up your development environment — funkwhale 1.4.0 documentation](https://docs.funkwhale.audio/developer/setup/index.html).
|
180
front/index.html
180
front/index.html
|
@ -1,101 +1,99 @@
|
|||
<!DOCTYPE html>
|
||||
<!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">
|
||||
<title>Funkwhale</title>
|
||||
<meta name="description" content="Your free and federated audio platform">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=1">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=1">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=1">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg?v=1" color="#009fe3">
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=1">
|
||||
<meta name="apple-mobile-web-app-title" content="Funkwhale">
|
||||
<meta name="application-name" content="Funkwhale">
|
||||
<meta name="msapplication-TileColor" content="#009fe3">
|
||||
<meta name="theme-color" content="#f2711c">
|
||||
<style>
|
||||
#fake-app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: -1;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
#fake-sidebar {
|
||||
width: 275px;
|
||||
height: 100vh;
|
||||
background-color: #2D2F33;
|
||||
}
|
||||
#fake-sidebar.loaded, #fake-content.loaded {
|
||||
display: none;
|
||||
}
|
||||
#orange-square {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background-color: #f2711c;
|
||||
}
|
||||
#fake-content {
|
||||
height: 100vh;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#fake-content h1 {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
#fake-content .placeholder {
|
||||
width: 20em;
|
||||
max-width: 95%;
|
||||
}
|
||||
@media only screen and (max-width: 768px) {
|
||||
<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" />
|
||||
<title>Funkwhale</title>
|
||||
<meta name="description" content="Your free and federated audio platform" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=1" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=1" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=1" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg?v=1" color="#009fe3" />
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=1" />
|
||||
<meta name="apple-mobile-web-app-title" content="Funkwhale" />
|
||||
<meta name="application-name" content="Funkwhale" />
|
||||
<meta name="msapplication-TileColor" content="#009fe3" />
|
||||
<meta name="theme-color" content="#f2711c" />
|
||||
<style>
|
||||
#fake-app {
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: -1;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
#fake-sidebar {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
width: 275px;
|
||||
height: 100vh;
|
||||
background-color: #2D2F33;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
#fake-sidebar.loaded, #fake-content.loaded {
|
||||
display: none;
|
||||
}
|
||||
#orange-square {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background-color: #f2711c;
|
||||
}
|
||||
#fake-content {
|
||||
height: 100vh;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#fake-content h1 {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
#fake-content .placeholder {
|
||||
width: 20em;
|
||||
max-width: 95%;
|
||||
}
|
||||
@media only screen and (max-width: 768px) {
|
||||
#fake-app {
|
||||
flex-direction: column;
|
||||
}
|
||||
#fake-sidebar {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body id="body">
|
||||
<div id="fake-app">
|
||||
<div id="fake-sidebar">
|
||||
<div id="orange-square"></div>
|
||||
</div>
|
||||
<div id="fake-content">
|
||||
<noscript>
|
||||
<strong>We're sorry but Funkwhale doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<h1>Loading Funkwhale…</h1>
|
||||
<div class="ui placeholder">
|
||||
<div class="image header">
|
||||
<div class="full line"></div>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div class="image header">
|
||||
<div class="line"></div>
|
||||
<div class="full line"></div>
|
||||
</div>
|
||||
<div class="image header">
|
||||
<div class="medium line"></div>
|
||||
<div class="full line"></div>
|
||||
<body id="body" style="margin:0">
|
||||
<div id="fake-app">
|
||||
<div id="fake-sidebar">
|
||||
<div id="orange-square"></div>
|
||||
</div>
|
||||
<div id="fake-content">
|
||||
<noscript>
|
||||
<strong>We're sorry but Funkwhale doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<h1>Loading Funkwhale…</h1>
|
||||
<div class="ui placeholder">
|
||||
<div class="image header">
|
||||
<div class="full line"></div>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div class="image header">
|
||||
<div class="line"></div>
|
||||
<div class="full line"></div>
|
||||
</div>
|
||||
<div class="image header">
|
||||
<div class="medium line"></div>
|
||||
<div class="full line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,52 +1,67 @@
|
|||
{
|
||||
"name": "front",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Funkwhale front-end",
|
||||
"author": "Funkwhale Collective <contact@funkwhale.audio>",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:docs": "VP_DOCS=true vitepress dev ui-docs",
|
||||
"build": "vite build --mode development",
|
||||
"build:deployment": "vite build",
|
||||
"build:docs": "VP_DOCS=true vitepress build ui-docs",
|
||||
"serve:docs": "VP_DOCS=true vitepress serve ui-docs",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run --coverage",
|
||||
"test:generate-mock-server": "msw-auto-mock ../docs/schema.yml -o test/msw-server.ts --node",
|
||||
"lint": "eslint --cache --cache-strategy content --ext .ts,.js,.vue,.json,.html src test cypress public/embed.html",
|
||||
"lint:tsc": "vue-tsc --noEmit --incremental && tsc --noEmit --incremental -p cypress",
|
||||
"fix-fomantic-css": "scripts/fix-fomantic-css.sh",
|
||||
"postinstall": "yarn run fix-fomantic-css"
|
||||
"lint": "yarn lint:es && yarn lint:tsc",
|
||||
"lint:es": "eslint --max-warnings 0 --cache --cache-strategy content --ext .ts,.js,.vue,.json,.html,.cjs . cypress public/embed.html src test ui-docs",
|
||||
"lint:tsc": "vue-tsc --noEmit --incremental && tsc --noEmit --incremental --project tsconfig.json",
|
||||
"generate-types-from-local-schema": "yarn run openapi-typescript ../api/funkwhale_api/common/schema.yml -o src/generated/types.ts",
|
||||
"generate-types-from-remote-schema": "yarn run openapi-typescript https://docs.funkwhale.audio/develop/swagger/schema.yml -o src/generated/types.ts",
|
||||
"fmt:es": "yarn lint:es --fix",
|
||||
"fmt:html": "node --experimental-strip-types node_modules/prettier/bin/prettier.cjs index.html public/embed.html --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@funkwhale/ui": "0.2.2",
|
||||
"@sentry/tracing": "7.47.0",
|
||||
"@sentry/vue": "7.47.0",
|
||||
"@tauri-apps/api": "2.0.0-beta.1",
|
||||
"@types/jsmediatags": "3.9.6",
|
||||
"@vue/runtime-core": "3.3.11",
|
||||
"@vueuse/core": "10.3.0",
|
||||
"@vueuse/integrations": "10.3.0",
|
||||
"@vueuse/math": "10.3.0",
|
||||
"@vueuse/router": "10.3.0",
|
||||
"@vueuse/components": "10.6.1",
|
||||
"@vueuse/core": "10.6.1",
|
||||
"@vueuse/integrations": "10.6.1",
|
||||
"@vueuse/math": "10.6.1",
|
||||
"@vueuse/router": "10.6.1",
|
||||
"axios": "1.7.2",
|
||||
"axios-auth-refresh": "3.3.6",
|
||||
"butterchurn": "3.0.0-beta.4",
|
||||
"butterchurn-presets": "3.0.0-beta.4",
|
||||
"diff": "5.1.0",
|
||||
"dompurify": "3.0.8",
|
||||
"dompurify": "3.2.4",
|
||||
"focus-trap": "7.2.0",
|
||||
"fomantic-ui-css": "2.9.3",
|
||||
"idb-keyval": "6.2.1",
|
||||
"jquery": "3.7.1",
|
||||
"jsmediatags": "3.9.7",
|
||||
"lodash-es": "4.17.21",
|
||||
"lru-cache": "10.2.0",
|
||||
"magic-regexp": "0.8.0",
|
||||
"moment": "2.29.4",
|
||||
"music-metadata-browser": "2.5.10",
|
||||
"nanoid": "5.0.4",
|
||||
"pinia": "2.1.7",
|
||||
"showdown": "2.1.0",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"standardized-audio-context": "25.3.60",
|
||||
"text-clipper": "2.2.0",
|
||||
"transliteration": "2.3.5",
|
||||
"type-fest": "4.30.1",
|
||||
"universal-cookie": "4.0.4",
|
||||
"vite-plugin-pwa": "0.14.4",
|
||||
"vue": "3.3.11",
|
||||
"vue": "3.5.13",
|
||||
"vue-dompurify-html": "5.2.0",
|
||||
"vue-gettext": "2.1.12",
|
||||
"vue-i18n": "9.9.1",
|
||||
"vue-router": "4.2.5",
|
||||
|
@ -61,6 +76,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "8.4.1",
|
||||
"@iconify/vue": "4.1.1",
|
||||
"@intlify/eslint-plugin-vue-i18n": "2.0.0",
|
||||
"@intlify/unplugin-vue-i18n": "2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.2",
|
||||
|
@ -74,18 +90,19 @@
|
|||
"@types/showdown": "2.0.6",
|
||||
"@types/vue-virtual-scroller": "npm:@earltp/vue-virtual-scroller",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"@vitejs/plugin-vue": "5.1.4",
|
||||
"@vitest/coverage-v8": "1.3.1",
|
||||
"@vue-macros/volar": "0.13.3",
|
||||
"@vue-macros/common": "1.15.1",
|
||||
"@vue-macros/volar": "0.17.2",
|
||||
"@vue/compiler-sfc": "3.3.11",
|
||||
"@vue/eslint-config-standard": "8.0.1",
|
||||
"@vue/eslint-config-typescript": "12.0.0",
|
||||
"@vue/test-utils": "2.2.7",
|
||||
"@vue/tsconfig": "0.5.1",
|
||||
"@vue/test-utils": "2.4.1",
|
||||
"@vue/tsconfig": "0.6.0",
|
||||
"cypress": "13.6.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-standard": "17.1.0",
|
||||
"eslint-plugin-html": "8.0.0",
|
||||
"eslint-plugin-html": "8.1.2",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-n": "16.6.2",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
|
@ -95,20 +112,27 @@
|
|||
"jsonc-eslint-parser": "2.4.0",
|
||||
"msw": "2.2.1",
|
||||
"msw-auto-mock": "0.18.0",
|
||||
"openapi-typescript": "7.6.0",
|
||||
"patch-package": "8.0.0",
|
||||
"rollup-plugin-visualizer": "5.9.0",
|
||||
"sass": "1.57.1",
|
||||
"sass": "1.68.0",
|
||||
"sinon": "15.0.2",
|
||||
"standardized-audio-context-mock": "9.6.32",
|
||||
"typescript": "5.3.3",
|
||||
"unplugin-vue-macros": "2.4.6",
|
||||
"unocss": "0.58.0",
|
||||
"unplugin-vue-macros": "2.6.2",
|
||||
"utility-types": "3.10.0",
|
||||
"vite": "5.2.12",
|
||||
"vite-plugin-node-polyfills": "0.17.0",
|
||||
"vite-plugin-pwa": "0.14.4",
|
||||
"vite-plugin-vue-devtools": "^7.5.2",
|
||||
"vitepress": "1.5.0",
|
||||
"vitest": "1.3.1",
|
||||
"vue-tsc": "1.8.27",
|
||||
"workbox-core": "6.5.4",
|
||||
"workbox-precaching": "6.5.4",
|
||||
"workbox-routing": "6.5.4",
|
||||
"workbox-strategies": "6.5.4"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
|
|
@ -1,517 +1,508 @@
|
|||
<!DOCTYPE html>
|
||||
<!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" />
|
||||
|
||||
<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.ico" />
|
||||
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<title>Funkwhale Widget</title>
|
||||
|
||||
<title>Funkwhale Widget</title>
|
||||
<link rel="stylesheet" href="/embed.css" />
|
||||
|
||||
<link rel="stylesheet" href="/embed.css">
|
||||
<script type="module">
|
||||
import { createApp, reactive, nextTick } from 'https://unpkg.com/petite-vue@0.4.1?module'
|
||||
|
||||
<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']
|
||||
|
||||
const SUPPORTED_TYPES = ['track', 'album', 'artist', 'playlist', 'channel']
|
||||
// Params
|
||||
const params = new URL(location.href).searchParams
|
||||
let baseUrl = params.get('instance') ?? params.get('b') ?? location.origin
|
||||
const type = params.get('type')
|
||||
const id = params.get('id')
|
||||
|
||||
// Params
|
||||
const params = new URL(location.href).searchParams
|
||||
let baseUrl = params.get('instance') ?? params.get('b') ?? location.origin
|
||||
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}.`
|
||||
}
|
||||
|
||||
// Standardize base URL
|
||||
try {
|
||||
baseUrl = new URL(baseUrl).origin
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
error.value = `The embed widget couldn't read the provided instance URL: ${baseUrl}.`
|
||||
}
|
||||
|
||||
// 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 compatibility, 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(({ mimetype }) => mimetype === 'audio/mpeg')) {
|
||||
const source = sources[0].listen_url
|
||||
const regex = /^https?:/
|
||||
const url = new URL(regex.test(source)
|
||||
? source
|
||||
: source[0] === '/'
|
||||
? `${baseUrl}${source}`
|
||||
: `${baseUrl}/${source}`
|
||||
)
|
||||
url.searchParams.set('to', 'mp3')
|
||||
sources.push({ mimetype: 'audio/mpeg', listen_url: url.toString() })
|
||||
// Error
|
||||
const error = reactive({ value: false })
|
||||
if (!SUPPORTED_TYPES.includes(type)) {
|
||||
error.value = `The embed widget doesn't support this media type: ${type}.`
|
||||
}
|
||||
|
||||
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
|
||||
if (id === null || isNaN(+id)) {
|
||||
error.value = `The embed widget couldn't read the provided media ID: ${id}.`
|
||||
}
|
||||
|
||||
const response = await fetch(`${url}?${filters}`)
|
||||
const data = await response.json()
|
||||
// Standardize base URL
|
||||
try {
|
||||
baseUrl = new URL(baseUrl).origin
|
||||
} catch (err) {
|
||||
// console.error(err)
|
||||
error.value = `The embed widget couldn't read the provided instance URL: ${baseUrl}.`
|
||||
}
|
||||
|
||||
if (response.status > 299) {
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
case 404:
|
||||
error.value = `This ${type} wasn't found on the server.`
|
||||
break
|
||||
// Cover
|
||||
const DEFAULT_COVER = '/embed-default-cover.jpeg'
|
||||
const cover = reactive({ value: DEFAULT_COVER })
|
||||
|
||||
case 403:
|
||||
error.value = `You need to log in to access this ${type}.`
|
||||
break
|
||||
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
|
||||
}
|
||||
|
||||
case 500:
|
||||
error.value = `An unknown error occurred while loading this ${type} from the server.`
|
||||
break
|
||||
if (type === 'artist') {
|
||||
fetchArtistCover(id).catch(() => undefined)
|
||||
}
|
||||
|
||||
default:
|
||||
error.value = `An unknown error occurred while loading this ${type}.`
|
||||
// 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 compatibility, 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(({ mimetype }) => mimetype === 'audio/mpeg')) {
|
||||
const source = sources[0].listen_url
|
||||
const regex = /^https?:/
|
||||
const url = new URL(regex.test(source)
|
||||
? source
|
||||
: source[0] === '/'
|
||||
? `${baseUrl}${source}`
|
||||
: `${baseUrl}/${source}`
|
||||
)
|
||||
url.searchParams.set('to', 'mp3')
|
||||
sources.push({ mimetype: 'audio/mpeg', listen_url: url.toString() })
|
||||
}
|
||||
|
||||
// NOTE: If we already have some tracks, let's fail silently
|
||||
if (tracks.length > 0) {
|
||||
console.error(error.value)
|
||||
error.value = false
|
||||
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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
const response = await fetch(`${url}?${filters}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (type === 'track') {
|
||||
data.results = [data]
|
||||
}
|
||||
if (response.status > 299) {
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
case 404:
|
||||
error.value = `This ${type} wasn't found on the server.`
|
||||
break
|
||||
|
||||
if (type === 'playlist') {
|
||||
data.results = data.results.map(({ track }) => track)
|
||||
}
|
||||
case 403:
|
||||
error.value = `You need to log in to access this ${type}.`
|
||||
break
|
||||
|
||||
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)
|
||||
)
|
||||
case 500:
|
||||
error.value = `An unknown error occurred while loading this ${type} from the server.`
|
||||
break
|
||||
|
||||
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
|
||||
default:
|
||||
error.value = `An unknown error occurred while loading this ${type}.`
|
||||
}
|
||||
|
||||
player.next()
|
||||
// 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}.`
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
// 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'
|
||||
})
|
||||
})
|
||||
|
||||
app.mount()
|
||||
</script>
|
||||
</head>
|
||||
const formatDuration = (duration) => {
|
||||
if (duration === 0) return
|
||||
|
||||
<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>
|
||||
const time = intl.format(new Date(ZERO_DATE + duration * 1e3))
|
||||
return time.replace(/^00:/, '')
|
||||
}
|
||||
|
||||
<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>
|
||||
// Logo component
|
||||
const Logo = () => ({ $template: '#logo-template' })
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>You need to enable Javascript to use the embed widget.</strong>
|
||||
</noscript>
|
||||
// Icon component
|
||||
const Icon = ({ icon }) => ({ $template: '#icon-template', icon })
|
||||
|
||||
<main v-scope v-cloak>
|
||||
<div v-if="error.value !== false" class="error">
|
||||
{{ error.value }}
|
||||
<div v-scope="Logo()"></div>
|
||||
</div>
|
||||
// Media Session
|
||||
const initializeMediaSession = () => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
player.playing = true
|
||||
audio.element.play()
|
||||
})
|
||||
|
||||
<template v-else>
|
||||
<div class="player">
|
||||
<img :src="tracks[player.current]?.cover ?? cover.value" class="cover-image" />
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
player.playing = false
|
||||
audio.element.pause()
|
||||
})
|
||||
|
||||
<div class="player-content">
|
||||
<h1>{{ tracks[player.current]?.title }}</h1>
|
||||
<h2>{{ tracks[player.current]?.artist.name }}</h2>
|
||||
</div>
|
||||
navigator.mediaSession.setActionHandler('seekbackward', () => player.seekTime({
|
||||
target: { value: (audio.element.currentTime - 5) / audio.element.duration * 100 }
|
||||
}))
|
||||
|
||||
<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>
|
||||
navigator.mediaSession.setActionHandler('seekforward', () => player.seekTime({
|
||||
target: { value: (audio.element.currentTime + 5) / audio.element.duration * 100 }
|
||||
}))
|
||||
|
||||
<input
|
||||
v-model.number="player.seek"
|
||||
v-range="player.seek"
|
||||
@input="player.seekTime"
|
||||
type="range"
|
||||
step="0.1"
|
||||
/>
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => player.prev())
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => player.next())
|
||||
}
|
||||
}
|
||||
|
||||
<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>
|
||||
const updateMediaSessionMetadata = () => {
|
||||
const { current } = player
|
||||
|
||||
<input
|
||||
v-model.number="volume.level"
|
||||
v-range="volume.level"
|
||||
type="range"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
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' }
|
||||
]
|
||||
})
|
||||
|
||||
<span v-scope="Logo()" class="logo-wrapper"></span>
|
||||
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>
|
||||
|
||||
<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>
|
||||
<template v-else>
|
||||
<div class="player">
|
||||
<img :src="tracks[player.current]?.cover ?? cover.value" class="cover-image" />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
@ -1,984 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
This scripts handles all the heavy-lifting of parsing CSS files from ``fomantic-ui-css`` and:
|
||||
|
||||
1. Replace hardcoded values by their CSS vars counterparts, for easier theming
|
||||
2. Strip unused styles and icons to reduce the final size of CSS
|
||||
|
||||
Updated files are not modified in place, but instead copied to another directory (``fomantic-ui-css/tweaked``), in order
|
||||
to allow easy comparison detection of changes.
|
||||
|
||||
If you change this file, you'll need to run ``yarn run fix-fomantic-css`` manually for the changes
|
||||
to be picked up. If the ``NOSTRIP`` environment variable is set, the second step will be skipped.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
STRIP_UNUSED = "NOSTRIP" not in os.environ
|
||||
|
||||
# Perform a blind replacement of some strings in all fomantic CSS files
|
||||
GLOBAL_REPLACES = [
|
||||
# some selectors are repeated in the stylesheet, for some reason
|
||||
(".ui.ui.ui.ui", ".ui"),
|
||||
(".ui.ui.ui", ".ui"),
|
||||
(".ui.ui", ".ui"),
|
||||
(".icon.icon.icon.icon", ".icon"),
|
||||
(".icon.icon.icon", ".icon"),
|
||||
(".icon.icon", ".icon"),
|
||||
# actually useful stuff
|
||||
("'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif", "var(--font-family)"),
|
||||
(".orange", ".vibrant"),
|
||||
("#F2711C", "var(--vibrant-color)"),
|
||||
("#FF851B", "var(--vibrant-color)"),
|
||||
("#f26202", "var(--vibrant-hover-color)"),
|
||||
("#e76b00", "var(--vibrant-hover-color)"),
|
||||
("#cf590c", "var(--vibrant-active-color)"),
|
||||
("#f56100", "var(--vibrant-active-color)"),
|
||||
("#e76b00", "var(--vibrant-active-color)"),
|
||||
("#e55b00", "var(--vibrant-focus-color)"),
|
||||
("#f17000", "var(--vibrant-focus-color)"),
|
||||
(".teal", ".accent"),
|
||||
("#00B5AD", "var(--accent-color)"),
|
||||
("#009c95", "var(--accent-hover-color)"),
|
||||
("#00827c", "var(--accent-active-color)"),
|
||||
("#008c86", "var(--accent-focus-color)"),
|
||||
(".green", ".success"),
|
||||
("#21BA45", "var(--success-color)"),
|
||||
("#2ECC40", "var(--success-color)"),
|
||||
("#16ab39", "var(--success-hover-color)"),
|
||||
("#1ea92e", "var(--success-hover-color)"),
|
||||
("#198f35", "var(--success-active-color)"),
|
||||
("#25a233", "var(--success-active-color)"),
|
||||
("#0ea432", "var(--success-focus-color)"),
|
||||
("#19b82b", "var(--success-focus-color)"),
|
||||
(".blue", ".primary"),
|
||||
("#2185D0", "var(--primary-color)"),
|
||||
("#54C8FF", "var(--primary-color)"),
|
||||
("#54C8FF", "var(--primary-color)"),
|
||||
("#1678c2", "var(--primary-hover-color)"),
|
||||
("#21b8ff", "var(--primary-hover-color)"),
|
||||
("#1a69a4", "var(--primary-active-color)"),
|
||||
("#0d71bb", "var(--primary-focus-color)"),
|
||||
("#2bbbff", "var(--primary-focus-color)"),
|
||||
(".yellow", ".warning"),
|
||||
("#FBBD08", "var(--warning-color)"),
|
||||
("#FFE21F", "var(--warning-color)"),
|
||||
("#eaae00", "var(--warning-hover-color)"),
|
||||
("#ebcd00", "var(--warning-hover-color)"),
|
||||
("#cd9903", "var(--warning-active-color)"),
|
||||
("#ebcd00", "var(--warning-active-color)"),
|
||||
("#daa300", "var(--warning-focus-color)"),
|
||||
("#f5d500", "var(--warning-focus-color)"),
|
||||
(".red.", ".danger."),
|
||||
("#DB2828", "var(--danger-color)"),
|
||||
("#FF695E", "var(--danger-color)"),
|
||||
("#d01919", "var(--danger-hover-color)"),
|
||||
("#ff392b", "var(--danger-hover-color)"),
|
||||
("#b21e1e", "var(--danger-active-color)"),
|
||||
("#ca1010", "var(--danger-focus-color)"),
|
||||
("#ff4335", "var(--danger-focus-color)"),
|
||||
]
|
||||
|
||||
|
||||
def discard_unused_icons(rule):
|
||||
"""
|
||||
Add an icon to this list if you want to use it in the app.
|
||||
"""
|
||||
used_icons = [
|
||||
".angle",
|
||||
".arrow",
|
||||
".at",
|
||||
".ban",
|
||||
".bell",
|
||||
".book",
|
||||
".bookmark",
|
||||
".check",
|
||||
".clock",
|
||||
".close",
|
||||
".cloud",
|
||||
".code",
|
||||
".comment",
|
||||
".copy",
|
||||
".copyright",
|
||||
".danger",
|
||||
".database",
|
||||
".delete",
|
||||
".disc",
|
||||
".down angle",
|
||||
".download",
|
||||
".dropdown",
|
||||
".edit",
|
||||
".ellipsis",
|
||||
".eraser",
|
||||
".external",
|
||||
".eye",
|
||||
".feed",
|
||||
".file",
|
||||
".folder",
|
||||
".forward",
|
||||
".globe",
|
||||
".hashtag",
|
||||
".headphones",
|
||||
".heart",
|
||||
".home",
|
||||
".hourglass",
|
||||
".info",
|
||||
".layer",
|
||||
".lines",
|
||||
".link",
|
||||
".list",
|
||||
".loading",
|
||||
".lock",
|
||||
".minus",
|
||||
".mobile",
|
||||
".music",
|
||||
".paper",
|
||||
".pause",
|
||||
".pencil",
|
||||
".play",
|
||||
".plus",
|
||||
".podcast",
|
||||
".question",
|
||||
".question ",
|
||||
".random",
|
||||
".redo",
|
||||
".refresh",
|
||||
".repeat",
|
||||
".rss",
|
||||
".search",
|
||||
".server",
|
||||
".share",
|
||||
".shield",
|
||||
".sidebar",
|
||||
".sign",
|
||||
".spinner",
|
||||
".step",
|
||||
".stream",
|
||||
".track",
|
||||
".trash",
|
||||
".undo",
|
||||
".upload",
|
||||
".user",
|
||||
".users",
|
||||
".volume",
|
||||
".wikipedia",
|
||||
".wrench",
|
||||
".x",
|
||||
".key",
|
||||
".cog",
|
||||
".life.ring",
|
||||
".language",
|
||||
".palette",
|
||||
".sun",
|
||||
".moon",
|
||||
".gitlab",
|
||||
".chevron",
|
||||
".right",
|
||||
".left",
|
||||
".compress",
|
||||
".expand",
|
||||
".image",
|
||||
]
|
||||
if ":before" not in rule["lines"][0]:
|
||||
return False
|
||||
|
||||
return not match(rule, used_icons)
|
||||
|
||||
|
||||
"""
|
||||
Below is the main configuration object that is used for fine-grained replacement of properties
|
||||
in component files. It also handles removal of unused selectors.
|
||||
|
||||
Example config for a component:
|
||||
|
||||
REPLACEMENTS = {
|
||||
# applies to fomantic-ui-css/components/component-name.css
|
||||
"component-name": {
|
||||
# Discard any CSS rule matching one of the selectors listed below
|
||||
# matching is done using a simple string search, so ``.pink`` will remove
|
||||
# rules applied to ``.pink``, ``.pink.button`` and `.pinkdark`
|
||||
"skip": [
|
||||
".unused.variation",
|
||||
".pink",
|
||||
],
|
||||
# replace some CSS properties values in specific selectors
|
||||
(".inverted", ".dark"): [
|
||||
("background", "var(--inverted-background)"),
|
||||
("color", "var(--inverted-color)"),
|
||||
],
|
||||
(".active"): [
|
||||
("font-size", "var(--active-font-size)"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
Given the previous config, the following style sheet:
|
||||
|
||||
.. code-block:: css
|
||||
|
||||
.unused.variation {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: white;
|
||||
}
|
||||
.primary.pink {
|
||||
color: pink;
|
||||
}
|
||||
.inverted.primary {
|
||||
background: black;
|
||||
color: white;
|
||||
border-top: 1px solid red;
|
||||
}
|
||||
.inverted.primary.active {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
Would be converted to:
|
||||
|
||||
.. code-block:: css
|
||||
|
||||
.primary {
|
||||
color: white;
|
||||
}
|
||||
.inverted.primary {
|
||||
background: var(--inverted-background);
|
||||
color: var(--inverted-color);
|
||||
border-top: 1px solid red;
|
||||
}
|
||||
.inverted.primary.active {
|
||||
font-size: var(--active-font-size);
|
||||
}
|
||||
|
||||
"""
|
||||
REPLACEMENTS = {
|
||||
"site": {
|
||||
("a",): [
|
||||
("color", "var(--link-color)"),
|
||||
("text-decoration", "var(--link-text-decoration)"),
|
||||
],
|
||||
("a:hover",): [
|
||||
("color", "var(--link-hover-color)"),
|
||||
("text-decoration", "var(--link-hover-text-decoration)"),
|
||||
],
|
||||
("body",): [
|
||||
("background", "var(--site-background)"),
|
||||
("color", "var(--text-color)"),
|
||||
],
|
||||
(
|
||||
"::-webkit-selection",
|
||||
"::-moz-selection",
|
||||
"::selection",
|
||||
): [
|
||||
("color", "var(--text-selection-color)"),
|
||||
("background-color", "var(--text-selection-background)"),
|
||||
],
|
||||
(
|
||||
"textarea::-webkit-selection",
|
||||
"input::-webkit-selection",
|
||||
"textarea::-moz-selection",
|
||||
"input::-moz-selection",
|
||||
"textarea::selection",
|
||||
"input::selection",
|
||||
): [
|
||||
("color", "var(--input-selection-color)"),
|
||||
("background-color", "var(--input-selection-background)"),
|
||||
],
|
||||
},
|
||||
"button": {
|
||||
"skip": [
|
||||
".vertical",
|
||||
".animated",
|
||||
".active",
|
||||
".olive",
|
||||
".brown",
|
||||
".teal",
|
||||
".violet",
|
||||
".purple",
|
||||
".brown",
|
||||
".grey",
|
||||
".black",
|
||||
".positive",
|
||||
".negative",
|
||||
".secondary",
|
||||
".tertiary",
|
||||
".facebook",
|
||||
".twitter",
|
||||
".google.plus",
|
||||
".vk",
|
||||
".linkedin",
|
||||
".instagram",
|
||||
".youtube",
|
||||
".whatsapp",
|
||||
".telegram",
|
||||
],
|
||||
(".ui.orange.button", ".ui.orange.button:hover"): [
|
||||
("background-color", "var(--button-orange-background)")
|
||||
],
|
||||
(".ui.basic.button",): [
|
||||
("background", "var(--button-basic-background)"),
|
||||
("color", "var(--button-basic-color)"),
|
||||
("box-shadow", "var(--button-basic-box-shadow)"),
|
||||
],
|
||||
(".ui.basic.button:hover",): [
|
||||
("background", "var(--button-basic-hover-background)"),
|
||||
("color", "var(--button-basic-hover-color)"),
|
||||
("box-shadow", "var(--button-basic-hover-box-shadow)"),
|
||||
],
|
||||
(".ui.basic.button:focus",): [
|
||||
("background", "var(--button-basic-hover-background)"),
|
||||
("color", "var(--button-basic-hover-color)"),
|
||||
("box-shadow", "var(--button-basic-hover-box-shadow)"),
|
||||
],
|
||||
},
|
||||
"card": {
|
||||
"skip": [
|
||||
".inverted",
|
||||
".olive",
|
||||
".brown",
|
||||
".teal",
|
||||
".violet",
|
||||
".purple",
|
||||
".brown",
|
||||
".grey",
|
||||
".pink",
|
||||
".black",
|
||||
".vibrant",
|
||||
".success",
|
||||
".warning",
|
||||
".danger",
|
||||
".primary",
|
||||
".secondary",
|
||||
".horizontal",
|
||||
".raised",
|
||||
]
|
||||
},
|
||||
"checkbox": {
|
||||
(
|
||||
".ui.toggle.checkbox label",
|
||||
".ui.toggle.checkbox input:checked ~ label",
|
||||
'.ui.checkbox input[type="checkbox"]',
|
||||
".ui.checkbox input:focus ~ label",
|
||||
".ui.toggle.checkbox input:focus:checked ~ label",
|
||||
".ui.checkbox input:active ~ label",
|
||||
): [
|
||||
("color", "var(--form-label-color)"),
|
||||
],
|
||||
(".ui.toggle.checkbox label:before",): [
|
||||
("background", "var(--input-background)"),
|
||||
],
|
||||
},
|
||||
"divider": {
|
||||
(".ui.divider:not(.vertical):not(.horizontal)",): [
|
||||
("border-top", "var(--divider)"),
|
||||
("border-bottom", "var(--divider)"),
|
||||
],
|
||||
(".ui.divider",): [
|
||||
("color", "var(--text-color)"),
|
||||
],
|
||||
},
|
||||
"dimmer": {
|
||||
(".ui.inverted.dimmer",): [
|
||||
("background-color", "var(--dimmer-background)"),
|
||||
("color", "var(--dropdown-color)"),
|
||||
],
|
||||
},
|
||||
"dropdown": {
|
||||
"skip": [
|
||||
".error",
|
||||
".info",
|
||||
".success",
|
||||
".warning",
|
||||
],
|
||||
(
|
||||
".ui.selection.dropdown",
|
||||
".ui.selection.visible.dropdown > .text:not(.default)",
|
||||
".ui.dropdown .menu",
|
||||
): [
|
||||
("background", "var(--dropdown-background)"),
|
||||
("color", "var(--dropdown-color)"),
|
||||
],
|
||||
(".ui.dropdown .menu > .item",): [
|
||||
("color", "var(--dropdown-item-color)"),
|
||||
],
|
||||
(".ui.dropdown .menu > .item:hover",): [
|
||||
("color", "var(--dropdown-item-hover-color)"),
|
||||
("background", "var(--dropdown-item-hover-background)"),
|
||||
],
|
||||
(".ui.dropdown .menu .selected.item",): [
|
||||
("color", "var(--dropdown-item-selected-color)"),
|
||||
("background", "var(--dropdown-item-selected-background)"),
|
||||
],
|
||||
(".ui.dropdown .menu > .header:not(.ui)",): [
|
||||
("color", "var(--dropdown-header-color)"),
|
||||
],
|
||||
(".ui.dropdown .menu > .divider",): [
|
||||
("border-top", "var(--divider)"),
|
||||
],
|
||||
},
|
||||
"form": {
|
||||
"skip": [
|
||||
".inverted",
|
||||
".success",
|
||||
".warning",
|
||||
".error",
|
||||
".info",
|
||||
],
|
||||
('.ui.form input[type="text"]', ".ui.form select", ".ui.input textarea"): [
|
||||
("background", "var(--input-background)"),
|
||||
("color", "var(--input-color)"),
|
||||
],
|
||||
(
|
||||
'.ui.form input[type="text"]:focus',
|
||||
".ui.form select:focus",
|
||||
".ui.form textarea:focus",
|
||||
): [
|
||||
("background", "var(--input-focus-background)"),
|
||||
("color", "var(--input-focus-color)"),
|
||||
],
|
||||
(
|
||||
".ui.form ::-webkit-input-placeholder",
|
||||
".ui.form :-ms-input-placeholder",
|
||||
".ui.form ::-moz-placeholder",
|
||||
): [
|
||||
("color", "var(--input-placeholder-color)"),
|
||||
],
|
||||
(
|
||||
".ui.form :focus::-webkit-input-placeholder",
|
||||
".ui.form :focus:-ms-input-placeholder",
|
||||
".ui.form :focus::-moz-placeholder",
|
||||
): [
|
||||
("color", "var(--input-focus-placeholder-color)"),
|
||||
],
|
||||
(
|
||||
".ui.form .field > label",
|
||||
".ui.form .inline.fields .field > label",
|
||||
): [
|
||||
("color", "var(--form-label-color)"),
|
||||
],
|
||||
},
|
||||
"grid": {
|
||||
"skip": [
|
||||
"wide tablet",
|
||||
"screen",
|
||||
"mobile only",
|
||||
"tablet only",
|
||||
"computer only",
|
||||
"computer reversed",
|
||||
"tablet reversed",
|
||||
"wide computer",
|
||||
"wide mobile",
|
||||
"wide tablet",
|
||||
"vertically",
|
||||
".celled",
|
||||
".doubling",
|
||||
".olive",
|
||||
".brown",
|
||||
".teal",
|
||||
".violet",
|
||||
".purple",
|
||||
".brown",
|
||||
".grey",
|
||||
".black",
|
||||
".positive",
|
||||
".negative",
|
||||
".secondary",
|
||||
".tertiary",
|
||||
".danger",
|
||||
".vibrant",
|
||||
".warning",
|
||||
".primary",
|
||||
".success",
|
||||
".justified",
|
||||
".centered",
|
||||
]
|
||||
},
|
||||
"icon": {"skip": discard_unused_icons},
|
||||
"input": {
|
||||
(".ui.input > input",): [
|
||||
("background", "var(--input-background)"),
|
||||
("color", "var(--input-color)"),
|
||||
],
|
||||
(".ui.input > input:focus",): [
|
||||
("background", "var(--input-focus-background)"),
|
||||
("color", "var(--input-focus-color)"),
|
||||
],
|
||||
(
|
||||
".ui.input > input::-webkit-input-placeholder",
|
||||
".ui.input > input::-moz-placeholder",
|
||||
".ui.input > input:-ms-input-placeholder",
|
||||
): [
|
||||
("color", "var(--input-placeholder-color)"),
|
||||
],
|
||||
(
|
||||
".ui.input > input:focus::-webkit-input-placeholder",
|
||||
".ui.input > input:focus::-moz-placeholder",
|
||||
".ui.input > input:focus:-ms-input-placeholder",
|
||||
): [
|
||||
("color", "var(--input-focus-placeholder-color)"),
|
||||
],
|
||||
},
|
||||
"item": {
|
||||
(".ui.divided.items > .item",): [
|
||||
("border-top", "var(--divider)"),
|
||||
],
|
||||
(".ui.items > .item > .content",): [
|
||||
("color", "var(--text-color)"),
|
||||
],
|
||||
(".ui.items > .item .extra",): [
|
||||
("color", "var(--really-discrete-text-color)"),
|
||||
],
|
||||
},
|
||||
"header": {
|
||||
"skip": [
|
||||
".inverted",
|
||||
".block",
|
||||
".olive",
|
||||
".brown",
|
||||
".teal",
|
||||
".violet",
|
||||
".purple",
|
||||
".brown",
|
||||
".grey",
|
||||
".black",
|
||||
".pink",
|
||||
],
|
||||
(".ui.header",): [
|
||||
("color", "var(--header-color)"),
|
||||
],
|
||||
(".ui.header .sub.header",): [
|
||||
("color", "var(--header-color)"),
|
||||
],
|
||||
},
|
||||
"label": {
|
||||
"skip": [
|
||||
".olive",
|
||||
".brown",
|
||||
".teal",
|
||||
".violet",
|
||||
".purple",
|
||||
".brown",
|
||||
".grey",
|
||||
".black",
|
||||
".positive",
|
||||
".negative",
|
||||
".secondary",
|
||||
".tertiary",
|
||||
".facebook",
|
||||
".twitter",
|
||||
".google.plus",
|
||||
".vk",
|
||||
".linkedin",
|
||||
".instagram",
|
||||
".youtube",
|
||||
".whatsapp",
|
||||
".telegram",
|
||||
".corner",
|
||||
"ribbon",
|
||||
"pointing",
|
||||
"attached",
|
||||
],
|
||||
},
|
||||
"list": {
|
||||
"skip": [
|
||||
".mini",
|
||||
".tiny",
|
||||
".small",
|
||||
".large",
|
||||
".big",
|
||||
".huge",
|
||||
".massive",
|
||||
".celled",
|
||||
".horizontal",
|
||||
".bulleted",
|
||||
".ordered",
|
||||
".suffixed",
|
||||
".inverted",
|
||||
".fitted",
|
||||
"aligned",
|
||||
],
|
||||
(".ui.list .list > .item a.header", ".ui.list .list > a.item"): [
|
||||
("color", "var(--link-color)"),
|
||||
("text-decoration", "var(--link-text-decoration)"),
|
||||
],
|
||||
("a:hover", ".ui.list .list > a.item:hover"): [
|
||||
("color", "var(--link-hover-color)"),
|
||||
("text-decoration", "var(--link-hover-text-decoration)"),
|
||||
],
|
||||
},
|
||||
"loader": {
|
||||
"skip": [
|
||||
".olive",
|
||||
".brown",
|
||||
".teal",
|
||||
".violet",
|
||||
".purple",
|
||||
".brown",
|
||||
".grey",
|
||||
".black",
|
||||
".pink",
|
||||
".primary",
|
||||
".vibrant",
|
||||
".warning",
|
||||
".success",
|
||||
".danger",
|
||||
".elastic",
|
||||
],
|
||||
(".ui.inverted.dimmer > .ui.loader",): [
|
||||
("color", "var(--dimmer-color)"),
|
||||
],
|
||||
},
|
||||
"message": {
|
||||
"skip": [
|
||||
".olive",
|
||||
".brown",
|
||||
".teal",
|
||||
".violet",
|
||||
".purple",
|
||||
".brown",
|
||||
".grey",
|
||||
".black",
|
||||
".pink",
|
||||
".vibrant",
|
||||
".primary",
|
||||
".secondary",
|
||||
".floating",
|
||||
],
|
||||
},
|
||||
"menu": {
|
||||
"skip": [
|
||||
".inverted.pointing",
|
||||
".olive",
|
||||
".brown",
|
||||
".teal",
|
||||
".violet",
|
||||
".purple",
|
||||
".brown",
|
||||
".grey",
|
||||
".black",
|
||||
".vertical.tabular",
|
||||
".primary.menu",
|
||||
".pink.menu",
|
||||
".vibrant.menu",
|
||||
".warning.menu",
|
||||
".success.menu",
|
||||
".danger.menu",
|
||||
".fitted",
|
||||
"fixed",
|
||||
],
|
||||
(".ui.menu .item",): [
|
||||
("color", "var(--menu-item-color)"),
|
||||
],
|
||||
(".ui.vertical.inverted.menu .menu .item", ".ui.inverted.menu .item"): [
|
||||
("color", "var(--inverted-menu-item-color)"),
|
||||
],
|
||||
(".inverted-ui.menu .active.item",): [
|
||||
("color", "var(--menu-inverted-active-item-color)"),
|
||||
],
|
||||
(".ui.secondary.pointing.menu .active.item",): [
|
||||
("color", "var(--secondary-menu-active-item-color)"),
|
||||
],
|
||||
(
|
||||
".ui.secondary.pointing.menu a.item:hover",
|
||||
".ui.secondary.pointing.menu .active.item:hover",
|
||||
): [
|
||||
("color", "var(--secondary-menu-hover-item-color)"),
|
||||
],
|
||||
(".ui.menu .ui.dropdown .menu > .item",): [
|
||||
("color", "var(--dropdown-item-color) !important"),
|
||||
],
|
||||
(".ui.menu .ui.dropdown .menu > .item:hover",): [
|
||||
("color", "var(--dropdown-item-hover-color) !important"),
|
||||
("background", "var(--dropdown-item-hover-background) !important"),
|
||||
],
|
||||
(".ui.menu .dropdown.item .menu",): [
|
||||
("color", "var(--dropdown--color)"),
|
||||
("background", "var(--dropdown-background)"),
|
||||
],
|
||||
(".ui.menu .ui.dropdown .menu > .active.item",): [
|
||||
("color", "var(--dropdown-item-selected-color)"),
|
||||
("background", "var(--dropdown-item-selected-background) !important"),
|
||||
],
|
||||
},
|
||||
"modal": {
|
||||
(".ui.modal", ".ui.modal > .actions", ".ui.modal > .content"): [
|
||||
("background", "var(--modal-background)"),
|
||||
("border-bottom", "var(--divider)"),
|
||||
("border-top", "var(--divider)"),
|
||||
],
|
||||
(".ui.modal > .close.inside",): [
|
||||
("color", "var(--text-color)"),
|
||||
],
|
||||
(".ui.modal > .header",): [
|
||||
("color", "var(--header-color)"),
|
||||
("background", "var(--modal-background)"),
|
||||
("border-bottom", "var(--divider)"),
|
||||
("border-top", "var(--divider)"),
|
||||
],
|
||||
},
|
||||
"search": {
|
||||
(
|
||||
".ui.search > .results",
|
||||
".ui.search > .results .result",
|
||||
".ui.category.search > .results .category .results",
|
||||
".ui.category.search > .results .category",
|
||||
".ui.category.search > .results .category > .name",
|
||||
".ui.search > .results > .message .header",
|
||||
".ui.search > .results > .message .description",
|
||||
): [
|
||||
("background", "var(--dropdown-background)"),
|
||||
("color", "var(--dropdown-item-color)"),
|
||||
],
|
||||
(
|
||||
".ui.search > .results .result .title",
|
||||
".ui.search > .results .result .description",
|
||||
): [
|
||||
("color", "var(--dropdown-item-color)"),
|
||||
],
|
||||
(".ui.search > .results .result:hover",): [
|
||||
("color", "var(--dropdown-item-hover-color)"),
|
||||
("background", "var(--dropdown-item-hover-background)"),
|
||||
],
|
||||
},
|
||||
"segment": {
|
||||
"skip": [
|
||||
".stacked",
|
||||
".horizontal.segment",
|
||||
".inverted.segment",
|
||||
".circular",
|
||||
".piled",
|
||||
],
|
||||
},
|
||||
"sidebar": {
|
||||
(".ui.left.visible.sidebar",): [
|
||||
("box-shadow", "var(--sidebar-box-shadow)"),
|
||||
]
|
||||
},
|
||||
"statistic": {
|
||||
(".ui.statistic > .value", ".ui.statistic > .label"): [
|
||||
("color", "var(--text-color)"),
|
||||
],
|
||||
},
|
||||
"progress": {
|
||||
(".ui.progress.success > .label",): [
|
||||
("color", "var(--text-color)"),
|
||||
],
|
||||
},
|
||||
"table": {
|
||||
"skip": [
|
||||
".marked",
|
||||
".olive",
|
||||
".brown",
|
||||
".teal",
|
||||
".violet",
|
||||
".purple",
|
||||
".brown",
|
||||
".grey",
|
||||
".black",
|
||||
".padded",
|
||||
".column.table",
|
||||
".inverted",
|
||||
".definition",
|
||||
".error",
|
||||
".negative",
|
||||
".structured",
|
||||
"tablet stackable",
|
||||
],
|
||||
(
|
||||
".ui.table",
|
||||
".ui.table > thead > tr > th",
|
||||
): [
|
||||
("color", "var(--text-color)"),
|
||||
("background", "var(--table-background)"),
|
||||
],
|
||||
(".ui.table > tr > td", ".ui.table > tbody + tbody tr:first-child > td"): [
|
||||
("border-top", "var(--table-border)"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def match(rule, skip):
|
||||
if hasattr(skip, "__call__"):
|
||||
return skip(rule)
|
||||
for s in skip:
|
||||
for rs in rule["selectors"]:
|
||||
if s in rs:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def rules_from_media_query(rule):
|
||||
internal = rule["lines"][1:-1]
|
||||
return parse_rules("\n".join(internal))
|
||||
|
||||
|
||||
def wraps(rule, internal_rules):
|
||||
return {
|
||||
"lines": [rule["lines"][0]]
|
||||
+ [line for r in internal_rules for line in r["lines"]]
|
||||
+ ["}"]
|
||||
}
|
||||
|
||||
|
||||
def set_vars(component_name, rules):
|
||||
"""
|
||||
Given rules parsed via ``parse_rules``, replace properties values when needed
|
||||
using ``REPLACEMENTS`` and ``GLOBAL_REPLACES``.
|
||||
|
||||
Also remove unused styles if STRIP_UNUSED is set to True.
|
||||
"""
|
||||
final_rules = []
|
||||
try:
|
||||
conf = REPLACEMENTS[component_name]
|
||||
except KeyError:
|
||||
return rules
|
||||
selectors = list(conf.keys()) + list()
|
||||
skip = None
|
||||
if STRIP_UNUSED:
|
||||
skip = conf.get("skip", [])
|
||||
try:
|
||||
skip = set(skip)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
for rule in rules:
|
||||
if rule["lines"][0].startswith("@media"):
|
||||
# manual handling of media queries, because our parser is really
|
||||
# simplistic
|
||||
internal_rules = rules_from_media_query(rule)
|
||||
internal_rules = set_vars(component_name, internal_rules)
|
||||
rule = wraps(rule, internal_rules)
|
||||
if len(rule["lines"]) > 2:
|
||||
final_rules.append(rule)
|
||||
continue
|
||||
|
||||
if skip and match(rule, skip):
|
||||
# discard rule entirely
|
||||
continue
|
||||
|
||||
matching = []
|
||||
for s in selectors:
|
||||
if set(s) & set(rule["selectors"]):
|
||||
matching.append(s)
|
||||
if not matching:
|
||||
# no replacements to apply, keep rule as is
|
||||
final_rules.append(rule)
|
||||
continue
|
||||
new_rule = {"lines": []}
|
||||
|
||||
for m in matching:
|
||||
# the block match one of our replacement rules, so we loop on each line
|
||||
# and replace values if needed.
|
||||
replacements = conf[m]
|
||||
for line in rule["lines"]:
|
||||
for property, new_value in replacements:
|
||||
if line.strip().startswith(f"{property}:"):
|
||||
new_property = f"{property}: {new_value};"
|
||||
indentation = " " * (len(line) - len(line.lstrip(" ")))
|
||||
line = indentation + new_property
|
||||
break
|
||||
new_rule["lines"].append(line)
|
||||
final_rules.append(new_rule)
|
||||
return final_rules
|
||||
|
||||
|
||||
def parse_rules(text):
|
||||
"""
|
||||
Really basic CSS parsers that stores selectors and corresponding properties. Only works
|
||||
because the source files have coma-separated selectors (one per line), and one
|
||||
property/value per line.
|
||||
|
||||
Returns a list of dictionaries, each dictionarry containing the selectors and
|
||||
lines of of each block.
|
||||
"""
|
||||
rules = []
|
||||
current_rule = None
|
||||
opened_brackets = 0
|
||||
current_selector = []
|
||||
for line in text.splitlines():
|
||||
if not current_rule and line.endswith(","):
|
||||
current_selector.append(line.rstrip(",").strip())
|
||||
elif line.endswith(" {"):
|
||||
# for media queries
|
||||
opened_brackets += 1
|
||||
if not current_rule:
|
||||
current_selector.append(line.rstrip("{").strip())
|
||||
current_rule = {
|
||||
"lines": [",\n".join(current_selector) + " {"],
|
||||
"selectors": current_selector,
|
||||
}
|
||||
else:
|
||||
current_rule["lines"].append(line)
|
||||
elif current_rule:
|
||||
current_rule["lines"].append(line)
|
||||
if line.strip() == "}":
|
||||
opened_brackets -= 1
|
||||
if not opened_brackets:
|
||||
# move on to next rule
|
||||
rules.append(current_rule)
|
||||
current_rule = None
|
||||
current_selector = []
|
||||
|
||||
return rules
|
||||
|
||||
|
||||
def serialize_rules(rules):
|
||||
"""
|
||||
Convert rules back to valid CSS.
|
||||
"""
|
||||
lines = []
|
||||
for rule in rules:
|
||||
for line in rule["lines"]:
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def iter_components(dir):
|
||||
for dname, dirs, files in os.walk(dir):
|
||||
for fname in files:
|
||||
if fname.endswith(".min.css"):
|
||||
continue
|
||||
if fname.endswith(".js"):
|
||||
continue
|
||||
if "semantic" in fname:
|
||||
continue
|
||||
if fname.endswith(".css"):
|
||||
yield os.path.join(dname, fname)
|
||||
|
||||
|
||||
def replace_vars(source, dest):
|
||||
components = list(sorted(iter_components(os.path.join(source, "components"))))
|
||||
for c in components:
|
||||
with open(c) as f:
|
||||
text = f.read()
|
||||
|
||||
for s, r in GLOBAL_REPLACES:
|
||||
text = text.replace(s, r)
|
||||
text = text.replace(s.lower(), r)
|
||||
text = text.replace(s.upper(), r)
|
||||
rules = parse_rules(text)
|
||||
name = c.split("/")[-1].split(".")[0]
|
||||
updated_rules = set_vars(name, rules)
|
||||
text = serialize_rules(updated_rules)
|
||||
with open(os.path.join(dest, f"{name}.css"), "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Replace hardcoded values by CSS vars and strip unused rules"
|
||||
)
|
||||
parser.add_argument(
|
||||
"source", help="Source path of the fomantic-ui-less distribution to fix"
|
||||
)
|
||||
parser.add_argument(
|
||||
"dest", help="Destination directory where fixed files should be written"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
replace_vars(source=args.source, dest=args.dest)
|
|
@ -1,24 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -eux
|
||||
|
||||
cd "$(dirname "$0")/.." # change into base directory
|
||||
|
||||
FOMANTIC_SRC_PATH="node_modules/fomantic-ui-css"
|
||||
|
||||
find "$FOMANTIC_SRC_PATH/components" -name "*.min.css" -delete
|
||||
mkdir -p "$FOMANTIC_SRC_PATH/tweaked"
|
||||
|
||||
echo 'Removing google font…'
|
||||
sed -i '/@import url(/d' "$FOMANTIC_SRC_PATH/components/site.css"
|
||||
|
||||
echo "Replacing hardcoded values by CSS vars…"
|
||||
scripts/fix-fomantic-css.py "$FOMANTIC_SRC_PATH" "$FOMANTIC_SRC_PATH/tweaked"
|
||||
|
||||
echo 'Fixing jQuery import…'
|
||||
|
||||
# shellcheck disable=SC2046
|
||||
sed -i '1s/^import jQuery from "jquery"//' $(find "$FOMANTIC_SRC_PATH" -name '*.js')
|
||||
|
||||
# shellcheck disable=SC2046
|
||||
sed -i '1s/^/import jQuery from "jquery"\n/' $(find "$FOMANTIC_SRC_PATH" -name '*.js')
|
|
@ -1,26 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import type { QueueTrack } from '~/composables/audio/queue'
|
||||
import { watchEffect, computed, onMounted, nextTick } from 'vue'
|
||||
|
||||
import { useIntervalFn, useStyleTag, useToggle, useWindowSize } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, watchEffect, defineAsyncComponent } from 'vue'
|
||||
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { type QueueTrack, useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
import useLogger from '~/composables/useLogger'
|
||||
import { useStyleTag, useIntervalFn } from '@vueuse/core'
|
||||
import { color } from '~/composables/color'
|
||||
|
||||
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
|
||||
|
||||
const ChannelUploadModal = defineAsyncComponent(() => import('~/components/channels/UploadModal.vue'))
|
||||
const PlaylistModal = defineAsyncComponent(() => import('~/components/playlists/PlaylistModal.vue'))
|
||||
const FilterModal = defineAsyncComponent(() => import('~/components/moderation/FilterModal.vue'))
|
||||
const ReportModal = defineAsyncComponent(() => import('~/components/moderation/ReportModal.vue'))
|
||||
const ServiceMessages = defineAsyncComponent(() => import('~/components/ServiceMessages.vue'))
|
||||
const ShortcutsModal = defineAsyncComponent(() => import('~/components/ShortcutsModal.vue'))
|
||||
const AudioPlayer = defineAsyncComponent(() => import('~/components/audio/Player.vue'))
|
||||
const Sidebar = defineAsyncComponent(() => import('~/components/Sidebar.vue'))
|
||||
const Queue = defineAsyncComponent(() => import('~/components/Queue.vue'))
|
||||
import PlaylistModal from '~/components/playlists/PlaylistModal.vue'
|
||||
import FilterModal from '~/components/moderation/FilterModal.vue'
|
||||
import ReportModal from '~/components/moderation/ReportModal.vue'
|
||||
import ServiceMessages from '~/components/ServiceMessages.vue'
|
||||
import AudioPlayer from '~/components/audio/Player.vue'
|
||||
import Queue from '~/components/Queue.vue'
|
||||
import Sidebar from '~/ui/components/Sidebar.vue'
|
||||
import ShortcutsModal from '~/ui/modals/Shortcuts.vue'
|
||||
import LanguagesModal from '~/ui/modals/Language.vue'
|
||||
import SearchModal from '~/ui/modals/Search.vue'
|
||||
import UploadModal from '~/ui/modals/Upload.vue'
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
|
||||
// Fake content
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
document.getElementById('fake-app')?.remove()
|
||||
})
|
||||
|
||||
const logger = useLogger()
|
||||
logger.debug('App setup()')
|
||||
|
@ -28,7 +34,7 @@ logger.debug('App setup()')
|
|||
const store = useStore()
|
||||
|
||||
// Tracks
|
||||
const { currentTrack, tracks } = useQueue()
|
||||
const { currentTrack } = useQueue()
|
||||
const getTrackInformationText = (track: QueueTrack | undefined) => {
|
||||
if (!track) {
|
||||
return null
|
||||
|
@ -50,10 +56,6 @@ watchEffect(() => {
|
|||
})
|
||||
|
||||
// Styles
|
||||
const customStylesheets = computed(() => {
|
||||
return store.state.instance.frontSettings.additionalStylesheets ?? []
|
||||
})
|
||||
|
||||
useStyleTag(computed(() => store.state.instance.settings.ui.custom_css.value))
|
||||
|
||||
// Fake content
|
||||
|
@ -68,63 +70,74 @@ useIntervalFn(() => {
|
|||
store.commit('ui/computeLastDate')
|
||||
}, 1000 * 60)
|
||||
|
||||
// Shortcuts
|
||||
const [showShortcutsModal, toggleShortcutsModal] = useToggle(false)
|
||||
onKeyboardShortcut('h', () => toggleShortcutsModal())
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// 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
|
||||
:key="store.state.instance.instanceUrl"
|
||||
:class="{
|
||||
'has-bottom-player': tracks.length > 0,
|
||||
'queue-focused': store.state.ui.queueFocused
|
||||
}"
|
||||
>
|
||||
<!-- here, we display custom stylesheets, if any -->
|
||||
<link
|
||||
v-for="url in customStylesheets"
|
||||
:key="url"
|
||||
rel="stylesheet"
|
||||
property="stylesheet"
|
||||
:href="url"
|
||||
<div class="funkwhale responsive">
|
||||
<Sidebar />
|
||||
<RouterView
|
||||
v-slot="{ Component }"
|
||||
v-bind="color({}, ['default', 'solid'])()"
|
||||
:class="$style.layout"
|
||||
>
|
||||
|
||||
<sidebar
|
||||
:width="width"
|
||||
@show:shortcuts-modal="toggleShortcutsModal"
|
||||
/>
|
||||
<service-messages />
|
||||
<transition name="queue">
|
||||
<queue v-show="store.state.ui.queueFocused" />
|
||||
</transition>
|
||||
|
||||
<router-view v-slot="{ Component }">
|
||||
<template v-if="Component">
|
||||
<keep-alive :max="1">
|
||||
<Transition
|
||||
v-if="Component"
|
||||
name="main"
|
||||
mode="out-in"
|
||||
>
|
||||
<KeepAlive :max="10">
|
||||
<Suspense>
|
||||
<component :is="Component" />
|
||||
<template #fallback>
|
||||
<!-- TODO (wvffle): Add loader -->
|
||||
{{ $t('App.loading') }}
|
||||
<Loader />
|
||||
</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 v-model:show="showShortcutsModal" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
<transition name="queue">
|
||||
<Queue v-show="store.state.ui.queueFocused" />
|
||||
</transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
<AudioPlayer
|
||||
class="funkwhale"
|
||||
v-bind="color({}, ['default', 'solid'])()"
|
||||
/>
|
||||
<ServiceMessages />
|
||||
<LanguagesModal />
|
||||
<ShortcutsModal />
|
||||
<PlaylistModal v-if="store.state.auth.authenticated" />
|
||||
<FilterModal v-if="store.state.auth.authenticated" />
|
||||
<ReportModal />
|
||||
<UploadModal v-if="store.state.auth.authenticated" />
|
||||
<SearchModal />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.responsive {
|
||||
display: grid !important;
|
||||
grid-template-rows: min-content;
|
||||
min-height: calc(100vh - 64px);
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
grid-template-columns: 300px 1fr;
|
||||
grid-template-rows: 100% 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* Make inert pages (behind modals) unscrollable */
|
||||
body:has(#app[inert="true"]) {
|
||||
overflow:hidden;
|
||||
}
|
||||
</style>
|
||||
<style module>
|
||||
.layout {
|
||||
padding: 32px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,8 +5,16 @@ import { get } from 'lodash-es'
|
|||
import { humanSize } from '~/utils/filters'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { components } from '~/generated/types.ts'
|
||||
|
||||
import SignupForm from '~/components/auth/SignupForm.vue'
|
||||
import LogoText from '~/components/LogoText.vue'
|
||||
import useMarkdown from '~/composables/useMarkdown'
|
||||
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
|
||||
const store = useStore()
|
||||
const nodeinfo = computed(() => store.state.instance.nodeinfo)
|
||||
|
@ -16,7 +24,8 @@ const labels = computed(() => ({
|
|||
title: t('components.About.title')
|
||||
}))
|
||||
|
||||
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') ?? 'Funkwhale')
|
||||
const podName = computed(() => (n => n === '' ? 'No name' : n ?? 'Funkwhale')(get(nodeinfo.value, 'metadata.nodeName')))
|
||||
|
||||
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
|
||||
const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
|
||||
|
||||
|
@ -28,10 +37,22 @@ const stats = computed(() => {
|
|||
return null
|
||||
}
|
||||
|
||||
return { users, hours }
|
||||
const info = nodeinfo.value ?? {} as components['schemas']['NodeInfo21']
|
||||
|
||||
const data = {
|
||||
users: info.usage.users.activeMonth || null,
|
||||
hours: info.metadata.content.local.hoursOfContent || null,
|
||||
artists: info.metadata.content.local.artists || null,
|
||||
albums: info.metadata.content.local.releases || null,
|
||||
tracks: info.metadata.content.local.recordings || null,
|
||||
listenings: info.metadata.usage?.listenings.total || null
|
||||
}
|
||||
|
||||
return { users, hours, data }
|
||||
})
|
||||
|
||||
const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations'))
|
||||
|
||||
const defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota', 0) * 1000 * 1000))
|
||||
|
||||
const headerStyle = computed(() => {
|
||||
|
@ -43,219 +64,554 @@ const headerStyle = computed(() => {
|
|||
backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})`
|
||||
}
|
||||
})
|
||||
|
||||
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(() => {
|
||||
const features = get(nodeinfo.value, 'metadata.metadata.feature', []) as string[]
|
||||
const hasAnonymousCanListen = features.includes('anonymousCanListen')
|
||||
return hasAnonymousCanListen
|
||||
})
|
||||
const allowListEnabled = computed(() => get(nodeinfo.value, 'metadata.allowList.enabled'))
|
||||
const version = computed(() => get(nodeinfo.value, 'software.version'))
|
||||
const federationEnabled = computed(() => {
|
||||
const features = get(nodeinfo.value, 'metadata.metadata.feature', []) as string[]
|
||||
const hasAnonymousCanListen = features.includes('federation')
|
||||
return hasAnonymousCanListen
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
<Layout
|
||||
v-title="labels.title"
|
||||
class="main pusher page-about"
|
||||
stack
|
||||
main
|
||||
style="align-items: center;"
|
||||
>
|
||||
<div class="ui container">
|
||||
<div class="ui horizontally fitted basic stripe segment">
|
||||
<div class="ui horizontally fitted basic very padded segment">
|
||||
<div class="ui center aligned text container">
|
||||
<div class="ui text container">
|
||||
<div class="ui equal width compact stackable grid">
|
||||
<div class="column" />
|
||||
<div class="ten wide column">
|
||||
<div class="ui vertically fitted basic segment">
|
||||
<router-link to="/">
|
||||
<logo-text />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column" />
|
||||
<!-- About funkwhale -->
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
width="full"
|
||||
align-text="stretch"
|
||||
style="width:min(480px, 100%)"
|
||||
>
|
||||
<logo-text />
|
||||
</Link>
|
||||
|
||||
<h2 class="header">
|
||||
{{ t('components.About.header.funkwhale') }}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{{ t('components.About.description.funkwhale') }}
|
||||
</p>
|
||||
|
||||
<Layout
|
||||
flex
|
||||
style="justify-content: center;"
|
||||
>
|
||||
<Card
|
||||
v-if="!store.state.auth.authenticated"
|
||||
:title="t('components.About.header.signup')"
|
||||
width="256px"
|
||||
>
|
||||
<template v-if="openRegistrations">
|
||||
<p>
|
||||
{{ t('components.About.description.signup') }}
|
||||
</p>
|
||||
<p v-if="defaultUploadQuota">
|
||||
{{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
|
||||
</p>
|
||||
<signup-form
|
||||
button-classes="success"
|
||||
:show-login="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else>
|
||||
<p>
|
||||
{{ t('components.About.help.closedRegistrations') }}
|
||||
</p>
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://funkwhale.audio/#get-started"
|
||||
>
|
||||
{{ t('components.About.link.findOtherPod') }}
|
||||
<i class="external alternate icon" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!(store.state.auth.authenticated || openRegistrations)"
|
||||
class="signup-form content"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ t('components.About.header.signup') }}
|
||||
<div class="ui positive message">
|
||||
<div class="header">
|
||||
{{ t('components.About.message.loggedIn') }}
|
||||
</div>
|
||||
<h2 class="header">
|
||||
{{ $t('components.About.header.funkwhale') }}
|
||||
</h2>
|
||||
<p>
|
||||
{{ $t('components.About.description.funkwhale') }}
|
||||
{{ t('components.About.message.greeting', {username: store.state.auth.username}) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui vertically fitted basic stripe segment">
|
||||
<div class="ui two stackable cards">
|
||||
<div class="ui card">
|
||||
<div
|
||||
v-if="!$store.state.auth.authenticated"
|
||||
class="signup-form content"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.About.header.signup') }}
|
||||
</h3>
|
||||
<template v-if="openRegistrations">
|
||||
<p>
|
||||
{{ $t('components.About.description.signup') }}
|
||||
</p>
|
||||
<p v-if="defaultUploadQuota">
|
||||
{{ $t('components.About.description.quota', {quota: defaultUploadQuota}) }}
|
||||
</p>
|
||||
<signup-form
|
||||
button-classes="success"
|
||||
:show-login="false"
|
||||
/>
|
||||
</template>
|
||||
<div v-else>
|
||||
<p>
|
||||
{{ $t('components.About.help.closedRegistrations') }}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://funkwhale.audio/#get-started"
|
||||
>
|
||||
{{ $t('components.About.link.findOtherPod') }}
|
||||
<i class="external alternate icon" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="signup-form content"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.About.header.signup') }}
|
||||
<div class="ui positive message">
|
||||
<div class="header">
|
||||
{{ $t('components.About.message.loggedIn') }}
|
||||
</div>
|
||||
<p>
|
||||
{{ $t('components.About.message.greeting', {username: $store.state.auth.username}) }}
|
||||
</p>
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui card">
|
||||
<section
|
||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<h1>
|
||||
<i class="music icon" />
|
||||
{{ podName }}
|
||||
</h1>
|
||||
</section>
|
||||
<div class="content pod-description">
|
||||
<h3
|
||||
id="description"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.About.header.aboutPod') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="shortDescription"
|
||||
class="sub header"
|
||||
>
|
||||
{{ shortDescription }}
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ $t('components.About.placeholder.noDescription') }}
|
||||
</p>
|
||||
<Card
|
||||
v-else
|
||||
:title="t('components.About.message.greeting', {username: store.state.auth.username})"
|
||||
width="256px"
|
||||
>
|
||||
<p v-if="defaultUploadQuota">
|
||||
{{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
|
||||
</p>
|
||||
|
||||
<template v-if="stats">
|
||||
<div class="statistics-container ui doubling grid">
|
||||
<div class="two column row">
|
||||
<div class="column">
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.users.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ $t('components.About.stat.activeUsers', stats.users) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="column">
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.hours.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ $t('components.About.stat.hoursOfMusic', stats.hours) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<router-link
|
||||
to="/about/pod"
|
||||
class="ui fluid basic secondary button"
|
||||
>
|
||||
{{ $t('components.About.link.learnMore') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO (wvffle): Remove style when migrate away from fomantic -->
|
||||
<div
|
||||
class="ui three stackable cards"
|
||||
style="z-index: 1; position: relative;"
|
||||
<template #action>
|
||||
<Button
|
||||
full
|
||||
disabled
|
||||
>
|
||||
<router-link
|
||||
to="/"
|
||||
class="ui card"
|
||||
>
|
||||
<div class="content">
|
||||
<h3
|
||||
id="description"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.About.header.publicContent') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ $t('components.About.description.publicContent') }}
|
||||
</p>
|
||||
</div>
|
||||
</router-link>
|
||||
<a
|
||||
href="https://funkwhale.audio/#get-started"
|
||||
class="ui card"
|
||||
target="_blank"
|
||||
>
|
||||
<div class="content">
|
||||
<h3
|
||||
id="description"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.About.link.findOtherPod') }}
|
||||
<i class="external alternate icon" />
|
||||
</h3>
|
||||
<p>
|
||||
{{ $t('components.About.description.publicContent') }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://funkwhale.audio/apps"
|
||||
class="ui card"
|
||||
target="_blank"
|
||||
>
|
||||
<div class="content">
|
||||
<h3
|
||||
id="description"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.About.header.findApp') }}
|
||||
<i class="external alternate icon" />
|
||||
</h3>
|
||||
<p>
|
||||
{{ $t('components.About.description.findApp') }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="ui fluid horizontally fitted basic clearing segment container">
|
||||
<router-link
|
||||
to="/about/pod"
|
||||
class="ui right floated basic secondary button"
|
||||
>
|
||||
{{ $t('components.About.header.aboutPod') }}
|
||||
<i class="icon arrow right" />
|
||||
</router-link>
|
||||
{{ t('components.About.message.loggedIn') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
:title="podName"
|
||||
width="256px"
|
||||
>
|
||||
<section
|
||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<h1>
|
||||
<i class="music icon" />
|
||||
</h1>
|
||||
</section>
|
||||
<div class="content pod-description">
|
||||
<h3
|
||||
id="description"
|
||||
class="ui header"
|
||||
>
|
||||
{{ t('components.About.header.aboutPod') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="shortDescription"
|
||||
class="sub header"
|
||||
>
|
||||
{{ shortDescription }}
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ t('components.About.placeholder.noDescription') }}
|
||||
</p>
|
||||
|
||||
<template v-if="stats">
|
||||
<div class="statistics-container ui doubling grid">
|
||||
<div class="two column row">
|
||||
<div class="column">
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.users?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ stats.users ? t('components.About.stat.activeUsers', stats.users) : "" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="column">
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.hours ? stats.hours.toLocaleString(store.state.ui.momentLocale) : "" }}</strong></span>
|
||||
<br>
|
||||
{{ stats.hours ? t('components.About.stat.hoursOfMusic', stats.hours) : "" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #action>
|
||||
<Link
|
||||
align-text="center"
|
||||
to="/about/pod"
|
||||
>
|
||||
{{ t('components.About.link.learnMore') }}
|
||||
</Link>
|
||||
</template>
|
||||
</Card>
|
||||
</Layout>
|
||||
|
||||
<Layout
|
||||
flex
|
||||
style="justify-content: center;"
|
||||
>
|
||||
<Card
|
||||
width="256px"
|
||||
to="/"
|
||||
:title="t('components.About.header.publicContent')"
|
||||
icon="bi-box-arrow-up-right"
|
||||
>
|
||||
<!-- TODO: Link to Explore page? -->
|
||||
{{ t('components.About.description.publicContent') }}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
width="256px"
|
||||
:title="t('components.About.link.findOtherPod')"
|
||||
to="https://funkwhale.audio/#get-started"
|
||||
icon="bi-box-arrow-up-right"
|
||||
>
|
||||
{{ t('components.About.description.publicContent') }}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
width="256px"
|
||||
:title="t('components.About.header.findApp')"
|
||||
to="https://funkwhale.audio/apps"
|
||||
icon="bi-box-arrow-up-right"
|
||||
>
|
||||
{{ t('components.About.description.findApp') }}
|
||||
</Card>
|
||||
</Layout>
|
||||
|
||||
<section
|
||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<h1>
|
||||
<i class="music icon" />
|
||||
{{ podName }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<!-- About Pod -->
|
||||
<div class="about-pod-info-container">
|
||||
<div class="about-pod-info-toc">
|
||||
<div class="ui vertical pointing secondary menu">
|
||||
<router-link
|
||||
to="/about/pod"
|
||||
class="item"
|
||||
>
|
||||
{{ t('components.AboutPod.link.about') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about/pod#rules"
|
||||
class="item"
|
||||
>
|
||||
{{ t('components.AboutPod.link.rules') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about/pod#terms"
|
||||
class="item"
|
||||
>
|
||||
{{ t('components.AboutPod.link.terms') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about/pod#features"
|
||||
class="item"
|
||||
>
|
||||
{{ t('components.AboutPod.link.features') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="stats"
|
||||
to="/about/pod#statistics"
|
||||
class="item"
|
||||
>
|
||||
{{ t('components.AboutPod.link.statistics') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="about-pod-info">
|
||||
<h2
|
||||
id="description about-this-pod"
|
||||
class="ui header"
|
||||
>
|
||||
{{ t('components.AboutPod.header.about') }}
|
||||
</h2>
|
||||
<sanitized-html
|
||||
v-if="longDescription"
|
||||
:html="longDescription"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ t('components.AboutPod.placeholder.noDescription') }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
id="rules"
|
||||
class="ui header"
|
||||
>
|
||||
{{ t('components.AboutPod.header.rules') }}
|
||||
</h3>
|
||||
<sanitized-html
|
||||
v-if="rules"
|
||||
:html="rules"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ t('components.AboutPod.placeholder.noRules') }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
id="terms"
|
||||
class="ui header"
|
||||
>
|
||||
{{ t('components.AboutPod.header.terms') }}
|
||||
</h3>
|
||||
<sanitized-html
|
||||
v-if="terms"
|
||||
:html="terms"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ t('components.AboutPod.placeholder.noTerms') }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
id="features"
|
||||
class="header"
|
||||
>
|
||||
{{ t('components.AboutPod.header.features') }}
|
||||
</h3>
|
||||
<div class="features-container ui two column stackable grid">
|
||||
<div class="column">
|
||||
<table class="ui very basic table unstackable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{ t('components.AboutPod.feature.version') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="version"
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
{{ version }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
v-else
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
{{ t('components.AboutPod.notApplicable') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ t('components.AboutPod.feature.federation') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="federationEnabled"
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
v-else
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ t('components.AboutPod.feature.allowList') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="allowListEnabled"
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
v-else
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="column">
|
||||
<table class="ui very basic table unstackable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{ t('components.AboutPod.feature.anonymousAccess') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="anonymousCanListen"
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
v-else
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ t('components.AboutPod.feature.registrations') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="openRegistrations"
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ t('components.AboutPod.feature.status.open') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
v-else
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ t('components.AboutPod.feature.status.closed') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ t('components.AboutPod.feature.quota') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="defaultUploadQuota"
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
{{ defaultUploadQuota }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
v-else
|
||||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
{{ t('components.AboutPod.notApplicable') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="stats">
|
||||
<h3
|
||||
id="statistics"
|
||||
class="header"
|
||||
>
|
||||
{{ t('components.AboutPod.header.statistics') }}
|
||||
</h3>
|
||||
<div class="statistics-container">
|
||||
<div
|
||||
v-if="stats.hours"
|
||||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.hours.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="stats.data.artists"
|
||||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.data.artists.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ t('components.AboutPod.stat.artistsCount', stats.data.artists) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="stats.data.albums"
|
||||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.data.albums.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ t('components.AboutPod.stat.albumsCount', stats.data.albums) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="stats.data.tracks"
|
||||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.data.tracks.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ t('components.AboutPod.stat.tracksCount', stats.data.tracks) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="stats.users"
|
||||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.users.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ t('components.AboutPod.stat.activeUsers', stats.users) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="stats.data.listenings"
|
||||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.data.listenings.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ t('components.AboutPod.stat.listeningsCount', stats.data.listenings) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="contactEmail">
|
||||
<h3
|
||||
id="contact"
|
||||
class="ui header"
|
||||
>
|
||||
{{ t('components.AboutPod.header.contact') }}
|
||||
</h3>
|
||||
<a
|
||||
v-if="contactEmail"
|
||||
:href="`mailto:${contactEmail}`"
|
||||
>
|
||||
{{ t('components.AboutPod.message.contact', { contactEmail }) }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<div class="ui hidden divider" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -5,7 +5,6 @@ import { get } from 'lodash-es'
|
|||
import { computed } from 'vue'
|
||||
|
||||
import useMarkdown from '~/composables/useMarkdown'
|
||||
import type { NodeInfo } from '~/store/instance'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const store = useStore()
|
||||
|
@ -39,24 +38,14 @@ const federationEnabled = computed(() => {
|
|||
|
||||
const onDesktop = computed(() => window.innerWidth > 800)
|
||||
|
||||
const stats = computed(() => {
|
||||
const info = nodeinfo.value ?? {} as NodeInfo
|
||||
|
||||
const data = {
|
||||
users: get(info, 'usage.users.activeMonth', null),
|
||||
hours: get(info, 'metadata.content.local.hoursOfContent', null),
|
||||
artists: get(info, 'metadata.content.local.artists.total', null),
|
||||
albums: get(info, 'metadata.content.local.albums.total', null),
|
||||
tracks: get(info, 'metadata.content.local.tracks.total', null),
|
||||
listenings: get(info, 'metadata.usage.listenings.total', null)
|
||||
}
|
||||
|
||||
if (data.users === null || data.artists === null) {
|
||||
return data
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
const stats = computed(() => ({
|
||||
users: nodeinfo.value?.usage.users.activeMonth,
|
||||
hours: nodeinfo.value?.metadata.content.local.hoursOfContent,
|
||||
artists: nodeinfo.value?.metadata.content.local.artists,
|
||||
albums: nodeinfo.value?.metadata.content.local.releases, // TODO: Check where to get 'metadata.content.local.albums.total'
|
||||
tracks: nodeinfo.value?.metadata.content.local.recordings, // TODO: 'metadata.content.local.tracks.total'
|
||||
listenings: nodeinfo.value?.metadata.usage?.listenings.total
|
||||
}))
|
||||
|
||||
const headerStyle = computed(() => {
|
||||
if (!banner.value) {
|
||||
|
@ -72,7 +61,7 @@ const headerStyle = computed(() => {
|
|||
<template>
|
||||
<main
|
||||
v-title="labels.title"
|
||||
class="main pusher page-about"
|
||||
class="main page-about"
|
||||
>
|
||||
<div
|
||||
class="ui"
|
||||
|
@ -99,32 +88,32 @@ const headerStyle = computed(() => {
|
|||
to="/about/pod"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.about') }}
|
||||
{{ t('components.AboutPod.link.about') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about/pod#rules"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.rules') }}
|
||||
{{ t('components.AboutPod.link.rules') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about/pod#terms"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.terms') }}
|
||||
{{ t('components.AboutPod.link.terms') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about/pod#features"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.features') }}
|
||||
{{ t('components.AboutPod.link.features') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="stats"
|
||||
to="/about/pod#statistics"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.statistics') }}
|
||||
{{ t('components.AboutPod.link.statistics') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -134,49 +123,49 @@ const headerStyle = computed(() => {
|
|||
id="description about-this-pod"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.about') }}
|
||||
{{ t('components.AboutPod.header.about') }}
|
||||
</h2>
|
||||
<sanitized-html
|
||||
v-if="longDescription"
|
||||
:html="longDescription"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ $t('components.AboutPod.placeholder.noDescription') }}
|
||||
{{ t('components.AboutPod.placeholder.noDescription') }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
id="rules"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.rules') }}
|
||||
{{ t('components.AboutPod.header.rules') }}
|
||||
</h3>
|
||||
<sanitized-html
|
||||
v-if="rules"
|
||||
:html="rules"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ $t('components.AboutPod.placeholder.noRules') }}
|
||||
{{ t('components.AboutPod.placeholder.noRules') }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
id="terms"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.terms') }}
|
||||
{{ t('components.AboutPod.header.terms') }}
|
||||
</h3>
|
||||
<sanitized-html
|
||||
v-if="terms"
|
||||
:html="terms"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ $t('components.AboutPod.placeholder.noTerms') }}
|
||||
{{ t('components.AboutPod.placeholder.noTerms') }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
id="features"
|
||||
class="header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.features') }}
|
||||
{{ t('components.AboutPod.header.features') }}
|
||||
</h3>
|
||||
<div class="features-container ui two column stackable grid">
|
||||
<div class="column">
|
||||
|
@ -184,7 +173,7 @@ const headerStyle = computed(() => {
|
|||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.version') }}
|
||||
{{ t('components.AboutPod.feature.version') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="version"
|
||||
|
@ -199,13 +188,13 @@ const headerStyle = computed(() => {
|
|||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
{{ $t('components.AboutPod.notApplicable') }}
|
||||
{{ t('components.AboutPod.notApplicable') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.federation') }}
|
||||
{{ t('components.AboutPod.feature.federation') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="federationEnabled"
|
||||
|
@ -213,7 +202,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ $t('components.AboutPod.feature.status.enabled') }}
|
||||
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
|
@ -222,13 +211,13 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ $t('components.AboutPod.feature.status.disabled') }}
|
||||
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.allowList') }}
|
||||
{{ t('components.AboutPod.feature.allowList') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="allowListEnabled"
|
||||
|
@ -236,7 +225,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ $t('components.AboutPod.feature.status.enabled') }}
|
||||
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
|
@ -245,7 +234,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ $t('components.AboutPod.feature.status.disabled') }}
|
||||
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -257,7 +246,7 @@ const headerStyle = computed(() => {
|
|||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.anonymousAccess') }}
|
||||
{{ t('components.AboutPod.feature.anonymousAccess') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="anonymousCanListen"
|
||||
|
@ -265,7 +254,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ $t('components.AboutPod.feature.status.enabled') }}
|
||||
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
|
@ -274,13 +263,13 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ $t('components.AboutPod.feature.status.disabled') }}
|
||||
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.registrations') }}
|
||||
{{ t('components.AboutPod.feature.registrations') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="openRegistrations"
|
||||
|
@ -288,7 +277,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ $t('components.AboutPod.feature.status.open') }}
|
||||
{{ t('components.AboutPod.feature.status.open') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
|
@ -297,13 +286,13 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ $t('components.AboutPod.feature.status.closed') }}
|
||||
{{ t('components.AboutPod.feature.status.closed') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.quota') }}
|
||||
{{ t('components.AboutPod.feature.quota') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="defaultUploadQuota"
|
||||
|
@ -318,7 +307,7 @@ const headerStyle = computed(() => {
|
|||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
{{ $t('components.AboutPod.notApplicable') }}
|
||||
{{ t('components.AboutPod.notApplicable') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -332,7 +321,7 @@ const headerStyle = computed(() => {
|
|||
id="statistics"
|
||||
class="header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.statistics') }}
|
||||
{{ t('components.AboutPod.header.statistics') }}
|
||||
</h3>
|
||||
<div class="statistics-container">
|
||||
<div
|
||||
|
@ -340,9 +329,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.hours.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
||||
<span class="ui big text"><strong>{{ stats.hours?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ $t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
|
||||
{{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -350,9 +339,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.artists.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
||||
<span class="ui big text"><strong>{{ stats.artists?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ $t('components.AboutPod.stat.artistsCount', stats.artists) }}
|
||||
{{ t('components.AboutPod.stat.artistsCount', stats.artists) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -360,9 +349,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.albums.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
||||
<span class="ui big text"><strong>{{ stats.albums?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ $t('components.AboutPod.stat.albumsCount', stats.albums) }}
|
||||
{{ t('components.AboutPod.stat.albumsCount', stats.albums) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -370,9 +359,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.tracks.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
||||
<span class="ui big text"><strong>{{ stats.tracks?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ $t('components.AboutPod.stat.tracksCount', stats.tracks) }}
|
||||
{{ t('components.AboutPod.stat.tracksCount', stats.tracks) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -380,9 +369,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.users.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
||||
<span class="ui big text"><strong>{{ stats.users.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ $t('components.AboutPod.stat.activeUsers', stats.users) }}
|
||||
{{ t('components.AboutPod.stat.activeUsers', stats.users) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -390,9 +379,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.listenings.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
||||
<span class="ui big text"><strong>{{ stats.listenings.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ $t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
|
||||
{{ t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -403,13 +392,13 @@ const headerStyle = computed(() => {
|
|||
id="contact"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.contact') }}
|
||||
{{ t('components.AboutPod.header.contact') }}
|
||||
</h3>
|
||||
<a
|
||||
v-if="contactEmail"
|
||||
:href="`mailto:${contactEmail}`"
|
||||
>
|
||||
{{ $t('components.AboutPod.message.contact', { contactEmail }) }}
|
||||
{{ t('components.AboutPod.message.contact', { contactEmail }) }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
|
@ -420,7 +409,7 @@ const headerStyle = computed(() => {
|
|||
class="ui left floated basic secondary button"
|
||||
>
|
||||
<i class="icon arrow left" />
|
||||
{{ $t('components.AboutPod.link.introduction') }}
|
||||
{{ t('components.AboutPod.link.introduction') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { get } from 'lodash-es'
|
||||
import AlbumWidget from '~/components/audio/album/Widget.vue'
|
||||
import AlbumWidget from '~/components/album/Widget.vue'
|
||||
import ChannelsWidget from '~/components/audio/ChannelsWidget.vue'
|
||||
import LoginForm from '~/components/auth/LoginForm.vue'
|
||||
import SignupForm from '~/components/auth/SignupForm.vue'
|
||||
|
@ -64,7 +64,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<template>
|
||||
<main
|
||||
v-title="labels.title"
|
||||
class="main pusher page-home"
|
||||
class="main page-home"
|
||||
>
|
||||
<section
|
||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||
|
@ -73,7 +73,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="segment-content">
|
||||
<h1 class="ui center aligned large header">
|
||||
<span>
|
||||
{{ $t('components.Home.header.welcome', {podName: podName}) }}
|
||||
{{ t('components.Home.header.welcome', {podName: podName}) }}
|
||||
</span>
|
||||
<div
|
||||
v-if="shortDescription"
|
||||
|
@ -88,7 +88,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="ui stackable grid">
|
||||
<div class="ten wide column">
|
||||
<h2 class="header">
|
||||
{{ $t('components.Home.header.about') }}
|
||||
{{ t('components.Home.header.about') }}
|
||||
</h2>
|
||||
<div
|
||||
id="pod"
|
||||
|
@ -97,7 +97,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="ui stackable grid">
|
||||
<div class="eight wide column">
|
||||
<p v-if="!longDescription">
|
||||
{{ $t('components.Home.placeholder.noDescription') }}
|
||||
{{ t('components.Home.placeholder.noDescription') }}
|
||||
</p>
|
||||
<template v-if="longDescription || rules">
|
||||
<sanitized-html
|
||||
|
@ -120,7 +120,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
class="ui link"
|
||||
:to="{name: 'about'}"
|
||||
>
|
||||
{{ $t('components.Home.link.learnMore') }}
|
||||
{{ t('components.Home.link.learnMore') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -135,7 +135,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
class="ui link"
|
||||
:to="{name: 'about', hash: '#rules'}"
|
||||
>
|
||||
{{ $t('components.Home.link.rules') }}
|
||||
{{ t('components.Home.link.rules') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -145,20 +145,20 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="eight wide column">
|
||||
<template v-if="stats">
|
||||
<h3 class="sub header">
|
||||
{{ $t('components.Home.header.statistics') }}
|
||||
{{ t('components.Home.header.statistics') }}
|
||||
</h3>
|
||||
<p>
|
||||
<i class="user icon" />
|
||||
{{ $t('components.Home.stat.activeUsers', stats.users) }}
|
||||
{{ t('components.Home.stat.activeUsers', stats.users) }}
|
||||
</p>
|
||||
<p>
|
||||
<i class="music icon" />
|
||||
{{ $t('components.Home.stat.hoursOfMusic', stats.hours) }}
|
||||
{{ t('components.Home.stat.hoursOfMusic', stats.hours) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="contactEmail">
|
||||
<h3 class="sub header">
|
||||
{{ $t('components.Home.header.contact') }}
|
||||
{{ t('components.Home.header.contact') }}
|
||||
</h3>
|
||||
<i class="at icon" />
|
||||
<a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a>
|
||||
|
@ -181,13 +181,13 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="ui stackable grid">
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Home.header.aboutFunkwhale') }}
|
||||
{{ t('components.Home.header.aboutFunkwhale') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ $t('components.Home.description.funkwhale.paragraph1') }}
|
||||
{{ t('components.Home.description.funkwhale.paragraph1') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('components.Home.description.funkwhale.paragraph2') }}
|
||||
{{ t('components.Home.description.funkwhale.paragraph2') }}
|
||||
</p>
|
||||
<a
|
||||
target="_blank"
|
||||
|
@ -195,12 +195,12 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
href="https://funkwhale.audio"
|
||||
>
|
||||
<i class="external alternate icon" />
|
||||
{{ $t('components.Home.link.funkwhale') }}
|
||||
{{ t('components.Home.link.funkwhale') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Home.header.login') }}
|
||||
{{ t('components.Home.header.login') }}
|
||||
</h3>
|
||||
<login-form
|
||||
button-classes="success"
|
||||
|
@ -210,14 +210,14 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
</div>
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Home.header.signup') }}
|
||||
{{ t('components.Home.header.signup') }}
|
||||
</h3>
|
||||
<template v-if="openRegistrations">
|
||||
<p>
|
||||
{{ $t('components.Home.description.signup') }}
|
||||
{{ t('components.Home.description.signup') }}
|
||||
</p>
|
||||
<p v-if="defaultUploadQuota">
|
||||
{{ $t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }}
|
||||
{{ t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }}
|
||||
</p>
|
||||
<signup-form
|
||||
button-classes="success"
|
||||
|
@ -226,7 +226,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
</template>
|
||||
<div v-else>
|
||||
<p>
|
||||
{{ $t('components.Home.help.registrationsClosed') }}
|
||||
{{ t('components.Home.help.registrationsClosed') }}
|
||||
</p>
|
||||
<a
|
||||
target="_blank"
|
||||
|
@ -234,14 +234,14 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
href="https://funkwhale.audio/#get-started"
|
||||
>
|
||||
<i class="external alternate icon" />
|
||||
{{ $t('components.Home.link.findOtherPod') }}
|
||||
{{ t('components.Home.link.findOtherPod') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Home.header.links') }}
|
||||
{{ t('components.Home.header.links') }}
|
||||
</h3>
|
||||
<div class="ui relaxed list">
|
||||
<div class="item">
|
||||
|
@ -252,10 +252,10 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
class="header"
|
||||
to="/library"
|
||||
>
|
||||
{{ $t('components.Home.link.publicContent.label') }}
|
||||
{{ t('components.Home.link.publicContent.label') }}
|
||||
</router-link>
|
||||
<div class="description">
|
||||
{{ $t('components.Home.link.publicContent.description') }}
|
||||
{{ t('components.Home.link.publicContent.description') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -268,10 +268,10 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ $t('components.Home.link.mobileApps.label') }}
|
||||
{{ t('components.Home.link.mobileApps.label') }}
|
||||
</a>
|
||||
<div class="description">
|
||||
{{ $t('components.Home.link.mobileApps.description') }}
|
||||
{{ t('components.Home.link.mobileApps.description') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -284,10 +284,10 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ $t('components.Home.link.userGuides.label') }}
|
||||
{{ t('components.Home.link.userGuides.label') }}
|
||||
</a>
|
||||
<div class="description">
|
||||
{{ $t('components.Home.link.userGuides.description') }}
|
||||
{{ t('components.Home.link.userGuides.description') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -304,16 +304,16 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
:limit="10"
|
||||
>
|
||||
<template #title>
|
||||
{{ $t('components.Home.header.newAlbums') }}
|
||||
{{ t('components.Home.header.newAlbums') }}
|
||||
</template>
|
||||
<router-link to="/library">
|
||||
{{ $t('components.Home.link.viewMore') }}
|
||||
{{ t('components.Home.link.viewMore') }}
|
||||
<div class="ui hidden divider" />
|
||||
</router-link>
|
||||
</album-widget>
|
||||
<div class="ui hidden section divider" />
|
||||
<h3 class="ui header">
|
||||
{{ $t('components.Home.header.newChannels') }}
|
||||
{{ t('components.Home.header.newChannels') }}
|
||||
</h3>
|
||||
<channels-widget
|
||||
:show-modification-date="true"
|
||||
|
|
|
@ -4,7 +4,7 @@ interface Props {
|
|||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
fill: '#222222'
|
||||
fill: 'var(--color)'
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ const labels = computed(() => ({
|
|||
|
||||
<template>
|
||||
<main
|
||||
class="main pusher"
|
||||
class="main"
|
||||
:v-title="labels.title"
|
||||
>
|
||||
<section class="ui vertical stripe segment">
|
||||
|
@ -20,11 +20,11 @@ const labels = computed(() => ({
|
|||
<h1 class="ui huge header">
|
||||
<i class="warning icon" />
|
||||
<div class="content">
|
||||
{{ $t('components.PageNotFound.header.pageNotFound') }}
|
||||
{{ t('components.PageNotFound.header.pageNotFound') }}
|
||||
</div>
|
||||
</h1>
|
||||
<p>
|
||||
{{ $t('components.PageNotFound.message.pageNotFound') }}
|
||||
{{ t('components.PageNotFound.message.pageNotFound') }}
|
||||
</p>
|
||||
<a :href="path">{{ path }}</a>
|
||||
<div class="ui hidden divider" />
|
||||
|
@ -32,7 +32,7 @@ const labels = computed(() => ({
|
|||
class="ui icon labeled right button"
|
||||
to="/"
|
||||
>
|
||||
{{ $t('components.PageNotFound.link.home') }}
|
||||
{{ t('components.PageNotFound.link.home') }}
|
||||
<i class="right arrow icon" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
|
@ -5,7 +5,6 @@ import { whenever, watchDebounced, useCurrentElement, useScrollLock, useFullscre
|
|||
import { nextTick, ref, computed, watchEffect, defineAsyncComponent } from 'vue'
|
||||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import { usePlayer } from '~/composables/audio/player'
|
||||
|
@ -14,6 +13,8 @@ import { useQueue } from '~/composables/audio/queue'
|
|||
|
||||
import time from '~/utils/time'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||
import PlayerControls from '~/components/audio/PlayerControls.vue'
|
||||
|
@ -21,6 +22,12 @@ import PlayerControls from '~/components/audio/PlayerControls.vue'
|
|||
import VirtualList from '~/components/vui/list/VirtualList.vue'
|
||||
import QueueItem from '~/components/QueueItem.vue'
|
||||
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
|
||||
|
||||
const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue'))
|
||||
|
||||
const {
|
||||
|
@ -173,7 +180,7 @@ if (!isWebGLSupported) {
|
|||
|
||||
<template>
|
||||
<section
|
||||
class="main with-background component-queue"
|
||||
class="main opaque component-queue"
|
||||
:aria-label="labels.queue"
|
||||
>
|
||||
<div
|
||||
|
@ -194,12 +201,12 @@ if (!isWebGLSupported) {
|
|||
<img
|
||||
v-if="fullscreen"
|
||||
class="cover-shadow"
|
||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
:src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
>
|
||||
<img
|
||||
ref="cover"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
:src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
>
|
||||
</template>
|
||||
<milk-drop
|
||||
|
@ -212,47 +219,40 @@ if (!isWebGLSupported) {
|
|||
v-if="!fullscreen || !idle"
|
||||
class="cover-buttons"
|
||||
>
|
||||
<tooltip :content="!isWebGLSupported && $t('components.Queue.message.webglUnsupported')">
|
||||
<button
|
||||
<tooltip :content="!isWebGLSupported && t('components.Queue.message.webglUnsupported')">
|
||||
<Button
|
||||
v-if="coverType === CoverType.COVER_ART"
|
||||
class="ui secondary button"
|
||||
:aria-label="labels.showVisualizer"
|
||||
:title="labels.showVisualizer"
|
||||
:disabled="!isWebGLSupported"
|
||||
icon="bi-display"
|
||||
@click="coverType = CoverType.MILK_DROP"
|
||||
>
|
||||
<i class="icon signal" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
v-else-if="coverType === CoverType.MILK_DROP"
|
||||
class="ui secondary button"
|
||||
:aria-label="labels.showCoverArt"
|
||||
:title="labels.showCoverArt"
|
||||
:disabled="!isWebGLSupported"
|
||||
icon="bi-image-fill"
|
||||
@click="coverType = CoverType.COVER_ART"
|
||||
>
|
||||
<i class="icon image outline" />
|
||||
</button>
|
||||
/>
|
||||
</tooltip>
|
||||
|
||||
<button
|
||||
<Button
|
||||
v-if="!fullscreen"
|
||||
class="ui secondary button"
|
||||
:aria-label="labels.fullscreen"
|
||||
:title="labels.fullscreen"
|
||||
icon="bi-arrows-fullscreen"
|
||||
@click="enter"
|
||||
>
|
||||
<i class="icon expand" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="ui secondary button"
|
||||
secondary
|
||||
:aria-label="labels.exitFullscreen"
|
||||
:title="labels.exitFullscreen"
|
||||
icon="bi-fullscreen-exit"
|
||||
@click="exit"
|
||||
>
|
||||
<i class="icon compress" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="queue">
|
||||
|
@ -267,65 +267,53 @@ if (!isWebGLSupported) {
|
|||
v-for="ac in currentTrack.artistCredit"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
|
||||
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</div>
|
||||
<span class="symbol hyphen middle" />
|
||||
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }}
|
||||
{{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
|
||||
</h2>
|
||||
</div>
|
||||
</Transition>
|
||||
</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">
|
||||
<span>
|
||||
<template
|
||||
v-for="ac in currentTrack.artistCredit"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
<router-link
|
||||
class="discrete link"
|
||||
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
|
||||
</router-link>
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<template v-if="currentTrack.albumId !== -1">
|
||||
<span class="middle slash symbol" />
|
||||
<router-link
|
||||
class="discrete link album"
|
||||
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
||||
>
|
||||
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
class="track"
|
||||
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
||||
>
|
||||
{{ currentTrack.title }}
|
||||
</Link>
|
||||
</h1>
|
||||
<h2>
|
||||
<template v-if="currentTrack.albumId !== -1">
|
||||
<Link
|
||||
class="album"
|
||||
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
||||
>
|
||||
{{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
|
||||
</Link>
|
||||
</template>
|
||||
</h2>
|
||||
<span>
|
||||
<ArtistCreditLabel
|
||||
v-if="currentTrack.artistCredit"
|
||||
:artist-credit="currentTrack.artistCredit"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
v-if="currentTrack && errored"
|
||||
class="ui small warning message"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.Queue.header.failure') }}
|
||||
{{ t('components.Queue.header.failure') }}
|
||||
</h3>
|
||||
<p v-if="hasNext && isPlaying">
|
||||
{{ $t('components.Queue.message.automaticPlay') }}
|
||||
{{ t('components.Queue.message.automaticPlay') }}
|
||||
<i class="loading spinner icon" />
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('components.Queue.warning.connectivity') }}
|
||||
{{ t('components.Queue.warning.connectivity') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
@ -333,32 +321,40 @@ if (!isWebGLSupported) {
|
|||
class="ui small warning message"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.Queue.header.noSources') }}
|
||||
{{ t('components.Queue.header.noSources') }}
|
||||
</h3>
|
||||
<p v-if="hasNext && isPlaying">
|
||||
{{ $t('components.Queue.message.automaticPlay') }}
|
||||
{{ t('components.Queue.message.automaticPlay') }}
|
||||
<i class="loading spinner icon" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="additional-controls desktop-and-below">
|
||||
<Spacer
|
||||
:size="16"
|
||||
class="desktop-and-below"
|
||||
/>
|
||||
<Layout
|
||||
flex
|
||||
class="additional-controls desktop-and-below"
|
||||
>
|
||||
<track-favorite-icon
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
:track="currentTrack"
|
||||
ghost
|
||||
/>
|
||||
<track-playlist-icon
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
:track="currentTrack"
|
||||
ghost
|
||||
/>
|
||||
<button
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button']"
|
||||
<Button
|
||||
v-if="store.state.auth.authenticated"
|
||||
ghost
|
||||
icon="bi-eye-slash"
|
||||
:aria-label="labels.addArtistContentFilter"
|
||||
:title="labels.addArtistContentFilter"
|
||||
@click="hideArtist"
|
||||
>
|
||||
<i :class="['eye slash outline', 'basic', 'icon']" />
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
</Layout>
|
||||
<div class="progress-wrapper">
|
||||
<div class="progress-area">
|
||||
<div
|
||||
|
@ -386,28 +382,33 @@ if (!isWebGLSupported) {
|
|||
<span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="left floated timer">{{ $t('components.Queue.meta.startTime') }}</span>
|
||||
<span class="right floated timer">{{ $t('components.Queue.meta.startTime') }}</span>
|
||||
<span class="left floated timer">{{ t('components.Queue.meta.startTime') }}</span>
|
||||
<span class="right floated timer">{{ t('components.Queue.meta.startTime') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<player-controls class="desktop-and-below" />
|
||||
<player-controls class="desktop-and-below queue-controls" />
|
||||
</template>
|
||||
</div>
|
||||
<div id="queue">
|
||||
<div class="ui basic clearing segment">
|
||||
<h2 class="ui header">
|
||||
<div class="content">
|
||||
<button
|
||||
v-t="'components.Queue.button.close'"
|
||||
class="ui right floated basic button"
|
||||
@click="$store.commit('ui/queueFocused', null)"
|
||||
<Button
|
||||
ghost
|
||||
icon="bi-chevron-down"
|
||||
style="float: right; margin-right: 24px;"
|
||||
@click="store.commit('ui/queueFocused', null)"
|
||||
/>
|
||||
<button
|
||||
v-t="'components.Queue.button.clear'"
|
||||
class="ui right floated basic button danger"
|
||||
<Button
|
||||
red
|
||||
outline
|
||||
icon="bi-trash-fill"
|
||||
style="float: right; margin-right: 16px;"
|
||||
@click="clear"
|
||||
/>
|
||||
>
|
||||
{{ t('components.Queue.button.clear') }}
|
||||
</Button>
|
||||
{{ labels.queue }}
|
||||
<div class="sub header">
|
||||
<div>
|
||||
|
@ -420,7 +421,10 @@ if (!isWebGLSupported) {
|
|||
</template>
|
||||
</i18n-t>
|
||||
<span class="middle pipe symbol" />
|
||||
<span v-t="'components.Queue.meta.end'" />
|
||||
<span
|
||||
v-t="'components.Queue.meta.end'"
|
||||
style="margin-right: 8px;"
|
||||
/>
|
||||
<span :title="labels.duration">
|
||||
{{ endsIn }}
|
||||
</span>
|
||||
|
@ -451,30 +455,31 @@ if (!isWebGLSupported) {
|
|||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="$store.state.radios.populating"
|
||||
v-if="store.state.radios.populating"
|
||||
class="radio-populating"
|
||||
>
|
||||
<i class="loading spinner icon" />
|
||||
{{ labels.populating }}
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.radios.running"
|
||||
v-if="store.state.radios.running"
|
||||
class="ui info message radio-message"
|
||||
>
|
||||
<div class="content">
|
||||
<h3 class="header">
|
||||
<i class="feed icon" />
|
||||
{{ $t('components.Queue.header.radio') }}
|
||||
<i class="bi bi-boombox-fill" />
|
||||
{{ t('components.Queue.header.radio') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ $t('components.Queue.message.radio') }}
|
||||
{{ t('components.Queue.message.radio') }}
|
||||
</p>
|
||||
<button
|
||||
class="ui basic primary button"
|
||||
@click="$store.dispatch('radios/stop')"
|
||||
<Button
|
||||
primary
|
||||
icon="bi-stop-fill"
|
||||
@click="store.dispatch('radios/stop')"
|
||||
>
|
||||
{{ $t('components.Queue.button.stopRadio') }}
|
||||
</button>
|
||||
{{ t('components.Queue.button.stopRadio') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -3,6 +3,11 @@ import type { QueueItemSource } from '~/types'
|
|||
|
||||
import time from '~/utils/time'
|
||||
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
interface Events {
|
||||
(e: 'play', index: number): void
|
||||
|
@ -24,7 +29,7 @@ defineProps<Props>()
|
|||
tabindex="0"
|
||||
>
|
||||
<div class="handle">
|
||||
<i class="grip lines icon" />
|
||||
<i class="bi bi-list" />
|
||||
</div>
|
||||
<div
|
||||
class="image-cell"
|
||||
|
@ -37,7 +42,7 @@ defineProps<Props>()
|
|||
>
|
||||
</div>
|
||||
<div @click="$emit('play', index)">
|
||||
<button
|
||||
<div
|
||||
class="title reset ellipsis"
|
||||
:title="source.title"
|
||||
:aria-label="source.labels.selectTrack"
|
||||
|
@ -46,7 +51,7 @@ defineProps<Props>()
|
|||
<span>
|
||||
{{ generateTrackCreditStringFromQueue(source) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="duration-cell">
|
||||
<template v-if="source.sources.length > 0">
|
||||
|
@ -54,26 +59,28 @@ defineProps<Props>()
|
|||
</template>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button
|
||||
v-if="$store.state.auth.authenticated"
|
||||
<Button
|
||||
v-if="store.state.auth.authenticated"
|
||||
:aria-label="source.labels.favorite"
|
||||
:title="source.labels.favorite"
|
||||
class="ui really basic circular icon button"
|
||||
@click.stop="$store.dispatch('favorites/toggle', source.id)"
|
||||
>
|
||||
<i
|
||||
:class="$store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
|
||||
class="heart icon"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
:icon="store.getters['favorites/isFavorite'](source.id) ? 'bi-heart-fill' : 'bi-heart'"
|
||||
round
|
||||
ghost
|
||||
square-small
|
||||
style="align-self: center;"
|
||||
:class="store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
|
||||
@click.stop="store.dispatch('favorites/toggle', source.id)"
|
||||
/>
|
||||
<Button
|
||||
:aria-label="source.labels.remove"
|
||||
:title="source.labels.remove"
|
||||
class="ui really tiny basic circular icon button"
|
||||
icon="bi-x"
|
||||
round
|
||||
ghost
|
||||
square-small
|
||||
style="align-self: center;"
|
||||
@click.stop="$emit('remove', index)"
|
||||
>
|
||||
<i class="x icon" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -8,6 +8,11 @@ import { useStore } from '~/store'
|
|||
|
||||
import axios from 'axios'
|
||||
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Alert from '~/components/ui/Alert.vue'
|
||||
|
||||
import updateQueryString from '~/composables/updateQueryString'
|
||||
import useLogger from '~/composables/useLogger'
|
||||
|
||||
|
@ -165,85 +170,85 @@ watch(() => props.initialId, () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<Layout
|
||||
v-if="type === 'both'"
|
||||
class="two ui buttons"
|
||||
stack
|
||||
>
|
||||
<button
|
||||
class="ui left floated labeled icon button"
|
||||
<Button
|
||||
secondary
|
||||
raised
|
||||
split
|
||||
round
|
||||
icon="bi-rss-fill"
|
||||
split-icon="bi-globe"
|
||||
style="align-self: center;"
|
||||
:split-title="t('components.RemoteSearchForm.button.fediverse')"
|
||||
@click.prevent="type = 'rss'"
|
||||
@split-click.prevent="type = 'artists'"
|
||||
>
|
||||
<i class="feed icon" />
|
||||
{{ $t('components.RemoteSearchForm.button.rss') }}
|
||||
</button>
|
||||
<div class="or" />
|
||||
<button
|
||||
class="ui right floated right labeled icon button"
|
||||
@click.prevent="type = 'artists'"
|
||||
>
|
||||
<i class="globe icon" />
|
||||
{{ $t('components.RemoteSearchForm.button.fediverse') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<form
|
||||
id="remote-search"
|
||||
:class="['ui', {loading: isLoading}, 'form']"
|
||||
@submit.stop.prevent="submit"
|
||||
>
|
||||
<div
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.RemoteSearchForm.header.fetchFailed') }}
|
||||
</h3>
|
||||
<ul class="list">
|
||||
<li
|
||||
v-for="(error, key) in errors"
|
||||
:key="key"
|
||||
>
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="ui required field">
|
||||
<label for="object-id">
|
||||
{{ labels.fieldLabel }}
|
||||
</label>
|
||||
<p v-if="type === 'rss'">
|
||||
{{ $t('components.RemoteSearchForm.description.rss') }}
|
||||
</p>
|
||||
<p v-else-if="type === 'artists'">
|
||||
{{ $t('components.RemoteSearchForm.description.fediverse') }}
|
||||
</p>
|
||||
<input
|
||||
id="object-id"
|
||||
v-model="id"
|
||||
type="text"
|
||||
name="object-id"
|
||||
:placeholder="labels.fieldPlaceholder"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
v-if="showSubmit"
|
||||
type="submit"
|
||||
:class="['ui', 'primary', {loading: isLoading}, 'button']"
|
||||
:disabled="isLoading || !id || id.length === 0"
|
||||
>
|
||||
{{ $t('components.RemoteSearchForm.button.search') }}
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
|
||||
{{ t('components.RemoteSearchForm.button.rss') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
<Layout
|
||||
v-else
|
||||
id="remote-search"
|
||||
form
|
||||
:class="['ui', {loading: isLoading}, 'form']"
|
||||
@submit.stop.prevent="submit"
|
||||
>
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
red
|
||||
role="alert"
|
||||
class="ui warning message"
|
||||
title="t('components.RemoteSearchForm.header.fetchFailed')"
|
||||
>
|
||||
<p>
|
||||
{{ $t('components.RemoteSearchForm.warning.unsupported') }}
|
||||
<ul
|
||||
v-if="errors.length > 1"
|
||||
class="list"
|
||||
>
|
||||
<li
|
||||
v-for="(error, key) in errors"
|
||||
:key="key"
|
||||
>
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>
|
||||
{{ errors[0] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
<p v-if="type === 'rss'">
|
||||
{{ t('components.RemoteSearchForm.description.rss') }}
|
||||
</p>
|
||||
<p v-else-if="type === 'artists'">
|
||||
{{ t('components.RemoteSearchForm.description.fediverse') }}
|
||||
</p>
|
||||
|
||||
<Input
|
||||
id="object-id"
|
||||
v-model="id"
|
||||
type="text"
|
||||
name="object-id"
|
||||
:label="labels.fieldLabel"
|
||||
:placeholder="labels.fieldPlaceholder"
|
||||
style="width: 100%;"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="showSubmit"
|
||||
primary
|
||||
type="submit"
|
||||
:class="{loading: isLoading}"
|
||||
:disabled="isLoading || !id || id.length === 0"
|
||||
>
|
||||
{{ t('components.RemoteSearchForm.button.search') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
<Alert
|
||||
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
|
||||
red
|
||||
>
|
||||
{{ t('components.RemoteSearchForm.warning.unsupported') }}
|
||||
</Alert>
|
||||
</template>
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { useStore } from '~/store'
|
||||
|
||||
const store = useStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui toast-container">
|
||||
<message
|
||||
v-for="message in $store.state.ui.messages"
|
||||
v-for="message in store.state.ui.messages"
|
||||
:key="message.key"
|
||||
:message="message"
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,188 @@
|
|||
<script setup lang="ts">
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import axios from 'axios'
|
||||
import { uniq } from 'lodash-es'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// TODO: Delete this file?
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(['update:show'])
|
||||
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'))
|
||||
|
||||
// TODO: replace translation mechanism { $pgettext } with { t }
|
||||
|
||||
// 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: 'You are now using the Funkwhale instance at %{ url }',
|
||||
// $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>
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
<Modal
|
||||
v-model="show"
|
||||
:title="t('views.ChooseInstance.header.chooseInstance')"
|
||||
@update="isError = false"
|
||||
>
|
||||
<h3 class="header">
|
||||
<!-- TODO: translate -->
|
||||
|
||||
<!-- <translate translate-context="Popup/Instance/Title">
|
||||
</translate> -->
|
||||
</h3>
|
||||
<div class="scrolling content">
|
||||
<div
|
||||
v-if="isError"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
>
|
||||
<h4 class="header">
|
||||
<!-- TODO: translate -->
|
||||
It is not possible to connect to the given URL
|
||||
<!-- <translate translate-context="Popup/Instance/Error message.Title">
|
||||
</translate> -->
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li>
|
||||
<!-- TODO: translate -->
|
||||
The server might be down
|
||||
<!-- <translate translate-context="Popup/Instance/Error message.List item">
|
||||
</translate> -->
|
||||
</li>
|
||||
<li>
|
||||
<!-- TODO: translate -->
|
||||
The given address is not a Funkwhale server
|
||||
<!-- <translate translate-context="Popup/Instance/Error message.List item">
|
||||
</translate> -->
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<form
|
||||
class="ui form"
|
||||
@submit.prevent="checkAndSwitch(instanceUrl)"
|
||||
>
|
||||
<p
|
||||
v-if="store.state.instance.instanceUrl"
|
||||
v-translate="{url: store.state.instance.instanceUrl, hostname: store.getters['instance/domain'] }"
|
||||
class="description"
|
||||
translate-context="Popup/Login/Paragraph"
|
||||
>
|
||||
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.
|
||||
</p>
|
||||
<p v-else>
|
||||
<!-- TODO: translate -->
|
||||
To continue, please select the Funkwhale instance you want to connect to. Enter the address directly, or select one of the suggested choices.
|
||||
<!-- <translate translate-context="Popup/Instance/Paragraph">
|
||||
</translate> -->
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="instance-picker">
|
||||
<!-- TODO: translate -->
|
||||
<!-- <translate translate-context="Popup/Instance/Input.Label/Noun">Instance URL</translate>
|
||||
-->
|
||||
</label>
|
||||
<div class="ui action input">
|
||||
<input
|
||||
id="instance-picker"
|
||||
v-model="instanceUrl"
|
||||
type="text"
|
||||
placeholder="https://funkwhale.server"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
:class="['ui', 'icon', {loading: isLoading}, 'button']"
|
||||
>
|
||||
<!-- TODO: translate -->
|
||||
Submit
|
||||
<!-- <translate translate-context="*/*/Button.Label/Verb">
|
||||
</translate> -->
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="ui hidden divider" />
|
||||
<form
|
||||
class="ui form"
|
||||
@submit.prevent=""
|
||||
>
|
||||
<div class="field">
|
||||
<h4>
|
||||
<!-- TODO: translate -->
|
||||
Suggested choices
|
||||
<!-- <translate translate-context="Popup/Instance/List.Label">
|
||||
</translate> -->
|
||||
</h4>
|
||||
<button
|
||||
v-for="(url, key) in suggestedInstances"
|
||||
:key="key"
|
||||
class="ui basic button"
|
||||
@click="checkAndSwitch(url)"
|
||||
>
|
||||
{{ url }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ui basic cancel button">
|
||||
<!-- TODO: translate -->
|
||||
Cancel
|
||||
<!-- <translate translate-context="*/*/Button.Label/Verb">
|
||||
</translate> -->
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
</template>
|
|
@ -1,589 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { RouteRecordName } from 'vue-router'
|
||||
|
||||
import { computed, ref, watch, watchEffect, onMounted } from 'vue'
|
||||
import { setI18nLanguage, SUPPORTED_LOCALES } from '~/init/locale'
|
||||
import { useCurrentElement } from '@vueuse/core'
|
||||
import { setupDropdown } from '~/utils/fomantic'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
||||
import UserModal from '~/components/common/UserModal.vue'
|
||||
import SearchBar from '~/components/audio/SearchBar.vue'
|
||||
import UserMenu from '~/components/common/UserMenu.vue'
|
||||
import Logo from '~/components/Logo.vue'
|
||||
|
||||
import useThemeList from '~/composables/useThemeList'
|
||||
import useTheme from '~/composables/useTheme'
|
||||
import { isTauri as checkTauri } from '~/composables/tauri'
|
||||
|
||||
interface Props {
|
||||
width: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const store = useStore()
|
||||
const { theme } = useTheme()
|
||||
const themes = useThemeList()
|
||||
const { t, locale: i18nLocale } = useI18n()
|
||||
|
||||
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: t('components.Sidebar.label.main'),
|
||||
selectTrack: t('components.Sidebar.label.play'),
|
||||
pendingFollows: t('components.Sidebar.label.follows'),
|
||||
pendingReviewEdits: t('components.Sidebar.label.edits'),
|
||||
pendingReviewReports: t('components.Sidebar.label.reports'),
|
||||
language: t('components.Sidebar.label.language'),
|
||||
theme: t('components.Sidebar.label.theme'),
|
||||
addContent: t('components.Sidebar.label.add'),
|
||||
administration: t('components.Sidebar.label.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 showLanguageModal = ref(false)
|
||||
const locale = ref(i18nLocale.value)
|
||||
watch(locale, (locale) => {
|
||||
setI18nLanguage(locale)
|
||||
})
|
||||
|
||||
const isProduction = import.meta.env.PROD
|
||||
const isTauri = checkTauri()
|
||||
|
||||
const showUserModal = ref(false)
|
||||
const showThemeModal = ref(false)
|
||||
|
||||
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">
|
||||
<router-link
|
||||
:title="'Funkwhale'"
|
||||
:to="{name: logoUrl}"
|
||||
>
|
||||
<i class="logo bordered inverted vibrant big icon">
|
||||
<logo class="logo" />
|
||||
<span class="visually-hidden">{{ $t('components.Sidebar.link.home') }}</span>
|
||||
</i>
|
||||
</router-link>
|
||||
<nav class="top ui compact right aligned inverted text menu">
|
||||
<div class="right menu">
|
||||
<div
|
||||
v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']"
|
||||
class="item"
|
||||
:title="labels.administration"
|
||||
>
|
||||
<div class="item ui inline admin-dropdown dropdown">
|
||||
<i class="wrench icon" />
|
||||
<div
|
||||
v-if="moderationNotifications > 0"
|
||||
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
|
||||
>
|
||||
{{ moderationNotifications }}
|
||||
</div>
|
||||
<div class="menu">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Sidebar.header.administration') }}
|
||||
</h3>
|
||||
<div class="divider" />
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['library']"
|
||||
class="item"
|
||||
:to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"
|
||||
>
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.pendingReviewEdits > 0"
|
||||
:title="labels.pendingReviewEdits"
|
||||
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
|
||||
>
|
||||
{{ $store.state.ui.notifications.pendingReviewEdits }}
|
||||
</div>
|
||||
{{ $t('components.Sidebar.link.library') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['moderation']"
|
||||
class="item"
|
||||
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"
|
||||
>
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests > 0"
|
||||
:title="labels.pendingReviewReports"
|
||||
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
|
||||
>
|
||||
{{ $store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests }}
|
||||
</div>
|
||||
{{ $t('components.Sidebar.link.moderation') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
class="item"
|
||||
:to="{name: 'manage.users.users.list'}"
|
||||
>
|
||||
{{ $t('components.Sidebar.link.users') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
class="item"
|
||||
:to="{path: '/manage/settings'}"
|
||||
>
|
||||
{{ $t('components.Sidebar.link.settings') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="item"
|
||||
:to="{name: 'content.index'}"
|
||||
>
|
||||
<i class="upload icon" />
|
||||
<span class="visually-hidden">{{ labels.addContent }}</span>
|
||||
</router-link>
|
||||
<template v-if="width > 768">
|
||||
<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"
|
||||
class="ui avatar image"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
|
||||
>
|
||||
<actor-avatar
|
||||
v-else-if="$store.state.auth.authenticated"
|
||||
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="cog icon"
|
||||
/>
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0"
|
||||
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
|
||||
>
|
||||
{{ $store.state.ui.notifications.inbox + additionalNotifications }}
|
||||
</div>
|
||||
<user-menu
|
||||
v-bind="$attrs"
|
||||
:width="width"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a
|
||||
href=""
|
||||
class="item"
|
||||
@click.prevent.exact="showUserModal = !showUserModal"
|
||||
>
|
||||
<img
|
||||
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)"
|
||||
>
|
||||
<actor-avatar
|
||||
v-else-if="$store.state.auth.authenticated"
|
||||
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="cog icon"
|
||||
/>
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0"
|
||||
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
|
||||
>
|
||||
{{ $store.state.ui.notifications.inbox + additionalNotifications }}
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<user-modal
|
||||
v-model:show="showUserModal"
|
||||
@show-theme-modal-event="showThemeModal=true"
|
||||
@show-language-modal-event="showLanguageModal=true"
|
||||
/>
|
||||
<semantic-modal
|
||||
ref="languageModal"
|
||||
v-model:show="showLanguageModal"
|
||||
:fullscreen="false"
|
||||
>
|
||||
<i
|
||||
role="button"
|
||||
class="left chevron back inside icon"
|
||||
@click.prevent.exact="showUserModal = !showUserModal"
|
||||
/>
|
||||
<div class="header">
|
||||
<h3 class="title">
|
||||
{{ labels.language }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="content">
|
||||
<fieldset
|
||||
v-for="(language, key) in SUPPORTED_LOCALES"
|
||||
:key="key"
|
||||
>
|
||||
<input
|
||||
:id="`${key}`"
|
||||
v-model="locale"
|
||||
type="radio"
|
||||
name="language"
|
||||
:value="key"
|
||||
>
|
||||
<label :for="`${key}`">{{ language }}</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
<semantic-modal
|
||||
ref="themeModal"
|
||||
v-model:show="showThemeModal"
|
||||
:fullscreen="false"
|
||||
>
|
||||
<i
|
||||
role="button"
|
||||
class="left chevron back inside icon"
|
||||
@click.prevent.exact="showUserModal = !showUserModal"
|
||||
/>
|
||||
<div class="header">
|
||||
<h3 class="title">
|
||||
{{ labels.theme }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="content">
|
||||
<fieldset
|
||||
v-for="th in themes"
|
||||
:key="th.key"
|
||||
>
|
||||
<input
|
||||
:id="th.key"
|
||||
v-model="theme"
|
||||
type="radio"
|
||||
name="theme"
|
||||
:value="th.key"
|
||||
>
|
||||
<label :for="th.key">{{ th.name }}</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
<div class="item collapse-button-wrapper">
|
||||
<button
|
||||
:class="['ui', 'basic', 'big', {'vibrant': !isCollapsed}, 'inverted icon', 'collapse', 'button']"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
>
|
||||
<i class="sidebar icon" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="ui basic search-wrapper segment">
|
||||
<search-bar @search="isCollapsed = false" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!$store.state.auth.authenticated"
|
||||
class="ui basic signup segment"
|
||||
>
|
||||
<router-link
|
||||
class="ui fluid tiny primary button"
|
||||
:to="{name: 'login'}"
|
||||
>
|
||||
{{ $t('components.Sidebar.link.login') }}
|
||||
</router-link>
|
||||
<div class="ui small hidden divider" />
|
||||
<router-link
|
||||
class="ui fluid tiny button"
|
||||
:to="{path: '/signup'}"
|
||||
>
|
||||
{{ $t('components.Sidebar.link.createAccount') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<nav
|
||||
class="secondary"
|
||||
role="navigation"
|
||||
aria-labelledby="navigation-label"
|
||||
>
|
||||
<h1
|
||||
id="navigation-label"
|
||||
class="visually-hidden"
|
||||
>
|
||||
{{ $t('components.Sidebar.header.main') }}
|
||||
</h1>
|
||||
<div class="ui small hidden divider" />
|
||||
<section
|
||||
: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: expanded !== 'explore' }, 'collapsible item']">
|
||||
<h2
|
||||
class="header"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="expanded = 'explore'"
|
||||
@focus="expanded = 'explore'"
|
||||
>
|
||||
{{ $t('components.Sidebar.header.explore') }}
|
||||
<i
|
||||
v-if="expanded !== 'explore'"
|
||||
class="angle right icon"
|
||||
/>
|
||||
</h2>
|
||||
<div class="menu">
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'search'}"
|
||||
>
|
||||
<i class="search icon" />
|
||||
{{ $t('components.Sidebar.link.search') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.index'}"
|
||||
active-class="_active"
|
||||
>
|
||||
<i class="music icon" />
|
||||
{{ $t('components.Sidebar.link.browse') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.podcasts.browse'}"
|
||||
>
|
||||
<i class="podcast icon" />
|
||||
{{ $t('components.Sidebar.link.podcasts') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.albums.browse'}"
|
||||
>
|
||||
<i class="compact disc icon" />
|
||||
{{ $t('components.Sidebar.link.albums') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.artists.browse'}"
|
||||
>
|
||||
<i class="user icon" />
|
||||
{{ $t('components.Sidebar.link.artists') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.playlists.browse'}"
|
||||
>
|
||||
<i class="list icon" />
|
||||
{{ $t('components.Sidebar.link.playlists') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.radios.browse'}"
|
||||
>
|
||||
<i class="feed icon" />
|
||||
{{ $t('components.Sidebar.link.radios') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:class="[{ collapsed: expanded !== 'myLibrary' }, 'collapsible item']"
|
||||
>
|
||||
<h3
|
||||
class="header"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="expanded = 'myLibrary'"
|
||||
@focus="expanded = 'myLibrary'"
|
||||
>
|
||||
{{ $t('components.Sidebar.header.library') }}
|
||||
<i
|
||||
v-if="expanded !== 'myLibrary'"
|
||||
class="angle right icon"
|
||||
/>
|
||||
</h3>
|
||||
<div class="menu">
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.me'}"
|
||||
>
|
||||
<i class="music icon" />
|
||||
{{ $t('components.Sidebar.link.browse') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.albums.me'}"
|
||||
>
|
||||
<i class="compact disc icon" />
|
||||
{{ $t('components.Sidebar.link.albums') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.artists.me'}"
|
||||
>
|
||||
<i class="user icon" />
|
||||
{{ $t('components.Sidebar.link.artists') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.playlists.me'}"
|
||||
>
|
||||
<i class="list icon" />
|
||||
{{ $t('components.Sidebar.link.playlists') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'library.radios.me'}"
|
||||
>
|
||||
<i class="feed icon" />
|
||||
{{ $t('components.Sidebar.link.radios') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
:to="{name: 'favorites'}"
|
||||
>
|
||||
<i class="heart icon" />
|
||||
{{ $t('components.Sidebar.link.favorites') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="header item"
|
||||
:to="{name: 'subscriptions'}"
|
||||
>
|
||||
{{ $t('components.Sidebar.link.channels') }}
|
||||
</router-link>
|
||||
<div class="item">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Sidebar.header.more') }}
|
||||
</h3>
|
||||
<div class="menu">
|
||||
<router-link
|
||||
class="item"
|
||||
to="/about"
|
||||
active-class="router-link-exact-active active"
|
||||
>
|
||||
<i class="info icon" />
|
||||
{{ $t('components.Sidebar.link.about') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isProduction || isTauri"
|
||||
class="item"
|
||||
>
|
||||
<router-link
|
||||
to="/instance-chooser"
|
||||
class="link item"
|
||||
>
|
||||
{{ $t('components.Sidebar.link.switchInstance') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</section>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
[type="radio"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
[type="radio"] + label::after {
|
||||
content: "";
|
||||
font-size: 1.4em;
|
||||
}
|
||||
[type="radio"]:checked + label::after {
|
||||
margin-left: 10px;
|
||||
content: "\2713"; /* Checkmark */
|
||||
font-size: 1.4em;
|
||||
}
|
||||
[type="radio"]:checked + label {
|
||||
font-weight: bold;
|
||||
}
|
||||
fieldset {
|
||||
border: none;
|
||||
}
|
||||
.back {
|
||||
font-size: 1.25em !important;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: 2.25rem !important;
|
||||
height: 2.25rem !important;
|
||||
padding: 0.625rem 0 0 0;
|
||||
}
|
||||
</style>
|
|
@ -6,6 +6,17 @@ import useFormData from '~/composables/useFormData'
|
|||
import { ref, computed, reactive } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import useLogger from '~/composables/useLogger'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Section from '~/components/ui/Section.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Toggle from '~/components/ui/Toggle.vue'
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Alert from '~/components/ui/Alert.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
group: SettingsGroup
|
||||
|
@ -96,166 +107,180 @@ const save = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
:id="group.id"
|
||||
class="ui form component-settings-group"
|
||||
@submit.prevent="save"
|
||||
<!-- TODO: type the different values in `settings` (use generics) -->
|
||||
<!-- eslint-disable vue/valid-v-model -->
|
||||
<Section
|
||||
align-left
|
||||
:h2="group.label"
|
||||
large-section-heading
|
||||
>
|
||||
<div class="ui divider" />
|
||||
<h3 class="ui header">
|
||||
{{ group.label }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
<form
|
||||
:id="group.id"
|
||||
class="ui form component-settings-group"
|
||||
style="grid-column: 1 / -1;"
|
||||
@submit.prevent="save"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.admin.SettingsGroup.header.error') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
v-for="(error, key) in errors"
|
||||
:key="key"
|
||||
>
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="result"
|
||||
class="ui positive message"
|
||||
>
|
||||
{{ $t('components.admin.SettingsGroup.message.success') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(setting, key) in settings"
|
||||
:key="key"
|
||||
class="ui field"
|
||||
>
|
||||
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
|
||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||
<p v-if="setting.help_text">
|
||||
{{ setting.help_text }}
|
||||
</p>
|
||||
</template>
|
||||
<content-form
|
||||
v-if="setting.fieldType === 'markdown'"
|
||||
v-bind="setting.fieldParams"
|
||||
v-model="values[setting.identifier]"
|
||||
/>
|
||||
<!-- eslint-disable vue/valid-v-model -->
|
||||
<signup-form-builder
|
||||
v-else-if="setting.fieldType === 'formBuilder'"
|
||||
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"
|
||||
v-model="values[setting.identifier]"
|
||||
:name="setting.identifier"
|
||||
type="password"
|
||||
class="ui input"
|
||||
>
|
||||
<input
|
||||
v-else-if="setting.field.widget.class === 'TextInput'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
:name="setting.identifier"
|
||||
type="text"
|
||||
class="ui input"
|
||||
>
|
||||
<input
|
||||
v-else-if="setting.field.class === 'IntegerField'"
|
||||
:id="setting.identifier"
|
||||
v-model.number="values[setting.identifier]"
|
||||
:name="setting.identifier"
|
||||
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] as string"
|
||||
:name="setting.identifier"
|
||||
type="text"
|
||||
class="ui input"
|
||||
/>
|
||||
<!-- eslint-enable vue/valid-v-model -->
|
||||
<Spacer :size="16" />
|
||||
<div
|
||||
v-else-if="setting.field.widget.class === 'CheckboxInput'"
|
||||
class="ui toggle checkbox"
|
||||
v-for="(setting, key) in settings"
|
||||
:key="key"
|
||||
:class="[$style.field, 'ui', 'field']"
|
||||
>
|
||||
<!-- eslint-disable vue/valid-v-model -->
|
||||
<input
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier] as boolean"
|
||||
:name="setting.identifier"
|
||||
type="checkbox"
|
||||
>
|
||||
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
|
||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||
<p v-if="setting.help_text">
|
||||
{{ setting.help_text }}
|
||||
</p>
|
||||
</template>
|
||||
<content-form
|
||||
v-if="setting.fieldType === 'markdown'"
|
||||
v-bind="setting.fieldParams"
|
||||
v-model="values[setting.identifier]"
|
||||
/>
|
||||
<signup-form-builder
|
||||
v-else-if="setting.fieldType === 'formBuilder'"
|
||||
v-model="values[setting.identifier] as Form"
|
||||
:signup-approval-enabled="!!values.moderation__signup_approval_enabled"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="setting.field.widget.class === 'PasswordInput'"
|
||||
v-model="values[setting.identifier] as string"
|
||||
password
|
||||
type="password"
|
||||
class="ui input"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="setting.field.widget.class === 'TextInput'"
|
||||
v-model="values[setting.identifier] as string"
|
||||
type="text"
|
||||
class="ui input"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="setting.field.class === 'IntegerField'"
|
||||
v-model.number="values[setting.identifier] as number"
|
||||
type="number"
|
||||
class="ui input"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="setting.field.widget.class === 'Textarea'"
|
||||
v-model="values[setting.identifier] as string"
|
||||
type="text"
|
||||
class="ui input"
|
||||
/>
|
||||
<!-- eslint-enable vue/valid-v-model -->
|
||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||
<p v-if="setting.help_text">
|
||||
{{ setting.help_text }}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
v-else-if="setting.field.class === 'MultipleChoiceField'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
multiple
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="v in setting.additional_data?.choices"
|
||||
:key="v[0]"
|
||||
:value="v[0]"
|
||||
<div
|
||||
v-else-if="setting.field.widget.class === 'CheckboxInput'"
|
||||
>
|
||||
{{ v[1] }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-else-if="setting.field.class === 'ChoiceField'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="v in setting.additional_data?.choices"
|
||||
:key="v[0]"
|
||||
:value="v[0]"
|
||||
>
|
||||
{{ v[1] }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else-if="setting.field.widget.class === 'ImageWidget'">
|
||||
<input
|
||||
:id="setting.identifier"
|
||||
:ref="setFileRef(setting.identifier)"
|
||||
type="file"
|
||||
>
|
||||
<div v-if="values[setting.identifier]">
|
||||
<div class="ui hidden divider" />
|
||||
<h3 class="ui header">
|
||||
{{ $t('components.admin.SettingsGroup.header.image') }}
|
||||
</h3>
|
||||
<img
|
||||
v-if="values[setting.identifier]"
|
||||
class="ui image"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](values[setting.identifier])"
|
||||
>
|
||||
<Toggle
|
||||
v-model="values[setting.identifier] as boolean"
|
||||
big
|
||||
:label="setting.verbose_name"
|
||||
/>
|
||||
<Spacer :size="8" />
|
||||
<p v-if="setting.help_text">
|
||||
{{ setting.help_text }}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
v-else-if="setting.field.class === 'MultipleChoiceField'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
multiple
|
||||
class="ui search selection dropdown"
|
||||
style="height: 150px;"
|
||||
>
|
||||
<option
|
||||
v-for="v in setting.additional_data?.choices"
|
||||
:key="v[0]"
|
||||
:value="v[0]"
|
||||
>
|
||||
{{ v[1] }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-else-if="setting.field.class === 'ChoiceField'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="v in setting.additional_data?.choices"
|
||||
:key="v[0]"
|
||||
:value="v[0]"
|
||||
>
|
||||
{{ v[1] }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else-if="setting.field.widget.class === 'ImageWidget'">
|
||||
<!-- TODO: Implement image input -->
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<Input
|
||||
:id="setting.identifier"
|
||||
:ref="setFileRef(setting.identifier)"
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<div v-if="values[setting.identifier]">
|
||||
<h3 class="ui header">
|
||||
{{ t('components.admin.SettingsGroup.header.image') }}
|
||||
</h3>
|
||||
<img
|
||||
v-if="values[setting.identifier]"
|
||||
class="ui image"
|
||||
alt=""
|
||||
:src="store.getters['instance/absoluteUrl'](values[setting.identifier])"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<Spacer />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'success', 'button']"
|
||||
>
|
||||
{{ $t('components.admin.SettingsGroup.button.save') }}
|
||||
</button>
|
||||
</form>
|
||||
<Layout flex>
|
||||
<Spacer grow />
|
||||
<Button
|
||||
type="submit"
|
||||
:class="[{'loading': isLoading}]"
|
||||
primary
|
||||
>
|
||||
{{ t('components.admin.SettingsGroup.button.save') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
<Spacer />
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
red
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ t('components.admin.SettingsGroup.header.error', {label: group.label}) }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
v-for="(error, key) in errors"
|
||||
:key="key"
|
||||
>
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
<Alert
|
||||
v-if="result"
|
||||
green
|
||||
>
|
||||
{{ t('components.admin.SettingsGroup.message.success') }}
|
||||
</Alert>
|
||||
</form>
|
||||
</Section>
|
||||
<hr :class="$style.separator">
|
||||
<Spacer size-64 />
|
||||
<!-- eslint-enable vue/valid-v-model -->
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.field > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.separator:last-of-type {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
import type { Form } from '~/types'
|
||||
|
||||
import SignupForm from '~/components/auth/SignupForm.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
@ -65,18 +67,20 @@ const move = (idx: number, increment: number) => {
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui top attached tabular menu">
|
||||
<button
|
||||
<Button
|
||||
color="primary"
|
||||
:class="[{active: !isPreviewing}, 'item']"
|
||||
@click.stop.prevent="isPreviewing = false"
|
||||
>
|
||||
{{ $t('components.admin.SignupFormBuilder.button.edit') }}
|
||||
</button>
|
||||
<button
|
||||
{{ t('components.admin.SignupFormBuilder.button.edit') }}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
:class="[{active: isPreviewing}, 'item']"
|
||||
@click.stop.prevent="isPreviewing = true"
|
||||
>
|
||||
{{ $t('components.admin.SignupFormBuilder.button.preview') }}
|
||||
</button>
|
||||
{{ t('components.admin.SignupFormBuilder.button.preview') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="isPreviewing"
|
||||
|
@ -95,10 +99,10 @@ const move = (idx: number, increment: number) => {
|
|||
>
|
||||
<div class="field">
|
||||
<label for="help-text">
|
||||
{{ $t('components.admin.SignupFormBuilder.label.helpText') }}
|
||||
{{ t('components.admin.SignupFormBuilder.label.helpText') }}
|
||||
</label>
|
||||
<p>
|
||||
{{ $t('components.admin.SignupFormBuilder.help.helpText') }}
|
||||
{{ t('components.admin.SignupFormBuilder.help.helpText') }}
|
||||
</p>
|
||||
<content-form
|
||||
v-if="value.help_text"
|
||||
|
@ -109,24 +113,24 @@ const move = (idx: number, increment: number) => {
|
|||
</div>
|
||||
<div class="field">
|
||||
<label>
|
||||
{{ $t('components.admin.SignupFormBuilder.label.additionalFields') }}
|
||||
{{ t('components.admin.SignupFormBuilder.label.additionalFields') }}
|
||||
</label>
|
||||
<p>
|
||||
{{ $t('components.admin.SignupFormBuilder.help.additionalFields') }}
|
||||
{{ t('components.admin.SignupFormBuilder.help.additionalFields') }}
|
||||
</p>
|
||||
<table v-if="value.fields?.length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
|
||||
</th>
|
||||
<th><span class="visually-hidden">{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th>
|
||||
<th><span class="visually-hidden">{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -144,20 +148,20 @@ const move = (idx: number, increment: number) => {
|
|||
<td>
|
||||
<select v-model="field.input_type">
|
||||
<option value="short_text">
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
|
||||
</option>
|
||||
<option value="long_text">
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select v-model="field.required">
|
||||
<option :value="true">
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
|
||||
</option>
|
||||
<option :value="false">
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
|
@ -187,13 +191,13 @@ const move = (idx: number, increment: number) => {
|
|||
</tbody>
|
||||
</table>
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
<Button
|
||||
v-if="value.fields?.length < maxFields"
|
||||
class="ui basic button"
|
||||
color="primary"
|
||||
@click.stop.prevent="addField"
|
||||
>
|
||||
{{ $t('components.admin.SignupFormBuilder.button.add') }}
|
||||
</button>
|
||||
{{ t('components.admin.SignupFormBuilder.button.add') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui hidden divider" />
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { momentFormat } from '~/utils/filters'
|
||||
import defaultCover from '~/assets/audio/default-cover.png'
|
||||
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
|
||||
import { type Album } from '~/types'
|
||||
|
||||
interface Props {
|
||||
album: Album;
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { album } = props
|
||||
|
||||
const artistCredit = album.artist_credit || []
|
||||
|
||||
const store = useStore()
|
||||
const imageUrl = computed(() => props.album.cover?.urls.original
|
||||
? store.getters['instance/absoluteUrl'](props.album.cover?.urls.medium_square_crop)
|
||||
: defaultCover
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
:title="album.title"
|
||||
:image="imageUrl"
|
||||
:tags="album.tags"
|
||||
:to="{name: 'library.albums.detail', params: {id: album.id}}"
|
||||
small
|
||||
>
|
||||
<template #topright>
|
||||
<PlayButton
|
||||
icon-only
|
||||
:is-playable="album.is_playable"
|
||||
:album="album"
|
||||
/>
|
||||
</template>
|
||||
<Layout
|
||||
flex
|
||||
gap-4
|
||||
style="overflow: hidden;"
|
||||
>
|
||||
<template
|
||||
v-for="ac in artistCredit"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
<Link
|
||||
align-text="start"
|
||||
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id }}"
|
||||
>
|
||||
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
|
||||
</Link>
|
||||
<span style="font-weight: 600;">{{ ac.joinphrase }}</span>
|
||||
</template>
|
||||
</Layout>
|
||||
|
||||
<template #footer>
|
||||
<span v-if="album.release_date">
|
||||
{{ momentFormat(new Date(album.release_date), 'Y') }}
|
||||
</span>
|
||||
<i class="bi bi-dot" />
|
||||
<span>
|
||||
{{ t('components.audio.album.Card.meta.tracks', album.tracks_count) }}
|
||||
</span>
|
||||
<Spacer
|
||||
h
|
||||
grow
|
||||
/>
|
||||
<PlayButton
|
||||
:dropdown-only="true"
|
||||
discrete
|
||||
:is-playable="album.is_playable"
|
||||
:album="album"
|
||||
/>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.play-button {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
</style>
|
|
@ -6,21 +6,27 @@ import { useStore } from '~/store'
|
|||
|
||||
import axios from 'axios'
|
||||
|
||||
import AlbumCard from '~/components/audio/album/Card.vue'
|
||||
|
||||
import usePage from '~/composables/navigation/usePage'
|
||||
import useErrorHandler from '~/composables/useErrorHandler'
|
||||
|
||||
import AlbumCard from '~/components/album/Card.vue'
|
||||
import Section from '~/components/ui/Section.vue'
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
import Pagination from '~/components/ui/Pagination.vue'
|
||||
|
||||
interface Props {
|
||||
filters: Record<string, string | boolean>
|
||||
showCount?: boolean
|
||||
search?: boolean
|
||||
limit?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showCount: false,
|
||||
search: false,
|
||||
limit: 12
|
||||
limit: 12,
|
||||
title: undefined
|
||||
})
|
||||
|
||||
const store = useStore()
|
||||
|
@ -28,6 +34,7 @@ const store = useStore()
|
|||
const query = ref('')
|
||||
const albums = reactive([] as Album[])
|
||||
const count = ref(0)
|
||||
const page = usePage()
|
||||
const nextPage = ref()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
@ -38,13 +45,14 @@ const fetchData = async (url = 'albums/') => {
|
|||
const params = {
|
||||
q: query.value,
|
||||
...props.filters,
|
||||
page: page.value,
|
||||
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)
|
||||
albums.splice(0, albums.length, ...response.data.results)
|
||||
} catch (error) {
|
||||
useErrorHandler(error as Error)
|
||||
}
|
||||
|
@ -52,68 +60,58 @@ const fetchData = async (url = 'albums/') => {
|
|||
isLoading.value = false
|
||||
}
|
||||
|
||||
setTimeout(fetchData, 1000)
|
||||
|
||||
const performSearch = () => {
|
||||
albums.length = 0
|
||||
fetchData()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.state.moderation.lastUpdate,
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<h3
|
||||
v-if="!!$slots.title"
|
||||
class="ui header"
|
||||
>
|
||||
<slot name="title" />
|
||||
<span
|
||||
v-if="showCount"
|
||||
class="ui tiny circular label"
|
||||
>{{ count }}</span>
|
||||
</h3>
|
||||
<slot />
|
||||
<Section
|
||||
align-left
|
||||
:h2="title"
|
||||
:columns-per-item="1"
|
||||
>
|
||||
<inline-search-bar
|
||||
v-if="search"
|
||||
v-model="query"
|
||||
style="grid-column: 1 / -1;"
|
||||
@search="performSearch"
|
||||
/>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui app-cards cards">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
<template v-if="!isLoading && albums.length > 0">
|
||||
<album-card
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
:album="album"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<slot
|
||||
v-if="!isLoading && albums.length === 0"
|
||||
name="empty-state"
|
||||
>
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
style="grid-column: 1 / -1;"
|
||||
@refresh="fetchData"
|
||||
/>
|
||||
</slot>
|
||||
<template v-if="nextPage">
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
v-if="nextPage"
|
||||
:class="['ui', 'basic', 'button']"
|
||||
@click="fetchData(nextPage)"
|
||||
>
|
||||
{{ $t('components.audio.album.Widget.button.more') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="page && albums && count > props.limit"
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((count || 0) / props.limit)"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
</Section>
|
||||
</template>
|
|
@ -0,0 +1,103 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { components } from '~/generated/types.ts'
|
||||
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
|
||||
import type { Artist, Album } from '~/types'
|
||||
|
||||
const albums = ref([] as Album[])
|
||||
|
||||
interface Props {
|
||||
artist: Artist | components['schemas']['ArtistWithAlbums'];
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { artist } = props
|
||||
|
||||
if ('albums' in artist && Array.isArray(artist.albums)) {
|
||||
albums.value = artist.albums
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
:title="artist.name"
|
||||
class="artist-card"
|
||||
:tags="artist.tags"
|
||||
:to="{name: 'library.artists.detail', params: {id: artist.id}}"
|
||||
small
|
||||
style="align-self: flex-start;"
|
||||
>
|
||||
<template #topright>
|
||||
<PlayButton
|
||||
icon-only
|
||||
:is-playable="true"
|
||||
:artist="artist"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #image>
|
||||
<img
|
||||
v-if="artist.cover"
|
||||
v-lazy="artist.cover.urls.medium_square_crop"
|
||||
:alt="artist.name"
|
||||
:class="[artist.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
|
||||
>
|
||||
<i
|
||||
v-else
|
||||
class="bi bi-person-circle"
|
||||
style="font-size: 167px; margin: 16px;"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<span v-if="artist.content_category === 'music' && 'tracks_count' in artist">
|
||||
{{ t('components.audio.artist.Card.meta.tracks', artist.tracks_count) }}
|
||||
</span>
|
||||
<span v-else-if="'tracks_count' in artist">
|
||||
{{ t('components.audio.artist.Card.meta.episodes', artist.tracks_count) }}
|
||||
</span>
|
||||
<i
|
||||
v-if="albums"
|
||||
class="bi bi-dot"
|
||||
/>
|
||||
<span v-if="albums">
|
||||
{{ t('components.audio.artist.Card.meta.albums', albums.length) }}
|
||||
</span>
|
||||
<Spacer style="flex-grow: 1" />
|
||||
<PlayButton
|
||||
:dropdown-only="true"
|
||||
:is-playable="Boolean(albums.find(album => album.is_playable))"
|
||||
:artist="artist"
|
||||
discrete
|
||||
/>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.channel-image {
|
||||
border-radius: 50%;
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.podcast-image {
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
</style>
|
|
@ -1,26 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import type { Artist } from '~/types'
|
||||
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { reactive, ref, watch, onMounted } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
import ArtistCard from '~/components/audio/artist/Card.vue'
|
||||
|
||||
import useErrorHandler from '~/composables/useErrorHandler'
|
||||
import usePage from '~/composables/navigation/usePage'
|
||||
|
||||
import ArtistCard from '~/components/artist/Card.vue'
|
||||
import Section from '~/components/ui/Section.vue'
|
||||
import Pagination from '~/components/ui/Pagination.vue'
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
|
||||
interface Props {
|
||||
filters: Record<string, string | boolean>
|
||||
search?: boolean
|
||||
header?: boolean
|
||||
limit?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
search: false,
|
||||
header: true,
|
||||
limit: 12
|
||||
limit: 12,
|
||||
title: undefined
|
||||
})
|
||||
|
||||
const store = useStore()
|
||||
|
@ -28,6 +34,7 @@ const store = useStore()
|
|||
const query = ref('')
|
||||
const artists = reactive([] as Artist[])
|
||||
const count = ref(0)
|
||||
const page = usePage()
|
||||
const nextPage = ref()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
@ -38,13 +45,14 @@ const fetchData = async (url = 'artists/') => {
|
|||
const params = {
|
||||
q: query.value,
|
||||
...props.filters,
|
||||
page: page.value,
|
||||
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)
|
||||
artists.splice(0, artists.length, ...response.data.results)
|
||||
} catch (error) {
|
||||
useErrorHandler(error as Error)
|
||||
}
|
||||
|
@ -52,64 +60,58 @@ const fetchData = async (url = 'artists/') => {
|
|||
isLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(fetchData, 1000)
|
||||
})
|
||||
|
||||
const performSearch = () => {
|
||||
artists.length = 0
|
||||
fetchData()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.state.moderation.lastUpdate,
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<h3
|
||||
v-if="header"
|
||||
class="ui header"
|
||||
>
|
||||
<slot name="title" />
|
||||
<span class="ui tiny circular label">{{ count }}</span>
|
||||
</h3>
|
||||
<inline-search-bar
|
||||
v-if="search"
|
||||
v-model="query"
|
||||
@search="performSearch"
|
||||
<Section
|
||||
align-left
|
||||
:columns-per-item="1"
|
||||
:h2="title"
|
||||
>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui five app-cards cards">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<artist-card
|
||||
v-for="artist in artists"
|
||||
:key="artist.id"
|
||||
:artist="artist"
|
||||
/>
|
||||
</div>
|
||||
<slot
|
||||
v-if="!isLoading && artists.length === 0"
|
||||
name="empty-state"
|
||||
>
|
||||
<empty-state
|
||||
style="grid-column: 1 / -1;"
|
||||
:refresh="true"
|
||||
@refresh="fetchData"
|
||||
/>
|
||||
</slot>
|
||||
<template v-if="nextPage">
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
v-if="nextPage"
|
||||
:class="['ui', 'basic', 'button']"
|
||||
@click="fetchData(nextPage)"
|
||||
>
|
||||
{{ $t('components.audio.artist.Widget.button.more') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<inline-search-bar
|
||||
v-if="!isLoading && search"
|
||||
v-model="query"
|
||||
style="grid-column: 1 / -1;"
|
||||
@search="performSearch"
|
||||
/>
|
||||
<artist-card
|
||||
v-for="artist in artists"
|
||||
:key="artist.id"
|
||||
:artist="artist"
|
||||
/>
|
||||
<Pagination
|
||||
v-if="page && artists && count > limit"
|
||||
v-model:page="page"
|
||||
style="grid-column: 1 / -1;"
|
||||
:pages="Math.ceil((count || 0) / limit)"
|
||||
/>
|
||||
</Section>
|
||||
</template>
|
|
@ -1,5 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import type { ArtistCredit } from '~/types'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Pill from '~/components/ui/Pill.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
interface Props {
|
||||
artistCredit: ArtistCredit[]
|
||||
|
@ -7,6 +13,10 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// TODO: Fix getRoute
|
||||
|
||||
// TODO: check if still needed:
|
||||
/*
|
||||
const getRoute = (ac: ArtistCredit) => {
|
||||
return {
|
||||
name: ac.artist.channel ? 'channels.detail' : 'library.artists.detail',
|
||||
|
@ -15,30 +25,47 @@ const getRoute = (ac: ArtistCredit) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="artist-label ui image label">
|
||||
<Layout
|
||||
flex
|
||||
gap-8
|
||||
>
|
||||
<template
|
||||
v-for="ac in props.artistCredit"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
<router-link
|
||||
:to="getRoute(ac)"
|
||||
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
|
||||
class="username"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
<img
|
||||
v-if="ac.index === 0 && ac.artist.cover && ac.artist.cover.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
:class="[{circular: ac.artist.content_category != 'podcast'}]"
|
||||
>
|
||||
<i
|
||||
v-else-if="ac.index === 0"
|
||||
:class="[ac.artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']"
|
||||
/>
|
||||
{{ ac.credit }}
|
||||
<Pill>
|
||||
<template #image>
|
||||
<img
|
||||
v-if="ac.artist.cover && ac.artist.cover.urls.original"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](ac.artist.cover.urls.small_square_crop)"
|
||||
:alt="ac.artist.name"
|
||||
>
|
||||
<i
|
||||
v-else
|
||||
class="bi bi-person-circle"
|
||||
style="font-size: 24px;"
|
||||
/>
|
||||
</template>
|
||||
{{ ac.credit }}
|
||||
</Pill>
|
||||
</router-link>
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
a.username {
|
||||
text-decoration: none;
|
||||
height: 25px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
import type { Artist } from '~/types'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
interface Props {
|
||||
artist: Artist
|
||||
|
@ -10,7 +13,7 @@ interface Props {
|
|||
const props = defineProps<Props>()
|
||||
|
||||
const route = computed(() => props.artist.channel
|
||||
? { name: 'channels.detail', params: { id: props.artist.channel.uuid } }
|
||||
? { name: 'channels.detail', params: { id: props.artist.channel } }
|
||||
: { name: 'library.artists.detail', params: { id: props.artist.id } }
|
||||
)
|
||||
</script>
|
||||
|
@ -22,7 +25,7 @@ const route = computed(() => props.artist.channel
|
|||
>
|
||||
<img
|
||||
v-if="artist.cover && artist.cover.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop)"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](artist.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
:class="[{circular: artist.content_category != 'podcast'}]"
|
||||
>
|
||||
|
|
|
@ -9,7 +9,9 @@ import { computed } from 'vue'
|
|||
import moment from 'moment'
|
||||
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
import TagsList from '~/components/tags/List.vue'
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import ActorLink from '~/components/common/ActorLink.vue'
|
||||
|
||||
interface Props {
|
||||
object: Channel
|
||||
|
@ -41,64 +43,92 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
|
|||
</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}]"
|
||||
@click="$router.push({name: 'channels.detail', params: {id: urlId}})"
|
||||
>
|
||||
<play-button
|
||||
:icon-only="true"
|
||||
:is-playable="true"
|
||||
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
|
||||
<Card
|
||||
:title="object.artist?.name"
|
||||
:tags="object.artist?.tags ?? []"
|
||||
class="artist-card"
|
||||
:to="{name: 'channels.detail', params: {id: urlId}}"
|
||||
solid
|
||||
small
|
||||
>
|
||||
<template #topright>
|
||||
<PlayButton
|
||||
icon-only
|
||||
:artist="object.artist"
|
||||
:is-playable="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="content">
|
||||
<strong>
|
||||
<router-link
|
||||
class="discrete link"
|
||||
:to="{name: 'channels.detail', params: {id: urlId}}"
|
||||
>
|
||||
{{ object.artist?.name }}
|
||||
</router-link>
|
||||
</strong>
|
||||
<div class="description">
|
||||
<span
|
||||
v-if="object.artist?.content_category === 'podcast'"
|
||||
class="meta ellipsis"
|
||||
>
|
||||
{{ $t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
|
||||
</span>
|
||||
<tags-list
|
||||
label-classes="tiny"
|
||||
:truncate-size="20"
|
||||
:limit="2"
|
||||
:show-more="false"
|
||||
:tags="object.artist?.tags ?? []"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
</template>
|
||||
|
||||
<template #image>
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
v-lazy="imageUrl"
|
||||
:alt="object.artist?.name"
|
||||
:class="[object.artist?.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
|
||||
>
|
||||
<i
|
||||
v-else
|
||||
class="bi bi-person-circle"
|
||||
style="font-size: 167px; margin: 16px;"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<Spacer :size="8" />
|
||||
<ActorLink
|
||||
:actor="object.attributed_to"
|
||||
discrete
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<time
|
||||
class="meta ellipsis"
|
||||
:datetime="object.artist?.modification_date"
|
||||
:title="updatedTitle"
|
||||
>
|
||||
{{ updatedAgo }}
|
||||
</time>
|
||||
<play-button
|
||||
class="right floated basic icon"
|
||||
<i class="bi bi-dot" />
|
||||
<span
|
||||
v-if="object.artist?.content_category === 'podcast'"
|
||||
>
|
||||
{{ t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
|
||||
</span>
|
||||
<Spacer
|
||||
h
|
||||
grow
|
||||
/>
|
||||
<PlayButton
|
||||
:dropdown-only="true"
|
||||
:is-playable="true"
|
||||
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
|
||||
:artist="object.artist"
|
||||
:channel="object"
|
||||
:account="object.attributed_to"
|
||||
discrete
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.channel-image {
|
||||
border-radius: 50%;
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.podcast-image {
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,11 +3,14 @@ import type { Cover, Track, BackendResponse, BackendError } from '~/types'
|
|||
|
||||
import { clone } from 'lodash-es'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import axios from 'axios'
|
||||
import PodcastTable from '~/components/audio/podcast/Table.vue'
|
||||
import TrackTable from '~/components/audio/track/Table.vue'
|
||||
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'fetched', data: BackendResponse<Track[]>): void
|
||||
}
|
||||
|
@ -18,6 +21,7 @@ interface Props {
|
|||
defaultCover: Cover | null
|
||||
isPodcast: boolean
|
||||
}
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
@ -61,51 +65,45 @@ watch(page, fetchData, { immediate: true })
|
|||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
<div class="ui hidden divider" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<podcast-table
|
||||
v-if="isPodcast"
|
||||
v-model:page="page"
|
||||
:paginate-by="limit"
|
||||
:default-cover="defaultCover"
|
||||
:is-podcast="isPodcast"
|
||||
:show-art="true"
|
||||
:show-position="false"
|
||||
:tracks="channels"
|
||||
:show-artist="false"
|
||||
:show-album="false"
|
||||
:paginate-results="true"
|
||||
:total="count"
|
||||
/>
|
||||
<track-table
|
||||
v-else
|
||||
v-model:page="page"
|
||||
:default-cover="defaultCover"
|
||||
:is-podcast="isPodcast"
|
||||
:show-art="true"
|
||||
:show-position="false"
|
||||
:tracks="channels"
|
||||
:show-artist="false"
|
||||
:show-album="false"
|
||||
:paginate-results="true"
|
||||
:total="count"
|
||||
:paginate-by="limit"
|
||||
:filters="filters"
|
||||
/>
|
||||
<template v-if="!isLoading && channels.length === 0">
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
@refresh="fetchData()"
|
||||
>
|
||||
<p>
|
||||
{{ $t('components.audio.ChannelEntries.help.subscribe') }}
|
||||
</p>
|
||||
</empty-state>
|
||||
</template>
|
||||
<Loader v-if="isLoading" />
|
||||
</div>
|
||||
<podcast-table
|
||||
v-if="isPodcast"
|
||||
v-model:page="page"
|
||||
:paginate-by="limit"
|
||||
:default-cover="defaultCover"
|
||||
:is-podcast="isPodcast"
|
||||
:show-art="true"
|
||||
:show-position="false"
|
||||
:tracks="channels"
|
||||
:show-artist="false"
|
||||
:show-album="false"
|
||||
:paginate-results="true"
|
||||
:total="count"
|
||||
/>
|
||||
<track-table
|
||||
v-else
|
||||
v-model:page="page"
|
||||
:default-cover="defaultCover"
|
||||
:is-podcast="isPodcast"
|
||||
:show-art="true"
|
||||
:show-position="false"
|
||||
:tracks="channels"
|
||||
:show-artist="false"
|
||||
:show-album="false"
|
||||
:paginate-results="true"
|
||||
:total="count"
|
||||
:paginate-by="limit"
|
||||
:filters="filters"
|
||||
/>
|
||||
<template v-if="!isLoading && channels.length === 0">
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
@refresh="fetchData()"
|
||||
>
|
||||
<p>
|
||||
{{ t('components.audio.ChannelEntries.help.subscribe') }}
|
||||
</p>
|
||||
</empty-state>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
@ -5,10 +5,15 @@ import { computed } from 'vue'
|
|||
|
||||
import { usePlayer } from '~/composables/audio/player'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
interface Props {
|
||||
entry: Track
|
||||
defaultCover: Cover
|
||||
|
@ -37,30 +42,30 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
|
|||
</div>
|
||||
<img
|
||||
v-if="cover && cover.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="channel-image image"
|
||||
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||
>
|
||||
<img
|
||||
v-else-if="entry.artist_credit?.[0].artist.content_category === 'podcast' && defaultCover != undefined"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
|
||||
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}})"
|
||||
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||
>
|
||||
<img
|
||||
v-else-if="entry.album && entry.album.cover && entry.album.cover.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="channel-image image"
|
||||
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
alt=""
|
||||
class="channel-image image"
|
||||
src="../../assets/audio/default-cover.png"
|
||||
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||
>
|
||||
<div class="ellipsis content">
|
||||
<strong>
|
||||
|
@ -78,7 +83,7 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
|
|||
/>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](entry.id)">
|
||||
<template v-if="store.state.auth.authenticated && store.getters['favorites/isFavorite'](entry.id)">
|
||||
<track-favorite-icon
|
||||
class="tiny"
|
||||
:track="entry"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContentCategory, Channel, BackendError } from '~/types'
|
||||
import type { paths } from '~/generated/types'
|
||||
|
||||
import { slugify } from 'transliteration'
|
||||
import { reactive, computed, ref, watchEffect, watch } from 'vue'
|
||||
|
@ -7,23 +8,27 @@ import { useI18n } from 'vue-i18n'
|
|||
|
||||
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
|
||||
}
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Alert from '~/components/ui/Alert.vue'
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Textarea from '~/components/ui/Textarea.vue'
|
||||
import Pills from '~/components/ui/Pills.vue'
|
||||
|
||||
|
||||
interface Props {
|
||||
object?: Channel | null
|
||||
step: number
|
||||
step?: number
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const emit = defineEmits<{
|
||||
category: [contentCategory: ContentCategory]
|
||||
submittable: [value: boolean]
|
||||
loading: [value: boolean]
|
||||
errored: [errors: string[]]
|
||||
created: [channel: Channel]
|
||||
updated: [channel: Channel]
|
||||
}>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
object: null,
|
||||
step: 1
|
||||
|
@ -38,9 +43,11 @@ const newValues = reactive({
|
|||
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 ?? {}) }
|
||||
metadata: { ...(props.object?.metadata ?? {}) } as Channel['metadata']
|
||||
})
|
||||
|
||||
// If props has an object, then this form edits, else it creates
|
||||
// TODO: rename to `process : 'creating' | 'editing'`
|
||||
const creating = computed(() => props.object === null)
|
||||
const categoryChoices = computed(() => [
|
||||
{
|
||||
|
@ -72,6 +79,8 @@ interface MetadataChoices {
|
|||
const metadataChoices = ref({ itunes_category: null } as MetadataChoices)
|
||||
const itunesSubcategories = computed(() => {
|
||||
for (const element of metadataChoices.value.itunes_category ?? []) {
|
||||
// TODO: Backend: Define schema for `metadata` field
|
||||
// @ts-expect-error No types defined by backend schema for `metadata` field
|
||||
if (element.value === newValues.metadata.itunes_category) {
|
||||
return element.children ?? []
|
||||
}
|
||||
|
@ -87,6 +96,7 @@ const labels = computed(() => ({
|
|||
|
||||
const submittable = computed(() => !!(
|
||||
newValues.content_category === 'podcast'
|
||||
// @ts-expect-error No types defined by backend schema for `metadata` field
|
||||
? newValues.name && newValues.username && newValues.metadata.itunes_category && newValues.metadata.language
|
||||
: newValues.name && newValues.username
|
||||
))
|
||||
|
@ -97,13 +107,16 @@ watch(() => newValues.name, (name) => {
|
|||
}
|
||||
})
|
||||
|
||||
// @ts-expect-error No types defined by backend schema for `metadata` field
|
||||
watch(() => newValues.metadata.itunes_category, () => {
|
||||
// @ts-expect-error No types defined by backend schema for `metadata` field
|
||||
newValues.metadata.itunes_subcategory = null
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errors = ref([] as string[])
|
||||
|
||||
// @ts-expect-error Re-check emits
|
||||
watchEffect(() => emit('category', newValues.content_category))
|
||||
watchEffect(() => emit('loading', isLoading.value))
|
||||
watchEffect(() => emit('submittable', submittable.value))
|
||||
|
@ -111,8 +124,9 @@ 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
|
||||
const response = await axios.get<paths['/api/v2/channels/metadata-choices/']['get']['responses']['200']['content']['application/json']>('channels/metadata-choices/')
|
||||
// TODO: Fix schema generation so we don't need to typecast here!
|
||||
metadataChoices.value = response.data as unknown as MetadataChoices
|
||||
} catch (error) {
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
}
|
||||
|
@ -155,17 +169,17 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
<Layout
|
||||
form
|
||||
class="ui form"
|
||||
@submit.prevent.stop="submit"
|
||||
>
|
||||
<div
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
red
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.audio.ChannelForm.header.error') }}
|
||||
{{ t('components.audio.ChannelForm.header.error') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -175,14 +189,14 @@ defineExpose({
|
|||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
<template v-if="metadataChoices">
|
||||
<fieldset
|
||||
v-if="creating && step === 1"
|
||||
class="ui grouped channel-type required field"
|
||||
>
|
||||
<legend>
|
||||
{{ $t('components.audio.ChannelForm.legend.purpose') }}
|
||||
{{ t('components.audio.ChannelForm.legend.purpose') }}
|
||||
</legend>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="field">
|
||||
|
@ -199,7 +213,7 @@ defineExpose({
|
|||
:value="choice.value"
|
||||
>
|
||||
<label :for="`category-${choice.value}`">
|
||||
<span :class="['right floated', 'placeholder', 'image', {circular: choice.value === 'music'}]" />
|
||||
<span :class="['right floated', 'placeholder', 'image', 'shifted', {circular: choice.value === 'music'}]" />
|
||||
<strong>{{ choice.label }}</strong>
|
||||
<div class="ui small hidden divider" />
|
||||
{{ choice.helpText }}
|
||||
|
@ -207,38 +221,30 @@ defineExpose({
|
|||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<template v-if="!creating || step === 2">
|
||||
<div class="ui required field">
|
||||
<label for="channel-name">
|
||||
{{ $t('components.audio.ChannelForm.label.name') }}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
v-model="newValues.name"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="labels.namePlaceholder"
|
||||
>
|
||||
:label="t('components.audio.ChannelForm.label.name')"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui required field">
|
||||
<label for="channel-username">
|
||||
{{ $t('components.audio.ChannelForm.label.username') }}
|
||||
</label>
|
||||
<div class="ui left labeled input">
|
||||
<div class="ui basic label">
|
||||
<span class="at symbol" />
|
||||
</div>
|
||||
<input
|
||||
v-model="newValues.username"
|
||||
type="text"
|
||||
:required="creating"
|
||||
:disabled="!creating"
|
||||
:placeholder="labels.usernamePlaceholder"
|
||||
>
|
||||
</div>
|
||||
<Input
|
||||
v-model="newValues.username"
|
||||
type="text"
|
||||
:required="creating"
|
||||
:disabled="!creating"
|
||||
:placeholder="labels.usernamePlaceholder"
|
||||
:label="t('components.audio.ChannelForm.label.username')"
|
||||
/>
|
||||
<template v-if="creating">
|
||||
<div class="ui small hidden divider" />
|
||||
<p>
|
||||
{{ $t('components.audio.ChannelForm.help.username') }}
|
||||
{{ t('components.audio.ChannelForm.help.username') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -248,64 +254,57 @@ defineExpose({
|
|||
:image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
|
||||
@delete="newValues.cover = null"
|
||||
>
|
||||
{{ $t('components.audio.ChannelForm.label.image') }}
|
||||
{{ t('components.audio.ChannelForm.label.image') }}
|
||||
</attachment-input>
|
||||
</div>
|
||||
<div class="ui small hidden divider" />
|
||||
<div class="ui stackable grid row">
|
||||
<div class="ten wide column">
|
||||
<div class="ui field">
|
||||
<label for="channel-tags">
|
||||
{{ $t('components.audio.ChannelForm.label.tags') }}
|
||||
</label>
|
||||
<tags-selector
|
||||
id="channel-tags"
|
||||
v-model="newValues.tags"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="newValues.content_category === 'podcast'"
|
||||
class="six wide column"
|
||||
>
|
||||
<div class="ui required field">
|
||||
<label for="channel-language">
|
||||
{{ $t('components.audio.ChannelForm.label.language') }}
|
||||
</label>
|
||||
<select
|
||||
id="channel-language"
|
||||
v-model="newValues.metadata.language"
|
||||
name="channel-language"
|
||||
required
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="(v, key) in metadataChoices.language"
|
||||
:key="key"
|
||||
:value="v.value"
|
||||
>
|
||||
{{ v.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui small hidden divider" />
|
||||
<div class="ui field">
|
||||
<label for="channel-name">
|
||||
{{ $t('components.audio.ChannelForm.label.description') }}
|
||||
</label>
|
||||
<content-form v-model="newValues.description" />
|
||||
</div>
|
||||
<Pills
|
||||
:get="model => { newValues.tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
currents: newValues.tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
others: [].map(tag => ({ type: 'custom' as const, label: tag }))
|
||||
})"
|
||||
:label="t('components.audio.ChannelForm.label.tags')"
|
||||
/>
|
||||
<div
|
||||
v-if="newValues.content_category === 'podcast'"
|
||||
class="ui two fields"
|
||||
>
|
||||
<label for="channel-language">
|
||||
{{ t('components.audio.ChannelForm.label.language') }}
|
||||
</label>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<select
|
||||
id="channel-language"
|
||||
v-model="newValues.metadata.language"
|
||||
name="channel-language"
|
||||
required
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="(v, key) in metadataChoices.language"
|
||||
:key="key"
|
||||
:value="v.value"
|
||||
>
|
||||
{{ v.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<Textarea
|
||||
v-model="newValues.description"
|
||||
:label="t('components.audio.ChannelForm.label.description')"
|
||||
initial-lines="3"
|
||||
/>
|
||||
</div>
|
||||
<template
|
||||
v-if="newValues.content_category === 'podcast'"
|
||||
>
|
||||
<div class="ui required field">
|
||||
<label for="channel-itunes-category">
|
||||
{{ $t('components.audio.ChannelForm.label.category') }}
|
||||
{{ t('components.audio.ChannelForm.label.category') }}
|
||||
</label>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<select
|
||||
id="itunes-category"
|
||||
v-model="newValues.metadata.itunes_category"
|
||||
|
@ -324,8 +323,10 @@ defineExpose({
|
|||
</div>
|
||||
<div class="ui field">
|
||||
<label for="channel-itunes-category">
|
||||
{{ $t('components.audio.ChannelForm.label.subcategory') }}
|
||||
{{ t('components.audio.ChannelForm.label.subcategory') }}
|
||||
</label>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<select
|
||||
id="itunes-category"
|
||||
v-model="newValues.metadata.itunes_subcategory"
|
||||
|
@ -342,37 +343,37 @@ defineExpose({
|
|||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
</template>
|
||||
<template
|
||||
v-if="newValues.content_category === 'podcast'"
|
||||
class="ui two fields"
|
||||
>
|
||||
<Alert blue>
|
||||
<span>
|
||||
<i class="bi bi-info-circle-fill" />
|
||||
{{ t('components.audio.ChannelForm.help.podcastFields') }}
|
||||
</span>
|
||||
</Alert>
|
||||
<div class="ui field">
|
||||
<label for="channel-itunes-email">
|
||||
{{ $t('components.audio.ChannelForm.label.email') }}
|
||||
</label>
|
||||
<input
|
||||
<!-- @vue-ignore -->
|
||||
<Input
|
||||
id="channel-itunes-email"
|
||||
v-model="newValues.metadata.owner_email"
|
||||
name="channel-itunes-email"
|
||||
type="email"
|
||||
>
|
||||
:label="t('components.audio.ChannelForm.label.email')"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<label for="channel-itunes-name">
|
||||
{{ $t('components.audio.ChannelForm.label.owner') }}
|
||||
</label>
|
||||
<input
|
||||
<!-- @vue-ignore -->
|
||||
<Input
|
||||
id="channel-itunes-name"
|
||||
v-model="newValues.metadata.owner_name"
|
||||
name="channel-itunes-name"
|
||||
maxlength="255"
|
||||
>
|
||||
:label="t('components.audio.ChannelForm.label.owner')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
{{ $t('components.audio.ChannelForm.help.podcastFields') }}
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<div
|
||||
|
@ -380,8 +381,8 @@ defineExpose({
|
|||
class="ui active inverted dimmer"
|
||||
>
|
||||
<div class="ui text loader">
|
||||
{{ $t('components.audio.ChannelForm.loader.loading') }}
|
||||
{{ t('components.audio.ChannelForm.loader.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -1,72 +1,74 @@
|
|||
<script setup lang="ts">
|
||||
import type { Album } from '~/types'
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { momentFormat } from '~/utils/filters'
|
||||
import defaultCover from '~/assets/audio/default-cover.png'
|
||||
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
import { computed } from 'vue'
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
|
||||
import { type Album } from '~/types'
|
||||
|
||||
interface Props {
|
||||
serie: Album
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const cover = computed(() => props.serie?.cover ?? null)
|
||||
const { serie } = props
|
||||
|
||||
const store = useStore()
|
||||
const imageUrl = computed(() => serie?.cover?.urls.original
|
||||
? store.getters['instance/absoluteUrl'](serie.cover?.urls.medium_square_crop)
|
||||
: defaultCover
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="channel-serie-card">
|
||||
<div class="two-images">
|
||||
<img
|
||||
v-if="cover && cover.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="channel-image"
|
||||
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
alt=""
|
||||
class="channel-image"
|
||||
src="../../assets/audio/default-cover.png"
|
||||
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
|
||||
>
|
||||
<img
|
||||
v-if="cover && cover.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="channel-image"
|
||||
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
alt=""
|
||||
class="channel-image"
|
||||
src="../../assets/audio/default-cover.png"
|
||||
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
|
||||
>
|
||||
</div>
|
||||
<div class="content ellipsis">
|
||||
<strong>
|
||||
<router-link
|
||||
class="discrete link"
|
||||
:to="{name: 'library.albums.detail', params: {id: serie.id}}"
|
||||
>
|
||||
{{ serie.title }}
|
||||
</router-link>
|
||||
</strong>
|
||||
<div class="description">
|
||||
<span>
|
||||
{{ $t('components.audio.ChannelSerieCard.meta.episodes', serie.tracks_count) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<play-button
|
||||
:icon-only="true"
|
||||
:is-playable="true"
|
||||
:button-classes="['ui', 'circular', 'vibrant', 'icon', 'button']"
|
||||
<Card
|
||||
:title="serie?.title"
|
||||
:image="imageUrl"
|
||||
:tags="serie?.tags"
|
||||
:to="{name: 'library.albums.detail', params: {id: serie?.id}}"
|
||||
small
|
||||
>
|
||||
<template #topright>
|
||||
<PlayButton
|
||||
icon-only
|
||||
:is-playable="serie?.is_playable"
|
||||
:album="serie"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<span v-if="serie?.release_date">
|
||||
{{ momentFormat(new Date(serie?.release_date), 'Y') }}
|
||||
</span>
|
||||
<i class="bi bi-dot" />
|
||||
<span>
|
||||
{{ t('components.audio.album.Card.meta.tracks', serie?.tracks_count) }}
|
||||
</span>
|
||||
<Spacer
|
||||
h
|
||||
grow
|
||||
/>
|
||||
<PlayButton
|
||||
:dropdown-only="true"
|
||||
discrete
|
||||
:is-playable="serie?.is_playable"
|
||||
:album="serie"
|
||||
/>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.play-button {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,10 +3,14 @@ import type { BackendError, Album } from '~/types'
|
|||
|
||||
import { clone } from 'lodash-es'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import axios from 'axios'
|
||||
import ChannelSerieCard from '~/components/audio/ChannelSerieCard.vue'
|
||||
import AlbumCard from '~/components/audio/album/Card.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
interface Props {
|
||||
filters: object
|
||||
|
@ -14,6 +18,8 @@ interface Props {
|
|||
limit?: number
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isPodcast: true,
|
||||
limit: 5
|
||||
|
@ -51,15 +57,9 @@ fetchData()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
<div class="ui hidden divider" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<slot />
|
||||
<Layout flex>
|
||||
<Loader v-if="isLoading" />
|
||||
<template v-if="isPodcast">
|
||||
<channel-serie-card
|
||||
v-for="serie in albums"
|
||||
|
@ -67,35 +67,31 @@ fetchData()
|
|||
:serie="serie"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="ui app-cards cards"
|
||||
>
|
||||
<template v-else>
|
||||
<album-card
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
:album="album"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="nextPage">
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
<Button
|
||||
v-if="nextPage"
|
||||
:class="['ui', 'basic', 'button']"
|
||||
secondary
|
||||
@click="fetchData(nextPage)"
|
||||
>
|
||||
{{ $t('components.audio.ChannelSeries.button.showMore') }}
|
||||
</button>
|
||||
{{ t('components.audio.ChannelSeries.button.showMore') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-if="!isLoading && albums.length === 0">
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
@refresh="fetchData()"
|
||||
>
|
||||
<p>
|
||||
{{ $t('components.audio.ChannelSeries.help.subscribe') }}
|
||||
</p>
|
||||
</empty-state>
|
||||
</template>
|
||||
</div>
|
||||
</Layout>
|
||||
<template v-if="!isLoading && albums.length === 0">
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
@refresh="fetchData()"
|
||||
>
|
||||
<p>
|
||||
{{ t('components.audio.ChannelSeries.help.subscribe') }}
|
||||
</p>
|
||||
</empty-state>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
@ -1,47 +1,57 @@
|
|||
<script setup lang="ts">
|
||||
import type { BackendError, BackendResponse, Channel } from '~/types'
|
||||
import type { BackendError, PaginatedChannelList } from '~/types'
|
||||
import { type operations } from '~/generated/types.ts'
|
||||
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { clone } from 'lodash-es'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
import usePage from '~/composables/navigation/usePage'
|
||||
|
||||
import ChannelCard from '~/components/audio/ChannelCard.vue'
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
import Section from '~/components/ui/Section.vue'
|
||||
import Pagination from '~/components/ui/Pagination.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'fetched', channels: BackendResponse<Channel>): void
|
||||
(e: 'fetched', channels: PaginatedChannelList): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filters: object
|
||||
limit?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
limit: 5
|
||||
limit: 5,
|
||||
title: undefined
|
||||
})
|
||||
|
||||
const channels = reactive([] as Channel[])
|
||||
const result = ref<PaginatedChannelList>()
|
||||
const errors = ref([] as string[])
|
||||
const nextPage = ref()
|
||||
const page = usePage()
|
||||
const count = ref(0)
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const fetchData = async (url = 'channels/') => {
|
||||
isLoading.value = true
|
||||
|
||||
const params = {
|
||||
const params: operations['get_channels_2']['parameters']['query'] = {
|
||||
...clone(props.filters),
|
||||
page_size: props.limit,
|
||||
include_channels: true
|
||||
page: page.value,
|
||||
page_size: props.limit
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, { params })
|
||||
const response = await axios.get<PaginatedChannelList>(url, { params })
|
||||
nextPage.value = response.data.next
|
||||
count.value = response.data.count
|
||||
channels.push(...response.data.results)
|
||||
result.value = response.data
|
||||
emit('fetched', response.data)
|
||||
} catch (error) {
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
|
@ -50,41 +60,51 @@ const fetchData = async (url = 'channels/') => {
|
|||
isLoading.value = false
|
||||
}
|
||||
|
||||
fetchData()
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
watch([() => props.filters, page],
|
||||
() => fetchData(),
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui app-cards cards">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<channel-card
|
||||
v-for="object in channels"
|
||||
:key="object.uuid"
|
||||
:object="object"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="nextPage">
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
v-if="nextPage"
|
||||
:class="['ui', 'basic', 'button']"
|
||||
@click="fetchData(nextPage)"
|
||||
>
|
||||
{{ $t('components.audio.ChannelsWidget.button.showMore') }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="!isLoading && channels.length === 0">
|
||||
<Section
|
||||
align-left
|
||||
:columns-per-item="1"
|
||||
:h2="title || undefined"
|
||||
>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
<template
|
||||
v-if="!isLoading && result?.count === 0"
|
||||
>
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
style="grid-column: 1 / -1;"
|
||||
@refresh="fetchData('channels/')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="page && result && count > limit && limit > 16"
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((count || 0) / limit)"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
<channel-card
|
||||
v-for="channel in result?.results"
|
||||
:key="channel.uuid"
|
||||
:object="channel"
|
||||
/>
|
||||
<Pagination
|
||||
v-if="page && result && count > limit"
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((count || 0) / limit)"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
</Section>
|
||||
</template>
|
||||
|
|
|
@ -3,14 +3,25 @@ import { get } from 'lodash-es'
|
|||
import { ref, computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Alert from '~/components/ui/Alert.vue'
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
|
||||
interface Props {
|
||||
type: string
|
||||
id: number
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const width = ref(null)
|
||||
|
||||
// TODO: This used to be `null`. Is `0` correct?
|
||||
const width = ref(0)
|
||||
|
||||
const height = ref(150)
|
||||
const minHeight = ref(100)
|
||||
|
||||
|
@ -51,69 +62,70 @@ const { copy, copied } = useClipboard({ source: embedCode })
|
|||
>
|
||||
<p>
|
||||
<strong>
|
||||
{{ $t('components.audio.EmbedWizard.warning.anonymous') }}
|
||||
{{ t('components.audio.EmbedWizard.warning.anonymous') }}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('components.audio.EmbedWizard.help.anonymous') }}
|
||||
{{ t('components.audio.EmbedWizard.help.anonymous') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ui form">
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<div class="field">
|
||||
<label for="embed-width">{{ $t('components.audio.EmbedWizard.label.width') }}</label>
|
||||
<label for="embed-width">{{ t('components.audio.EmbedWizard.label.width') }}</label>
|
||||
<p>
|
||||
{{ $t('components.audio.EmbedWizard.help.width') }}
|
||||
{{ t('components.audio.EmbedWizard.help.width') }}
|
||||
</p>
|
||||
<input
|
||||
<Input
|
||||
id="embed-width"
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
min="0"
|
||||
step="10"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
<template v-if="type != 'track'">
|
||||
<br>
|
||||
<div class="field">
|
||||
<label for="embed-height">{{ $t('components.audio.EmbedWizard.label.height') }}</label>
|
||||
<input
|
||||
<label for="embed-height">{{ t('components.audio.EmbedWizard.label.height') }}</label>
|
||||
<Input
|
||||
id="embed-height"
|
||||
v-model="height"
|
||||
type="number"
|
||||
:min="minHeight"
|
||||
max="1000"
|
||||
step="10"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<Spacer />
|
||||
<div class="field">
|
||||
<button
|
||||
class="ui right accent labeled icon floated button"
|
||||
<Button
|
||||
class="right floated"
|
||||
icon="bi-copy"
|
||||
secondary
|
||||
@click="copy()"
|
||||
>
|
||||
<i class="copy icon" />
|
||||
{{ $t('components.audio.EmbedWizard.button.copy') }}
|
||||
</button>
|
||||
<label for="embed-width">{{ $t('components.audio.EmbedWizard.label.embed') }}</label>
|
||||
{{ t('components.audio.EmbedWizard.button.copy') }}
|
||||
</Button>
|
||||
<label for="embed-width">{{ t('components.audio.EmbedWizard.label.embed') }}</label>
|
||||
<p>
|
||||
{{ $t('components.audio.EmbedWizard.help.embed') }}
|
||||
{{ t('components.audio.EmbedWizard.help.embed') }}
|
||||
</p>
|
||||
<textarea
|
||||
v-model="embedCode"
|
||||
rows="5"
|
||||
rows="3"
|
||||
readonly
|
||||
style="width: 100%;"
|
||||
/>
|
||||
<div class="ui right">
|
||||
<p
|
||||
v-if="copied"
|
||||
class="message"
|
||||
>
|
||||
{{ $t('components.audio.EmbedWizard.message.copy') }}
|
||||
</p>
|
||||
</div>
|
||||
<Alert
|
||||
v-if="copied"
|
||||
green
|
||||
>
|
||||
{{ t('components.audio.EmbedWizard.message.copy') }}
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -123,7 +135,7 @@ const { copy, copied } = useClipboard({ source: embedCode })
|
|||
:href="iframeSrc"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('components.audio.EmbedWizard.header.preview') }}
|
||||
{{ t('components.audio.EmbedWizard.header.preview') }}
|
||||
</a>
|
||||
</h3>
|
||||
<iframe
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { Library } from '~/types'
|
|||
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Events {
|
||||
(e: 'unfollowed'): void
|
||||
|
@ -13,6 +14,7 @@ interface Props {
|
|||
library: Library
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
|
@ -39,13 +41,13 @@ const toggle = () => {
|
|||
>
|
||||
<i class="heart icon" />
|
||||
<span v-if="isApproved">
|
||||
{{ $t('components.audio.LibraryFollowButton.button.unfollow') }}
|
||||
{{ t('components.audio.LibraryFollowButton.button.unfollow') }}
|
||||
</span>
|
||||
<span v-else-if="isPending">
|
||||
{{ $t('components.audio.LibraryFollowButton.button.cancel') }}
|
||||
{{ t('components.audio.LibraryFollowButton.button.cancel') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.audio.LibraryFollowButton.button.follow') }}
|
||||
{{ t('components.audio.LibraryFollowButton.button.follow') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||
import type { components } from '~/generated/types'
|
||||
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||
import useReport from '~/composables/moderation/useReport'
|
||||
import { useCurrentElement } from '@vueuse/core'
|
||||
import { setupDropdown } from '~/utils/fomantic'
|
||||
import { useStore } from '~/store'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import OptionsButton from '~/components/ui/button/Options.vue'
|
||||
import Popover from '~/components/ui/Popover.vue'
|
||||
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
||||
|
||||
interface Props extends PlayOptionsProps {
|
||||
split?: boolean
|
||||
dropdownIconClasses?: string[]
|
||||
playIconClass?: string
|
||||
buttonClasses?: string[]
|
||||
|
@ -18,20 +25,22 @@ interface Props extends PlayOptionsProps {
|
|||
iconOnly?: boolean
|
||||
playing?: boolean
|
||||
paused?: boolean
|
||||
lowHeight?: boolean
|
||||
|
||||
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||
isPlayable?: boolean
|
||||
tracks?: Track[]
|
||||
track?: Track | null
|
||||
artist?: Artist | null
|
||||
artist?: Artist | components["schemas"]["SimpleChannelArtist"] | components['schemas']['ArtistWithAlbums'] | null
|
||||
album?: Album | null
|
||||
playlist?: Playlist | null
|
||||
library?: Library | null
|
||||
channel?: Channel | null
|
||||
account?: Actor | null
|
||||
account?: Actor | components['schemas']['APIActor'] | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
split: false,
|
||||
tracks: () => [],
|
||||
track: null,
|
||||
artist: null,
|
||||
|
@ -40,17 +49,22 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
library: null,
|
||||
channel: null,
|
||||
account: null,
|
||||
dropdownIconClasses: () => ['dropdown'],
|
||||
playIconClass: () => 'play icon',
|
||||
dropdownIconClasses: () => ['bi-caret-down-fill'],
|
||||
playIconClass: () => 'bi-play-fill',
|
||||
buttonClasses: () => ['button'],
|
||||
discrete: () => false,
|
||||
dropdownOnly: () => false,
|
||||
iconOnly: () => false,
|
||||
isPlayable: () => false,
|
||||
playing: () => false,
|
||||
paused: () => false
|
||||
paused: () => false,
|
||||
lowHeight: () => false
|
||||
})
|
||||
|
||||
// (1) Create a PlayButton
|
||||
// Some of the props are meant for `usePlayOptions`!
|
||||
// UsePlayOptions accepts the props from this component and returns the following things:
|
||||
|
||||
const {
|
||||
playable,
|
||||
filterableArtist,
|
||||
|
@ -64,6 +78,10 @@ const {
|
|||
const { report, getReportableObjects } = useReport()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const labels = computed(() => ({
|
||||
playNow: t('components.audio.PlayButton.button.playNow'),
|
||||
addToQueue: t('components.audio.PlayButton.button.addToQueue'),
|
||||
|
@ -83,149 +101,163 @@ const labels = computed(() => ({
|
|||
: t('components.audio.PlayButton.button.playTracks')
|
||||
}))
|
||||
|
||||
const title = computed(() => {
|
||||
if (playable.value) {
|
||||
return t('components.audio.PlayButton.title.more')
|
||||
}
|
||||
|
||||
if (props.track) {
|
||||
return t('components.audio.PlayButton.title.unavailable')
|
||||
}
|
||||
|
||||
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 display it on the right if there is enough room
|
||||
const menu = dropdown.value.find('.menu')
|
||||
if (menu.hasClass('visible')) return
|
||||
const viewportOffset = menu.get(0)?.getBoundingClientRect() ?? { right: 0, left: 0 }
|
||||
const viewportWidth = document.documentElement.clientWidth
|
||||
const rightOverflow = viewportOffset.right - viewportWidth
|
||||
const leftOverflow = -viewportOffset.left
|
||||
|
||||
menu.css({
|
||||
cssText: rightOverflow > 0
|
||||
? `left: ${-rightOverflow - 5}px !important;`
|
||||
: `right: ${-leftOverflow + 5}px !important;`
|
||||
})
|
||||
}
|
||||
const isOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:title="title"
|
||||
:class="['ui', {'tiny': discrete, 'icon': !discrete, 'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']"
|
||||
<Popover
|
||||
v-if="split || (!iconOnly && dropdownOnly)"
|
||||
v-model="isOpen"
|
||||
>
|
||||
<button
|
||||
v-if="!dropdownOnly"
|
||||
:disabled="!playable"
|
||||
<OptionsButton
|
||||
v-if="dropdownOnly"
|
||||
v-bind="$attrs"
|
||||
:is-ghost="discrete"
|
||||
@click="isOpen = !isOpen"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
v-bind="{
|
||||
disabled: !playable && !filterableArtist,
|
||||
primary: playable,
|
||||
split: true,
|
||||
splitIcon: 'bi-caret-down-fill'
|
||||
}"
|
||||
:aria-label="labels.replacePlay"
|
||||
:class="[...buttonClasses, 'ui', {loading: isLoading, 'mini': discrete, disabled: !playable}]"
|
||||
:class="[...buttonClasses, 'play-button']"
|
||||
:isloading="isLoading"
|
||||
:dropdown-only="dropdownOnly"
|
||||
:low-height="lowHeight || undefined"
|
||||
style="align-self: start;"
|
||||
@click.stop.prevent="replacePlay()"
|
||||
@split-click="isOpen = !isOpen"
|
||||
>
|
||||
<i
|
||||
v-if="playing"
|
||||
class="pause icon"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="[playIconClass, 'icon']"
|
||||
/>
|
||||
<template v-if="!discrete && !iconOnly"> <slot>{{ $t('components.audio.PlayButton.button.discretePlay') }}</slot></template>
|
||||
</button>
|
||||
<button
|
||||
v-if="!discrete && !iconOnly"
|
||||
:class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"
|
||||
@click.stop.prevent="openMenu"
|
||||
>
|
||||
<i
|
||||
:class="dropdownIconClasses.concat(['icon'])"
|
||||
:title="title"
|
||||
/>
|
||||
<div class="menu">
|
||||
<button
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
:title="labels.addToQueue"
|
||||
@click.stop.prevent="enqueue"
|
||||
>
|
||||
<i class="plus icon" />{{ labels.addToQueue }}
|
||||
</button>
|
||||
<button
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
:title="labels.playNext"
|
||||
@click.stop.prevent="enqueueNext()"
|
||||
>
|
||||
<i class="step forward icon" />{{ labels.playNext }}
|
||||
</button>
|
||||
<button
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
:title="labels.playNow"
|
||||
@click.stop.prevent="enqueueNext(true)"
|
||||
>
|
||||
<i class="play icon" />{{ labels.playNow }}
|
||||
</button>
|
||||
<button
|
||||
v-if="track"
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
:title="labels.startRadio"
|
||||
@click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
|
||||
>
|
||||
<i class="feed icon" />{{ labels.startRadio }}
|
||||
</button>
|
||||
<button
|
||||
v-if="track"
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
||||
>
|
||||
<i class="list icon" />
|
||||
{{ labels.addToPlaylist }}
|
||||
</button>
|
||||
<button
|
||||
v-if="track && $route.name !== 'library.tracks.detail'"
|
||||
class="item basic"
|
||||
@click.stop.prevent="$router.push(`/library/tracks/${track?.id}/`)"
|
||||
>
|
||||
<i class="info icon" />
|
||||
<span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')">
|
||||
{{ $t('components.audio.PlayButton.button.episodeDetails') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.audio.PlayButton.button.trackDetails') }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="divider" />
|
||||
<button
|
||||
v-if="filterableArtist"
|
||||
class="item basic"
|
||||
:disabled="!filterableArtist"
|
||||
:title="labels.hideArtist"
|
||||
@click.stop.prevent="filterArtist"
|
||||
>
|
||||
<i class="eye slash outline icon" />
|
||||
{{ labels.hideArtist }}
|
||||
</button>
|
||||
<button
|
||||
v-for="obj in getReportableObjects({track, album, artist, playlist, account, channel})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
class="item basic"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</span>
|
||||
<template #main>
|
||||
<i
|
||||
v-if="playing"
|
||||
class="bi bi-pause-fill"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="['bi', playIconClass]"
|
||||
/>
|
||||
<template v-if="!discrete && !iconOnly">
|
||||
<slot>{{ t('components.audio.PlayButton.button.discretePlay') }}</slot>
|
||||
</template>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<template #items>
|
||||
<PopoverItem
|
||||
:disabled="!playable"
|
||||
:title="labels.addToQueue"
|
||||
icon="bi-plus"
|
||||
@click.stop.prevent="enqueue"
|
||||
>
|
||||
{{ labels.addToQueue }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
:disabled="!playable"
|
||||
:title="labels.playNext"
|
||||
icon="bi-skip-forward-fill"
|
||||
@click.stop.prevent="enqueueNext()"
|
||||
>
|
||||
{{ labels.playNext }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
:disabled="!playable"
|
||||
:title="labels.playNow"
|
||||
icon="bi-play-fill"
|
||||
@click.stop.prevent="enqueueNext(true)"
|
||||
>
|
||||
{{ labels.playNow }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="track"
|
||||
:disabled="!playable"
|
||||
:title="labels.startRadio"
|
||||
icon="bi-broadcast"
|
||||
@click.stop.prevent="store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
|
||||
>
|
||||
{{ labels.startRadio }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="track"
|
||||
:disabled="!playable"
|
||||
icon="bi-list"
|
||||
@click.stop="store.commit('playlists/chooseTrack', track)"
|
||||
>
|
||||
{{ labels.addToPlaylist }}
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
v-if="track && route.name !== 'library.tracks.detail'"
|
||||
icon="bi-info-circle"
|
||||
@click.stop.prevent="router.push(`/library/tracks/${track?.id}/`)"
|
||||
>
|
||||
<span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')">
|
||||
{{ t('components.audio.PlayButton.button.episodeDetails') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('components.audio.PlayButton.button.trackDetails') }}
|
||||
</span>
|
||||
</PopoverItem>
|
||||
|
||||
<hr v-if="filterableArtist || Object.keys(getReportableObjects({ track, album, artist, playlist, account, channel })).length > 0">
|
||||
|
||||
<PopoverItem
|
||||
v-if="filterableArtist"
|
||||
:disabled="!filterableArtist"
|
||||
:title="labels.hideArtist"
|
||||
icon="bi-eye-slash"
|
||||
@click.stop.prevent="filterArtist"
|
||||
>
|
||||
{{ labels.hideArtist }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-for="obj in getReportableObjects({ track, album, artist, playlist, account, channel })"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
icon="bi-exclamation-triangle-fill"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
{{ obj.label }}
|
||||
</PopoverItem>
|
||||
</template>
|
||||
</Popover>
|
||||
<Button
|
||||
v-else
|
||||
v-bind="{
|
||||
disabled: !playable,
|
||||
primary: playable,
|
||||
}"
|
||||
:aria-label="labels.replacePlay"
|
||||
:class="[...buttonClasses, 'play-button']"
|
||||
:isloading="isLoading"
|
||||
:square="iconOnly"
|
||||
:icon="!playing ? playIconClass : 'bi-pause-fill'"
|
||||
:round="iconOnly"
|
||||
:primary="iconOnly && !discrete"
|
||||
:ghost="discrete"
|
||||
:low-height="lowHeight || undefined"
|
||||
@click.stop.prevent="replacePlay()"
|
||||
>
|
||||
<template v-if="!discrete && !iconOnly">
|
||||
<span>
|
||||
{{ t('components.audio.PlayButton.button.discretePlay') }}
|
||||
</span>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.funkwhale.split-button {
|
||||
&.button {
|
||||
gap: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -6,6 +6,7 @@ import { useMouse, useWindowSize } from '@vueuse/core'
|
|||
import { computed, ref } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
import time from '~/utils/time'
|
||||
|
@ -14,6 +15,8 @@ import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
|||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||
import PlayerControls from './PlayerControls.vue'
|
||||
import VolumeControl from './VolumeControl.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const {
|
||||
LoopingMode,
|
||||
|
@ -43,14 +46,26 @@ const {
|
|||
} = useQueue()
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const toggleMobilePlayer = () => {
|
||||
store.commit('ui/queueFocused', ['queue', 'player'].includes(store.state.ui.queueFocused as string) ? null : 'player')
|
||||
/** Toggle between null and player */
|
||||
const togglePlayer = () => {
|
||||
store.commit('ui/queueFocused',
|
||||
store.state.ui.queueFocused === 'queue'
|
||||
? null
|
||||
: store.state.ui.queueFocused === 'player'
|
||||
? null
|
||||
: 'player'
|
||||
)
|
||||
}
|
||||
|
||||
const switchTab = () => {
|
||||
store.commit('ui/queueFocused', store.state.ui.queueFocused === 'player' ? 'queue' : 'player')
|
||||
}
|
||||
|
||||
// Key binds
|
||||
onKeyboardShortcut('e', toggleMobilePlayer)
|
||||
onKeyboardShortcut('e', togglePlayer)
|
||||
onKeyboardShortcut('p', () => { isPlaying.value = !isPlaying.value })
|
||||
onKeyboardShortcut('s', shuffle)
|
||||
onKeyboardShortcut('q', clear)
|
||||
|
@ -84,10 +99,6 @@ const labels = computed(() => ({
|
|||
addArtistContentFilter: t('components.audio.Player.label.addArtistContentFilter')
|
||||
}))
|
||||
|
||||
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
|
||||
|
@ -108,17 +119,18 @@ const loopingTitle = computed(() => {
|
|||
: t('components.audio.Player.label.loopingWholeQueue')
|
||||
})
|
||||
|
||||
const hideArtist = () => {
|
||||
if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
|
||||
return store.dispatch('moderation/hide', {
|
||||
type: 'artist',
|
||||
target: {
|
||||
id: currentTrack.value.artistCredit[0].artist.id,
|
||||
name: currentTrack.value.artistCredit[0].artist.name
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// TODO: check if still useful for filtering
|
||||
// const hideArtist = () => {
|
||||
// if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
|
||||
// return store.dispatch('moderation/hide', {
|
||||
// type: 'artist',
|
||||
// target: {
|
||||
// id: currentTrack.value.artistCredit[0].artist.id,
|
||||
// name: currentTrack.value.artistCredit[0].artist.name
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -135,7 +147,7 @@ const hideArtist = () => {
|
|||
/>
|
||||
<div
|
||||
class="ui inverted segment fixed-controls"
|
||||
@click.prevent.stop="toggleMobilePlayer"
|
||||
@click.prevent.stop="togglePlayer"
|
||||
>
|
||||
<div
|
||||
ref="progressBar"
|
||||
|
@ -156,12 +168,13 @@ const hideArtist = () => {
|
|||
<div class="controls track-controls queue-not-focused desktop-and-up">
|
||||
<div
|
||||
class="ui tiny image"
|
||||
@click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})"
|
||||
@click.stop.prevent="router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})"
|
||||
>
|
||||
<!-- TODO: Use smaller covers -->
|
||||
<img
|
||||
ref="cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
|
@ -170,7 +183,7 @@ const hideArtist = () => {
|
|||
>
|
||||
<strong>
|
||||
<router-link
|
||||
class="small header discrete link track"
|
||||
class="header discrete link track"
|
||||
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
|
@ -184,11 +197,11 @@ const hideArtist = () => {
|
|||
:key="ac.artist.id"
|
||||
>
|
||||
<router-link
|
||||
class="discrete link"
|
||||
class="small discrete link"
|
||||
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }}
|
||||
{{ ac.credit ?? t('components.audio.Player.meta.unknownArtist') }}
|
||||
</router-link>
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</template>
|
||||
|
@ -196,11 +209,11 @@ const hideArtist = () => {
|
|||
<template v-if="currentTrack.albumId !== -1">
|
||||
<span class="middle slash symbol" />
|
||||
<router-link
|
||||
class="discrete link"
|
||||
class="small discrete link"
|
||||
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
{{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }}
|
||||
{{ currentTrack.albumTitle ?? t('components.audio.Player.meta.unknownAlbum') }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -208,51 +221,57 @@ const hideArtist = () => {
|
|||
</div>
|
||||
<div class="controls track-controls queue-not-focused desktop-and-below">
|
||||
<div class="ui tiny image">
|
||||
<!-- TODO: Use smaller covers -->
|
||||
<img
|
||||
ref="cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
>
|
||||
</div>
|
||||
<div class="middle aligned content ellipsis">
|
||||
<strong>
|
||||
{{ currentTrack.title }}
|
||||
</strong>
|
||||
<div class="meta">
|
||||
<Layout
|
||||
flex
|
||||
no-gap
|
||||
class="meta"
|
||||
>
|
||||
<div
|
||||
v-for="ac in currentTrack.artistCredit"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }}
|
||||
{{ ac.credit ?? t('components.audio.Player.meta.unknownArtist') }}
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</div>
|
||||
<template v-if="currentTrack.albumId !== -1">
|
||||
<span class="middle slash symbol" />
|
||||
{{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }}
|
||||
{{ currentTrack.albumTitle ?? t('components.audio.Player.meta.unknownAlbum') }}
|
||||
</template>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
class="controls desktop-and-up fluid align-right"
|
||||
>
|
||||
<track-favorite-icon
|
||||
class="control white"
|
||||
ghost
|
||||
:track="currentTrack"
|
||||
/>
|
||||
<track-playlist-icon
|
||||
class="control white"
|
||||
ghost
|
||||
:track="currentTrack"
|
||||
/>
|
||||
<button
|
||||
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'control']"
|
||||
<!-- <Button
|
||||
round
|
||||
ghost
|
||||
icon="bi-eye-slash"
|
||||
:aria-label="labels.addArtistContentFilter"
|
||||
:title="labels.addArtistContentFilter"
|
||||
@click="hideArtist"
|
||||
>
|
||||
<i :class="['eye slash outline', 'basic', 'icon']" />
|
||||
</button>
|
||||
</Button> -->
|
||||
</div>
|
||||
<player-controls class="controls queue-not-focused" />
|
||||
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
|
||||
|
@ -272,49 +291,39 @@ const hideArtist = () => {
|
|||
<div class="controls queue-controls when-queue-focused align-right">
|
||||
<div class="group">
|
||||
<volume-control class="expandable" />
|
||||
<button
|
||||
class="circular control button"
|
||||
<Button
|
||||
:class="{ looping: looping !== LoopingMode.None }"
|
||||
:title="loopingTitle"
|
||||
ghost
|
||||
round
|
||||
:aria-label="loopingTitle"
|
||||
:disabled="!currentTrack"
|
||||
:icon="looping === LoopingMode.LoopTrack ? 'bi-repeat-1' : 'bi-repeat'"
|
||||
@click.prevent.stop="toggleLooping"
|
||||
>
|
||||
<i class="repeat icon">
|
||||
<span
|
||||
v-if="looping !== LoopingMode.None"
|
||||
class="ui circular tiny vibrant label"
|
||||
>
|
||||
<span
|
||||
v-if="looping === LoopingMode.LoopTrack"
|
||||
class="symbol single"
|
||||
/>
|
||||
<span
|
||||
v-else-if="looping === LoopingMode.LoopQueue"
|
||||
class="infinity symbol"
|
||||
/>
|
||||
</span>
|
||||
</i>
|
||||
</button>
|
||||
/>
|
||||
|
||||
<button
|
||||
class="circular control button"
|
||||
<Button
|
||||
round
|
||||
ghost
|
||||
:class="{ shuffling: isShuffled }"
|
||||
:disabled="queue.length === 0"
|
||||
:title="labels.shuffle"
|
||||
:aria-label="labels.shuffle"
|
||||
icon="bi-shuffle"
|
||||
@click.prevent.stop="shuffle()"
|
||||
>
|
||||
<i :class="['ui', 'random', { disabled: queue.length === 0, shuffling: isShuffled }, 'icon']" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TODO: Remove fake responsive elements -->
|
||||
<div class="group">
|
||||
<div class="fake-dropdown">
|
||||
<button
|
||||
class="position circular control button desktop-and-up"
|
||||
<Button
|
||||
aria-expanded="true"
|
||||
@click.stop="toggleMobilePlayer"
|
||||
ghost
|
||||
round
|
||||
icon="bi-music-note-list"
|
||||
@click.stop="togglePlayer"
|
||||
>
|
||||
<i class="stream icon" />
|
||||
<i18n-t keypath="components.audio.Player.meta.position">
|
||||
<template #index>
|
||||
{{ currentIndex + 1 }}
|
||||
|
@ -323,12 +332,11 @@ const hideArtist = () => {
|
|||
{{ queue.length }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
class="position circular control button desktop-and-below"
|
||||
@click.stop="switchTab"
|
||||
icon="bi-music-note-list"
|
||||
>
|
||||
<i class="stream icon" />
|
||||
<i18n-t keypath="components.audio.Player.meta.position">
|
||||
<template #index>
|
||||
{{ currentIndex + 1 }}
|
||||
|
@ -337,46 +345,35 @@ const hideArtist = () => {
|
|||
{{ queue.length }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
v-if="$store.state.ui.queueFocused"
|
||||
class="circular control button close-control desktop-and-up"
|
||||
@click.stop="toggleMobilePlayer"
|
||||
>
|
||||
<i class="large down angle icon" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="circular control button desktop-and-up"
|
||||
@click.stop="toggleMobilePlayer"
|
||||
>
|
||||
<i class="large up angle icon" />
|
||||
</button>
|
||||
<button
|
||||
v-if="$store.state.ui.queueFocused === 'player'"
|
||||
class="circular control button close-control desktop-and-below"
|
||||
<Button
|
||||
ghost
|
||||
:class="['desktop-and-up', { 'close-control': store.state.ui.queueFocused }]"
|
||||
:icon="store.state.ui.queueFocused ? 'bi-chevron-down' : 'bi-chevron-up'"
|
||||
:aria-pressed="store.state.ui.queueFocused ? true : undefined"
|
||||
@click.stop="togglePlayer"
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
:class="['desktop-and-below', { 'close-control': store.state.ui.queueFocused === 'player' }]"
|
||||
:icon="store.state.ui.queueFocused === 'queue' ? 'bi-chevron-down' : 'bi-chevron-up'"
|
||||
:aria-pressed="store.state.ui.queueFocused ? true : undefined"
|
||||
@click.stop="switchTab"
|
||||
>
|
||||
<i class="large up angle icon" />
|
||||
</button>
|
||||
<button
|
||||
v-if="$store.state.ui.queueFocused === 'queue'"
|
||||
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 desktop-and-below"
|
||||
@click.stop="$store.commit('ui/queueFocused', null)"
|
||||
>
|
||||
<i class="x icon" />
|
||||
</button>
|
||||
<Button
|
||||
class="close-control desktop-and-below"
|
||||
icon="bi-x"
|
||||
@click.stop="store.commit('ui/queueFocused', null)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
|
|
|
@ -5,7 +5,10 @@ import { computed } from 'vue'
|
|||
import { usePlayer } from '~/composables/audio/player'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
|
||||
const { playPrevious, hasNext, playNext, currentTrack } = useQueue()
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
// TODO: Check if we want to use `currentTrack` from useQueue() in order to disable some icon. Or not.
|
||||
const { playPrevious, hasNext, playNext } = useQueue()
|
||||
const { isPlaying } = usePlayer()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
@ -19,40 +22,33 @@ const labels = computed(() => ({
|
|||
|
||||
<template>
|
||||
<div class="player-controls">
|
||||
<button
|
||||
<Button
|
||||
:title="labels.previous"
|
||||
:aria-label="labels.previous"
|
||||
class="circular button control tablet-and-up"
|
||||
round
|
||||
ghost
|
||||
class="control tablet-and-up"
|
||||
icon="bi-skip-backward-fill"
|
||||
@click.prevent.stop="playPrevious()"
|
||||
>
|
||||
<i :class="['ui', 'large', 'backward step', 'icon']" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!isPlaying"
|
||||
:title="labels.play"
|
||||
:aria-label="labels.play"
|
||||
class="circular button control"
|
||||
@click.prevent.stop="isPlaying = true"
|
||||
>
|
||||
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:title="labels.pause"
|
||||
:aria-label="labels.pause"
|
||||
class="circular button control"
|
||||
@click.prevent.stop="isPlaying = false"
|
||||
>
|
||||
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
:title="isPlaying ? labels.pause : labels.play"
|
||||
round
|
||||
ghost
|
||||
:aria-label="isPlaying ? labels.pause : labels.play"
|
||||
:class="['control', isPlaying ? 'pause' : 'play', 'large']"
|
||||
:icon="isPlaying ? 'bi-pause-fill' : 'bi-play-fill'"
|
||||
@click.prevent.stop="isPlaying = !isPlaying"
|
||||
/>
|
||||
<Button
|
||||
:title="labels.next"
|
||||
:aria-label="labels.next"
|
||||
round
|
||||
ghost
|
||||
:disabled="!hasNext"
|
||||
class="circular button control"
|
||||
class="control"
|
||||
icon="bi-skip-forward-fill"
|
||||
@click.prevent.stop="playNext()"
|
||||
>
|
||||
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -6,8 +6,8 @@ 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 AlbumCard from '~/components/album/Card.vue'
|
||||
import ArtistCard from '~/components/artist/Card.vue'
|
||||
|
||||
import useErrorHandler from '~/composables/useErrorHandler'
|
||||
import useLogger from '~/composables/useLogger'
|
||||
|
@ -73,7 +73,7 @@ const labels = computed(() => ({
|
|||
<template>
|
||||
<div>
|
||||
<h2>
|
||||
{{ $t('components.audio.Search.header.search') }}
|
||||
{{ t('components.audio.Search.header.search') }}
|
||||
</h2>
|
||||
<div :class="['ui', {'loading': isLoading }, 'search']">
|
||||
<div class="ui icon big input">
|
||||
|
@ -89,7 +89,7 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
<template v-if="query.length > 0">
|
||||
<h3 class="ui title">
|
||||
{{ $t('components.audio.Search.header.artists') }}
|
||||
{{ t('components.audio.Search.header.artists') }}
|
||||
</h3>
|
||||
<div v-if="results.artists.length > 0">
|
||||
<div class="ui cards">
|
||||
|
@ -101,12 +101,12 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ $t('components.audio.Search.empty.noArtists') }}
|
||||
{{ t('components.audio.Search.empty.noArtists') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="query.length > 0">
|
||||
<h3 class="ui title">
|
||||
{{ $t('components.audio.Search.header.albums') }}
|
||||
{{ t('components.audio.Search.header.albums') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="results.albums.length > 0"
|
||||
|
@ -124,7 +124,7 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ $t('components.audio.Search.empty.noAlbums') }}
|
||||
{{ t('components.audio.Search.empty.noAlbums') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
@ -1,49 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { Artist, Track, Album, Tag } from '~/types'
|
||||
import type { RouteRecordName, RouteLocationNamedRaw } from 'vue-router'
|
||||
|
||||
import jQuery from 'jquery'
|
||||
import { trim } from 'lodash-es'
|
||||
import { useFocus, useCurrentElement } from '@vueuse/core'
|
||||
import { useFocus } from '@vueuse/core'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStore } from '~/store'
|
||||
import { generateTrackCreditString } from '~/utils/utils'
|
||||
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
|
||||
interface Events {
|
||||
(e: 'search'): void
|
||||
}
|
||||
|
||||
type CategoryCode = 'federation' | 'podcasts' | 'artists' | 'albums' | 'tracks' | 'tags' | 'more'
|
||||
interface Category {
|
||||
code: CategoryCode,
|
||||
name: string,
|
||||
route: RouteRecordName
|
||||
getId: (obj: unknown) => number
|
||||
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?: number
|
||||
description?: string
|
||||
routerUrl: RouteLocationNamedRaw
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
|
||||
const search = ref()
|
||||
const { focused } = useFocus(search)
|
||||
onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true)
|
||||
|
@ -60,12 +23,11 @@ const labels = computed(() => ({
|
|||
}))
|
||||
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
const el = useCurrentElement()
|
||||
const query = ref()
|
||||
|
||||
const enter = () => {
|
||||
jQuery(el.value).search('cancel query')
|
||||
// TODO: Find out what jQuery version supports `search`
|
||||
// jQuery(el.value).search('cancel query')
|
||||
|
||||
// Cancel any API search request to backend…
|
||||
return router.push(`/search?q=${query.value}&type=artists`)
|
||||
|
@ -75,168 +37,109 @@ const blur = () => {
|
|||
search.value.blur()
|
||||
}
|
||||
|
||||
const categories = computed(() => [
|
||||
{
|
||||
code: 'federation',
|
||||
name: t('components.audio.SearchBar.label.category.federation')
|
||||
},
|
||||
{
|
||||
code: 'podcasts',
|
||||
name: t('components.audio.SearchBar.label.category.podcasts')
|
||||
},
|
||||
{
|
||||
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) => generateTrackCreditString(obj)
|
||||
},
|
||||
{
|
||||
code: 'tracks',
|
||||
route: 'library.tracks.detail',
|
||||
name: labels.value.track,
|
||||
getId: (obj: Track) => obj.id,
|
||||
getTitle: (obj: Track) => obj.title,
|
||||
getDescription: (track: Track) => {
|
||||
const album = track.album ?? null
|
||||
return generateTrackCreditString(album) ?? generateTrackCreditString(track) ?? ''
|
||||
}
|
||||
},
|
||||
{
|
||||
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)[])
|
||||
|
||||
const objectId = computed(() => {
|
||||
const trimmedQuery = trim(trim(query.value), '@')
|
||||
|
||||
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: t('components.audio.SearchBar.header.noResults'),
|
||||
noResults: t('components.audio.SearchBar.empty.noResults')
|
||||
},
|
||||
// TODO: Find out what jQuery version supports `search`
|
||||
// jQuery(el.value).search({
|
||||
// type: 'category',
|
||||
// minCharacters: 3,
|
||||
// showNoResults: true,
|
||||
// error: {
|
||||
// // @ts-expect-error Semantic is broken
|
||||
// noResultsHeader: t('components.audio.SearchBar.header.noResults'),
|
||||
// noResults: t('components.audio.SearchBar.empty.noResults')
|
||||
// },
|
||||
|
||||
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
|
||||
}
|
||||
// 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'])
|
||||
}
|
||||
// if (store.state.auth.oauth.accessToken) {
|
||||
// xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
|
||||
// }
|
||||
|
||||
return xhrObject
|
||||
},
|
||||
onResponse: function (initialResponse) {
|
||||
const id = objectId.value
|
||||
const results: Partial<Record<CategoryCode, Results>> = {}
|
||||
// return xhrObject
|
||||
// },
|
||||
// onResponse: function (initialResponse) {
|
||||
// const id = objectId.value
|
||||
// const results: Partial<Record<CategoryCode, Results>> = {}
|
||||
|
||||
let resultsEmpty = true
|
||||
for (const category of categories.value) {
|
||||
results[category.code] = {
|
||||
name: category.name,
|
||||
results: []
|
||||
}
|
||||
// let resultsEmpty = true
|
||||
// for (const category of categories.value) {
|
||||
// results[category.code] = {
|
||||
// name: category.name,
|
||||
// results: []
|
||||
// }
|
||||
|
||||
if (category.code === 'federation' && id) {
|
||||
resultsEmpty = false
|
||||
results[category.code]?.results.push({
|
||||
title: t('components.audio.SearchBar.link.fediverse'),
|
||||
routerUrl: {
|
||||
name: 'search',
|
||||
query: { id }
|
||||
}
|
||||
})
|
||||
}
|
||||
// if (category.code === 'federation' && id) {
|
||||
// resultsEmpty = false
|
||||
// results[category.code]?.results.push({
|
||||
// title: t('components.audio.SearchBar.link.fediverse'),
|
||||
// routerUrl: {
|
||||
// name: 'search',
|
||||
// query: { id }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
if (category.code === 'podcasts' && id) {
|
||||
resultsEmpty = false
|
||||
results[category.code]?.results.push({
|
||||
title: t('components.audio.SearchBar.link.rss'),
|
||||
routerUrl: {
|
||||
name: 'search',
|
||||
query: { id, type: 'rss' }
|
||||
}
|
||||
})
|
||||
}
|
||||
// if (category.code === 'podcasts' && id) {
|
||||
// resultsEmpty = false
|
||||
// results[category.code]?.results.push({
|
||||
// title: t('components.audio.SearchBar.link.rss'),
|
||||
// routerUrl: {
|
||||
// name: 'search',
|
||||
// query: { id, type: 'rss' }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
if (category.code === 'more') {
|
||||
results[category.code]?.results.push({
|
||||
title: t('components.audio.SearchBar.link.more'),
|
||||
routerUrl: {
|
||||
name: 'search',
|
||||
query: { type: 'artists', q: query.value }
|
||||
}
|
||||
})
|
||||
}
|
||||
// if (category.code === 'more') {
|
||||
// results[category.code]?.results.push({
|
||||
// title: t('components.audio.SearchBar.link.more'),
|
||||
// routerUrl: {
|
||||
// name: 'search',
|
||||
// query: { type: 'artists', q: query.value }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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: resultsEmpty
|
||||
? {}
|
||||
: results
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// return {
|
||||
// results: resultsEmpty
|
||||
// ? {}
|
||||
// : results
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ import { useTimeoutFn } from '@vueuse/core'
|
|||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const { volume, mute } = usePlayer()
|
||||
const expanded = ref(false)
|
||||
|
||||
|
@ -32,8 +34,10 @@ const scroll = (event: WheelEvent) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="circular control button"
|
||||
<Button
|
||||
round
|
||||
ghost
|
||||
square
|
||||
:class="['component-volume-control', {'expanded': expanded}]"
|
||||
@click.prevent.stop=""
|
||||
@mouseover="handleOver"
|
||||
|
@ -47,7 +51,7 @@ const scroll = (event: WheelEvent) => {
|
|||
:aria-label="labels.unmute"
|
||||
@click.prevent.stop="mute"
|
||||
>
|
||||
<i class="volume off icon" />
|
||||
<i class="bi bi-volume-mute-fill" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="volume < 0.5"
|
||||
|
@ -56,7 +60,7 @@ const scroll = (event: WheelEvent) => {
|
|||
:aria-label="labels.mute"
|
||||
@click.prevent.stop="mute"
|
||||
>
|
||||
<i class="volume down icon" />
|
||||
<i class="bi bi-volume-down-fill" />
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
|
@ -65,7 +69,7 @@ const scroll = (event: WheelEvent) => {
|
|||
:aria-label="labels.mute"
|
||||
@click.prevent.stop="mute"
|
||||
>
|
||||
<i class="volume up icon" />
|
||||
<i class="bi bi-volume-up-fill" />
|
||||
</span>
|
||||
<div class="popup">
|
||||
<label
|
||||
|
@ -81,5 +85,5 @@ const scroll = (event: WheelEvent) => {
|
|||
max="1"
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</template>
|
||||
|
|
|
@ -1,83 +1,16 @@
|
|||
<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'
|
||||
import AlbumCard from '~/components/album/Card.vue'
|
||||
|
||||
interface Props {
|
||||
album: Album
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const store = useStore()
|
||||
defineProps<Props>()
|
||||
|
||||
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
|
||||
class="discrete link"
|
||||
:to="{name: 'library.albums.detail', params: {id: album.id}}"
|
||||
>
|
||||
<div
|
||||
v-lazy:background-image="imageUrl"
|
||||
:class="['ui', 'head-image', 'image', {'default-cover': !album.cover || !album.cover.urls.original}]"
|
||||
>
|
||||
<play-button
|
||||
:icon-only="true"
|
||||
:is-playable="album.is_playable"
|
||||
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
|
||||
:album="album"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
<div class="content">
|
||||
<strong>
|
||||
<router-link
|
||||
class="discrete link"
|
||||
:to="{name: 'library.albums.detail', params: {id: album.id}}"
|
||||
>
|
||||
{{ album.title }}
|
||||
</router-link>
|
||||
</strong>
|
||||
<div class="description">
|
||||
<span>
|
||||
<template
|
||||
v-for="ac in album.artist_credit"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
<router-link
|
||||
class="discrete link"
|
||||
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id }}"
|
||||
>
|
||||
{{ ac.credit }}
|
||||
</router-link>
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<span v-if="album.release_date">
|
||||
{{ momentFormat(new Date(album.release_date), 'Y') }}
|
||||
<span class="middle middledot symbol" />
|
||||
</span>
|
||||
<span>
|
||||
{{ $t('components.audio.album.Card.meta.tracks', album.tracks_count) }}
|
||||
</span>
|
||||
<play-button
|
||||
class="right floated basic icon"
|
||||
:dropdown-only="true"
|
||||
:is-playable="album.is_playable"
|
||||
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
|
||||
:album="album"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AlbumCard :album="album" />
|
||||
</template>
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
<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'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
import TagsList from '~/components/tags/List.vue'
|
||||
|
||||
interface Props {
|
||||
artist: Artist
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const cover = computed(() => !props.artist.cover?.urls.original
|
||||
? props.artist.albums.find(album => !!album.cover?.urls.original)?.cover
|
||||
? undefined // TODO: Also check Albums. Like in props.artist.albums.find(album => !!album.cover?.urls.original)?.cover
|
||||
: props.artist.cover
|
||||
)
|
||||
|
||||
|
@ -37,7 +41,7 @@ const imageUrl = computed(() => cover.value?.urls.original
|
|||
>
|
||||
<play-button
|
||||
:icon-only="true"
|
||||
:is-playable="artist.is_playable"
|
||||
:is-playable="true /* TODO: check if artist.is_playable exists instead */"
|
||||
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
|
||||
:artist="artist"
|
||||
/>
|
||||
|
@ -53,7 +57,7 @@ const imageUrl = computed(() => cover.value?.urls.original
|
|||
</router-link>
|
||||
</strong>
|
||||
|
||||
<tags-list
|
||||
<TagsList
|
||||
label-classes="tiny"
|
||||
:truncate-size="20"
|
||||
:limit="2"
|
||||
|
@ -63,15 +67,15 @@ const imageUrl = computed(() => cover.value?.urls.original
|
|||
</div>
|
||||
<div class="extra content">
|
||||
<span v-if="artist.content_category === 'music'">
|
||||
{{ $t('components.audio.artist.Card.meta.tracks', artist.tracks_count) }}
|
||||
{{ t('components.audio.artist.Card.meta.tracks', (0 /* TODO: check where artist.tracks_count exists */)) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.audio.artist.Card.meta.episodes', artist.tracks_count) }}
|
||||
{{ t('components.audio.artist.Card.meta.episodes', (0 /* TODO: check where artist.tracks_count exists */)) }}
|
||||
</span>
|
||||
<play-button
|
||||
class="right floated basic icon"
|
||||
:dropdown-only="true"
|
||||
:is-playable="artist.is_playable"
|
||||
:is-playable="true /* TODO: check if is_playable can be derived from the data */"
|
||||
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
|
||||
:artist="artist"
|
||||
/>
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'
|
|||
|
||||
import { usePlayer } from '~/composables/audio/player'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||
|
||||
|
@ -54,6 +55,8 @@ const { isPlaying } = usePlayer()
|
|||
const { activateTrack } = usePlayOptions(props)
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
|
||||
const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.button.actions'))
|
||||
</script>
|
||||
|
||||
|
@ -71,13 +74,13 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
|
|||
>
|
||||
<img
|
||||
v-if="track.album?.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_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-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
|
@ -136,7 +139,7 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
|
|||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
||||
v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
||||
:class="[
|
||||
'meta',
|
||||
'right',
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<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 { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||
import useReport from '~/composables/moderation/useReport'
|
||||
|
@ -55,11 +55,13 @@ const show = useVModel(props, 'show', emit)
|
|||
|
||||
const { report, getReportableObjects } = useReport()
|
||||
const { enqueue, enqueueNext } = usePlayOptions(props)
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
||||
|
||||
const { t } = useI18n()
|
||||
const favoriteButton = computed(() => isFavorite.value
|
||||
? t('components.audio.podcast.Modal.button.removeFromFavorites')
|
||||
: t('components.audio.podcast.Modal.button.addToFavorites')
|
||||
|
@ -90,18 +92,19 @@ const labels = computed(() => ({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<semantic-modal
|
||||
<Modal
|
||||
ref="modal"
|
||||
v-model:show="show"
|
||||
v-model="show"
|
||||
:title="track.title"
|
||||
:scrolling="true"
|
||||
:additional-classes="['scrolling-track-options']"
|
||||
class="scrolling-track-options"
|
||||
>
|
||||
<div class="header">
|
||||
<template #topright>
|
||||
<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'](
|
||||
store.getters['instance/absoluteUrl'](
|
||||
track.album.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
|
@ -111,7 +114,7 @@ const labels = computed(() => ({
|
|||
<img
|
||||
v-else-if="track.cover"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
store.getters['instance/absoluteUrl'](
|
||||
track.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
|
@ -133,18 +136,15 @@ const labels = computed(() => ({
|
|||
src="../../../assets/audio/default-cover.png"
|
||||
>
|
||||
</div>
|
||||
<h3 class="track-modal-title">
|
||||
{{ track.title }}
|
||||
</h3>
|
||||
<h4 class="track-modal-subtitle">
|
||||
{{ generateTrackCreditString(track) }}
|
||||
</h4>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="content">
|
||||
<div class="ui one column unstackable grid">
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist?.content_category !== 'podcast'"
|
||||
v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist?.content_category !== 'podcast'"
|
||||
class="row"
|
||||
>
|
||||
<div
|
||||
|
@ -152,7 +152,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="favoriteButton"
|
||||
@click.stop="$store.dispatch('favorites/toggle', track.id)"
|
||||
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
|
@ -175,7 +175,7 @@ const labels = computed(() => ({
|
|||
:aria-label="labels.addToQueue"
|
||||
@click.stop.prevent="
|
||||
enqueue();
|
||||
modal.closeModal();
|
||||
show=false
|
||||
"
|
||||
>
|
||||
<i class="plus icon track-modal list-icon" />
|
||||
|
@ -189,7 +189,7 @@ const labels = computed(() => ({
|
|||
:aria-label="labels.playNext"
|
||||
@click.stop.prevent="
|
||||
enqueueNext(true);
|
||||
modal.closeModal();
|
||||
show=false
|
||||
"
|
||||
>
|
||||
<i class="step forward icon track-modal list-icon" />
|
||||
|
@ -202,11 +202,11 @@ const labels = computed(() => ({
|
|||
role="button"
|
||||
:aria-label="labels.startRadio"
|
||||
@click.stop.prevent="
|
||||
$store.dispatch('radios/start', {
|
||||
store.dispatch('radios/start', {
|
||||
type: 'similar',
|
||||
objectId: track.id,
|
||||
});
|
||||
modal.closeModal();
|
||||
show=false
|
||||
"
|
||||
>
|
||||
<i class="rss icon track-modal list-icon" />
|
||||
|
@ -218,7 +218,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="labels.addToPlaylist"
|
||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
||||
@click.stop="store.commit('playlists/chooseTrack', track)"
|
||||
>
|
||||
<i class="list icon track-modal list-icon" />
|
||||
<span class="track-modal list-item">{{
|
||||
|
@ -236,7 +236,7 @@ const labels = computed(() => ({
|
|||
role="button"
|
||||
:aria-label="albumDetailsButton"
|
||||
@click.prevent.exact="
|
||||
$router.push({
|
||||
router.push({
|
||||
name: 'library.albums.detail',
|
||||
params: { id: track.album?.id },
|
||||
})
|
||||
|
@ -258,7 +258,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="artistDetailsButton"
|
||||
@click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
||||
@click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
||||
>
|
||||
<i class="user icon track-modal list-icon" />
|
||||
<span class="track-modal list-item">{{ ac.artist.name }}</span>
|
||||
|
@ -271,7 +271,7 @@ const labels = computed(() => ({
|
|||
role="button"
|
||||
:aria-label="trackDetailsButton"
|
||||
@click.prevent.exact="
|
||||
$router.push({
|
||||
router.push({
|
||||
name: 'library.tracks.detail',
|
||||
params: { id: track.id },
|
||||
})
|
||||
|
@ -298,5 +298,5 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { Track, Album, Playlist, Library, Channel, Actor, Cover, ArtistCredit } from '~/types'
|
||||
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||
import { getArtistCoverUrl } from '~/utils/utils'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
|
@ -48,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
account: null
|
||||
})
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const description = ref('')
|
||||
const renderedDescription = useMarkdown(description)
|
||||
|
||||
|
@ -83,13 +87,25 @@ await fetchData()
|
|||
>
|
||||
<img
|
||||
v-if="track.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-if="track.album?.cover?.urls.original"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
|
||||
v-lazy="getArtistCoverUrl(track.artist_credit)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="defaultCover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](defaultCover.urls.small_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
|
@ -120,10 +136,10 @@ await fetchData()
|
|||
class="meta right floated column"
|
||||
>
|
||||
<play-button
|
||||
id="playmenu"
|
||||
class="play-button basic icon"
|
||||
:dropdown-only="true"
|
||||
:is-playable="track.is_playable"
|
||||
discrete
|
||||
:dropdown-icon-classes="[
|
||||
'ellipsis',
|
||||
'vertical',
|
||||
|
|
|
@ -3,7 +3,7 @@ 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'
|
||||
import Pagination from '~/components/ui/Pagination.vue'
|
||||
|
||||
interface Props {
|
||||
tracks: Track[]
|
||||
|
@ -61,11 +61,9 @@ const { page } = defineModels<{ page: number, }>()
|
|||
v-if="paginateResults"
|
||||
class="ui center aligned basic segment desktop-and-up"
|
||||
>
|
||||
<pagination
|
||||
v-bind="$attrs"
|
||||
v-model:current="page"
|
||||
:total="total"
|
||||
:paginate-by="paginateBy"
|
||||
<Pagination
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((total || 0)/paginateBy)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -90,12 +88,10 @@ const { page } = defineModels<{ page: number, }>()
|
|||
v-if="paginateResults"
|
||||
class="ui center aligned basic segment tablet-and-below"
|
||||
>
|
||||
<pagination
|
||||
<Pagination
|
||||
v-if="paginateResults"
|
||||
v-bind="$attrs"
|
||||
v-model:current="page"
|
||||
:total="total"
|
||||
:compact="true"
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((total || 0)/paginateBy)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,12 +7,13 @@ import { useI18n } from 'vue-i18n'
|
|||
|
||||
import { usePlayer } from '~/composables/audio/player'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||
|
||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||
import TrackModal from '~/components/audio/track/Modal.vue'
|
||||
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
|
||||
import { generateTrackCreditString } from '~/utils/utils'
|
||||
|
||||
interface Props extends PlayOptionsProps {
|
||||
track: Track
|
||||
|
@ -54,6 +55,8 @@ const { isPlaying } = usePlayer()
|
|||
const { activateTrack } = usePlayOptions(props)
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
|
||||
const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.button.actions'))
|
||||
</script>
|
||||
|
||||
|
@ -70,20 +73,14 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
|||
@click.prevent.exact="activateTrack(track, index)"
|
||||
>
|
||||
<img
|
||||
v-if="track.album?.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||
v-if="track.cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_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)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
|
||||
v-lazy="getArtistCoverUrl(track.artist_credit)"
|
||||
v-else-if="track.album?.cover?.urls.original"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
|
@ -113,13 +110,13 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
|||
{{ generateTrackCreditString(track) }}
|
||||
<span class="middle middledot symbol" />
|
||||
<human-duration
|
||||
v-if="track.uploads[0] && track.uploads[0].duration"
|
||||
:duration="track.uploads[0].duration"
|
||||
v-if="track.uploads?.[0]?.duration"
|
||||
:duration="track.uploads[0]?.duration"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
:class="[
|
||||
'meta',
|
||||
'right',
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
<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 { useI18n } from 'vue-i18n'
|
||||
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 { useStore } from '~/store'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
|
||||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'update:show', value: boolean): void
|
||||
}
|
||||
|
@ -59,6 +61,8 @@ const store = useStore()
|
|||
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const favoriteButton = computed(() => isFavorite.value
|
||||
? t('components.audio.track.Modal.button.removeFromFavorites')
|
||||
: t('components.audio.track.Modal.button.addToFavorites')
|
||||
|
@ -89,23 +93,24 @@ const labels = computed(() => ({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<semantic-modal
|
||||
<Modal
|
||||
ref="modal"
|
||||
v-model:show="show"
|
||||
v-model="show"
|
||||
:title="track.title"
|
||||
:scrolling="true"
|
||||
:additional-classes="['scrolling-track-options']"
|
||||
class="scrolling-track-options"
|
||||
>
|
||||
<div class="header">
|
||||
<div class="ui large centered rounded image">
|
||||
<img
|
||||
v-if="track.album?.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||
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"
|
||||
>
|
||||
|
@ -122,9 +127,6 @@ const labels = computed(() => ({
|
|||
src="../../../assets/audio/default-cover.png"
|
||||
>
|
||||
</div>
|
||||
<h3 class="track-modal-title">
|
||||
{{ track.title }}
|
||||
</h3>
|
||||
<h4 class="track-modal-subtitle">
|
||||
{{ generateTrackCreditString(track) }}
|
||||
</h4>
|
||||
|
@ -133,7 +135,7 @@ const labels = computed(() => ({
|
|||
<div class="content">
|
||||
<div class="ui one column unstackable grid">
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
||||
v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
||||
class="row"
|
||||
>
|
||||
<div
|
||||
|
@ -141,7 +143,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="favoriteButton"
|
||||
@click.stop="$store.dispatch('favorites/toggle', track.id)"
|
||||
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
||||
>
|
||||
<i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" />
|
||||
<span class="track-modal list-item">{{ favoriteButton }}</span>
|
||||
|
@ -152,7 +154,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="labels.addToQueue"
|
||||
@click.stop.prevent="enqueue(); modal.closeModal()"
|
||||
@click.stop.prevent="enqueue(); show = false"
|
||||
>
|
||||
<i class="plus icon track-modal list-icon" />
|
||||
<span class="track-modal list-item">{{ labels.addToQueue }}</span>
|
||||
|
@ -163,7 +165,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="labels.playNext"
|
||||
@click.stop.prevent="enqueueNext(true);modal.closeModal()"
|
||||
@click.stop.prevent="enqueueNext(true);show = false"
|
||||
>
|
||||
<i class="step forward icon track-modal list-icon" />
|
||||
<span class="track-modal list-item">{{ labels.playNext }}</span>
|
||||
|
@ -174,7 +176,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="labels.startRadio"
|
||||
@click.stop.prevent="() => { $store.dispatch('radios/start', { type: 'similar', objectId: track.id }); modal.closeModal() }"
|
||||
@click.stop.prevent="() => { store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false }"
|
||||
>
|
||||
<i class="rss icon track-modal list-icon" />
|
||||
<span class="track-modal list-item">{{ labels.startRadio }}</span>
|
||||
|
@ -185,7 +187,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="labels.addToPlaylist"
|
||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
||||
@click.stop="store.commit('playlists/chooseTrack', track)"
|
||||
>
|
||||
<i class="list icon track-modal list-icon" />
|
||||
<span class="track-modal list-item">
|
||||
|
@ -202,7 +204,7 @@ const labels = computed(() => ({
|
|||
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>
|
||||
|
@ -218,7 +220,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="artistDetailsButton"
|
||||
@click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
||||
@click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
||||
>
|
||||
<i class="user icon track-modal list-icon" />
|
||||
<span class="track-modal list-item">{{ ac.credit }}</span>
|
||||
|
@ -230,7 +232,7 @@ const labels = computed(() => ({
|
|||
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>
|
||||
|
@ -250,5 +252,5 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
|
@ -4,5 +4,9 @@
|
|||
<div class="audio-bar" />
|
||||
<div class="audio-bar" />
|
||||
<div class="audio-bar" />
|
||||
<div class="audio-bar" />
|
||||
<div class="audio-bar" />
|
||||
<div class="audio-bar" />
|
||||
<div class="audio-bar" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue