diff --git a/api/funkwhale_api/common/management/commands/load_test_data.py b/api/funkwhale_api/common/management/commands/load_test_data.py index 26f787f48df7f6704a40549a9c425c1d976992af..9eab5ff083c7843f19e5846baf830f54d23bcc7c 100644 --- a/api/funkwhale_api/common/management/commands/load_test_data.py +++ b/api/funkwhale_api/common/management/commands/load_test_data.py @@ -46,16 +46,28 @@ def create_local_accounts(factories, count, dependencies): return actors -def create_tagged_tracks(factories, count, dependencies): +def create_taggable_items(dependency): + def inner(factories, count, dependencies): - objs = [] - for track in dependencies["tracks"]: - tag = random.choice(dependencies["tags"]) - objs.append(factories["tags.TaggedItem"].build(content_object=track, tag=tag)) + objs = [] + tagged_objects = dependencies.get( + dependency, list(CONFIG_BY_ID[dependency]["model"].objects.all().only("pk")) + ) + tags = dependencies.get("tags", list(tags_models.Tag.objects.all().only("pk"))) + for i in range(count): + tag = random.choice(tags) + tagged_object = random.choice(tagged_objects) + objs.append( + factories["tags.TaggedItem"].build( + content_object=tagged_object, tag=tag + ) + ) + + return tags_models.TaggedItem.objects.bulk_create( + objs, batch_size=BATCH_SIZE, ignore_conflicts=True + ) - return tags_models.TaggedItem.objects.bulk_create( - objs, batch_size=BATCH_SIZE, ignore_conflicts=True - ) + return inner CONFIG = [ @@ -110,7 +122,10 @@ CONFIG = [ { "id": "track_tags", "model": tags_models.TaggedItem, - "handler": create_tagged_tracks, + "queryset": tags_models.TaggedItem.objects.filter( + content_type__app_label="music", content_type__model="track" + ), + "handler": create_taggable_items("tracks"), "depends_on": [ { "field": "tag", @@ -127,6 +142,52 @@ CONFIG = [ }, ], }, + { + "id": "album_tags", + "model": tags_models.TaggedItem, + "queryset": tags_models.TaggedItem.objects.filter( + content_type__app_label="music", content_type__model="album" + ), + "handler": create_taggable_items("albums"), + "depends_on": [ + { + "field": "tag", + "id": "tags", + "default_factor": 0.1, + "queryset": tags_models.Tag.objects.all(), + "set": False, + }, + { + "field": "content_object", + "id": "albums", + "default_factor": 1, + "set": False, + }, + ], + }, + { + "id": "artist_tags", + "model": tags_models.TaggedItem, + "queryset": tags_models.TaggedItem.objects.filter( + content_type__app_label="music", content_type__model="artist" + ), + "handler": create_taggable_items("artists"), + "depends_on": [ + { + "field": "tag", + "id": "tags", + "default_factor": 0.1, + "queryset": tags_models.Tag.objects.all(), + "set": False, + }, + { + "field": "content_object", + "id": "artists", + "default_factor": 1, + "set": False, + }, + ], + }, ] CONFIG_BY_ID = {c["id"]: c for c in CONFIG} @@ -194,8 +255,8 @@ class Command(BaseCommand): self.stdout.write("\nFinal state of database:\n\n") for row in CONFIG: - model = row["model"] - total = model.objects.all().count() + qs = row.get("queryset", row["model"].objects.all()) + total = qs.count() self.stdout.write("- {} {} objects".format(total, row["id"])) self.stdout.write("") diff --git a/api/funkwhale_api/tags/filters.py b/api/funkwhale_api/tags/filters.py index 4be4afeefa42778a209bfa165cc1bf2c1f04d79a..e0ac9675ab7511f043195aaea83b31a1e9fe462b 100644 --- a/api/funkwhale_api/tags/filters.py +++ b/api/funkwhale_api/tags/filters.py @@ -18,4 +18,4 @@ class TagFilter(filters.FilterSet): class Meta: model = models.Tag - fields = ["q"] + fields = {"q": ["exact"], "name": ["exact", "startswith"]} diff --git a/api/funkwhale_api/tags/views.py b/api/funkwhale_api/tags/views.py index d7b1d8aa52880c58c41db130e9eebc490e130a7d..1d052ca7b2d71f7c9a9d351e244bdc2152842853 100644 --- a/api/funkwhale_api/tags/views.py +++ b/api/funkwhale_api/tags/views.py @@ -1,6 +1,8 @@ from django.db.models import functions from rest_framework import viewsets +import django_filters.rest_framework + from funkwhale_api.users.oauth import permissions as oauth_permissions from . import filters @@ -20,3 +22,4 @@ class TagViewSet(viewsets.ReadOnlyModelViewSet): required_scope = "libraries" anonymous_policy = "setting" filterset_class = filters.TagFilter + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] diff --git a/api/tests/common/test_commands.py b/api/tests/common/test_commands.py index e2755256e8a32661597fcd0f77e4d048af79dd3a..a6fabfc06f1e39166dffc3903a4e79043d46664a 100644 --- a/api/tests/common/test_commands.py +++ b/api/tests/common/test_commands.py @@ -81,11 +81,7 @@ def test_load_test_data_dry_run(factories, mocker): ), ( {"create_dependencies": True, "track_tags": 3}, - [ - (tags_models.Tag.objects.all(), 1), - (tags_models.TaggedItem.objects.all(), 3), - (music_models.Track.objects.all(), 3), - ], + [(tags_models.Tag.objects.all(), 1), (music_models.Track.objects.all(), 3)], ), ], ) diff --git a/api/tests/tags/test_views.py b/api/tests/tags/test_views.py index fd3246adb91301567bc3166b5af267279d9ad188..b42e9ab3713e66dc1759c7bc7e5d76e697ee9616 100644 --- a/api/tests/tags/test_views.py +++ b/api/tests/tags/test_views.py @@ -23,18 +23,21 @@ def test_tags_list_ordering_length(factories, logged_in_api_client): url = reverse("api:v1:tags-list") tags = [ factories["tags.Tag"](name="iamareallylongtag"), - factories["tags.Tag"](name="reallylongtag"), factories["tags.Tag"](name="short"), + factories["tags.Tag"](name="reallylongtag"), factories["tags.Tag"](name="bar"), ] expected = { "count": 4, "next": None, "previous": None, - "results": [serializers.TagSerializer(tag).data for tag in tags], + "results": [ + serializers.TagSerializer(tag).data + for tag in [tags[3], tags[1], tags[2], tags[0]] + ], } - response = logged_in_api_client.get(url, {"ordering": "-length"}) + response = logged_in_api_client.get(url, {"ordering": "length"}) assert response.data == expected diff --git a/dev.yml b/dev.yml index b0ca7fc72b80b8ea0d11f02505b1d81a83d57e78..eaa7ca8d31239b89f0f023049016cc80e6ffab42 100644 --- a/dev.yml +++ b/dev.yml @@ -57,6 +57,7 @@ services: - "${MUSIC_DIRECTORY_SERVE_PATH-./data/music}:/music:ro" - "./data/plugins:/srv/funkwhale/plugins" - "./data/staticfiles:/staticfiles" + - "./data/media:/data/media" environment: - "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}" - "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test" @@ -65,6 +66,7 @@ services: - "DATABASE_URL=postgresql://postgres@postgres/postgres" - "CACHE_URL=redis://redis:6379/0" - "STATIC_ROOT=/staticfiles" + - "MEDIA_ROOT=/data/media" depends_on: - postgres @@ -92,10 +94,12 @@ services: - "FUNKWHALE_PROTOCOL=${FUNKWHALE_PROTOCOL-http}" - "DATABASE_URL=postgresql://postgres@postgres/postgres" - "CACHE_URL=redis://redis:6379/0" + - "MEDIA_ROOT=/data/media" volumes: - ./api:/app - "${MUSIC_DIRECTORY_SERVE_PATH-./data/music}:/music:ro" - "./data/plugins:/srv/funkwhale/plugins" + - "./data/media:/data/media" networks: - internal nginx: @@ -122,9 +126,9 @@ services: - ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro - "${MUSIC_DIRECTORY_SERVE_PATH-./data/music}:/music:ro" - ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro - - "${MEDIA_ROOT-./api/funkwhale_api/media}:/protected/media:ro" - "./front:/frontend:ro" - "./data/staticfiles:/staticfiles:ro" + - "./data/media:/protected/media:ro" networks: - federation - internal diff --git a/front/package.json b/front/package.json index 54cf3c8e1672d784b57ecf797269db3de8fb4a10..2c8f041f0e66ea2aa2a8689baf611223bf7844a9 100644 --- a/front/package.json +++ b/front/package.json @@ -22,6 +22,7 @@ "masonry-layout": "^4.2.2", "moment": "^2.22.2", "fomantic-ui-css": "^2.7", + "qs": "^6.7.0", "showdown": "^1.8.6", "vue": "^2.5.17", "vue-gettext": "^2.1.0", diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index ecc0a280ed04b26779de42fad4537b77b4243426..609ef6ebcc2cf5d114f32f27732cc4ed9a75e88b 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -2,10 +2,11 @@ <div class="wrapper"> <h3 class="ui header"> <slot name="title"></slot> + <span v-if="showCount" class="ui tiny circular label">{{ count }}</span> </h3> - <button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button> - <button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button> - <button @click="fetchData('albums/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button> + <button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button> + <button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button> + <button v-if="controls" @click="fetchData('albums/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button> <div class="ui hidden divider"></div> <div class="ui five cards"> <div v-if="isLoading" class="ui inverted active dimmer"> @@ -33,6 +34,7 @@ </div> </div> </div> + <div v-if="!isLoading && albums.length === 0">No results matching your query.</div> </div> </template> @@ -43,7 +45,9 @@ import PlayButton from '@/components/audio/PlayButton' export default { props: { - filters: {type: Object, required: true} + filters: {type: Object, required: true}, + controls: {type: Boolean, default: true}, + showCount: {type: Boolean, default: false}, }, components: { PlayButton @@ -52,6 +56,7 @@ export default { return { albums: [], limit: 12, + count: 0, isLoading: false, errors: null, previousPage: null, @@ -76,6 +81,7 @@ export default { self.nextPage = response.data.next self.isLoading = false self.albums = response.data.results + self.count = response.data.count }, error => { self.isLoading = false self.errors = error.backendErrors diff --git a/front/src/components/audio/artist/Widget.vue b/front/src/components/audio/artist/Widget.vue new file mode 100644 index 0000000000000000000000000000000000000000..1b88feae76121b868b103139a6eb3dbb9ddf047c --- /dev/null +++ b/front/src/components/audio/artist/Widget.vue @@ -0,0 +1,172 @@ +<template> + <div class="wrapper"> + <h3 class="ui header"> + <slot name="title"></slot> + <span class="ui tiny circular label">{{ count }}</span> + </h3> + <button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button> + <button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button> + <button v-if="controls" @click="fetchData('artists/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button> + <div class="ui hidden divider"></div> + <div class="ui three cards"> + <div v-if="isLoading" class="ui inverted active dimmer"> + <div class="ui loader"></div> + </div> + <div class="flat inline card" v-for="object in objects" :key="object.id"> + <div :class="['ui', 'image', 'with-overlay', {'default-cover': !getCover(object).original}]" v-lazy:background-image="getImageUrl(object)"> + <play-button class="play-overlay" :icon-only="true" :is-playable="object.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="object"></play-button> + </div> + <div class="content"> + <router-link :title="object.name" :to="{name: 'library.artists.detail', params: {id: object.id}}"> + {{ object.name|truncate(30) }} + </router-link> + <div> + <i class="small sound icon"></i> + <translate translate-context="Content/Artist/Card" :translate-params="{count: object.albums.length}" :translate-n="object.albums.length" translate-plural="%{ count } albums">1 album</translate> + </div> + <tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.tags"></tags-list> + + <play-button + class="play-button basic icon" + :dropdown-only="true" + :is-playable="object.is_playable" + :dropdown-icon-classes="['ellipsis', 'vertical', 'large', 'grey']" + :artist="object"></play-button> + </div> + </div> + </div> + <div v-if="!isLoading && objects.length === 0">No results matching your query.</div> + </div> +</template> + +<script> +import _ from '@/lodash' +import axios from 'axios' +import PlayButton from '@/components/audio/PlayButton' +import TagsList from "@/components/tags/List" + +export default { + props: { + filters: {type: Object, required: true}, + controls: {type: Boolean, default: true}, + }, + components: { + PlayButton, + TagsList + }, + data () { + return { + objects: [], + limit: 12, + count: 0, + isLoading: false, + errors: null, + previousPage: null, + nextPage: null + } + }, + created () { + this.fetchData('artists/') + }, + methods: { + fetchData (url) { + if (!url) { + return + } + this.isLoading = true + let self = this + let params = _.clone(this.filters) + params.page_size = this.limit + params.offset = this.offset + axios.get(url, {params: params}).then((response) => { + self.previousPage = response.data.previous + self.nextPage = response.data.next + self.isLoading = false + self.objects = response.data.results + self.count = response.data.count + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + updateOffset (increment) { + if (increment) { + this.offset += this.limit + } else { + this.offset = Math.max(this.offset - this.limit, 0) + } + }, + getImageUrl (object) { + let url = '../../../assets/audio/default-cover.png' + let cover = this.getCover(object) + if (cover.original) { + url = this.$store.getters['instance/absoluteUrl'](cover.medium_square_crop) + } else { + return null + } + return url + }, + getCover (object) { + return object.albums.map((a) => { + return a.cover + }).filter((c) => { + return !!c + })[0] || {} + } + }, + watch: { + offset () { + this.fetchData() + }, + "$store.state.moderation.lastUpdate": function () { + this.fetchData('objects/') + } + } +} +</script> +<style scoped lang="scss"> +@import "../../../style/vendor/media"; + +.default-cover { + background-image: url("../../../assets/audio/default-cover.png") !important; +} + +.wrapper { + width: 100%; +} +.ui.cards { + justify-content: flex-start; +} +.play-button { + position: absolute; + right: 0; + bottom: 0; +} + +.ui.three.cards .card { + width: 100%; +} +@include media(">tablet") { + .ui.three.cards .card { + width: 25em; + } +} +.with-overlay { + background-size: cover !important; + background-position: center !important; + height: 8em; + width: 8em; + display: flex !important; + justify-content: center !important; + align-items: center !important; +} +.flat.card .with-overlay.image { + border-radius: 50% !important; + margin: 0 auto; +} +</style> +<style> +.ui.cards .ui.button { + margin-right: 0px; +} +</style> diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue index b8ad3c639c66eb756ca7f3d50148d08aa5e7c5df..ecb967ab82a1cca1db1de7304a8992e750a71956 100644 --- a/front/src/components/audio/track/Widget.vue +++ b/front/src/components/audio/track/Widget.vue @@ -2,12 +2,13 @@ <div> <h3 class="ui header"> <slot name="title"></slot> + <span v-if="showCount" class="ui tiny circular label">{{ count }}</span> </h3> <button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle up', 'icon']"></i></button> <button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle down', 'icon']"></i></button> <button @click="fetchData(url)" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button> <div class="ui divided unstackable items"> - <div class="item" v-for="object in objects" :key="object.id"> + <div :class="['item', itemClasses]" v-for="object in objects" :key="object.id"> <div class="ui tiny image"> <img v-if="object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)"> <img v-else src="../../../assets/audio/default-cover.png"> @@ -28,7 +29,9 @@ </router-link> </span> </div> - <div class="extra"> + <tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.track.tags"></tags-list> + + <div class="extra" v-if="isActivity"> <span class="left floated">@{{ object.user.username }}</span> <span class="right floated"><human-date :date="object.creation_date" /></span> </div> @@ -50,19 +53,25 @@ import _ from '@/lodash' import axios from 'axios' import PlayButton from '@/components/audio/PlayButton' +import TagsList from "@/components/tags/List" export default { props: { filters: {type: Object, required: true}, - url: {type: String, required: true} + url: {type: String, required: true}, + isActivity: {type: Boolean, default: true}, + showCount: {type: Boolean, default: false}, + limit: {type: Number, default: 5}, + itemClasses: {type: String, default: ''}, }, components: { - PlayButton + PlayButton, + TagsList }, data () { return { objects: [], - limit: 5, + count: 0, isLoading: false, errors: null, previousPage: null, @@ -86,7 +95,15 @@ export default { self.previousPage = response.data.previous self.nextPage = response.data.next self.isLoading = false - self.objects = response.data.results + self.count = response.data.count + if (self.isActivity) { + // we have listening/favorites objects, not directly tracks + self.objects = response.data.results + } else { + self.objects = response.data.results.map((r) => { + return {track: r} + }) + } }, error => { self.isLoading = false self.errors = error.backendErrors @@ -129,4 +146,18 @@ export default { .ui.divided.items > .item:last-child { padding-bottom: 1em !important; } + +@include media(">tablet") { + .divided.items > .track-item.inline { + width: 25em; + float: left; + border-top: none; + &, + &:first-child { + margin-top: 0.5em !important; + margin-right: 0.5em !important; + padding: 1em 0 !important; + } + } +} </style> diff --git a/front/src/components/library/AlbumBase.vue b/front/src/components/library/AlbumBase.vue index 016be2c3779e99ddc14c9dcd1ca585ea8e462a59..083e5547b09f883bfb8f00bf44ad79273d87b195 100644 --- a/front/src/components/library/AlbumBase.vue +++ b/front/src/components/library/AlbumBase.vue @@ -13,6 +13,7 @@ <div v-html="subtitle"></div> </div> </h2> + <tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list> <div class="ui hidden divider"></div> <div class="header-buttons"> @@ -103,6 +104,7 @@ import backend from "@/audio/backend" import PlayButton from "@/components/audio/PlayButton" import EmbedWizard from "@/components/audio/EmbedWizard" import Modal from '@/components/semantic/Modal' +import TagsList from "@/components/tags/List" const FETCH_URL = "albums/" @@ -123,7 +125,8 @@ export default { components: { PlayButton, EmbedWizard, - Modal + Modal, + TagsList, }, data() { return { diff --git a/front/src/components/library/Albums.vue b/front/src/components/library/Albums.vue index 9817af830770142065f1e84f4253cbdda23c41e6..ed97f7a4e0127063fc7f28e272de707c63d160d1 100644 --- a/front/src/components/library/Albums.vue +++ b/front/src/components/library/Albums.vue @@ -20,6 +20,10 @@ </option> </select> </div> + <div class="field"> + <label><translate translate-context="*/*/*/Noun">Tags</translate></label> + <tags-selector v-model="tags"></tags-selector> + </div> <div class="field"> <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering direction</translate></label> <select class="ui dropdown" v-model="orderingDirection"> @@ -70,6 +74,7 @@ </template> <script> +import qs from 'qs' import axios from "axios" import _ from "@/lodash" import $ from "jquery" @@ -81,17 +86,20 @@ import PaginationMixin from "@/components/mixins/Pagination" import TranslationsMixin from "@/components/mixins/Translations" import AlbumCard from "@/components/audio/album/Card" import Pagination from "@/components/Pagination" +import TagsSelector from '@/components/library/TagsSelector' const FETCH_URL = "albums/" export default { mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], props: { - defaultQuery: { type: String, required: false, default: "" } + defaultQuery: { type: String, required: false, default: "" }, + defaultTags: { type: Array, required: false, default: () => { return [] } }, }, components: { AlbumCard, - Pagination + Pagination, + TagsSelector, }, data() { let defaultOrdering = this.getOrderingFromString( @@ -102,6 +110,7 @@ export default { result: null, page: parseInt(this.defaultPage), query: this.defaultQuery, + tags: this.defaultTags.filter((t) => { return t.length > 0 }) || [], paginateBy: parseInt(this.defaultPaginateBy || 25), orderingDirection: defaultOrdering.direction || "+", ordering: defaultOrdering.field, @@ -130,6 +139,7 @@ export default { query: { query: this.query, page: this.page, + tag: this.tags, paginateBy: this.paginateBy, ordering: this.getOrderingAsString() } @@ -144,10 +154,19 @@ export default { page_size: this.paginateBy, q: this.query, ordering: this.getOrderingAsString(), - playable: "true" + playable: "true", + tag: this.tags, } logger.default.debug("Fetching albums") - axios.get(url, { params: params }).then(response => { + axios.get( + url, + { + params: params, + paramsSerializer: function(params) { + return qs.stringify(params, { indices: false }) + } + } + ).then(response => { self.result = response.data self.isLoading = false }) @@ -177,6 +196,10 @@ export default { this.updateQueryString() this.fetchData() }, + tags() { + this.updateQueryString() + this.fetchData() + }, "$store.state.moderation.lastUpdate": function () { this.fetchData() } diff --git a/front/src/components/library/ArtistBase.vue b/front/src/components/library/ArtistBase.vue index 3c21f603fe150109ade24448510bb42805ac4663..5472ee307cf76ddf65f6c203f23fb379d9aad62a 100644 --- a/front/src/components/library/ArtistBase.vue +++ b/front/src/components/library/ArtistBase.vue @@ -21,6 +21,7 @@ </div> </div> </h2> + <tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list> <div class="ui hidden divider"></div> <div class="header-buttons"> <div class="ui buttons"> @@ -123,17 +124,20 @@ import PlayButton from "@/components/audio/PlayButton" import EmbedWizard from "@/components/audio/EmbedWizard" import Modal from '@/components/semantic/Modal' import RadioButton from "@/components/radios/Button" +import TagsList from "@/components/tags/List" const FETCH_URL = "albums/" + export default { props: ["id"], components: { PlayButton, EmbedWizard, Modal, - RadioButton + RadioButton, + TagsList, }, data() { return { diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 5f4102ab1c269cc6610bf8cec3fa95b33b9d5b83..f16a6740f8954c7381cf53ef656b18d37e8952c2 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -12,6 +12,10 @@ </label> <input type="text" name="search" v-model="query" :placeholder="labels.searchPlaceholder"/> </div> + <div class="field"> + <label><translate translate-context="*/*/*/Noun">Tags</translate></label> + <tags-selector v-model="tags"></tags-selector> + </div> <div class="field"> <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> <select class="ui dropdown" v-model="ordering"> @@ -67,6 +71,7 @@ </template> <script> +import qs from 'qs' import axios from "axios" import _ from "@/lodash" import $ from "jquery" @@ -78,17 +83,20 @@ import PaginationMixin from "@/components/mixins/Pagination" import TranslationsMixin from "@/components/mixins/Translations" import ArtistCard from "@/components/audio/artist/Card" import Pagination from "@/components/Pagination" +import TagsSelector from '@/components/library/TagsSelector' const FETCH_URL = "artists/" export default { mixins: [OrderingMixin, PaginationMixin, TranslationsMixin], props: { - defaultQuery: { type: String, required: false, default: "" } + defaultQuery: { type: String, required: false, default: "" }, + defaultTags: { type: Array, required: false, default: () => { return [] } }, }, components: { ArtistCard, - Pagination + Pagination, + TagsSelector, }, data() { let defaultOrdering = this.getOrderingFromString( @@ -99,6 +107,7 @@ export default { result: null, page: parseInt(this.defaultPage), query: this.defaultQuery, + tags: this.defaultTags.filter((t) => { return t.length > 0 }) || [], paginateBy: parseInt(this.defaultPaginateBy || 12), orderingDirection: defaultOrdering.direction || "+", ordering: defaultOrdering.field, @@ -127,6 +136,7 @@ export default { query: { query: this.query, page: this.page, + tag: this.tags, paginateBy: this.paginateBy, ordering: this.getOrderingAsString() } @@ -141,10 +151,19 @@ export default { page_size: this.paginateBy, name__icontains: this.query, ordering: this.getOrderingAsString(), - playable: "true" + playable: "true", + tag: this.tags, } logger.default.debug("Fetching artists") - axios.get(url, { params: params }).then(response => { + axios.get( + url, + { + params: params, + paramsSerializer: function(params) { + return qs.stringify(params, { indices: false }) + } + } + ).then(response => { self.result = response.data self.isLoading = false }) @@ -174,6 +193,10 @@ export default { this.updateQueryString() this.fetchData() }, + tags() { + this.updateQueryString() + this.fetchData() + }, "$store.state.moderation.lastUpdate": function () { this.fetchData() } diff --git a/front/src/components/library/EditCard.vue b/front/src/components/library/EditCard.vue index e72823aa74d7aa406e14bb7e87eea1d7ff3c0179..bb8676ceebd7f5df507c494e6dd6882190752042 100644 --- a/front/src/components/library/EditCard.vue +++ b/front/src/components/library/EditCard.vue @@ -49,7 +49,7 @@ </tr> </thead> <tbody> - <tr v-for="field in getUpdatedFields(obj.payload, previousState)" :key="field.id"> + <tr v-for="field in updatedFields" :key="field.id"> <td>{{ field.id }}</td> <td v-if="field.diff"> @@ -61,12 +61,12 @@ <translate translate-context="*/*/*">N/A</translate> </td> - <td v-if="field.diff"> + <td v-if="field.diff" :title="field.newRepr"> <span v-if="!part.removed" v-for="part in field.diff" :class="['diff', {added: part.added}]"> {{ part.value }} </span> </td> - <td v-else>{{ field.new }}</td> + <td v-else :title="field.newRepr">{{ field.newRepr }}</td> </tr> </tbody> </table> @@ -126,6 +126,7 @@ export default { } }, computed: { + configs: edits.getConfigs, canApprove: edits.getCanApprove, canDelete: edits.getCanDelete, previousState () { @@ -154,6 +155,32 @@ export default { namespace = 'library.artists.edit.detail' } return this.$router.resolve({name: namespace, params: {id, editId: this.obj.uuid}}).href + }, + + updatedFields () { + let payload = this.obj.payload + let previousState = this.previousState + let fields = Object.keys(payload) + let self = this + return fields.map((f) => { + let fieldConfig = edits.getFieldConfig(self.configs, this.obj.target.type, f) + let dummyRepr = (v) => { return v } + let getValueRepr = fieldConfig.getValueRepr || dummyRepr + let d = { + id: f, + } + if (previousState && previousState[f]) { + d.old = previousState[f] + d.oldRepr = castValue(getValueRepr(d.old.value)) + } + d.new = payload[f] + d.newRepr = castValue(getValueRepr(d.new)) + if (d.old) { + // we compute the diffs between the old and new values + d.diff = diffWordsWithSpace(d.oldRepr, d.newRepr) + } + return d + }) } }, methods: { @@ -184,26 +211,6 @@ export default { self.isLoading = false }) }, - getUpdatedFields (payload, previousState) { - let fields = Object.keys(payload) - return fields.map((f) => { - let d = { - id: f, - } - if (previousState && previousState[f]) { - d.old = previousState[f] - } - d.new = payload[f] - if (d.old) { - // we compute the diffs between the old and new values - - let oldValue = castValue(d.old.value) - let newValue = castValue(d.new) - d.diff = diffWordsWithSpace(oldValue, newValue) - } - return d - }) - } } } </script> diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue index 617917c6812f03924934c48ec0e7d92079f95a72..ee462a1e20305eff2aa50b605d91fad1e6f8371b 100644 --- a/front/src/components/library/EditForm.vue +++ b/front/src/components/library/EditForm.vue @@ -77,10 +77,22 @@ </button> </template> + <template v-else-if="fieldConfig.type === 'tags'"> + <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> + <tags-selector + ref="tags" + v-model="values[fieldConfig.id]" + :id="fieldConfig.id" + required="fieldConfig.required"></tags-selector> + <button class="ui tiny basic left floated button" form="noop" @click.prevent="values[fieldConfig.id] = []"> + <i class="x icon"></i> + <translate translate-context="Content/Library/Button.Label">Clear</translate> + </button> + </template> <div v-if="values[fieldConfig.id] != initialValues[fieldConfig.id]"> <button class="ui tiny basic right floated reset button" form="noop" @click.prevent="values[fieldConfig.id] = initialValues[fieldConfig.id]"> <i class="undo icon"></i> - <translate translate-context="Content/Library/Button.Label" :translate-params="{value: initialValues[fieldConfig.id] || ''}">Reset to initial value: %{ value }</translate> + <translate translate-context="Content/Library/Button.Label">Reset to initial value</translate> </button> </div> </div> @@ -110,13 +122,17 @@ import _ from '@/lodash' import axios from "axios" import EditList from '@/components/library/EditList' import EditCard from '@/components/library/EditCard' +import TagsSelector from '@/components/library/TagsSelector' import edits from '@/edits' +import lodash from '@/lodash' + export default { props: ["objectType", "object", "licenses"], components: { EditList, - EditCard + EditCard, + TagsSelector }, data() { return { @@ -159,7 +175,7 @@ export default { mutationPayload () { let self = this let changedFields = this.config.fields.filter(f => { - return self.values[f.id] != self.initialValues[f.id] + return !lodash.isEqual(self.values[f.id], self.initialValues[f.id]) }) if (changedFields.length === 0) { return null diff --git a/front/src/components/library/TagDetail.vue b/front/src/components/library/TagDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..86ad1562a342397fbb9e2fdadbc20647318ce79d --- /dev/null +++ b/front/src/components/library/TagDetail.vue @@ -0,0 +1,79 @@ +<template> + <main v-title="labels.title"> + <section class="ui vertical stripe segment"> + <h2 class="ui header"> + <span class="ui circular huge hashtag label"> + {{ labels.title }} + </span> + </h2> + <div class="ui hidden divider"></div> + <div class="ui row"> + <artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}"> + <template slot="title"> + <router-link :to="{name: 'library.artists.browse', query: {tag: id}}"> + <translate translate-context="*/*/*">Artists</translate> + </router-link> + </template> + </artist-widget> + <div class="ui hidden divider"></div> + <div class="ui hidden divider"></div> + <album-widget :show-count="true" :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}"> + <template slot="title"> + <router-link :to="{name: 'library.albums.browse', query: {tag: id}}"> + <translate translate-context="*/*/*">Albums</translate> + </router-link> + </template> + </album-widget> + <div class="ui hidden divider"></div> + <div class="ui hidden divider"></div> + <track-widget :show-count="true" :limit="12" item-classes="track-item inline" :url="'/tracks/'" :is-activity="false" :filters="{playable: true, ordering: '-creation_date', tag: id}"> + <template slot="title"> + <translate translate-context="*/*/*">Tracks</translate> + </template> + </track-widget> + <div class="ui clearing hidden divider"></div> + </div> + </section> + </main> +</template> + +<script> + + +import TrackWidget from "@/components/audio/track/Widget" +import AlbumWidget from "@/components/audio/album/Widget" +import ArtistWidget from "@/components/audio/artist/Widget" + +export default { + props: { + id: { type: String, required: true } + }, + components: { + ArtistWidget, + AlbumWidget, + TrackWidget, + }, + computed: { + labels() { + let title = `#${this.id}` + return { + title + } + }, + isAuthenticated () { + return this.$store.state.auth.authenticated + }, + hasFavorites () { + return this.$store.state.favorites.count > 0 + }, + }, +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +.ui.circular.label { + padding-left: 1em !important; + padding-right: 1em !important; +} +</style> diff --git a/front/src/components/library/TagsSelector.vue b/front/src/components/library/TagsSelector.vue new file mode 100644 index 0000000000000000000000000000000000000000..47cb83faeb27d01af8a0665ec9c4ab4c18e7b2e1 --- /dev/null +++ b/front/src/components/library/TagsSelector.vue @@ -0,0 +1,68 @@ +<template> + <div ref="dropdown" class="ui multiple search selection dropdown"> + <input type="hidden"> + <i class="dropdown icon"></i> + <input type="text" class="search"> + <div class="default text"> + <translate translate-context="*/Dropdown/Placeholder/Verb">Search for tags…</translate> + </div> + </div> +</template> +<script> +import $ from 'jquery' + +import lodash from '@/lodash' +export default { + props: ['value'], + mounted () { + this.$nextTick(() => { + this.initDropdown() + + }) + }, + methods: { + initDropdown () { + let self = this + let handleUpdate = () => { + let value = $(self.$refs.dropdown).dropdown('get value').split(',') + self.$emit('input', value) + return value + } + let settings = { + saveRemoteData: false, + filterRemoteData: true, + apiSettings: { + url: this.$store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'), + beforeXHR: function (xhrObject) { + xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) + return xhrObject + }, + }, + fields: { + remoteValues: 'results', + value: 'name' + }, + allowAdditions: true, + minCharacters: 1, + onAdd: handleUpdate, + onRemove: handleUpdate, + onLabelRemove: handleUpdate, + onChange: handleUpdate, + } + $(this.$refs.dropdown).dropdown(settings) + $(this.$refs.dropdown).dropdown('set exactly', this.value) + } + }, + watch: { + value: { + handler (v) { + let current = $(this.$refs.dropdown).dropdown('get value').split(',').sort() + if (!lodash.isEqual([...v].sort(), current)) { + $(this.$refs.dropdown).dropdown('set exactly', v) + } + }, + deep: true + } + } +} +</script> diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index 2f3cc51a9d74b1b855e2edefa6288704a6bcb0db..32942958ba44a091207c98cfa96c2d5721b06a1a 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -17,6 +17,8 @@ <div class="sub header" v-html="subtitle"></div> </div> </h2> + <tags-list v-if="track.tags && track.tags.length > 0" :tags="track.tags"></tags-list> + <div class="ui hidden divider"></div> <div class="header-buttons"> <div class="ui buttons"> <play-button class="orange" :track="track"> @@ -121,6 +123,7 @@ import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon" import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon" import Modal from '@/components/semantic/Modal' import EmbedWizard from "@/components/audio/EmbedWizard" +import TagsList from "@/components/tags/List" const FETCH_URL = "tracks/" @@ -131,7 +134,8 @@ export default { TrackPlaylistIcon, TrackFavoriteIcon, Modal, - EmbedWizard + EmbedWizard, + TagsList, }, data() { return { diff --git a/front/src/components/tags/List.vue b/front/src/components/tags/List.vue new file mode 100644 index 0000000000000000000000000000000000000000..6c280822bd9bc6c5dc1859d288dd4983cbdb1600 --- /dev/null +++ b/front/src/components/tags/List.vue @@ -0,0 +1,48 @@ +<template> + <div class="tag-list"> + <router-link + :to="{name: 'library.tags.detail', params: {id: tag}}" + :class="['ui', 'circular', 'hashtag', 'label', labelClasses]" + v-for="tag in toDisplay" + :title="tag" + :key="tag"> + #{{ tag|truncate(truncateSize) }} + </router-link> + <div role="button" @click.prevent="honorLimit = false" class="ui circular inverted teal label" v-if="showMore && toDisplay.length < tags.length"> + <translate translate-context="Content/*/Button/Label/Verb" :translate-params="{count: tags.length - toDisplay.length}" :translate-n="tags.length - toDisplay.length" translate-plural="Show %{ count } more tags">Show 1 more tag</translate> + </div> + </div> +</template> +<script> +export default { + props: { + tags: {type: Array, required: true}, + showMore: {type: Boolean, default: true}, + truncateSize: {type: Number, default: 25}, + limit: {type: Number, default: 5}, + labelClasses: {type: String, default: ''}, + }, + data () { + return { + honorLimit: true, + } + }, + computed: { + toDisplay () { + if (!this.honorLimit) { + return this.tags + } + return (this.tags || []).slice(0, this.limit) + } + } +} +</script> +<style lang="scss" scoped> +.ui.circular.label { + padding-left: 1em !important; + padding-right: 1em !important; +} +.hashtag { + margin: 0.25em; +} +</style> diff --git a/front/src/edits.js b/front/src/edits.js index 76e92e8418b5ea4cfcfbf68e72476eae3f946bfd..5c9e9be880e90cdae1041e7ef96f1c1d3bca5324 100644 --- a/front/src/edits.js +++ b/front/src/edits.js @@ -1,3 +1,10 @@ +function getTagsValueRepr (val) { + if (!val) { + return '' + } + return val.slice().sort().join('\n') +} + export default { getConfigs () { return { @@ -10,6 +17,14 @@ export default { label: this.$pgettext('*/*/*/Noun', 'Name'), getValue: (obj) => { return obj.name } }, + { + id: 'tags', + type: 'tags', + required: true, + label: this.$pgettext('*/*/*/Noun', 'Tags'), + getValue: (obj) => { return obj.tags }, + getValueRepr: getTagsValueRepr + } ] }, album: { @@ -28,6 +43,14 @@ export default { label: this.$pgettext('Content/*/*/Noun', 'Release date'), getValue: (obj) => { return obj.release_date } }, + { + id: 'tags', + type: 'tags', + required: true, + label: this.$pgettext('*/*/*/Noun', 'Tags'), + getValue: (obj) => { return obj.tags }, + getValueRepr: getTagsValueRepr + } ] }, track: { @@ -61,6 +84,14 @@ export default { label: this.$pgettext('Content/*/*/Noun', 'License'), getValue: (obj) => { return obj.license }, }, + { + id: 'tags', + type: 'tags', + required: true, + label: this.$pgettext('*/*/*/Noun', 'Tags'), + getValue: (obj) => { return obj.tags }, + getValueRepr: getTagsValueRepr + } ] } } @@ -69,7 +100,12 @@ export default { getConfig () { return this.configs[this.objectType] }, - + getFieldConfig (configs, type, fieldId) { + let c = configs[type] + return c.fields.filter((f) => { + return f.id == fieldId + })[0] + }, getCurrentState () { let self = this let s = {} diff --git a/front/src/lodash.js b/front/src/lodash.js index 8cd3ed92f41e86b0eff9b88bd5af923742b1864e..31fdbaa58c7275ec94fc559e28b2d1d40c620dd9 100644 --- a/front/src/lodash.js +++ b/front/src/lodash.js @@ -12,4 +12,5 @@ export default { uniq: require('lodash/uniq'), remove: require('lodash/remove'), reverse: require('lodash/reverse'), + isEqual: require('lodash/isEqual'), } diff --git a/front/src/router/index.js b/front/src/router/index.js index 6a9ba6112f7680766b22945bfd41097db3f143aa..1d49967467a7cbfa15bcfe19f9b567d6cafb640b 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -401,6 +401,7 @@ export default new Router({ props: (route) => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, + defaultTags: Array.isArray(route.query.tag || []) ? route.query.tag : [route.query.tag], defaultPaginateBy: route.query.paginateBy, defaultPage: route.query.page }) @@ -413,6 +414,7 @@ export default new Router({ props: (route) => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, + defaultTags: Array.isArray(route.query.tag || []) ? route.query.tag : [route.query.tag], defaultPaginateBy: route.query.paginateBy, defaultPage: route.query.page }) @@ -471,6 +473,13 @@ export default new Router({ id: route.params.id, defaultEdit: route.query.mode === 'edit' }) }, + { + path: 'tags/:id', + name: 'library.tags.detail', + component: () => + import(/* webpackChunkName: "core" */ "@/components/library/TagDetail"), + props: true, + }, { path: 'artists/:id', component: () => diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index a15f339566e902646e327cb1f09c906a86a80b49..9a2fe1722a2889468881305c581f5745bf67d397 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -342,6 +342,20 @@ td.align.right { word-wrap: break-word; } +.ui.cards > .flat.card, .flat.card { + box-shadow: none; + .content { + border: none; + } +} + +.ui.cards > .inline.card { + flex-direction: row; + .content { + padding: 0.5em 0.75em; + } +} + .ui.checkbox label { cursor: pointer; } @@ -355,6 +369,9 @@ input + .help { } +.tag-list { + margin-top: 0.5em; +} @import "./themes/_light.scss"; @import "./themes/_dark.scss"; diff --git a/front/yarn.lock b/front/yarn.lock index 1959e9e1a99365dc02c6c9bd550592dce33854e0..68e128385b4c2f92d5b3fa9bbbc4228d9f0551f7 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -7324,7 +7324,7 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= -qs@6.7.0: +qs@6.7.0, qs@^6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==