- various __all__
- serializer: track search, reorder module files
- autocomplete: allow simple string value selection
- playlist editor:
    - ui & flow improve
    - init data
    - save user settings
    - autocomplete
    - fix bugs
    - discard changes
			
			
This commit is contained in:
		@ -4,10 +4,18 @@ import './index.js'
 | 
			
		||||
 | 
			
		||||
import App from './app';
 | 
			
		||||
import {admin as components} from './components'
 | 
			
		||||
import Track from './track'
 | 
			
		||||
 | 
			
		||||
const AdminApp = {
 | 
			
		||||
    ...App,
 | 
			
		||||
    components: {...App.components, ...components},
 | 
			
		||||
 | 
			
		||||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
            ...super.data,
 | 
			
		||||
            Track,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
export default AdminApp;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										78
									
								
								assets/src/components/AActionButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								assets/src/components/AActionButton.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,78 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <component :is="tag" @click="call" :class="buttonClass">
 | 
			
		||||
        <span v-if="promise && runIcon">
 | 
			
		||||
            <i :class="runIcon"></i>
 | 
			
		||||
        </span>
 | 
			
		||||
        <span v-else-if="icon" class="icon">
 | 
			
		||||
            <i :class="icon"></i>
 | 
			
		||||
        </span>
 | 
			
		||||
        <span v-if="$slots.default"><slot name="default"/></span>
 | 
			
		||||
    </component>
 | 
			
		||||
</template>
 | 
			
		||||
<script>
 | 
			
		||||
import Model from '../model'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Button that can be used to call API requests on provided url
 | 
			
		||||
 */
 | 
			
		||||
export default {
 | 
			
		||||
    emit: ['start', 'done'],
 | 
			
		||||
 | 
			
		||||
    props: {
 | 
			
		||||
        //! Component tag, by default, `button`
 | 
			
		||||
        tag: { type: String, default: 'a'},
 | 
			
		||||
        //! Button icon
 | 
			
		||||
        icon: String,
 | 
			
		||||
        //! Data or model instance to send
 | 
			
		||||
        data: Object,
 | 
			
		||||
        //! Action method, by default, `POST`
 | 
			
		||||
        method: { type: String, default: 'POST'},
 | 
			
		||||
        //! Action url
 | 
			
		||||
        url: String,
 | 
			
		||||
        //! Extra request options
 | 
			
		||||
        fetchOptions: {type: Object, default: () => {return {}}},
 | 
			
		||||
        //! Component class while action is running
 | 
			
		||||
        runClass: String,
 | 
			
		||||
        //! Icon class while action is running
 | 
			
		||||
        runIcon: String,
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    computed: {
 | 
			
		||||
        //! Input data as model instance
 | 
			
		||||
        item() {
 | 
			
		||||
            return this.data instanceof Model ? this.data
 | 
			
		||||
                        : new Model(this.data)
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        //! Computed button class
 | 
			
		||||
        buttonClass() {
 | 
			
		||||
            return this.promise ? this.runClass : ''
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
            promise: false
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    methods: {
 | 
			
		||||
        call() {
 | 
			
		||||
            if(this.promise || !this.url)
 | 
			
		||||
                return
 | 
			
		||||
            const options = Model.getOptions({
 | 
			
		||||
                ...this.fetchOptions,
 | 
			
		||||
                method: this.method,
 | 
			
		||||
                body: JSON.stringify(this.item.data),
 | 
			
		||||
            })
 | 
			
		||||
            this.promise = fetch(this.url, options).then(data => {
 | 
			
		||||
                const response = data.json();
 | 
			
		||||
                this.promise = null;
 | 
			
		||||
                this.$emit('done', response)
 | 
			
		||||
                return response
 | 
			
		||||
            }, data => { this.promise = null; return data })
 | 
			
		||||
            return this.promise
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@ -1,37 +1,44 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div :class="dropdownClass">
 | 
			
		||||
        <div class="dropdown-trigger is-fullwidth">
 | 
			
		||||
            <input type="hidden" :name="name"
 | 
			
		||||
                :value="selectedValue" />
 | 
			
		||||
            <div v-show="!selected" class="control is-expanded">
 | 
			
		||||
                <input type="text" :placeholder="placeholder"
 | 
			
		||||
                    ref="input" class="input is-fullwidth"
 | 
			
		||||
                    @keydown.capture="onKeyPress"
 | 
			
		||||
                    @keyup="onKeyUp" @focus="this.cursor < 0 && move(0)"/>
 | 
			
		||||
            </div>
 | 
			
		||||
            <button v-if="selected" class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
 | 
			
		||||
                    @click="select(-1, false, true)">
 | 
			
		||||
                <span class="icon is-small ml-1">
 | 
			
		||||
                    <i class="fa fa-pen"></i>
 | 
			
		||||
                </span>
 | 
			
		||||
                <span class="is-inline-block" v-if="selected">
 | 
			
		||||
                    <slot name="button" :index="selectedIndex" :item="selected"
 | 
			
		||||
                        :value-field="valueField" :labelField="labelField">
 | 
			
		||||
                    {{ selected.data[labelField] }}
 | 
			
		||||
                    </slot>
 | 
			
		||||
                </span>
 | 
			
		||||
            </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="dropdown-menu is-fullwidth">
 | 
			
		||||
            <div class="dropdown-content" style="overflow: hidden">
 | 
			
		||||
                <a v-for="(item, index) in items" :key="item.id"
 | 
			
		||||
                    :class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
 | 
			
		||||
                    @click.capture.prevent="select(index, false, false)" :title="item.data[labelField]">
 | 
			
		||||
                    <slot name="item" :index="index" :item="item" :value-field="valueField"
 | 
			
		||||
                        :labelField="labelField">
 | 
			
		||||
                    {{ item.data[labelField] }}
 | 
			
		||||
                    </slot>
 | 
			
		||||
                </a>
 | 
			
		||||
    <div class="control">
 | 
			
		||||
        <input type="hidden" :name="name" :value="selectedValue"
 | 
			
		||||
            @change="$emit('change', $event)"/>
 | 
			
		||||
        <input type="text" ref="input" class="input is-fullwidth" :class="inputClass"
 | 
			
		||||
            v-show="!button || !selected"
 | 
			
		||||
            v-model="inputValue"
 | 
			
		||||
            :placeholder="placeholder"
 | 
			
		||||
            @keydown.capture="onKeyDown"
 | 
			
		||||
            @keyup="onKeyUp($event); $emit('keyup', $event)"
 | 
			
		||||
            @keydown="$emit('keydown', $event)"
 | 
			
		||||
            @keypress="$emit('keypress', $event)"
 | 
			
		||||
            @focus="onInputFocus" @blur="onBlur" />
 | 
			
		||||
        <a v-if="selected && button"
 | 
			
		||||
                class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
 | 
			
		||||
                @click="select(-1, false, true)">
 | 
			
		||||
            <span class="icon is-small ml-1">
 | 
			
		||||
                <i class="fa fa-pen"></i>
 | 
			
		||||
            </span>
 | 
			
		||||
            <span class="is-inline-block" v-if="selected">
 | 
			
		||||
                <slot name="button" :index="selectedIndex" :item="selected"
 | 
			
		||||
                    :value-field="valueField" :labelField="labelField">
 | 
			
		||||
                {{ labelField && selected.data[labelField] || selected }}
 | 
			
		||||
                </slot>
 | 
			
		||||
            </span>
 | 
			
		||||
        </a>
 | 
			
		||||
        <div :class="dropdownClass">
 | 
			
		||||
            <div class="dropdown-menu is-fullwidth">
 | 
			
		||||
                <div class="dropdown-content" style="overflow: hidden">
 | 
			
		||||
                    <a v-for="(item, index) in items" :key="item.id"
 | 
			
		||||
                        href="#" :data-autocomplete-index="index"
 | 
			
		||||
                        @click="select(index, false, false)"
 | 
			
		||||
                        :class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
 | 
			
		||||
                        :title="labelField && item.data[labelField] || item"
 | 
			
		||||
                        tabindex="-1">
 | 
			
		||||
                        <slot name="item" :index="index" :item="item" :value-field="valueField"
 | 
			
		||||
                            :labelField="labelField">
 | 
			
		||||
                        {{ labelField && item.data[labelField] || item }}
 | 
			
		||||
                        </slot>
 | 
			
		||||
                    </a>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
@ -39,29 +46,63 @@
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
// import debounce from 'lodash/debounce'
 | 
			
		||||
import Model from '../model'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    emit: ['change', 'keypress', 'keydown', 'keyup', 'select', 'unselect',
 | 
			
		||||
           'update:modelValue'],
 | 
			
		||||
 | 
			
		||||
    props: {
 | 
			
		||||
        //! Search URL (where `${query}` is replaced by search term)
 | 
			
		||||
        url: String,
 | 
			
		||||
        //! Items' model
 | 
			
		||||
        model: Function,
 | 
			
		||||
        //! Input tag class
 | 
			
		||||
        inputClass: Array,
 | 
			
		||||
        //! input text placeholder
 | 
			
		||||
        placeholder: String,
 | 
			
		||||
        //! input form field name
 | 
			
		||||
        name: String,
 | 
			
		||||
        //! Field on items to use as label
 | 
			
		||||
        labelField: String,
 | 
			
		||||
        //! Field on selected item to get selectedValue from, if any
 | 
			
		||||
        valueField: {type: String, default: null},
 | 
			
		||||
        count: {type: Number, count: 10},
 | 
			
		||||
        //! If true, show button when value has been selected
 | 
			
		||||
        button: Boolean,
 | 
			
		||||
        //! If true, value must come from a selection
 | 
			
		||||
        mustExist: {type: Boolean, default: false},
 | 
			
		||||
        //! Minimum input size before fetching
 | 
			
		||||
        minFetchLength: {type: Number, default: 3},
 | 
			
		||||
        modelValue: {default: ''},
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
            value: '',
 | 
			
		||||
            inputValue: this.modelValue || '',
 | 
			
		||||
            query: '',
 | 
			
		||||
            items: [],
 | 
			
		||||
            selectedIndex: -1,
 | 
			
		||||
            cursor: -1,
 | 
			
		||||
            isFetching: false,
 | 
			
		||||
            promise: null,
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    watch: {
 | 
			
		||||
        modelValue(value) {
 | 
			
		||||
            this.inputValue = value
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        inputValue(value) {
 | 
			
		||||
            if(value != this.inputValue && value != this.modelValue)
 | 
			
		||||
                this.$emit('update:modelValue', value)
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    computed: {
 | 
			
		||||
        isFetching() { return !!this.promise },
 | 
			
		||||
 | 
			
		||||
        selected() {
 | 
			
		||||
            let index = this.selectedIndex
 | 
			
		||||
            if(index<0)
 | 
			
		||||
@ -71,23 +112,40 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
        
 | 
			
		||||
        selectedValue() {
 | 
			
		||||
            const sel = this.selected
 | 
			
		||||
            return sel && (this.valueField ?
 | 
			
		||||
                    sel.data[this.valueField] : sel.id)
 | 
			
		||||
            let value = this.itemValue(this.selected)
 | 
			
		||||
            if(!value && !this.mustExist)
 | 
			
		||||
                value = this.inputValue
 | 
			
		||||
            return value
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        selectedLabel() {
 | 
			
		||||
            const sel = this.selected
 | 
			
		||||
            return sel && sel.data[this.labelField]
 | 
			
		||||
            return this.itemLabel(this.selected)
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        dropdownClass() {
 | 
			
		||||
            const active = this.cursor > -1 && this.items.length;
 | 
			
		||||
            return ['dropdown', active ? 'is-active':'']
 | 
			
		||||
            var active = this.cursor > -1 && this.items.length;
 | 
			
		||||
            if(active && this.items.length == 1 &&
 | 
			
		||||
                    this.itemValue(this.items[0]) == this.inputValue)
 | 
			
		||||
                active = false
 | 
			
		||||
            return ['dropdown is-fullwidth', active ? 'is-active':'']
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    methods: {
 | 
			
		||||
        itemValue(item) {
 | 
			
		||||
            return this.valueField ? item && item[this.valueField] : item;
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        itemLabel(item) {
 | 
			
		||||
            return this.labelField ? item && item[this.labelField] : item;
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
        hide() {
 | 
			
		||||
            this.cursor = -1;
 | 
			
		||||
            this.selectedIndex = -1;
 | 
			
		||||
        },
 | 
			
		||||
        
 | 
			
		||||
        move(index=-1, relative=false) {
 | 
			
		||||
            if(relative)
 | 
			
		||||
                index += this.cursor
 | 
			
		||||
@ -100,9 +158,9 @@ export default {
 | 
			
		||||
            else if(index == this.selectedIndex)
 | 
			
		||||
                return
 | 
			
		||||
        
 | 
			
		||||
            this.selectedIndex =  Math.max(-1, Math.min(index, this.items.length-1))
 | 
			
		||||
            this.selectedIndex = Math.max(-1, Math.min(index, this.items.length-1))
 | 
			
		||||
            if(index >= 0) {
 | 
			
		||||
                this.$refs.input.value = this.selectedLabel
 | 
			
		||||
                this.inputValue = this.selectedLabel
 | 
			
		||||
                this.$refs.input.focus()
 | 
			
		||||
            }
 | 
			
		||||
            if(this.selectedIndex < 0)
 | 
			
		||||
@ -114,11 +172,24 @@ export default {
 | 
			
		||||
                active && this.move(0) || this.move(-1)
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        onKeyPress: function(event) {
 | 
			
		||||
        onInputFocus() {
 | 
			
		||||
            this.cursor < 0 && this.move(0)
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        onBlur(event) {
 | 
			
		||||
            var index = event.relatedTarget && event.relatedTarget.dataset.autocompleteIndex;
 | 
			
		||||
            if(index !== undefined)
 | 
			
		||||
                this.select(index, false, false)
 | 
			
		||||
            this.cursor = -1;
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        onKeyDown(event) {
 | 
			
		||||
            if(event.ctrlKey || event.altKey || event.metaKey)
 | 
			
		||||
                return
 | 
			
		||||
            switch(event.keyCode) {
 | 
			
		||||
                case 13: this.select(this.cursor, false, false)
 | 
			
		||||
                         break
 | 
			
		||||
                case 27: this.select()
 | 
			
		||||
                case 27: this.hide(); this.select()
 | 
			
		||||
                         break
 | 
			
		||||
                case 38: this.move(-1, true)
 | 
			
		||||
                         break
 | 
			
		||||
@ -130,35 +201,47 @@ export default {
 | 
			
		||||
            event.stopPropagation()
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        onKeyUp: function(event) {
 | 
			
		||||
            const value = event.target.value
 | 
			
		||||
            if(value === this.value)
 | 
			
		||||
        onKeyUp(event) {
 | 
			
		||||
            if(event.ctrlKey || event.altKey || event.metaKey)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            this.value = value;
 | 
			
		||||
            const value = event.target.value
 | 
			
		||||
            if(value === this.query)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            this.inputValue = value;
 | 
			
		||||
            if(!value)
 | 
			
		||||
                return this.selected && this.select(-1)
 | 
			
		||||
 | 
			
		||||
            this.fetch(value)
 | 
			
		||||
            if(!this.minFetchLength || value.length >= this.minFetchLength)
 | 
			
		||||
                this.fetch(value)
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        fetch: function(query) {
 | 
			
		||||
            if(!query || this.isFetching)
 | 
			
		||||
        fetch(query) {
 | 
			
		||||
            if(!query || this.promise)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            this.isFetching = true
 | 
			
		||||
            return this.model.fetch(this.url.replace('${query}', query), {many:true})
 | 
			
		||||
                .then(items => { this.items = items || []
 | 
			
		||||
                                 this.isFetching = false
 | 
			
		||||
                                 this.move(0)
 | 
			
		||||
                                 return items },
 | 
			
		||||
                      data => {this.isFetching = false; Promise.reject(data)})
 | 
			
		||||
            this.query = query
 | 
			
		||||
            var url = this.url.replace('${query}', query)
 | 
			
		||||
            var promise = this.model ? this.model.fetch(url, {many:true})
 | 
			
		||||
                                     : fetch(url, Model.getOptions()).then(d => d.json())
 | 
			
		||||
 | 
			
		||||
            promise = promise.then(items => {
 | 
			
		||||
                this.items = items || []
 | 
			
		||||
                this.promise = null;
 | 
			
		||||
                this.move(0)
 | 
			
		||||
                return items
 | 
			
		||||
            }, data => {this.promise = null; Promise.reject(data)})
 | 
			
		||||
            this.promise = promise
 | 
			
		||||
            return promise
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    mounted() {
 | 
			
		||||
        const form = this.$el.closest('form')
 | 
			
		||||
        form.addEventListener('reset', () => { this.value=''; this.select(-1) })
 | 
			
		||||
        form.addEventListener('reset', () => {
 | 
			
		||||
            this.inputValue = this.value;
 | 
			
		||||
            this.select(-1)
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@
 | 
			
		||||
        <component :is="listTag" :class="listClass">
 | 
			
		||||
            <template v-for="(item,index) in items" :key="index">
 | 
			
		||||
                <component :is="itemTag" :class="itemClass" @click="select(index)"
 | 
			
		||||
                        :draggable="orderable"
 | 
			
		||||
                        :draggable="orderable" :data-index="index"
 | 
			
		||||
                        @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
 | 
			
		||||
                    <slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot>
 | 
			
		||||
                </component>
 | 
			
		||||
@ -70,7 +70,7 @@ export default {
 | 
			
		||||
 | 
			
		||||
        onDragStart(ev) {
 | 
			
		||||
            const dataset = ev.target.dataset;
 | 
			
		||||
            const data = `cell:${dataset.index}`
 | 
			
		||||
            const data = `row:${dataset.index}`
 | 
			
		||||
            ev.dataTransfer.setData("text/cell", data)
 | 
			
		||||
            ev.dataTransfer.dropEffect = 'move'
 | 
			
		||||
        },
 | 
			
		||||
@ -82,11 +82,11 @@ export default {
 | 
			
		||||
 | 
			
		||||
        onDrop(ev) {
 | 
			
		||||
            const data = ev.dataTransfer.getData("text/cell")
 | 
			
		||||
            if(!data || !data.startsWith('cell:'))
 | 
			
		||||
            if(!data || !data.startsWith('row:'))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            ev.preventDefault()
 | 
			
		||||
            const from = Number(data.slice(5))
 | 
			
		||||
            const from = Number(data.slice(4))
 | 
			
		||||
            const target = ev.target.tagName == this.itemTag ? ev.target
 | 
			
		||||
                                : ev.target.closest(this.itemTag)
 | 
			
		||||
            this.$emit('move', {
 | 
			
		||||
 | 
			
		||||
@ -7,8 +7,8 @@
 | 
			
		||||
            <div class="column has-text-right">
 | 
			
		||||
                <div class="float-right field has-addons">
 | 
			
		||||
                    <p class="control">
 | 
			
		||||
                        <a :class="['button','p-2', mode == Modes.Text ? 'is-primary' : 'is-light']"
 | 
			
		||||
                                @click="mode = Modes.Text">
 | 
			
		||||
                        <a :class="['button','p-2', page == Page.Text ? 'is-primary' : 'is-light']"
 | 
			
		||||
                                @click="page = Page.Text">
 | 
			
		||||
                            <span class="icon is-small">
 | 
			
		||||
                                <i class="fa fa-pencil"></i>
 | 
			
		||||
                            </span>
 | 
			
		||||
@ -16,8 +16,8 @@
 | 
			
		||||
                        </a>
 | 
			
		||||
                    </p>
 | 
			
		||||
                    <p class="control">
 | 
			
		||||
                        <a :class="['button','p-2', mode == Modes.List ? 'is-primary' : 'is-light']"
 | 
			
		||||
                                @click="mode = Modes.List">
 | 
			
		||||
                        <a :class="['button','p-2', page == Page.List ? 'is-primary' : 'is-light']"
 | 
			
		||||
                                @click="page = Page.List">
 | 
			
		||||
                            <span class="icon is-small">
 | 
			
		||||
                                <i class="fa fa-list"></i>
 | 
			
		||||
                            </span>
 | 
			
		||||
@ -28,43 +28,16 @@
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <slot name="top" :set="set" :columns="columns" :items="items"/>
 | 
			
		||||
        <section class="page" v-show="mode == Modes.Text">
 | 
			
		||||
            <textarea ref="textarea" class="is-fullwidth" rows="20"
 | 
			
		||||
        <section class="page" v-show="page == Page.Text">
 | 
			
		||||
            <textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
 | 
			
		||||
                @change="updateList"
 | 
			
		||||
            />
 | 
			
		||||
 | 
			
		||||
            <div class="columns mt-2">
 | 
			
		||||
                <div class="column field is-vcentered">
 | 
			
		||||
                    <label class="label is-inline mr-2"
 | 
			
		||||
                            style="vertical-align: middle">
 | 
			
		||||
                        Ordre</label>
 | 
			
		||||
                    <table class="table is-bordered is-inline-block"
 | 
			
		||||
                            style="vertical-align: middle">
 | 
			
		||||
                        <tr>
 | 
			
		||||
                            <a-row :cell="{columns}" :item="FormatLabels"
 | 
			
		||||
                                @move="formatMove" :orderable="true">
 | 
			
		||||
                            </a-row>
 | 
			
		||||
                        </tr>
 | 
			
		||||
                    </table>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="column field is-vcentered">
 | 
			
		||||
                    <label class="label is-inline mr-2"
 | 
			
		||||
                            style="vertical-align: middle">
 | 
			
		||||
                        Séparateur</label>
 | 
			
		||||
                    <div class="control is-inline-block"
 | 
			
		||||
                            style="vertical-align: middle">
 | 
			
		||||
                        <input type="text" ref="sep" value="--" class="input is-inline"
 | 
			
		||||
                            @change="updateList()"/>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="column"/>
 | 
			
		||||
            </div>
 | 
			
		||||
        </section>
 | 
			
		||||
        <section class="page" v-show="mode == Modes.List">
 | 
			
		||||
            <a-rows :set="set" :columns="columns" :labels="FormatLabels"
 | 
			
		||||
        <section class="page" v-show="page == Page.List">
 | 
			
		||||
            <a-rows :set="set" :columns="columns" :labels="labels"
 | 
			
		||||
                    :allow-create="true"
 | 
			
		||||
                    :list-class="listClass" :item-class="itemClass"
 | 
			
		||||
                    :orderable="true" @move="listItemMove"
 | 
			
		||||
                    :orderable="true" @move="listItemMove" @colmove="columnMove"
 | 
			
		||||
                    @cell="onCellEvent">
 | 
			
		||||
                <template v-for="[name,slot] of rowsSlots" :key="slot"
 | 
			
		||||
                        v-slot:[slot]="data">
 | 
			
		||||
@ -72,51 +45,128 @@
 | 
			
		||||
                </template>
 | 
			
		||||
            </a-rows>
 | 
			
		||||
        </section>
 | 
			
		||||
        <section class="page" v-show="mode == Modes.Settings">
 | 
			
		||||
 | 
			
		||||
        </section>
 | 
			
		||||
        <div class="mt-2">
 | 
			
		||||
            <div class="field is-inline-block is-vcentered mr-3">
 | 
			
		||||
                <label class="label is-inline mr-2"
 | 
			
		||||
                        style="vertical-align: middle">
 | 
			
		||||
                    Séparateur</label>
 | 
			
		||||
                <div class="control is-inline-block"
 | 
			
		||||
                        style="vertical-align: middle;">
 | 
			
		||||
                    <input type="text" ref="sep" class="input is-inline is-text-centered is-small"
 | 
			
		||||
                        style="max-width: 5em;"
 | 
			
		||||
                        v-model="separator" @change="updateList()"/>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="field is-inline-block is-vcentered mr-5">
 | 
			
		||||
                <label class="label is-inline mr-2"
 | 
			
		||||
                        style="vertical-align: middle">
 | 
			
		||||
                    {{ labels.columns }}</label>
 | 
			
		||||
                <table class="table is-bordered is-inline-block"
 | 
			
		||||
                        style="vertical-align: middle">
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <a-row :columns="columns" :item="labels"
 | 
			
		||||
                                @move="formatMove" :orderable="true">
 | 
			
		||||
                            <template v-slot:cell-after="{cell}">
 | 
			
		||||
                                <td style="cursor:pointer;" v-if="cell.col < columns.length-1">
 | 
			
		||||
                                    <span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})"
 | 
			
		||||
                                        ><i class="fa fa-left-right"/>
 | 
			
		||||
                                    </span>
 | 
			
		||||
                                </td>
 | 
			
		||||
                            </template>
 | 
			
		||||
                        </a-row>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                </table>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="field is-vcentered is-inline-block"
 | 
			
		||||
                    v-if="settingsChanged">
 | 
			
		||||
                <a-action-button icon="fa fa-floppy-disk"
 | 
			
		||||
                        class="button control p-3 is-info" run-class="blink"
 | 
			
		||||
                        :url="settingsUrl" method="POST"
 | 
			
		||||
                        :data="settings"
 | 
			
		||||
                        :aria-label="labels.save_settings"
 | 
			
		||||
                        @done="settingsSaved()">
 | 
			
		||||
                    {{ labels.save_settings }}
 | 
			
		||||
                </a-action-button>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="float-right">
 | 
			
		||||
                <a class="button is-warning p-2 ml-2"
 | 
			
		||||
                        @click="loadData({items: this.initData.items},true)">
 | 
			
		||||
                    <span class="icon"><i class="fa fa-rotate" /></span>
 | 
			
		||||
                    <span>{{ labels.discard_changes }}</span>
 | 
			
		||||
                </a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <slot name="bottom" :set="set" :columns="columns" :items="items"/>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
<script>
 | 
			
		||||
import {dropRightWhile} from 'lodash'
 | 
			
		||||
import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
 | 
			
		||||
import {Set} from '../model'
 | 
			
		||||
import Track from '../track'
 | 
			
		||||
 | 
			
		||||
import AActionButton from './AActionButton'
 | 
			
		||||
import ARow from './ARow.vue'
 | 
			
		||||
import ARows from './ARows.vue'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export const Modes = {
 | 
			
		||||
/// Page display
 | 
			
		||||
export const Page = {
 | 
			
		||||
    Text: 0, List: 1, Settings: 2,
 | 
			
		||||
}
 | 
			
		||||
const FormatLabels = {
 | 
			
		||||
    artist: 'Artiste', album: 'Album', year: 'Année', tags: 'Tags',
 | 
			
		||||
    title: 'Titre',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    components: { ARow, ARows },
 | 
			
		||||
    components: { AActionButton, ARow, ARows },
 | 
			
		||||
    props: {
 | 
			
		||||
        dataEl: String,
 | 
			
		||||
        initData: Object,
 | 
			
		||||
        dataPrefix: String,
 | 
			
		||||
        listClass: String,
 | 
			
		||||
        itemClass: String,
 | 
			
		||||
        labels: Object,
 | 
			
		||||
        settingsUrl: String,
 | 
			
		||||
        defaultColumns: {
 | 
			
		||||
            type: Array,
 | 
			
		||||
            default: () => ['artist', 'title', 'tags', 'album', 'year']},
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    data() {
 | 
			
		||||
        const settings = {
 | 
			
		||||
            playlist_editor_columns: this.defaultColumns,
 | 
			
		||||
            playlist_editor_sep: ' -- ',
 | 
			
		||||
        }
 | 
			
		||||
        return {
 | 
			
		||||
            Modes: Modes,
 | 
			
		||||
            FormatLabels: FormatLabels,
 | 
			
		||||
            mode: Modes.Text,
 | 
			
		||||
            Page: Page,
 | 
			
		||||
            page: Page.Text,
 | 
			
		||||
            set: new Set(Track),
 | 
			
		||||
            columns: ['artist', 'title', 'tags', 'album', 'year'],
 | 
			
		||||
            extraData: {},
 | 
			
		||||
            settings,
 | 
			
		||||
            savedSettings: cloneDeep(settings),
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    computed: {
 | 
			
		||||
       
 | 
			
		||||
        settingsChanged() {
 | 
			
		||||
            var k = Object.keys(this.savedSettings)
 | 
			
		||||
                          .findIndex(k => !isEqual(this.settings[k], this.savedSettings[k]))
 | 
			
		||||
            return k != -1
 | 
			
		||||
        },
 | 
			
		||||
        
 | 
			
		||||
        separator: {
 | 
			
		||||
            set(value) {
 | 
			
		||||
                this.settings.playlist_editor_sep = value
 | 
			
		||||
                if(this.page == Page.List)
 | 
			
		||||
                    this.updateInput()
 | 
			
		||||
            },
 | 
			
		||||
            get() { return this.settings.playlist_editor_sep }
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        columns: {
 | 
			
		||||
            set(value) {
 | 
			
		||||
                var cols = value.filter(x => x in this.defaultColumns)
 | 
			
		||||
                var left = this.defaultColumns.filter(x => !(x in cols))
 | 
			
		||||
                value = cols.concat(left)
 | 
			
		||||
                this.settings.playlist_editor_columns = value
 | 
			
		||||
            },
 | 
			
		||||
            get() { return this.settings.playlist_editor_columns }
 | 
			
		||||
        },
 | 
			
		||||
        
 | 
			
		||||
        items() {
 | 
			
		||||
            return this.set.items
 | 
			
		||||
        },
 | 
			
		||||
@ -140,7 +190,17 @@ export default {
 | 
			
		||||
            const value = this.columns[from]
 | 
			
		||||
            this.columns.splice(from, 1)
 | 
			
		||||
            this.columns.splice(to, 0, value)
 | 
			
		||||
            this.updateList()
 | 
			
		||||
            if(this.page == Page.Text)
 | 
			
		||||
                this.updateList()
 | 
			
		||||
            else
 | 
			
		||||
                this.updateText()
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        columnMove({from, to}) {
 | 
			
		||||
            const value = this.columns[from]
 | 
			
		||||
            this.columns.splice(from, 1)
 | 
			
		||||
            this.columns.splice(to, 0, value)
 | 
			
		||||
            this.updateInput()
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        listItemMove({from, to, set}) {
 | 
			
		||||
@ -149,29 +209,28 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
    
 | 
			
		||||
        updateList() {
 | 
			
		||||
            const items = this.toList(this.$refs.textarea.value,
 | 
			
		||||
                                      this.$refs.sep.value)
 | 
			
		||||
            const items = this.toList(this.$refs.textarea.value)
 | 
			
		||||
            this.set.reset(items)
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        updateInput() {
 | 
			
		||||
            const input = this.toText(this.items, this.$refs.sep.value)
 | 
			
		||||
            const input = this.toText(this.items)
 | 
			
		||||
            this.$refs.textarea.value = input
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * From input and separator, return list of items.
 | 
			
		||||
         */
 | 
			
		||||
        toList(input, sep) {
 | 
			
		||||
        toList(input) {
 | 
			
		||||
            var lines = input.split('\n')
 | 
			
		||||
            var items = []
 | 
			
		||||
 | 
			
		||||
            for(let line of lines) {
 | 
			
		||||
                line = line.trim()
 | 
			
		||||
                line = line.trimLeft()
 | 
			
		||||
                if(!line)
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                var lineBits = line.split(sep)
 | 
			
		||||
                var lineBits = line.split(this.separator)
 | 
			
		||||
                var item = {}
 | 
			
		||||
                for(var col in this.columns) {
 | 
			
		||||
                    if(col >= lineBits.length)
 | 
			
		||||
@ -187,17 +246,18 @@ export default {
 | 
			
		||||
        /**
 | 
			
		||||
         * From items and separator return a string
 | 
			
		||||
         */
 | 
			
		||||
        toText(items, sep) {
 | 
			
		||||
            var lines = []
 | 
			
		||||
            sep = ` ${(sep || this.$refs.sep.value).trim()} `
 | 
			
		||||
        toText(items) {
 | 
			
		||||
            const sep = ` ${this.separator.trim()} `
 | 
			
		||||
            const lines = []
 | 
			
		||||
            for(let item of items) {
 | 
			
		||||
                if(!item)
 | 
			
		||||
                    continue
 | 
			
		||||
                var line = []
 | 
			
		||||
                for(var col of this.columns)
 | 
			
		||||
                    line.push(item.data[col] || '')
 | 
			
		||||
                line = dropRightWhile(line, x => !x)
 | 
			
		||||
                lines.push(line.join(sep))
 | 
			
		||||
                line = dropRightWhile(line, x => !x || !('' + x).trim())
 | 
			
		||||
                line = line.join(sep).trimRight()
 | 
			
		||||
                lines.push(line)
 | 
			
		||||
            }
 | 
			
		||||
            return lines.join('\n')
 | 
			
		||||
        },
 | 
			
		||||
@ -213,26 +273,38 @@ export default {
 | 
			
		||||
                return [null, key]
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        //! Update saved settings from this.settings
 | 
			
		||||
        settingsSaved(settings=null) {
 | 
			
		||||
            if(settings !== null)
 | 
			
		||||
                this.settings = settings
 | 
			
		||||
            this.savedSettings = cloneDeep(this.settings)
 | 
			
		||||
        },
 | 
			
		||||
        
 | 
			
		||||
        /**
 | 
			
		||||
         * Load initial data
 | 
			
		||||
         */
 | 
			
		||||
        loadData({items=[]}) {
 | 
			
		||||
        loadData({items=[], settings=null}, reset=false) {
 | 
			
		||||
            if(reset) {
 | 
			
		||||
                this.set.items = []
 | 
			
		||||
            }
 | 
			
		||||
            for(var index in items)
 | 
			
		||||
                this.set.push(items[index])
 | 
			
		||||
                this.set.push(cloneDeep(items[index]))
 | 
			
		||||
            if(settings)
 | 
			
		||||
                this.settingsSaved(settings)
 | 
			
		||||
            this.updateInput()
 | 
			
		||||
         },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    watch: {
 | 
			
		||||
        initData(val) {
 | 
			
		||||
            this.loadData(val)
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    mounted() {
 | 
			
		||||
        if(this.dataEl) {
 | 
			
		||||
            const el = document.getElementById(this.dataEl)
 | 
			
		||||
            if(el) {
 | 
			
		||||
                const data = JSON.parse(el.textContent)
 | 
			
		||||
                this.loadData(data)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.mode = (this.items) ? Modes.List : Modes.Text
 | 
			
		||||
        this.initData && this.loadData(this.initData)
 | 
			
		||||
        this.page = (this.items) ? Page.List : Page.Text
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,9 @@
 | 
			
		||||
    <tr>
 | 
			
		||||
        <slot name="head" :item="item" :row="row"/>
 | 
			
		||||
        <template v-for="(attr,col) in columns" :key="col">
 | 
			
		||||
            <td :class="['cell', 'cell-' + attr]" :data-col="col"
 | 
			
		||||
            <slot name="cell-before" :item="item" :cell="cells[col]"
 | 
			
		||||
                    :attr="attr"/>
 | 
			
		||||
            <component :is="cellTag" :class="['cell', 'cell-' + attr]" :data-col="col"
 | 
			
		||||
                    :draggable="orderable"
 | 
			
		||||
                    @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
 | 
			
		||||
                <slot :name="attr" :item="item" :cell="cells[col]"
 | 
			
		||||
@ -10,9 +12,11 @@
 | 
			
		||||
                        :value="itemData && itemData[attr]">
 | 
			
		||||
                    {{ itemData && itemData[attr] }}
 | 
			
		||||
                </slot>
 | 
			
		||||
            </td>
 | 
			
		||||
            </component>
 | 
			
		||||
            <slot name="cell-after" :item="item" :col="col" :cell="cells[col]"
 | 
			
		||||
                    :attr="attr"/>
 | 
			
		||||
        </template>
 | 
			
		||||
        <slot name="tail" :item="item" :row="cell.row"/>
 | 
			
		||||
        <slot name="tail" :item="item" :row="row"/>
 | 
			
		||||
    </tr>
 | 
			
		||||
</template>
 | 
			
		||||
<script>
 | 
			
		||||
@ -24,20 +28,21 @@ export default {
 | 
			
		||||
 | 
			
		||||
    props: {
 | 
			
		||||
        item: Object,
 | 
			
		||||
        cell: Object,
 | 
			
		||||
        columns: Array,
 | 
			
		||||
        cell: {type: Object, default() { return {row: 0}}},
 | 
			
		||||
        cellTag: {type: String, default: 'td'},
 | 
			
		||||
        orderable: {type: Boolean, default: false},
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    computed: {
 | 
			
		||||
        row() { return this.cell.row || 0 },
 | 
			
		||||
        columns() { return this.cell.columns },
 | 
			
		||||
        row() { return this.cell && this.cell.row },
 | 
			
		||||
        
 | 
			
		||||
        itemData() {
 | 
			
		||||
            return this.item instanceof Model ? this.item.data : this.item;
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        cells() {
 | 
			
		||||
            const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell
 | 
			
		||||
            const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell || {}
 | 
			
		||||
            const cells = []
 | 
			
		||||
            for(var col in this.columns)
 | 
			
		||||
                cells.push({...cell, col: Number(col)})
 | 
			
		||||
@ -45,7 +50,7 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        cellEls() {
 | 
			
		||||
            return [...this.$el.querySelectorAll('td')].filter(x => x.dataset.col)
 | 
			
		||||
            return [...this.$el.querySelectorAll(self.cellTag)].filter(x => x.dataset.col)
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,27 +1,30 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <table class="table is-stripped is-fullwidth">
 | 
			
		||||
        <thead>
 | 
			
		||||
            <tr>
 | 
			
		||||
                <slot name="header-head"/>
 | 
			
		||||
                <th v-for="col in columns" :key="col"
 | 
			
		||||
                    style="vertical-align: middle">{{ labels[col] }}</th>
 | 
			
		||||
                <slot name="header-tail"/>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <a-row :item="labels" :columns="columns" :orderable="orderable"
 | 
			
		||||
                    @move="$emit('colmove', $event)">
 | 
			
		||||
                <template v-if="$slots['header-head']" v-slot:head="data">
 | 
			
		||||
                    <slot name="header-head" v-bind="data"/>
 | 
			
		||||
                </template>
 | 
			
		||||
                <template v-if="$slots['header-tail']" v-slot:tail="data">
 | 
			
		||||
                    <slot name="header-tail" v-bind="data"/>
 | 
			
		||||
                </template>
 | 
			
		||||
            </a-row>
 | 
			
		||||
        </thead>
 | 
			
		||||
        <tbody>
 | 
			
		||||
            <slot name="head"/>
 | 
			
		||||
            <template v-for="(item,row) in items" :key="row">
 | 
			
		||||
                <!-- data-index comes from AList component drag & drop -->
 | 
			
		||||
                <a-row :item="item" :cell="{row, columns}" :data-index="row"
 | 
			
		||||
                <a-row :item="item" :cell="{row}" :columns="columns" :data-index="row"
 | 
			
		||||
                        :draggable="orderable"
 | 
			
		||||
                        @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
 | 
			
		||||
                        @cell="onCellEvent(index, $event)">
 | 
			
		||||
                        @cell="onCellEvent(row, $event)">
 | 
			
		||||
                    <template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
 | 
			
		||||
                        <template v-if="slot == 'head' || slot == 'tail'">
 | 
			
		||||
                            <slot :name="name" v-bind="data"/>
 | 
			
		||||
                        </template>
 | 
			
		||||
                        <template v-else>
 | 
			
		||||
                            <div @keydown.capture.ctrl="onControlKey($event, data.cell)">
 | 
			
		||||
                            <div @keydown.ctrl="onControlKey($event, data.cell)">
 | 
			
		||||
                                <slot :name="name" v-bind="data"/>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </template>
 | 
			
		||||
@ -47,7 +50,7 @@ import ARow from './ARow.vue'
 | 
			
		||||
const Component = {
 | 
			
		||||
    extends: AList,
 | 
			
		||||
    components: { ARow },
 | 
			
		||||
    emit: ['cell'],
 | 
			
		||||
    emit: ['cell', 'colmove'],
 | 
			
		||||
 | 
			
		||||
    props: {
 | 
			
		||||
        ...AList.props,
 | 
			
		||||
@ -67,7 +70,7 @@ const Component = {
 | 
			
		||||
        rowCells() {
 | 
			
		||||
            const cells = []
 | 
			
		||||
            for(var row in this.items)
 | 
			
		||||
                cells.push({row, columns: this.columns,})
 | 
			
		||||
                cells.push({row})
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        rows() {
 | 
			
		||||
 | 
			
		||||
@ -13,12 +13,15 @@ import AStreamer from './AStreamer.vue'
 | 
			
		||||
/**
 | 
			
		||||
 * Core components
 | 
			
		||||
 */
 | 
			
		||||
export default {
 | 
			
		||||
export const base = {
 | 
			
		||||
    AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist,
 | 
			
		||||
    AProgress, ASoundItem,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default base
 | 
			
		||||
 | 
			
		||||
export const admin = {
 | 
			
		||||
    ...base,
 | 
			
		||||
    AStatistics, AStreamer, APlaylistEditor
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user