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!2805
merge-requests/2805/merge
Arne Bollinger 2025-04-12 14:48:32 +00:00
commit 38e29690dd
491 zmienionych plików z 78186 dodań i 19523 usunięć

Wyświetl plik

@ -1,3 +1,5 @@
COMPOSE_BAKE=true
# api + celeryworker
DEBUG=True
DEFAULT_FROM_EMAIL=hello@funkwhale.test

21
.gitignore vendored
Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

28
.prettierrc 100644
Wyświetl plik

@ -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
}
}
]
}

Wyświetl plik

@ -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"),
],

Wyświetl plik

@ -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()

Wyświetl plik

@ -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,

Wyświetl plik

@ -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:

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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:

Wyświetl plik

@ -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()

Wyświetl plik

@ -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

Wyświetl plik

@ -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):

Wyświetl plik

@ -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,
)

Wyświetl plik

@ -49,6 +49,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
"duration",
"is_playable",
"actor",
"description",
)
read_only_fields = ["id", "modification_date", "creation_date"]

Wyświetl plik

@ -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
)

Wyświetl plik

@ -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

Wyświetl plik

@ -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"
),

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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
),

Wyświetl plik

@ -0,0 +1 @@
Improve mobile design (#2090)

Wyświetl plik

@ -0,0 +1 @@
Improve visuals & layout (#2091)

Wyświetl plik

@ -5,3 +5,4 @@ networks:
include:
- path: compose/docs.sphinx.yml
- path: compose/docs.openapi.yml
- path: compose/docs.ui.yml

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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']

Wyświetl plik

@ -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']

Wyświetl plik

@ -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'

Wyświetl plik

@ -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:

Wyświetl plik

@ -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'

Wyświetl plik

@ -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

Wyświetl plik

@ -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'

Wyświetl plik

@ -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

Wyświetl plik

@ -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'

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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.

Wyświetl plik

@ -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:

Wyświetl plik

@ -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.

Wyświetl plik

@ -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`

Wyświetl plik

@ -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: []

Wyświetl plik

@ -0,0 +1 @@
../../../api/funkwhale_api/common/schema.yml

Wyświetl plik

@ -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: [
{

Wyświetl plik

@ -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"]

3
front/README.md 100644
Wyświetl plik

@ -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).

Wyświetl plik

@ -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>

Wyświetl plik

@ -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"
}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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)

Wyświetl plik

@ -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')

Wyświetl plik

@ -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>

Wyświetl plik

@ -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') }}
&nbsp;<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') }}
&nbsp;<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') }}
&nbsp;<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') }}
&nbsp;<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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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"

Wyświetl plik

@ -4,7 +4,7 @@ interface Props {
}
withDefaults(defineProps<Props>(), {
fill: '#222222'
fill: 'var(--color)'
})
</script>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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"
/>

Wyświetl plik

@ -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 }&nbsp;<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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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" />

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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'}]"
>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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"

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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

Wyświetl plik

@ -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>

Wyświetl plik

@ -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">&nbsp;<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">
&nbsp;<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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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"
/>

Wyświetl plik

@ -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',

Wyświetl plik

@ -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>

Wyświetl plik

@ -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',

Wyświetl plik

@ -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>

Wyświetl plik

@ -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',

Wyświetl plik

@ -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>

Wyświetl plik

@ -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