diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index f5303d88c8e14d8a959d541903e23e13ce100528..c27313dc36d2a9d08c0cbba2c0e704c7527c5063 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -3,6 +3,7 @@ <div class="ui secondary pointing menu"> <router-link class="ui item" to="/library" exact>Browse</router-link> <router-link class="ui item" to="/library/artists" exact>Artists</router-link> + <router-link class="ui item" to="/library/radios" exact>Radios</router-link> <div class="ui secondary right menu"> <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link> <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link> diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue new file mode 100644 index 0000000000000000000000000000000000000000..409b6b6741137f6fef494cd42a11b33648498658 --- /dev/null +++ b/front/src/components/library/Radios.vue @@ -0,0 +1,164 @@ +<template> + <div> + <div class="ui vertical stripe segment"> + <h2 class="ui header">Browsing radios</h2> + <router-link class="ui green basic button" to="/library/radios/build" exact>Create your own radio</router-link> + <div class="ui hidden divider"></div> + <div :class="['ui', {'loading': isLoading}, 'form']"> + <div class="fields"> + <div class="field"> + <label>Search</label> + <input type="text" v-model="query" placeholder="Enter a radio 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="radio in result.results" + :key="radio.id" + class="column"> + <radio-card class="fluid" type="custom" :custom-radio="radio"></radio-card> + </div> + </div> + <div class="ui center aligned basic segment"> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + </div> + </div> + </div> +</template> + +<script> +import _ from 'lodash' +import $ from 'jquery' + +import config from '@/config' +import logger from '@/logging' + +import OrderingMixin from '@/components/mixins/Ordering' +import PaginationMixin from '@/components/mixins/Pagination' +import RadioCard from '@/components/radios/Card' +import Pagination from '@/components/Pagination' + +const FETCH_URL = config.API_URL + 'radios/radios/' + +export default { + mixins: [OrderingMixin, PaginationMixin], + props: { + defaultQuery: {type: String, required: false, default: ''} + }, + components: { + RadioCard, + Pagination + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + isLoading: true, + result: null, + 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: { + updateQueryString: _.debounce(function () { + this.$router.replace({ + query: { + query: this.query, + page: this.page, + paginateBy: this.paginateBy, + ordering: this.getOrderingAsString() + } + }) + }, 500), + fetchData: _.debounce(function () { + var self = this + this.isLoading = true + let url = FETCH_URL + let params = { + page: this.page, + page_size: this.paginateBy, + name__icontains: this.query, + ordering: this.getOrderingAsString() + } + logger.default.debug('Fetching radios') + this.$http.get(url, {params: params}).then((response) => { + self.result = response.data + 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() + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/library/import/FileUpload.vue b/front/src/components/library/import/FileUpload.vue index 4681d79322727f9341682f5b8586bc0685001676..93ca75c3961d445f924bf920df7ef5e1da330ab3 100644 --- a/front/src/components/library/import/FileUpload.vue +++ b/front/src/components/library/import/FileUpload.vue @@ -93,18 +93,15 @@ export default { inputFile (newFile, oldFile) { if (newFile && !oldFile) { // add - console.log('add', newFile) if (!this.batch) { this.createBatch() } } if (newFile && oldFile) { // update - console.log('update', newFile) } if (!newFile && oldFile) { // remove - console.log('remove', oldFile) } }, createBatch () { diff --git a/front/src/components/library/radios/Builder.vue b/front/src/components/library/radios/Builder.vue new file mode 100644 index 0000000000000000000000000000000000000000..f58d5003fd5b7ac21adac9b77142fa96206e0284 --- /dev/null +++ b/front/src/components/library/radios/Builder.vue @@ -0,0 +1,221 @@ +<template> + <div class="ui vertical stripe segment"> + <div> + <div> + <h2 class="ui header">Builder</h2> + <p> + You can use this interface to build your own custom radio, which + will play tracks according to your criteria + </p> + <div class="ui form"> + <div class="inline fields"> + <div class="field"> + <label for="name">Radio name</label> + <input id="name" type="text" v-model="radioName" placeholder="My awesome radio" /> + </div> + <div class="field"> + <input id="public" type="checkbox" v-model="isPublic" /> + <label for="public">Display publicly</label> + </div> + <button :disabled="!canSave" @click="save" class="ui green button">Save</button> + <radio-button v-if="id" type="custom" :custom-radio-id="id"></radio-button> + </div> + </div> + <div class="ui form"> + <p>Add filters to customize your radio</p> + <div class="inline field"> + <select class="ui dropdown" v-model="currentFilterType"> + <option value="">Select a filter</option> + <option v-for="f in availableFilters" :value="f.type">{{ f.label }}</option> + </select> + <button :disabled="!currentFilterType" @click="add" class="ui button">Add filter</button> + </div> + <p v-if="currentFilter"> + {{ currentFilter.help_text }} + </p> + </div> + <table class="ui table"> + <thead> + <tr> + <th class="two wide">Filter name</th> + <th class="one wide">Exclude</th> + <th class="six wide">Config</th> + <th class="five wide">Candidates</th> + <th class="two wide">Actions</th> + </tr> + </thead> + <tbody> + <builder-filter + v-for="(f, index) in filters" + :key="(f, index, f.hash)" + :index="index" + @update-config="updateConfig" + @delete="deleteFilter" + :config="f.config" + :filter="f.filter"> + </builder-filter> + </tbody> + </table> + <template v-if="checkResult"> + <h3 class="ui header"> + {{ checkResult.candidates.count }} tracks matching combined filters + </h3> + <track-table v-if="checkResult.candidates.sample" :tracks="checkResult.candidates.sample"></track-table> + </template> + </div> + </div> + </div> +</template> +<script> +import config from '@/config' +import $ from 'jquery' +import _ from 'lodash' +import BuilderFilter from './Filter' +import TrackTable from '@/components/audio/track/Table' +import RadioButton from '@/components/radios/Button' + +export default { + props: { + id: {required: false} + }, + components: { + BuilderFilter, + TrackTable, + RadioButton + }, + data: function () { + return { + availableFilters: [], + currentFilterType: null, + filters: [], + checkResult: null, + radioName: '', + isPublic: true + } + }, + created: function () { + let self = this + this.fetchFilters().then(() => { + if (self.id) { + self.fetch() + } + }) + }, + mounted () { + $('.ui.dropdown').dropdown() + }, + methods: { + fetchFilters: function () { + let self = this + let url = config.API_URL + 'radios/radios/filters/' + return this.$http.get(url).then((response) => { + self.availableFilters = response.data + }) + }, + add () { + this.filters.push({ + config: {}, + filter: this.currentFilter, + hash: +new Date() + }) + this.fetchCandidates() + }, + updateConfig (index, field, value) { + this.filters[index].config[field] = value + this.fetchCandidates() + }, + deleteFilter (index) { + this.filters.splice(index, 1) + this.fetchCandidates() + }, + fetch: function () { + let self = this + let url = config.API_URL + 'radios/radios/' + this.id + '/' + this.$http.get(url).then((response) => { + self.filters = response.data.config.map(f => { + return { + config: f, + filter: this.availableFilters.filter(e => { return e.type === f.type })[0], + hash: +new Date() + } + }) + self.radioName = response.data.name + self.isPublic = response.data.is_public + }) + }, + fetchCandidates: function () { + let self = this + let url = config.API_URL + 'radios/radios/validate/' + let final = this.filters.map(f => { + let c = _.clone(f.config) + c.type = f.filter.type + return c + }) + final = { + 'filters': [ + {'type': 'group', filters: final} + ] + } + this.$http.post(url, final).then((response) => { + self.checkResult = response.data.filters[0] + }) + }, + save: function () { + let self = this + let final = this.filters.map(f => { + let c = _.clone(f.config) + c.type = f.filter.type + return c + }) + final = { + 'name': this.radioName, + 'is_public': this.isPublic, + 'config': final + } + if (this.id) { + let url = config.API_URL + 'radios/radios/' + this.id + '/' + this.$http.put(url, final).then((response) => { + }) + } else { + let url = config.API_URL + 'radios/radios/' + this.$http.post(url, final).then((response) => { + self.$router.push({ + name: 'library.radios.edit', + params: { + id: response.data.id + } + }) + }) + } + } + }, + computed: { + canSave: function () { + return ( + this.radioName.length > 0 && this.checkErrors.length === 0 + ) + }, + checkErrors: function () { + if (!this.checkResult) { + return [] + } + let errors = this.checkResult.errors + return errors + }, + currentFilter: function () { + let self = this + return this.availableFilters.filter(e => { + return e.type === self.currentFilterType + })[0] + } + }, + watch: { + filters: { + handler: function () { + this.fetchCandidates() + }, + deep: true + } + } +} +</script> diff --git a/front/src/components/library/radios/Filter.vue b/front/src/components/library/radios/Filter.vue new file mode 100644 index 0000000000000000000000000000000000000000..dd170d8b3104da08e4fbd3b93091182fe4a3a2a4 --- /dev/null +++ b/front/src/components/library/radios/Filter.vue @@ -0,0 +1,150 @@ +<template> + <tr> + <td>{{ filter.label }}</td> + <td> + <div class="ui toggle checkbox"> + <input name="public" type="checkbox" v-model="exclude" @change="$emit('update-config', index, 'not', exclude)"> + <label></label> + </div> + </td> + <td> + <div + v-for="(f, index) in filter.fields" + class="ui field" + :key="(f.name, index)" + :ref="f.name"> + <div :class="['ui', 'search', 'selection', 'dropdown', {'autocomplete': f.autocomplete}, {'multiple': f.type === 'list'}]"> + <i class="dropdown icon"></i> + <div class="default text">{{ f.placeholder }}</div> + <input v-if="f.type === 'list' && config[f.name]" :value="config[f.name].join(',')" type="hidden"> + <div v-if="config[f.name]" class="ui menu"> + <div + v-if="f.type === 'list'" + v-for="(v, index) in config[f.name]" + class="ui item" + :data-value="v"> + <template v-if="config.names"> + {{ config.names[index] }} + </template> + <template v-else>{{ v }}</template> + </div> + </div> + </div> + </div> + </div> + </td> + <td> + <span + @click="showCandidadesModal = !showCandidadesModal" + v-if="checkResult" + :class="['ui', {'green': checkResult.candidates.count > 10}, 'label']"> + {{ checkResult.candidates.count }} tracks matching filter + </span> + <modal v-if="checkResult" :show.sync="showCandidadesModal"> + <div class="header"> + Track matching filter + </div> + <div class="content"> + <div class="description"> + <track-table v-if="checkResult.candidates.count > 0" :tracks="checkResult.candidates.sample"></track-table> + </div> + </div> + <div class="actions"> + <div class="ui black deny button"> + Cancel + </div> + </div> + </modal> + </td> + <td> + <button @click="$emit('delete', index)" class="ui basic red button">Remove</button> + </td> + </tr> +</template> +<script> +import config from '@/config' +import $ from 'jquery' +import _ from 'lodash' + +import Modal from '@/components/semantic/Modal' +import TrackTable from '@/components/audio/track/Table' +import BuilderFilter from './Filter' + +export default { + components: { + BuilderFilter, + TrackTable, + Modal + }, + props: { + filter: {type: Object}, + config: {type: Object}, + index: {type: Number} + }, + data: function () { + return { + checkResult: null, + showCandidadesModal: false, + exclude: config.not + } + }, + mounted: function () { + let self = this + this.filter.fields.forEach(f => { + let selector = ['.dropdown'] + let settings = { + onChange: function (value, text, $choice) { + value = $(this).dropdown('get value').split(',') + if (f.type === 'list' && f.subtype === 'number') { + value = value.map(e => { + return parseInt(e) + }) + } + self.value = value + self.$emit('update-config', self.index, f.name, value) + self.fetchCandidates() + } + } + if (f.type === 'list') { + selector.push('.multiple') + } + if (f.autocomplete) { + selector.push('.autocomplete') + settings.fields = f.autocomplete_fields + settings.minCharacters = 1 + settings.apiSettings = { + url: config.BACKEND_URL + f.autocomplete + '?' + f.autocomplete_qs, + beforeXHR: function (xhrObject) { + xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) + return xhrObject + }, + onResponse: function (initialResponse) { + if (settings.fields.remoteValues) { + return initialResponse + } + return {results: initialResponse} + } + } + } + $(self.$el).find(selector.join('')).dropdown(settings) + }) + }, + methods: { + fetchCandidates: function () { + let self = this + let url = config.API_URL + 'radios/radios/validate/' + let final = _.clone(this.config) + final.type = this.filter.type + final = {'filters': [final]} + this.$http.post(url, final).then((response) => { + self.checkResult = response.data.filters[0] + }) + } + }, + watch: { + exclude: function () { + this.fetchCandidates() + } + } +} +</script> diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue index 4bf4279890d05f39ac06a350164b9c2101747068..819aa8651f3509827b0460c54c9cec9ded18d23b 100644 --- a/front/src/components/radios/Button.vue +++ b/front/src/components/radios/Button.vue @@ -11,7 +11,8 @@ export default { props: { - type: {type: String, required: true}, + customRadioId: {required: false}, + type: {type: String, required: false}, objectId: {type: Number, default: null} }, methods: { @@ -19,7 +20,7 @@ export default { if (this.running) { this.$store.dispatch('radios/stop') } else { - this.$store.dispatch('radios/start', {type: this.type, objectId: this.objectId}) + this.$store.dispatch('radios/start', {type: this.type, objectId: this.objectId, customRadioId: this.customRadioId}) } } }, diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue index dc8a24ff3c2e31d901ee11fb27eceb976ca7e819..d2c14c37c78dfbc23b858c93a5eddd28b3519fb9 100644 --- a/front/src/components/radios/Card.vue +++ b/front/src/components/radios/Card.vue @@ -1,13 +1,19 @@ <template> <div class="ui card"> <div class="content"> - <div class="header">Radio : {{ radio.name }}</div> + <div class="header">{{ radio.name }}</div> <div class="description"> {{ radio.description }} </div> </div> <div class="extra content"> - <radio-button class="right floated button" :type="type"></radio-button> + <router-link + class="ui basic yellow button" + v-if="$store.state.auth.authenticated && type === 'custom' && customRadio.user === $store.state.auth.profile.id" + :to="{name: 'library.radios.edit', params: {id: customRadioId }}"> + Edit... + </router-link> + <radio-button class="right floated button" :type="type" :custom-radio-id="customRadioId"></radio-button> </div> </div> </template> @@ -17,14 +23,24 @@ import RadioButton from './Button' export default { props: { - type: {type: String, required: true} + type: {type: String, required: true}, + customRadio: {required: false} }, components: { RadioButton }, computed: { radio () { + if (this.customRadio) { + return this.customRadio + } return this.$store.getters['radios/types'][this.type] + }, + customRadioId: function () { + if (this.customRadio) { + return this.customRadio.id + } + return null } } } diff --git a/front/src/router/index.js b/front/src/router/index.js index f4efc723f4abc2fb9dfdca2e062d433c09d4e91e..971ef05cd82f034b9513716a42f35b02a5291054 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -13,6 +13,8 @@ import LibraryArtists from '@/components/library/Artists' import LibraryAlbum from '@/components/library/Album' import LibraryTrack from '@/components/library/Track' import LibraryImport from '@/components/library/import/Main' +import LibraryRadios from '@/components/library/Radios' +import RadioBuilder from '@/components/library/radios/Builder' import BatchList from '@/components/library/import/BatchList' import BatchDetail from '@/components/library/import/BatchDetail' @@ -76,6 +78,19 @@ export default new Router({ defaultPage: route.query.page }) }, + { + path: 'radios/', + name: 'library.radios.browse', + component: LibraryRadios, + props: (route) => ({ + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, + { path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true }, + { path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true }, { 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 }, diff --git a/front/src/store/radios.js b/front/src/store/radios.js index a9c429876a4ff974635de9f73062dd2597237209..600b24b31e7fb77eafdda8a0992bdf58879f3a54 100644 --- a/front/src/store/radios.js +++ b/front/src/store/radios.js @@ -38,15 +38,16 @@ export default { } }, actions: { - start ({commit, dispatch}, {type, objectId}) { + start ({commit, dispatch}, {type, objectId, customRadioId}) { let resource = Vue.resource(CREATE_RADIO_URL) var params = { radio_type: type, - related_object_id: objectId + related_object_id: objectId, + custom_radio: customRadioId } resource.save({}, params).then((response) => { logger.default.info('Successfully started radio ', type) - commit('current', {type, objectId, session: response.data.id}) + commit('current', {type, objectId, session: response.data.id, customRadioId}) commit('running', true) dispatch('populateQueue') }, (response) => {