- 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:
bkfox
2022-12-12 00:25:57 +01:00
parent 61af53eecb
commit 180cc8bc02
30 changed files with 708 additions and 259 deletions

View File

@ -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;

View 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>

View File

@ -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)
})
}
}

View File

@ -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', {

View File

@ -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>

View File

@ -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)
},
},

View File

@ -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() {

View File

@ -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
}