forked from rc/aircox
playlist editor draft
This commit is contained in:
@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"core-js": "^3.8.3",
|
||||
"lodash": "^4.17.21",
|
||||
"vue": "^3.2.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -11,6 +11,7 @@ $menu-item-active-background-color: #d2d2d2;
|
||||
|
||||
//-- helpers/modifiers
|
||||
.is-fullwidth { width: 100%; }
|
||||
.is-fullheight { height: 100%; }
|
||||
.is-fixed-bottom {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
@ -40,6 +41,19 @@ $menu-item-active-background-color: #d2d2d2;
|
||||
.overflow-hidden.is-fullwidth { max-width: 100%; }
|
||||
|
||||
|
||||
*[draggable="true"] {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
//-- forms
|
||||
input.half-field:not(:active):not(:hover) {
|
||||
border: none;
|
||||
background-color: rgba(0,0,0,0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
//-- animations
|
||||
@keyframes blink {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0.4; }
|
||||
|
@ -1,19 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- FIXME: header and footer should be inside list tags -->
|
||||
<slot name="header"></slot>
|
||||
<ul :class="listClass">
|
||||
<component :is="listTag" :class="listClass">
|
||||
<template v-for="(item,index) in items" :key="index">
|
||||
<li :class="itemClass" @click="select(index)">
|
||||
<component :is="itemTag" :class="itemClass" @click="select(index)"
|
||||
:draggable="orderable"
|
||||
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
|
||||
<slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot>
|
||||
</li>
|
||||
</component>
|
||||
</template>
|
||||
</ul>
|
||||
</component>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
emits: ['select', 'unselect'],
|
||||
emits: ['select', 'unselect', 'move'],
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: this.defaultIndex,
|
||||
@ -25,6 +28,9 @@ export default {
|
||||
itemClass: String,
|
||||
defaultIndex: { type: Number, default: -1},
|
||||
set: Object,
|
||||
orderable: { type: Boolean, default: false },
|
||||
itemTag: { default: 'li' },
|
||||
listTag: { default: 'ul' },
|
||||
},
|
||||
|
||||
computed: {
|
||||
@ -61,6 +67,34 @@ export default {
|
||||
this.$emit('unselect', { item: this.selected, index: this.selectedIndex});
|
||||
this.selectedIndex = -1;
|
||||
},
|
||||
|
||||
onDragStart(ev) {
|
||||
const dataset = ev.target.dataset;
|
||||
const data = `cell:${dataset.index}`
|
||||
ev.dataTransfer.setData("text/cell", data)
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
},
|
||||
|
||||
onDragOver(ev) {
|
||||
ev.preventDefault()
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
},
|
||||
|
||||
onDrop(ev) {
|
||||
const data = ev.dataTransfer.getData("text/cell")
|
||||
if(!data || !data.startsWith('cell:'))
|
||||
return
|
||||
|
||||
ev.preventDefault()
|
||||
const from = Number(data.slice(5))
|
||||
const target = ev.target.tagName == this.itemTag ? ev.target
|
||||
: ev.target.closest(this.itemTag)
|
||||
this.$emit('move', {
|
||||
from, target,
|
||||
to: Number(target.dataset.index),
|
||||
set: this.set,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
216
assets/src/components/APlaylistEditor.vue
Normal file
216
assets/src/components/APlaylistEditor.vue
Normal file
@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div class="playlist-editor">
|
||||
<slot name="top" :set="set" :columns="columns" :items="items"/>
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li :class="{'is-active': mode == Modes.Text}"
|
||||
@click="mode = Modes.Text">
|
||||
<a>
|
||||
<span class="icon is-small">
|
||||
<i class="fa fa-pencil"></i>
|
||||
</span>
|
||||
Texte
|
||||
</a>
|
||||
</li>
|
||||
<li :class="{'is-active': mode == Modes.List}"
|
||||
@click="mode = Modes.List">
|
||||
<a>
|
||||
<span class="icon is-small">
|
||||
<i class="fa fa-list"></i>
|
||||
</span>
|
||||
Liste
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<section class="page" v-show="mode == Modes.Text">
|
||||
<textarea ref="textarea" class="is-fullwidth" style="height: 10em;"
|
||||
@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 :columns="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"
|
||||
:allow-create="true"
|
||||
:list-class="listClass" :item-class="itemClass"
|
||||
:orderable="true" @move="listItemMove"
|
||||
@cell="onCellEvent">
|
||||
<template v-for="[name,slot] of rowsSlots" :key="slot"
|
||||
v-slot:[slot]="data">
|
||||
<slot :name="name" v-bind="data"/>
|
||||
</template>
|
||||
</a-rows>
|
||||
</section>
|
||||
<section class="page" v-show="mode == Modes.Settings">
|
||||
|
||||
</section>
|
||||
<slot name="bottom" :set="set" :columns="columns" :items="items"/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import {dropRightWhile} from 'lodash'
|
||||
import {Set} from '../model'
|
||||
import Track from '../track'
|
||||
|
||||
import ARow from './ARow.vue'
|
||||
import ARows from './ARows.vue'
|
||||
|
||||
|
||||
export const Modes = {
|
||||
Text: 0, List: 1, Settings: 2,
|
||||
}
|
||||
const FormatLabels = {
|
||||
artist: 'Artiste', album: 'Album', year: 'Année', tags: 'Tags',
|
||||
title: 'Titre',
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { ARow, ARows },
|
||||
props: {
|
||||
listClass: String,
|
||||
itemClass: String,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
dataEl: String,
|
||||
Modes: Modes,
|
||||
FormatLabels: FormatLabels,
|
||||
mode: Modes.Text,
|
||||
set: new Set(Track),
|
||||
columns: ['artist', 'title', 'tags', 'album', 'year'],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
items() {
|
||||
return this.set.items
|
||||
},
|
||||
|
||||
rowsSlots() {
|
||||
return Object.keys(this.$slots)
|
||||
.filter(x => x.startsWith('row-') || x.startsWith('rows-'))
|
||||
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onCellEvent(event) {
|
||||
switch(event.name) {
|
||||
case 'change': this.updateInput();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
formatMove({from, to}) {
|
||||
const value = this.columns[from]
|
||||
this.columns.splice(from, 1)
|
||||
this.columns.splice(to, 0, value)
|
||||
this.updateList()
|
||||
},
|
||||
|
||||
listItemMove({from, to, set}) {
|
||||
set.move(from, to);
|
||||
this.updateInput()
|
||||
},
|
||||
|
||||
updateList() {
|
||||
const items = this.toList(this.$refs.textarea.value,
|
||||
this.$refs.sep.value)
|
||||
this.set.reset(items)
|
||||
},
|
||||
|
||||
updateInput() {
|
||||
const input = this.toText(this.items, this.$refs.sep.value)
|
||||
this.$refs.textarea.value = input
|
||||
},
|
||||
|
||||
/**
|
||||
* From input and separator, return list of items.
|
||||
*/
|
||||
toList(input, sep) {
|
||||
var lines = input.split('\n')
|
||||
var items = []
|
||||
|
||||
for(let line of lines) {
|
||||
line = line.trim()
|
||||
if(!line)
|
||||
continue
|
||||
|
||||
var lineBits = line.split(sep)
|
||||
var item = {}
|
||||
for(var col in this.columns) {
|
||||
if(col >= lineBits.length)
|
||||
break
|
||||
const attr = this.columns[col]
|
||||
item[attr] = lineBits[col].trim()
|
||||
}
|
||||
item && items.push(item)
|
||||
}
|
||||
return items
|
||||
},
|
||||
|
||||
/**
|
||||
* From items and separator return a string
|
||||
*/
|
||||
toText(items, sep) {
|
||||
var lines = []
|
||||
sep = ` ${(sep || this.$refs.sep.value).trim()} `
|
||||
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))
|
||||
}
|
||||
return lines.join('\n')
|
||||
},
|
||||
|
||||
/**
|
||||
* Load initial data
|
||||
*/
|
||||
loadData({items=[], errors, fieldErrors, ...data}) {
|
||||
for(var item of items)
|
||||
this.set.push(item)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if(this.dataEl) {
|
||||
const el = document.getElementById(this.dataEl)
|
||||
if(el) {
|
||||
const data = JSON.parse(el.textContext)
|
||||
loadData(data)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
78
assets/src/components/ARow.vue
Normal file
78
assets/src/components/ARow.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<tr>
|
||||
<slot name="head" :item="item" :row="index"/>
|
||||
<template v-for="(attr,col) in columns" :key="col">
|
||||
<td :class="['cell', 'cell-' + attr]" :data-index="col"
|
||||
:draggable="orderable"
|
||||
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
|
||||
<slot :name="attr" :item="item" :row="index" :col="col"
|
||||
:data="itemData" :attr="attr" :emit="cellEmit"
|
||||
:value="itemData && itemData[attr]">
|
||||
{{ itemData && itemData[attr] }}
|
||||
</slot>
|
||||
</td>
|
||||
</template>
|
||||
<slot name="tail" :item="item" :row="index"/>
|
||||
</tr>
|
||||
</template>
|
||||
<script>
|
||||
import Model from '../model'
|
||||
|
||||
export default {
|
||||
emit: ['move', 'cell'],
|
||||
|
||||
props: {
|
||||
item: Object,
|
||||
index: Number,
|
||||
columns: Array,
|
||||
orderable: {type: Boolean, default: false},
|
||||
},
|
||||
|
||||
computed: {
|
||||
itemData() {
|
||||
return this.item instanceof Model ? this.item.data : this.item;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/// Emit a 'cell' event.
|
||||
/// Event data: `{index, name, data, item, attr}`
|
||||
///
|
||||
/// @param {Number} col: cell column's index
|
||||
/// @param {String} name: cell's event name
|
||||
/// @param {} data: cell's event data
|
||||
cellEmit(name, col, data) {
|
||||
this.$emit('cell', {
|
||||
name, col, data,
|
||||
item: this.item,
|
||||
attr: this.columns[col],
|
||||
})
|
||||
},
|
||||
|
||||
onDragStart(ev) {
|
||||
const dataset = ev.target.dataset;
|
||||
const data = `cell:${dataset.index}`
|
||||
ev.dataTransfer.setData("text/cell", data)
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
},
|
||||
|
||||
onDragOver(ev) {
|
||||
ev.preventDefault()
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
},
|
||||
|
||||
onDrop(ev) {
|
||||
const data = ev.dataTransfer.getData("text/cell")
|
||||
if(!data || !data.startsWith('cell:'))
|
||||
return
|
||||
|
||||
ev.preventDefault()
|
||||
this.$emit('move', {
|
||||
from: Number(data.slice(5)),
|
||||
to: Number(ev.target.dataset.index),
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
91
assets/src/components/ARows.vue
Normal file
91
assets/src/components/ARows.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
<slot name="head"/>
|
||||
<template v-for="(item,index) in items" :key="index">
|
||||
<a-row :item="item" :index="index" :columns="columns" :data-index="index"
|
||||
:draggable="orderable"
|
||||
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
|
||||
@cell="onCellEvent(index, $event)">
|
||||
<template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
|
||||
<slot :name="name" v-bind="data"/>
|
||||
</template>
|
||||
</a-row>
|
||||
</template>
|
||||
<template v-if="allowCreate">
|
||||
<a-row :item="extraItem" :index="items.length" :columns="columns"
|
||||
@keypress.enter.stop.prevent="validateExtraCell">
|
||||
<template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
|
||||
<slot :name="name" v-bind="data"/>
|
||||
</template>
|
||||
</a-row>
|
||||
</template>
|
||||
<slot name="tail"/>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<script>
|
||||
import AList from './AList.vue'
|
||||
import ARow from './ARow.vue'
|
||||
|
||||
const Component = {
|
||||
extends: AList,
|
||||
components: { ARow },
|
||||
emit: ['cell'],
|
||||
|
||||
props: {
|
||||
...AList.props,
|
||||
columns: Array,
|
||||
labels: Object,
|
||||
allowCreate: Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
...super.data,
|
||||
extraItem: new this.set.model(),
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
rowSlots() {
|
||||
return Object.keys(this.$slots).filter(x => x.startsWith('row-'))
|
||||
.map(x => [x, x.slice(4)])
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
validateExtraCell() {
|
||||
if(!this.allowCreate)
|
||||
return
|
||||
this.set.push(this.extraItem)
|
||||
this.extraItem = new this.set.model()
|
||||
},
|
||||
|
||||
/// React on 'cell' event, re-emitting it with additional values:
|
||||
/// - `set`: data set
|
||||
/// - `row`: row index
|
||||
///
|
||||
/// @param {Number} row: row index
|
||||
/// @param {} data: cell's event data
|
||||
onCellEvent(row, event) {
|
||||
this.$emit('cell', {
|
||||
...event, row,
|
||||
set: this.set
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
Component.props.itemTag.default = 'tr'
|
||||
Component.props.listTag.default = 'tbody'
|
||||
|
||||
export default Component
|
||||
</script>
|
@ -4,6 +4,7 @@ import AList from './AList.vue'
|
||||
import APage from './APage.vue'
|
||||
import APlayer from './APlayer.vue'
|
||||
import APlaylist from './APlaylist.vue'
|
||||
import APlaylistEditor from './APlaylistEditor.vue'
|
||||
import AProgress from './AProgress.vue'
|
||||
import ASoundItem from './ASoundItem.vue'
|
||||
import AStatistics from './AStatistics.vue'
|
||||
@ -18,6 +19,6 @@ export default {
|
||||
}
|
||||
|
||||
export const admin = {
|
||||
AStatistics, AStreamer,
|
||||
AStatistics, AStreamer, APlaylistEditor
|
||||
}
|
||||
|
||||
|
@ -32,7 +32,7 @@ window.aircox = {
|
||||
* Initialize main application and player.
|
||||
*/
|
||||
init(props=null, {config=null, builder=null, initBuilder=true,
|
||||
initPlayer=true, hotReload=false}={})
|
||||
initPlayer=true, hotReload=false, el=null}={})
|
||||
{
|
||||
if(initPlayer) {
|
||||
let playerBuilder = this.playerBuilder
|
||||
@ -44,6 +44,9 @@ window.aircox = {
|
||||
this.builder = builder
|
||||
if(config || window.App)
|
||||
builder.config = config || window.App
|
||||
if(el)
|
||||
builder.config.el = el
|
||||
|
||||
builder.title = document.title
|
||||
builder.mount({props})
|
||||
|
||||
|
@ -35,7 +35,7 @@ export default class Model {
|
||||
* Instanciate model with provided data and options.
|
||||
* By default `url` is taken from `data.url_`.
|
||||
*/
|
||||
constructor(data, {url=null, ...options}={}) {
|
||||
constructor(data={}, {url=null, ...options}={}) {
|
||||
this.url = url || data.url_;
|
||||
this.options = options;
|
||||
this.commit(data);
|
||||
@ -133,6 +133,8 @@ export default class Model {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* List of models
|
||||
*/
|
||||
@ -231,6 +233,25 @@ export class Set {
|
||||
this.items.splice(index,1);
|
||||
save && this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear items, assign new ones
|
||||
*/
|
||||
reset(items=[]) {
|
||||
// TODO: check reactivity
|
||||
this.items = []
|
||||
for(var item of items)
|
||||
this.push(item)
|
||||
}
|
||||
|
||||
move(from, to) {
|
||||
if(from >= this.length || to > this.length)
|
||||
throw "source or target index is not in range"
|
||||
|
||||
const value = this.items[from]
|
||||
this.items.splice(from, 1)
|
||||
this.items.splice(to, 0, value)
|
||||
}
|
||||
}
|
||||
|
||||
Set[Symbol.iterator] = function () {
|
||||
|
7
assets/src/track.js
Normal file
7
assets/src/track.js
Normal file
@ -0,0 +1,7 @@
|
||||
import Model from './model'
|
||||
|
||||
export default class Track extends Model {
|
||||
static getId(data) { return data.pk }
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user