playlist editor draft

This commit is contained in:
bkfox
2022-12-10 03:27:27 +01:00
parent 80cd5baa18
commit cfc0e45439
35 changed files with 13605 additions and 56 deletions

View File

@ -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": {

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

@ -0,0 +1,7 @@
import Model from './model'
export default class Track extends Model {
static getId(data) { return data.pk }
}