forked from rc/aircox
- 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