diff --git a/CHANGELOG b/CHANGELOG index aa2b67dfa748c9d0236c3ed78de7b681bac575ea..4f17b85195654fbb28ba09b98c70a85e6e5bcf3d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,8 @@ Changelog - Shortcuts: avoid collisions between shortcuts by using the exact modifier (#53) - Player: Added looping controls and shortcuts (#52) - Player: Added shuffling controls and shortcuts (#52) +- Favorites: can now modify the ordering of track list (#50) +- Library: can now search/reorder results on artist browsing view (#50) 0.2.6 (2017-12-15) diff --git a/api/config/urls.py b/api/config/urls.py index 8c490a5e6599e2f44bb60bb110a6a362f4153f93..de67ebb571de4b5f4e15cedd969d1adadeb42aee 100644 --- a/api/config/urls.py +++ b/api/config/urls.py @@ -31,3 +31,9 @@ if settings.DEBUG: url(r'^404/$', default_views.page_not_found), url(r'^500/$', default_views.server_error), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + if 'debug_toolbar' in settings.INSTALLED_APPS: + import debug_toolbar + urlpatterns += [ + url(r'^__debug__/', include(debug_toolbar.urls)), + ] diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index ba3fa453d77443eb75247b3987849db972963e1c..ff937a0f5c3aac8d5609b188d18b796e3b809e3a 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -8,5 +8,5 @@ class ArtistFilter(django_filters.FilterSet): class Meta: model = models.Artist fields = { - 'name': ['exact', 'iexact', 'startswith'] + 'name': ['exact', 'iexact', 'startswith', 'icontains'] } diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 43daf9d5a19404309b3ccf1f85e2e4c4713a9840..cf9d8749021ca8f5a1c03f297924de2ed1a583ca 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -13,14 +13,14 @@ class TagSerializer(serializers.ModelSerializer): class SimpleArtistSerializer(serializers.ModelSerializer): class Meta: model = models.Artist - fields = ('id', 'mbid', 'name') + fields = ('id', 'mbid', 'name', 'creation_date') class ArtistSerializer(serializers.ModelSerializer): tags = TagSerializer(many=True, read_only=True) class Meta: model = models.Artist - fields = ('id', 'mbid', 'name', 'tags') + fields = ('id', 'mbid', 'name', 'tags', 'creation_date') class TrackFileSerializer(serializers.ModelSerializer): diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index c32fa8f7ff49caa1ef9fbfff533e12a32835373e..532942e2e651c0262d96b61464b3411473f90fce 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -47,16 +47,15 @@ class TagViewSetMixin(object): class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): queryset = ( models.Artist.objects.all() - .order_by('name') .prefetch_related( 'albums__tracks__files', + 'albums__tracks__artist', 'albums__tracks__tags')) serializer_class = serializers.ArtistSerializerNested permission_classes = [ConditionalAuthentication] search_fields = ['name'] - ordering_fields = ('creation_date', 'name') filter_class = filters.ArtistFilter - + ordering_fields = ('id', 'name', 'creation_date') class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): queryset = ( @@ -96,7 +95,12 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): serializer_class = serializers.TrackSerializerNested permission_classes = [ConditionalAuthentication] search_fields = ['title', 'artist__name'] - ordering_fields = ('creation_date',) + ordering_fields = ( + 'creation_date', + 'title', + 'album__title', + 'artist__name', + ) def get_queryset(self): queryset = super().get_queryset() diff --git a/front/src/App.vue b/front/src/App.vue index f81d7d3daae22224f9d32450844a9677302c111e..c1c0874998b21af43407da9c87bb5ecb37ecd02b 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,7 +1,7 @@ <template> <div id="app"> <sidebar></sidebar> - <router-view></router-view> + <router-view :key="$route.fullPath"></router-view> <div class="ui divider"></div> <div id="footer" class="ui vertical footer segment"> <div class="ui container"> diff --git a/front/src/components/favorites/List.vue b/front/src/components/favorites/List.vue index 63c3ba79d18c7360cdef97f8739614cfb1f1f163..91efd72907e745244f5a74d7cdc3547e750611d2 100644 --- a/front/src/components/favorites/List.vue +++ b/front/src/components/favorites/List.vue @@ -9,9 +9,36 @@ {{ favoriteTracks.count }} favorites </h2> <radio-button type="favorites"></radio-button> - </div> <div class="ui vertical stripe segment"> + <div :class="['ui', {'loading': isLoading}, 'form']"> + <div class="fields"> + <div class="field"> + <label>Ordering</label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <label>Ordering direction</label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="">Ascending</option> + <option value="-">Descending</option> + </select> + </div> + <div class="field"> + <label>Results per page</label> + <select class="ui dropdown" v-model="paginateBy"> + <option :value="parseInt(12)">12</option> + <option :value="parseInt(25)">25</option> + <option :value="parseInt(50)">50</option> + </select> + </div> + </div> + </div> + <track-table v-if="results" :tracks="results.results"></track-table> <div class="ui center aligned basic segment"> <pagination @@ -27,6 +54,7 @@ </template> <script> +import $ from 'jquery' import Vue from 'vue' import logger from '@/logging' import config from '@/config' @@ -34,37 +62,60 @@ import favoriteTracks from '@/favorites/tracks' import TrackTable from '@/components/audio/track/Table' import RadioButton from '@/components/radios/Button' import Pagination from '@/components/Pagination' - +import OrderingMixin from '@/components/mixins/Ordering' +import PaginationMixin from '@/components/mixins/Pagination' const FAVORITES_URL = config.API_URL + 'tracks/' export default { + mixins: [OrderingMixin, PaginationMixin], components: { TrackTable, RadioButton, Pagination }, data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || 'artist__name') return { results: null, isLoading: false, nextLink: null, previousLink: null, - page: 1, - paginateBy: 25, - favoriteTracks + favoriteTracks, + page: parseInt(this.defaultPage), + paginateBy: parseInt(this.defaultPaginateBy || 25), + orderingDirection: defaultOrdering.direction, + ordering: defaultOrdering.field, + orderingOptions: [ + ['title', 'Track name'], + ['album__title', 'Album name'], + ['artist__name', 'Artist name'] + ] } }, created () { this.fetchFavorites(FAVORITES_URL) }, + mounted () { + $('.ui.dropdown').dropdown() + }, methods: { + updateQueryString: function () { + this.$router.replace({ + query: { + page: this.page, + paginateBy: this.paginateBy, + ordering: this.getOrderingAsString() + } + }) + }, fetchFavorites (url) { var self = this this.isLoading = true let params = { favorites: 'true', page: this.page, - page_size: this.paginateBy + page_size: this.paginateBy, + ordering: this.getOrderingAsString() } logger.default.time('Loading user favorites') this.$http.get(url, {params: params}).then((response) => { @@ -86,6 +137,19 @@ export default { }, watch: { page: function () { + this.updateQueryString() + this.fetchFavorites(FAVORITES_URL) + }, + paginateBy: function () { + this.updateQueryString() + this.fetchFavorites(FAVORITES_URL) + }, + orderingDirection: function () { + this.updateQueryString() + this.fetchFavorites(FAVORITES_URL) + }, + ordering: function () { + this.updateQueryString() this.fetchFavorites(FAVORITES_URL) } } diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 2f0fb0a9236197d725191d4d1106037f855d3955..8d0a4f552d753b3fe525b3ca7c2f9fed562147ea 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -1,11 +1,40 @@ <template> <div> - <div v-if="isLoading" class="ui vertical segment"> - <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - </div> - <div v-if="result" class="ui vertical stripe segment"> + <div class="ui vertical stripe segment"> <h2 class="ui header">Browsing artists</h2> - <div class="ui stackable three column grid"> + <div :class="['ui', {'loading': isLoading}, 'form']"> + <div class="fields"> + <div class="field"> + <label>Search</label> + <input type="text" v-model="query" placeholder="Enter an artist name..."/> + </div> + <div class="field"> + <label>Ordering</label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <label>Ordering direction</label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="">Ascending</option> + <option value="-">Descending</option> + </select> + </div> + <div class="field"> + <label>Results per page</label> + <select class="ui dropdown" v-model="paginateBy"> + <option :value="parseInt(12)">12</option> + <option :value="parseInt(25)">25</option> + <option :value="parseInt(50)">50</option> + </select> + </div> + </div> + </div> + <div class="ui hidden divider"></div> + <div v-if="result" class="ui stackable three column grid"> <div v-if="result.results.length > 0" v-for="artist in result.results" @@ -28,41 +57,71 @@ </template> <script> +import _ from 'lodash' +import $ from 'jquery' import config from '@/config' import backend from '@/audio/backend' import logger from '@/logging' + +import OrderingMixin from '@/components/mixins/Ordering' +import PaginationMixin from '@/components/mixins/Pagination' import ArtistCard from '@/components/audio/artist/Card' import Pagination from '@/components/Pagination' const FETCH_URL = config.API_URL + 'artists/' export default { + mixins: [OrderingMixin, PaginationMixin], + props: { + defaultQuery: {type: String, required: false, default: ''} + }, components: { ArtistCard, Pagination }, data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') return { isLoading: true, result: null, - page: 1, - orderBy: 'name', - paginateBy: 12 + page: parseInt(this.defaultPage), + query: this.defaultQuery, + paginateBy: parseInt(this.defaultPaginateBy || 12), + orderingDirection: defaultOrdering.direction, + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'Creation date'], + ['name', 'Name'] + ] } }, created () { this.fetchData() }, + mounted () { + $('.ui.dropdown').dropdown() + }, methods: { - fetchData () { + updateQueryString: function () { + this.$router.replace({ + query: { + query: this.query, + page: this.page, + paginateBy: this.paginateBy, + ordering: this.getOrderingAsString() + } + }) + }, + fetchData: _.debounce(function () { var self = this this.isLoading = true let url = FETCH_URL let params = { page: this.page, page_size: this.paginateBy, - order_by: 'name' + name__icontains: this.query, + ordering: this.getOrderingAsString() } logger.default.debug('Fetching artists') this.$http.get(url, {params: params}).then((response) => { @@ -76,13 +135,30 @@ export default { }) self.isLoading = false }) - }, + }, 500), selectPage: function (page) { this.page = page } }, watch: { page () { + this.updateQueryString() + this.fetchData() + }, + paginateBy () { + this.updateQueryString() + this.fetchData() + }, + ordering () { + this.updateQueryString() + this.fetchData() + }, + orderingDirection () { + this.updateQueryString() + this.fetchData() + }, + query () { + this.updateQueryString() this.fetchData() } } diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index da9ac19b3ad7cef127384b446296d76e9a39a638..e8b053b6d0124175fda02254e71a85a420401e5e 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -8,7 +8,7 @@ <router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link> </div> </div> - <router-view></router-view> + <router-view :key="$route.fullPath"></router-view> </div> </template> diff --git a/front/src/components/mixins/Ordering.vue b/front/src/components/mixins/Ordering.vue new file mode 100644 index 0000000000000000000000000000000000000000..494dddcee15983d7449e477e9a7638d9befc2d1a --- /dev/null +++ b/front/src/components/mixins/Ordering.vue @@ -0,0 +1,26 @@ +<script> +export default { + props: { + defaultOrdering: {type: String, required: false} + }, + methods: { + getOrderingFromString (s) { + let parts = s.split('-') + if (parts.length > 1) { + return { + direction: '-', + field: parts.slice(1).join('-') + } + } else { + return { + direction: '', + field: s + } + } + }, + getOrderingAsString () { + return [this.orderingDirection, this.ordering].join('') + } + } +} +</script> diff --git a/front/src/components/mixins/Pagination.vue b/front/src/components/mixins/Pagination.vue new file mode 100644 index 0000000000000000000000000000000000000000..532faaaa3bc52872a6be104d5577f94b8f58d30c --- /dev/null +++ b/front/src/components/mixins/Pagination.vue @@ -0,0 +1,8 @@ +<script> +export default { + props: { + defaultPage: {required: false, default: 1}, + defaultPaginateBy: {required: false} + } +} +</script> diff --git a/front/src/router/index.js b/front/src/router/index.js index d727276fc7b58f58d288bf4ae42282341cc65ce4..7db5da6bad5c78f4f18308502cf6f5f09dd74c9f 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -47,14 +47,28 @@ export default new Router({ }, { path: '/favorites', - component: Favorites + component: Favorites, + props: (route) => ({ + defaultOrdering: route.query.ordering, + defaultPage: route.query.page + }) }, { path: '/library', component: Library, children: [ { path: '', component: LibraryHome }, - { path: 'artists/', name: 'library.artists.browse', component: LibraryArtists }, + { + path: 'artists/', + name: 'library.artists.browse', + component: LibraryArtists, + props: (route) => ({ + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true }, { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true }, { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },