radiocampus: style update

This commit is contained in:
2024-05-31 14:40:31 +02:00
parent a323901d0e
commit f29cced5f5
66 changed files with 10589 additions and 10 deletions

View File

@ -0,0 +1,83 @@
<template>
<component :is="tag" @click.capture.stop="call" type="button" :class="[buttonClass, this.promise && 'blink' || '']">
<span v-if="promise && runIcon">
<i :class="runIcon"></i>
</span>
<span v-else-if="icon" class="icon is-small">
<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'},
//! If provided open confirmation box before proceeding
confirm: { type: String, default: ''},
//! 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
if(this.confirm && !confirm(this.confirm))
return
const options = Model.getOptions({
...this.fetchOptions,
method: this.method,
body: JSON.stringify(this.item.data),
})
this.promise = fetch(this.url, options).then(data => data.text()).then(data => {
data = data && JSON.parse(data) || null
this.promise = null;
this.$emit('done', data)
return data
}, data => { this.promise = null; return data })
return this.promise
},
},
}
</script>

View File

@ -0,0 +1,293 @@
<template>
<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">
{{ selectedLabel }}
</slot>
</span>
</a>
<div :class="dropdownClass">
<div class="dropdown-menu is-fullwidth">
<div class="dropdown-content" style="overflow: hidden">
<span v-for="(item, index) in items" :key="item.id"
:data-autocomplete-index="index"
@click="select(index, false, false)"
:class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
tabindex="-1">
<slot name="item" :index="index" :item="item" :value-field="valueField"
:labelField="labelField">
{{ getValue(item, labelField) || item }}
</slot>
</span>
</div>
</div>
</div>
</div>
</template>
<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,
//! Extra GET url parameters
urlParams: Object,
//! Items' model
model: Function,
//! Input tag class
inputClass: Array,
//! input text placeholder
placeholder: Object,
//! 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 {
inputValue: this.modelValue || '',
query: '',
items: [],
selectedIndex: -1,
cursor: -1,
promise: null,
}
},
watch: {
modelValue(value) {
this.inputValue = value
},
inputValue(value, old) {
if(value != old && value != this.modelValue) {
this.$emit('update:modelValue', value)
this.$emit('change', {target: this.$refs.input})
}
if(this.selectedLabel != value)
this.selectedIndex = -1
},
},
computed: {
fullUrl() {
if(!this.urlParams)
return this.url
const url = new URL(this.url, window.location.origin)
const params = new URLSearchParams(url.searchParams)
for(var key in this.urlParams)
params.set(key, this.urlParams[key])
const join = this.url.indexOf("?") >= 0 ? "&" : "?"
url.search = params.toString()
return url.href
},
isFetching() { return !!this.promise },
selected() {
let index = this.selectedIndex
if(index<0)
return null
index = Math.min(index, this.items.length-1)
return this.items[index]
},
selectedValue() {
let value = this.itemValue(this.selected)
if(!value && !this.mustExist)
value = this.inputValue
return value
},
selectedLabel() {
return this.itemLabel(this.selected)
},
dropdownClass() {
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: {
reset() {
this.inputValue = ""
this.selectedIndex = -1
this.items = []
},
// TODO: move to utils/data
getValue(data, path=null) {
if(!data)
return null
if(!path)
return data
const paths = path.split('.')
for(const key of paths) {
if(key in data)
data = data[key]
else return null;
}
return data
},
itemValue(item) {
return this.valueField ? this.getValue(item, this.valueField) : item;
},
itemLabel(item) {
return this.labelField ? this.getValue(item, this.labelField) : item;
},
hide() {
this.cursor = -1;
this.selectedIndex = -1;
},
move(index=-1, relative=false) {
if(relative)
index += this.cursor
this.cursor = Math.max(-1, Math.min(index, this.items.length-1))
},
select(index=-1, relative=false, active=null) {
if(relative)
index += this.selectedIndex
else if(index == this.selectedIndex)
return
this.selectedIndex = Math.max(-1, Math.min(index, this.items.length-1))
if(index >= 0) {
this.inputValue = this.selectedLabel
this.$refs.input.focus()
}
if(this.selectedIndex < 0)
this.$emit('unselect')
else
this.$emit('select', index, this.selected, this.selectedValue)
if(active!==null)
active && this.move(0) || this.move(-1)
},
onInputFocus() {
this.cursor < 0 && this.move(0)
},
onBlur(event) {
if(!this.items.length)
return
var index = event.relatedTarget && Math.floor(event.relatedTarget.dataset.autocompleteIndex);
if(index !== undefined && index !== null)
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.hide(); this.select()
break
case 38: this.move(-1, true)
break
case 40: this.move(1, true)
break
default: return
}
event.preventDefault()
event.stopPropagation()
},
onKeyUp(event) {
if(event.ctrlKey || event.altKey || event.metaKey)
return
const value = event.target.value
if(value === this.query)
return
this.inputValue = value;
if(!value)
return this.selected && this.select(-1)
if(!this.minFetchLength || value.length >= this.minFetchLength)
this.fetch(value)
},
fetch(query) {
if(!query || this.promise)
return
this.query = query
var url = this.fullUrl.replace('${query}', query).replace('%24%7Bquery%7D', query)
var promise = this.model ? this.model.fetch(url, {many:true})
: fetch(url, Model.getOptions()).then(d => d.json())
promise = promise.then(items => {
if(items.results)
items = items.results
this.items = items.filter((i) => i) || []
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 && form.addEventListener('reset', () => {
this.inputValue = this.value;
this.select(-1)
})
}
}
</script>

View File

@ -0,0 +1,242 @@
<template>
<section class="a-carousel">
<nav ref="viewport" class="a-carousel-viewport">
<section ref="container" :class="['a-carousel-container', containerClass]">
<slot name="default"></slot>
</section>
</nav>
<nav class="a-carousel-bullets-container">
<span class="left">
<span class="icon bullet" @click="prev()" v-if="showPrev">
<i :class="leftButtonIcon"></i>
</span>
</span>
<template v-if="bullets.length > 1">
<span class="icon bullet" v-bind:key="bullet" v-for="bullet of bullets" @click="select(bullet)">
<i v-if="bullet == index" class="fa fa-circle"></i>
<i v-else class="far fa-circle"></i>
</span>
</template>
<span class="right">
<span class="icon bullet" @click="next()" v-if="showNext">
<i :class="rightButtonIcon"></i>
</span>
</span>
<slot name="bullets-right" :v-bind="this"></slot>
</nav>
</section>
</template>
<style scoped>
.a-carousel {
width: 100%;
position: relative;
}
.a-carousel-viewport {
width: 100%;
overflow-x: hidden;
}
.a-carousel-container {
display: flex;
flex-direction: row;
align-items: left;
}
.a-carousel-container > * {
flex-shrink: 0;
}
.a-carousel-bullets-container {
flex-grow: 1;
}
.a-carousel-bullets-container .bullet {
cursor: pointer;
}
.a-carousel-bullets-container .left {
min-width: 2rem;
margin-right: auto;
}
.a-carousel-bullets-container .right {
min-width: 2rem;
margin-left: auto;
}
.a-carousel-bullets-container {
display: flex;
flex-direction: row;
}
</style>
<script>
import {ref} from 'vue'
class Offset {
constructor(el, min=null, max=null) {
this.el = el
this.rect = el.getBoundingClientRect();
({min, max} = this.minmax(min, max))
this.min = min
this.max = max
this.size = max-min
}
minmax(min=null, max=null) {
min = min === null ? this.rect.left : min
max = max === null ? this.rect.right : max
return {min, max}
}
relative(to) {
return new Offset(this.el, this.min-to.min, this.max-to.min)
}
}
class Card extends Offset {
constructor(el, index) {
super(el)
this.index = index
}
visible(viewportOffset) {
return viewportOffset.min <= this.min && viewportOffset.max >= this.max
}
}
export default {
setup() {
return {
viewport: ref(null),
container: ref(null),
}
},
data() {
return {
cards: [],
index: 0,
refresh_: 0,
}
},
props: {
cardSelector: {type: String, default: ''},
containerClass: {type: String, default: ''},
buttonClass: {type: String, default: 'button'},
leftButtonIcon: {type: String, default: "fas fa-chevron-left"},
rightButtonIcon: {type: String, default: "fas fa-chevron-right"},
},
computed: {
card() { return this.cards()[this.index] },
showPrev() {
return this.index > 0
},
showNext() {
if(!this.cards || this.cards.length <= 1)
return false
let last = this.bullets[this.bullets.length-1]
return this.index != last
},
bullets() {
if(!this.cards || !this.$refs.viewport)
return []
let contOff = new Offset(this.$refs.container)
let viewMax = new Offset(this.$refs.viewport).size
let bullets = []
let i = 0;
let max = viewMax
bullets.push(i)
while(i < this.cards.length) {
// skip until next view
for(; i < this.cards.length; i++) {
let card = this.cards[i].relative(contOff)
if(card.max > max) {
max = card.min + viewMax
bullets.push(i)
i++
break
}
}
}
return bullets
},
},
methods: {
getCards() {
if(!this.$refs.container)
return []
let nodes = (!this.cardSelector) ?
[...this.$refs.container.children] :
[...this.$refs.container.querySelectorAll(this.cardSelector)]
return nodes.map((el, index) => new Card(el, index))
},
select(index, relative=false) {
if(relative)
index = this.index + index
index = Math.min(index, this.cards.length)
index = Math.max(index, 0)
let card = this.cards[index]
if(!card)
return null;
card = new Card(card.el)
const cont = new Offset(this.$refs.container)
const rel = card.relative(cont)
this.$refs.container.style.marginLeft = `-${rel.min}px`
this.index = index;
return card.el
},
next() {
let n = this.bullets.indexOf(this.index)
let index = this.bullets[n+1]
this.select(index)
},
prev() {
let n = this.bullets.indexOf(this.index)
let index = this.bullets[n-1]
this.select(index)
},
refresh() {
this.cards = this.getCards()
this.select(this.index)
this.refresh_++
}
},
mounted() {
this.observers = [
new MutationObserver(() => this.refresh()),
new ResizeObserver(() => this.refresh())
]
this.observers[0].observe(this.$refs.container, {"childList": true})
this.observers[1].observe(this.$refs.container)
this.refresh()
},
unmounted() {
for(var observer of this.observers)
observer.disconnect()
}
}
</script>

View File

@ -0,0 +1,49 @@
<template>
<component :is="tag" :class="[itemClass, active ? activeClass : '']">
<slot name="before-button" :toggle="toggle" :active="active"></slot>
<slot name="button" :toggle="toggle" :active="active">
<component :is="buttonTag" :class="buttonClass" @click="toggle()">
<span class="icon" v-if="labelIcon">
<i :class="labelIcon"></i>
</span>
<span>{{ label }}</span>
<span class="icon">
<i v-if="!active" :class="buttonIcon"></i>
<i v-if="active" :class="buttonIconClose"></i>
</span>
</component>
</slot>
<div :class="contentClass" v-show="active">
<slot></slot>
</div>
</component>
</template>
<script>
export default {
data() {
return {
active: this.open,
}
},
props: {
tag: {type: String, default: "div"},
label: {type: String, default: ""},
labelIcon: {type: String, default: ""},
buttonTag: {type: String, default: "button"},
activeClass: {type: String, default: "is-active"},
buttonClass: {type: String, default: "button"},
buttonIcon: { type: String, default:"fa fa-angle-down"},
buttonIconClose: { type: String, default:"fa fa-angle-up"},
contentClass: String,
open: {type: Boolean, default: false},
noButton: {type: Boolean, default: false},
},
methods: {
toggle() {
this.active = !this.active
}
},
}
</script>

View File

@ -0,0 +1,132 @@
<template>
<input ref="input" type="hidden" :name="name" :value="value"/>
<div class="">
<template v-for="group, index in menu" :key="index">
<div class="button-group d-inline-block mr-3">
<template v-for="info, index in group" :key="index">
<button type="button" class="button square smaller" :title="info.label" @click="edit(info.action, ...(info.args || []))">
<span class="icon"><i :class="info.icon"/></span>
</button>
</template>
</div>
</template>
<div class="button-group d-inline-block">
<div class="dropdown is-hoverable">
<div class="dropdown-trigger">
<button type="button" class="button square smaller">
<span class="icon"><i class="fa fa-link"/></span>
</button>
</div>
<div class="dropdown-menu" style="min-width: 20rem; margin-top: -0.2rem;">
<div class="dropdown-content p-3">
<div class="field">
<label class="label">Lien</label>
<div class="control">
<input ref="link-url" type="text" class="input" placeholder="lien"/>
</div>
</div>
<div class="has-text-right">
<button type="button" class="button secondary"
@click="edit('setLink', {href:$refs['link-url'].value})">
Ajouter le lien
</button>
</div>
</div>
</div>
</div>
<button type="button" class="button square smaller" title="Remove link" @click="edit('unsetLink')">
<span class="icon"><i class="fa fa-link-slash"/></span>
</button>
</div>
</div>
<editor-content class="editor" v-if="editor" :editor="editor" />
</template>
<style>
.editor .tiptap {
border: 1px black solid;
padding: 0.3em;
}
.editor .tiptap ul, .editor .tiptap ol {
margin-left: 1.3em;
}
.editor .tiptap ul { list-style: disc }
</style>
<script>
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Link from '@tiptap/extension-link'
export default {
components: {EditorContent},
props: {
config: {type: Object, default: (() => {})},
//! Input field name.
name: String,
//! Initial input value
initial: String,
},
data() {
return {
editor: null,
menu: [
[
{label: "Bold", icon: "fa fa-bold", action: "toggleBold" },
{label: "Italic", icon: "fa fa-italic", action: "toggleItalic" },
{label: "Underline", icon: "fa fa-underline", action: "toggleUnderline" },
{label: "Strike", icon: "fa fa-strikethrough", action: "toggleStrike" },
],[
{label: "List", icon: "fa fa-list", action: "toggleBulletList" },
{label: "Ordered List", icon: "fa fa-list-ol", action: "toggleOrderedList" },
],[
{label: "Heading 1", icon: "fa fa-h", action: "setHeading", args: [{level:3}] },
{label: "Heading 2", icon: "fa fa-h smaller", action: "toggleHeading", args: [{level:4}] },
// {label: "Heading 3", icon: "fa fa-h small", action: "toggleHeading", args: [{level:5}] },
],
]
}
},
computed: {
value() { return this.editor && this.editor.getHTML() },
},
methods: {
chain(action, ...args) {
let chain = this.editor.chain().focus()
return chain[action](...args)
},
edit(action, ...args) {
this.chain(action, ...args).run()
},
setLink() {
this.edit("setLink", {href: this.$refs['link-url']})
},
},
mounted() {
this.editor = new Editor({
content: this.initial || "",
injectCss: false,
extensions: [
StarterKit.configure({
heading: {
levels: [3, 4, 5]
}
}),
Underline,
Link.configure({autolink: true}),
],
})
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>

View File

@ -0,0 +1,19 @@
<template>
<slot :page="page" :podcasts="podcasts"></slot>
</template>
<script>
import {Set} from '../model.js';
import Sound from '../sound.js';
import APage from './APage.vue';
export default {
extends: APage,
data() {
return {
podcasts: new Set(Sound, {items:this.page.podcasts}),
}
},
}
</script>

View File

@ -0,0 +1,110 @@
<template>
<div ref="list" class="a-select-file-list">
<form ref="form" class="flex-column" v-if="state == STATE.DEFAULT">
<slot name="form"></slot>
<div class="field is-horizontal">
<label class="label">{{ label }}</label>
<input type="file" ref="uploadFile" :name="fieldName" @change="onFileChange"/>
</div>
<div class="flex-row align-right" v-if="submitLabel">
<button type="button" class="button small" @click="submit">
{{ submitLabel }}
</button>
</div>
</form>
<div class="flex-column" v-else>
<slot name="preview" :fileUrl="fileUrl" :file="file" :loaded="loaded" :total="total"></slot>
<div class="flex-row">
<progress :max="total" :value="loaded"/>
<button type="button" class="button small square ml-2" @click="abort">
<span class="icon small">
<i class="fa fa-close"></i>
</span>
</button>
</div>
</div>
</div>
</template>
<script>
import {getCsrf} from "../model.js"
export default {
emit: ["fileChange", "load", "abort", "error"],
props: {
url: { type: String },
fieldName: { type: String, default: "file" },
label: { type: String, default: "Select a file" },
submitLabel: { type: String, default: "Upload" },
},
data() {
return {
STATE: {
DEFAULT: 0,
UPLOADING: 1,
},
state: 0,
upload: {},
file: null,
fileUrl: null,
total: 0,
loaded: 0,
request: null,
}
},
methods: {
abort() {
this.request && this.request.abort()
},
onFileChange() {
const [file] = this.$refs.uploadFile.files
if(!file)
return
this._setUploadFile(file)
this.$emit("fileChange", {upload: this, file: this.file, fileUrl: this.fileUrl})
},
submit() {
const req = new XMLHttpRequest()
req.open("POST", this.url)
req.upload.addEventListener("progress", (e) => this.onUploadProgress(e))
req.addEventListener("load", (e) => this.onUploadDone(e, 'load'))
req.addEventListener("abort", (e) => this.onUploadDone(e, 'abort'))
req.addEventListener("error", (e) => this.onUploadDone(e, 'error'))
const formData = new FormData(this.$refs.form);
formData.append('csrfmiddlewaretoken', getCsrf())
req.send(formData)
this._resetUpload(this.STATE.UPLOADING, false, req)
},
onUploadProgress(event) {
this.loaded = event.loaded
this.total = event.total
},
onUploadDone(event, eventName) {
this.$emit(eventName, event)
this._resetUpload(this.STATE.DEFAULT, true)
},
_setUploadFile(file) {
this.file = file
this.fileURL = file && URL.createObjectURL(file)
},
_resetUpload(state, resetFile=false, request=null) {
this.state = state
this.loaded = 0
this.total = 0
this.request = request
if(resetFile)
this.file = null
}
},}
</script>

View File

@ -0,0 +1,193 @@
<template>
<div>
<input type="hidden" :name="_prefix + 'TOTAL_FORMS'" :value="items.length || 0"/>
<template v-for="(value,name) in formData.management" v-bind:key="name">
<input type="hidden" :name="_prefix + name.toUpperCase()"
:value="value"/>
</template>
<a-rows ref="rows" :set="set" :context="this"
:columns="visibleFields" :columnsOrderable="columnsOrderable"
:orderable="orderable" @move="moveItem" @colmove="onColumnMove"
@cell="e => $emit('cell', e)">
<template #header-head>
<template v-if="orderable">
<th style="max-width:2em" :title="orderField.label"
:aria-label="orderField.label"
:aria-description="orderField.help || ''">
<span class="icon">
<i class="fa fa-arrow-down-1-9"></i>
</span>
</th>
<slot name="rows-header-head"></slot>
</template>
</template>
<template #row-head="data">
<input v-if="orderable" type="hidden"
:name="_prefix + data.row + '-' + orderBy"
:value="data.row"/>
<input type="hidden" :name="_prefix + data.row + '-id'"
:value="data.item ? data.item.id : ''"/>
<template v-for="field of hiddenFields" v-bind:key="field.name">
<input type="hidden"
v-if="!(field.name in ['id', orderBy])"
:name="_prefix + data.row + '-' + field.name"
:value="field.value in [null, undefined] ? data.item.data[name] : field.value"/>
</template>
<slot name="row-head" v-bind="data">
<td v-if="orderable">{{ data.row+1 }}</td>
</slot>
</template>
<template v-for="(field,slot) of fieldSlots" v-bind:key="field.name"
v-slot:[slot]="data">
<slot :name="slot" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name">
<div class="field">
<div class="control">
<slot :name="'control-' + field.name" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"/>
</div>
<p v-for="[error,index] in data.item.error(field.name)" class="help is-danger" v-bind:key="index">
{{ error }}
</p>
</div>
</slot>
</template>
<template #row-tail="data">
<slot v-if="$slots['row-tail']" name="row-tail" v-bind="data"/>
<td class="align-right pr-0">
<button type="button" class="button square"
@click.stop="removeItem(data.row, data.item)"
:title="labels.remove_item"
:aria-label="labels.remove_item">
<span class="icon"><i class="fa fa-trash" /></span>
</button>
</td>
</template>
</a-rows>
<div class="a-formset-footer flex-row">
<div class="flex-grow-1 flex-row">
<slot name="footer"/>
</div>
<div class="flex-grow-1 align-right">
<button type="button" class="button square is-warning p-2"
@click="reset()"
:title="labels.discard_changes"
:aria-label="labels.discard_changes"
>
<span class="icon"><i class="fa fa-rotate" /></span>
</button>
<button type="button" class="button square is-primary p-2"
@click="onActionAdd"
:title="labels.add_item"
:aria-label="labels.add_item"
>
<span class="icon">
<i class="fa fa-plus"/></span>
</button>
</div>
</div>
</div>
</template>
<script>
import {cloneDeep} from 'lodash'
import Model, {Set} from '../model'
import ARows from './ARows'
export default {
emit: ['cell', 'move', 'colmove', 'load'],
components: {ARows},
props: {
labels: Object,
//! If provided call this function instead of adding an item to rows on "+" button click.
actionAdd: Function,
//! If True, columns can be reordered
columnsOrderable: Boolean,
//! Field name used for ordering
orderBy: String,
//! Formset data as returned by get_formset_data
formData: Object,
//! Model class used for item's set
model: {type: Function, default: Model},
},
data() {
return {
set: new Set(Model),
}
},
computed: {
// ---- fields
_prefix() { return this.formData.prefix ? this.formData.prefix + '-' : '' },
fields() { return this.formData.fields },
orderField() { return this.orderBy && this.fields.find(f => f.name == this.orderBy) },
orderable() { return !!this.orderField },
hiddenFields() { return this.fields.filter(f => f.hidden && !(this.orderable && f == this.orderField)) },
visibleFields() { return this.fields.filter(f => !f.hidden) },
fieldSlots() { return this.visibleFields.reduce(
(slots, f) => ({...slots, ['row-' + f.name]: f}),
{}
)},
items() { return this.set.items },
rows() { return this.$refs.rows },
},
methods: {
onCellEvent(event) { this.$emit('cell', event) },
onColumnMove(event) { this.$emit('colmove', event) },
onActionAdd() {
if(this.actionAdd)
return this.actionAdd(this)
this.set.push()
},
moveItem(event) {
const {from, to} = event
const set_ = event.set || this.set
set_.move(from, to);
this.$emit('move', {...event, seŧ: set_})
},
removeItem(row) {
const item = this.items[row]
if(item.id) {
// TODO
}
else {
this.items.splice(row,1)
}
},
//! Load items into set
load(items=[], reset=false) {
if(reset)
this.set.items = []
for(var item of items)
this.set.push(cloneDeep(item))
this.$emit('load', items)
},
//! Reset forms to initials
reset() {
this.load(this.formData?.initials || [], true)
},
},
mounted() {
this.reset()
}
}
</script>

View File

@ -0,0 +1,105 @@
<template>
<div>
<!-- FIXME: header and footer should be inside list tags -->
<slot name="header"></slot>
<component :is="listTag" :class="listClass">
<template v-for="(item,index) in items" :key="index">
<component :is="itemTag" :class="itemClass" @click="select(index)"
: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>
</template>
</component>
<slot name="footer"></slot>
</div>
</template>
<script>
export default {
emits: ['select', 'unselect', 'move', 'remove'],
data() {
return {
selectedIndex: this.defaultIndex,
}
},
props: {
listClass: String,
itemClass: String,
defaultIndex: { type: Number, default: -1},
set: Object,
orderable: { type: Boolean, default: false },
itemTag: { default: 'li' },
listTag: { default: 'ul' },
},
computed: {
model() { return this.set.model },
items() { return this.set.items },
length() { return this.set.length },
selected() {
return this.selectedIndex > -1 && this.items.length > this.selectedIndex > -1
? this.items[this.selectedIndex] : null;
},
},
methods: {
get(index) { return this.set.get(index) },
find(pred) { return this.set.find(pred) },
findIndex(pred) { return this.set.findIndex(pred) },
remove(index, select=false) {
const item = this.set.get(index)
if(!item)
return
this.set.remove(index);
if(index < this.selectedIndex)
this.selectedIndex--;
if(select && this.selectedIndex == index)
this.select(index)
this.$emit('remove', {index, item, set: this.set})
},
select(index) {
this.selectedIndex = index > -1 && this.items.length ? index % this.items.length : -1;
this.$emit('select', { item: this.selected, index: this.selectedIndex });
return this.selectedIndex;
},
unselect() {
this.$emit('unselect', { item: this.selected, index: this.selectedIndex});
this.selectedIndex = -1;
},
onDragStart(ev) {
const dataset = ev.target.dataset;
const data = `row:${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('row:'))
return
ev.preventDefault()
const from = Number(data.slice(4))
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,109 @@
<template>
<div class="a-m2m-edit">
<table class="table is-fullwidth">
<thead>
<tr>
<th>
<slot name="items-title"></slot>
</th>
<th style="width: 1rem">
<span class="icon">
<i class="fa fa-trash"/>
</span>
</th>
</tr>
</thead>
<tbody>
<template v-for="item of items" :key="item.id">
<tr :class="[item.created && 'has-text-info', item.deleted && 'has-text-danger']">
<td>
<slot name="item" :item="item">
{{ item.data }}
</slot>
</td>
<td class="align-center">
<input type="checkbox" class="checkbox" @change="item.deleted = $event.target.checked">
</td>
</tr>
</template>
</tbody>
</table>
<div>
<label>
<span class="icon">
<i class="fa fa-plus"/>
</span>
Add
</label>
<a-autocomplete ref="autocomplete" v-bind="autocomplete"
@select="onSelect">
<template #item="{item}">
<slot name="autocomplete-item" :item="item">{{ item }}</slot>
</template>
</a-autocomplete>
</div>
</div>
</template>
<script>
import Model, { Set } from "../model.js"
import AAutocomplete from "./AAutocomplete.vue"
export default {
components: {AAutocomplete},
props: {
model: {type: Function, default: Model },
// List url
url: String,
// POST url
commitUrl: String,
// v-bind to autocomplete search box
autocomplete: {type: Object },
source_id: Number,
source_field: String,
target_field: String,
},
data() {
return {
set: new Set(this.model, {url: this.url, unique: true}),
}
},
computed: {
items() { return this.set?.items || [] },
initials() {
let obj = {}
obj[this.source_id_attr] = this.source_id
return obj
},
source_id_attr() { return this.source_field + "_id" },
target_id_attr() { return this.target_field + "_id" },
target_ids() { return this.set?.items.map(i => i.data[this.target_id_attr]) },
},
methods: {
onSelect(index, item, value) {
if(this.target_ids.indexOf(item.id) != -1)
return
let obj = {...this.initials}
obj[this.target_field] = {...item}
obj[this.target_id_attr] = item.id
this.set.push(obj)
this.$refs.autocomplete.reset()
},
save() {
this.set.commit(this.commitUrl, {
fields: [...Object.keys(this.initials), this.target_id_attr]
})
},
},
mounted() {
this.set.fetch()
},
}
</script>

View File

@ -0,0 +1,53 @@
<template>
<section :class="['modal', active && 'is-active' || '']">
<div class="modal-background" @click="close"></div>
<div class="modal-card">
<header class="modal-card-head">
<div class="modal-card-title">
<slot name="title" :item="item">{{ title }}</slot>
</div>
<slot name="bar" :item="item"></slot>
<button type="button" class="delete square" aria-label="close" @click="close">
<span class="icon">
<i class="fa fa-close"></i>
</span>
</button>
</header>
<section class="modal-card-body">
<slot name="default" :item="item"></slot>
</section>
<div class="modal-card-foot align-right">
<slot name="footer" :item="item" :close="close"></slot>
</div>
</div>
</section>
</template>
<script>
export default {
props: {
title: { type: String, default: ""},
},
data() {
return {
///! If true, modal is open
active: false,
///! Item or data passed down to slots.
item: null,
}
},
methods: {
///! Open modal dialog. Set provided `item` to dialog's one.
open(item=null) {
this.active = true
this.item = item
},
///! Close modal and reset item to null.
close() {
this.active = false
this.item = null
},
}
}
</script>

View File

@ -0,0 +1,18 @@
<template>
<div>
<slot></slot>
</div>
</template>
<script>
export default {
data() {
return {}
},
props: {
page: Object,
title: String,
},
}
</script>

View File

@ -0,0 +1,283 @@
<template>
<div class="a-player">
<div :class="['a-player-panels', panel ? 'is-open' : '']">
<template v-for="(info, key) in playlists" v-bind:key="key">
<APlaylist
:ref="key" class="a-player-panel a-playlist"
v-show="panel == key && sets[key].length"
:actions="['page', key != 'pin' && 'pin' || '']"
:editable="true" :player="self" :set="sets[key]"
@select="togglePlay(key, $event.index)"
listClass="menu-list" itemClass="menu-item">
<template v-slot:header="">
<div class="title is-flex-grow-1">
<span class="icon">
<i :class="info[1]"></i>
</span>
{{ info[0] }}
</div>
<button class="action button no-border">
<span class="icon" @click.stop="togglePanel()">
<i class="fa fa-close"></i>
</span>
</button>
</template>
</APlaylist>
</template>
</div>
<div class="a-player-progress" v-if="loaded && duration">
<AProgress v-if="loaded && duration" :value="currentTime" :max="this.duration"
:format="displayTime"
@select="audio.currentTime = $event"></AProgress>
</div>
<div class="a-player-bar button-group">
<button class="button" @click="togglePlay()"
:title="buttonTitle" :aria-label="buttonTitle">
<span class="fas fa-pause" v-if="playing"></span>
<span class="fas fa-play" v-else></span>
</button>
<div :class="['a-player-bar-content', loaded && duration ? 'has-progress' : '']">
<slot name="content" :loaded="loaded" :live="live" :current="current"></slot>
</div>
<button class="button has-text-weight-bold" v-if="loaded" @click="play()"
title="Live">
<span class="icon is-size-6 has-text-danger">
<span class="fa fa-circle"></span>
</span>
</button>
<template v-if="sets">
<template v-for="(info, key) in playlists" v-bind:key="key">
<button :class="playlistButtonClass(key)"
@click="togglePanel(key)"
v-show="sets[key] && sets[key].length">
<span class="is-size-6">{{ sets[key] && sets[key].length }}</span>
<span class="icon">
<i :class="info[1]"></i>
</span>
</button>
</template>
</template>
</div>
</div>
</template>
<script>
import {reactive} from 'vue'
import Live from '../live'
import Sound from '../sound'
import {Set} from '../model'
import APlaylist from './APlaylist'
import AProgress from './AProgress'
export const State = {
paused: 0,
playing: 1,
loading: 2,
}
export default {
components: { APlaylist, AProgress },
data() {
let audio = new Audio();
audio.addEventListener('ended', e => this.onState(e));
audio.addEventListener('pause', e => this.onState(e));
audio.addEventListener('playing', e => this.onState(e));
audio.addEventListener('timeupdate', () => {
this.currentTime = this.audio.currentTime;
});
audio.addEventListener('durationchange', () => {
this.duration = Number.isFinite(this.audio.duration) ? this.audio.duration : null;
});
let live = this.liveArgs ? reactive(new Live(this.liveArgs)) : null;
live && live.refresh();
const sets = {}
for(const key in this.playlists)
sets[key] = Set.storeLoad(Sound, 'playlist.' + key,
{max: 30, unique: true})
return {
audio, duration: 0, currentTime: 0, state: State.paused,
live,
/// Loaded item
loaded: null,
//! Active panel name
panel: null,
//! current playing playlist name
playlistName: null,
//! players' playlists' sets
sets,
}
},
props: {
buttonTitle: String,
liveArgs: Object,
///! dict of {'slug': ['Label', 'icon']}
playlists: Object,
},
computed: {
self() { return this; },
paused() { return this.state == State.paused; },
playing() { return this.state == State.playing; },
loading() { return this.state == State.loading; },
playlist() {
return this.playlistName ? this.$refs[this.playlistName][0] : null;
},
current() {
return this.loaded ? this.loaded : this.live && this.live.current;
},
},
methods: {
displayTime(seconds) {
seconds = parseInt(seconds);
let s = seconds % 60;
seconds = (seconds - s) / 60;
let m = seconds % 60;
let h = (seconds - m) / 60;
let [ss,mm,hh] = [s.toString().padStart(2, '0'),
m.toString().padStart(2, '0'),
h.toString().padStart(2, '0')];
return h ? `${hh}:${mm}:${ss}` : `${mm}:${ss}`;
},
playlistButtonClass(name) {
let set = this.sets[name];
return (set ? (set.length ? "" : "has-text-grey-light ")
+ (this.panel == name ? "open"
: this.playlistName == name ? 'active' : '') : '')
+ " button";
},
/// Show/hide panel
togglePanel(panel) { this.panel = this.panel == panel ? null : panel },
/// Return True if item is loaded
isLoaded(item) { return this.loaded && this.loaded.id == item.id },
/// Return True if item is loaded
isPlaying(item) { return this.isLoaded(item) && !this.paused },
_setPlaylist(playlist) {
this.playlistName = playlist;
for(var p in this.sets)
if(p != playlist && this.$refs[p])
this.$refs[p][0].unselect();
},
/// Load a sound from playlist or live
load(playlist=null, index=0) {
let src = null;
// from playlist
if(playlist !== null && index != -1) {
let item = this.$refs[playlist][0].get(index);
if(!item)
throw `No sound at index ${index} for playlist ${playlist}`;
this.loaded = item
src = item.src;
}
// from live
else {
this.loaded = null;
src = this.live.src;
}
this._setPlaylist(playlist);
// load sources
const audio = this.audio;
if(src instanceof Array) {
audio.innerHTML = '';
audio.removeAttribute('src');
for(var s of src) {
let source = document.createElement('source');
source.setAttribute('src', s);
audio.appendChild(source)
}
}
else {
audio.src = src;
}
audio.load();
},
play(playlist=null, index=0) {
this.load(playlist, index);
this.audio.play().catch(e => console.error(e))
},
/// Push items to playlist (by name)
push(playlist, ...items) {
return this.sets[playlist].push(...items);
},
/// Push and play items
playItems(playlist, ...items) {
let index = this.push(playlist, ...items);
this.$refs[playlist][0].selectedIndex = index;
this.play(playlist, index);
},
/// Handle click event that plays multiple items (from `data-sounds` attribute)
playButtonClick(event) {
var items = JSON.parse(event.currentTarget.dataset.sounds);
this.playItems('queue', ...items);
},
/// Pause
pause() {
this.audio.pause()
},
//! Play/pause
togglePlay(playlist=null, index=0) {
if(playlist !== null) {
this.panel = null;
let item = this.sets[playlist].get(index);
if(!this.playlist || this.playlistName !== playlist || this.loaded != item) {
this.play(playlist, index);
return;
}
}
if(this.paused)
this.audio.play().catch(e => console.error(e))
else
this.audio.pause();
},
//! Pin/Unpin an item
togglePlaylist(playlist, item) {
const set = this.sets[playlist]
let index = set.findIndex(item);
if(index > -1)
set.remove(index);
else {
set.push(item);
// this.$refs.pinPlaylistButton.focus();
}
},
/// Audio player state change event
onState(event) {
const audio = this.audio;
this.state = audio.paused ? State.paused : State.playing;
if(event.type == 'ended' && (!this.playlist || this.playlist.selectNext() == -1))
this.play();
},
},
mounted() {
this.load();
},
}
</script>

View File

@ -0,0 +1,65 @@
<template>
<div class="a-playlist">
<div class="header"><slot name="header"></slot></div>
<ul :class="listClass">
<li v-for="(item,index) in items" :class="[itemClass, player.isPlaying(item) ? 'is-active' : '']" @click="!hasAction('play') && select(index)"
:key="index">
<ASoundItem
:data="item" :index="index" :set="set" :player="player_"
@togglePlay="togglePlay(index)"
:actions="actions">
<template #after-title="bindings">
<slot name="after-title" v-bind="bindings"></slot>
</template>
<template #actions="bindings">
<slot name="actions" v-bind="bindings"></slot>
<button class="button" v-if="editable" @click.stop="remove(index,true)">
<span class="icon is-small"><span class="fa fa-close"></span></span>
</button>
</template>
</ASoundItem>
</li>
</ul>
<slot name="footer"></slot>
</div>
</template>
<script>
import AList from './AList';
import ASoundItem from './ASoundItem';
export default {
extends: AList,
emits: [...AList.emits],
components: { ASoundItem },
props: {
actions: Array,
// FIXME: remove
name: String,
player: Object,
editable: Boolean,
withLink: Boolean
},
computed: {
self() { return this; },
player_() { return this.player || window.aircox.player },
},
methods: {
hasAction(action) { return this.actions && this.actions.indexOf(action) != -1; },
selectNext() {
let index = this.selectedIndex + 1;
return this.select(index >= this.items.length ? -1 : index);
},
togglePlay(index) {
if(this.player_.isPlaying(this.set.get(index)))
this.player_.pause();
else
this.select(index)
},
},
}
</script>

View File

@ -0,0 +1,71 @@
<template>
<div class="a-progress m-0">
<time class="time-now">
<slot name="value" :value="value" :max="max">{{ format(value) }}</slot>
</time>
<div ref="bar" class="a-progress-bar-container" @click.stop="onClick" @mouseleave.stop="onMouseMove"
@mousemove.stop="onMouseMove">
<div :class="progressClass" :style="progressStyle">
<time v-if="hoverValue">
{{ format(hoverValue) }}
</time>
<template v-else>&nbsp;</template>
</div>
</div>
<time class="time-total">
<slot name="value" :value="valueDisplay" :max="max">{{ format(max) }}</slot>
</time>
</div>
</template>
<script>
export default {
data() {
return {
hoverValue: null,
}
},
props: {
value: Number,
max: Number,
format: { type: Function, default: x => x },
progressClass: { default: 'a-progress-bar' },
vertical: { type: Boolean, default: false },
},
computed: {
valueDisplay() { return this.hoverValue === null ? this.value : this.hoverValue; },
progressStyle() {
if(!this.max)
return null;
let value = this.max ? this.valueDisplay * 100 / this.max : 0;
return this.vertical ? { height: `${value}%` } : { width: `${value}%` };
},
},
methods: {
xToValue(x) { return x * this.max / this.$refs.bar.getBoundingClientRect().width },
yToValue(y) { return y * this.max / this.$refs.bar.getBoundingClientRect().height },
valueFromEvent(event) {
let rect = event.currentTarget.getBoundingClientRect()
return this.vertical ? this.yToValue(event.clientY - rect.y)
: this.xToValue(event.clientX - rect.x);
},
onClick(event) {
this.$emit('select', this.valueFromEvent(event));
},
onMouseMove(event) {
if(event.type == 'mouseleave')
this.hoverValue = null;
else {
this.hoverValue = this.valueFromEvent(event);
}
},
},
}
</script>

View File

@ -0,0 +1,151 @@
<template>
<tr>
<slot name="head" :context="context" :item="item" :row="row"/>
<template v-for="(attr,col) in columns" :key="col">
<slot name="cell-before" :context="context" :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" :context="context" :item="item" :cell="cells[col]"
:data="itemData" :attr="attr" :emit="cellEmit"
:value="itemData && itemData[attr]">
{{ itemData && itemData[attr] }}
</slot>
<slot name="cell" :context="context" :item="item" :cell="cells[col]"
:data="itemData" :attr="attr" :emit="cellEmit"
:value="itemData && itemData[attr]"/>
</component>
<slot name="cell-after" :context="context" :item="item" :col="col" :cell="cells[col]"
:attr="attr"/>
</template>
<slot name="tail" :context="context" :item="item" :row="row"/>
</tr>
</template>
<script>
import {isReactive, toRefs} from 'vue'
import Model from '../model'
export default {
emits: ['move', 'cell'],
props: {
//! Context object
context: {type: Object, default: () => ({})},
//! Item to display in row
item: {type: Object, default: () => ({})},
//! Columns to display, as items' attributes
//! - name: field name / item attribute value
//! - label: display label
//! - help: help text
columns: Array,
//! Default cell's info
cell: {type: Object, default() { return {row: 0}}},
//! Cell component tag
cellTag: {type: String, default: 'td'},
//! If true, can reorder cell by drag & drop
orderable: {type: Boolean, default: false},
},
computed: {
/**
* Row index
*/
row() { return this.cell && this.cell.row || 0 },
/**
* Item's data if model instance, otherwise item
*/
itemData() {
return this.item instanceof Model ? this.item.data : this.item;
},
/**
* Computed cell infos
*/
cells() {
const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell || {}
const cells = []
for(var col in this.columns)
cells.push({...cell, col: Number(col)})
return cells
},
},
methods: {
/**
* Emit a 'cell' event.
* Event data: `{name, cell, data, item}`
* @param {Number} col: cell column's index
* @param {String} name: cell's event name
* @param {} data: cell's event data
*/
cellEmit(name, cell, data) {
this.$emit('cell', {
name, cell, data,
item: this.item,
})
},
onDragStart(ev) {
const dataset = ev.target.dataset;
const data = `cell:${dataset.col}`
ev.dataTransfer.setData("text/cell", data)
ev.dataTransfer.dropEffect = 'move'
},
onDragOver(ev) {
ev.preventDefault()
ev.dataTransfer.dropEffect = 'move'
},
/**
* Handle drop event, emit `'move': { from, to }`.
*/
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.col),
})
},
/**
* Return DOM node for cells at provided position `col`
*/
getCellEl(col) {
const els = this.$el.querySelectorAll(this.cellTag)
for(var el of els)
if(col == Number(el.dataset.col))
return el;
return null
},
/**
* Focus cell's form input. If from is provided, related focus
*/
focus(col, from) {
if(from)
col += from.col
const target = this.getCellEl(col)
if(!target)
return
const control = target.querySelector('input:not([type="hidden"])') ||
target.querySelector('button') ||
target.querySelector('select') ||
target.querySelector('a');
control && control.focus()
}
},
mounted() {
this.$el.__row = this
},
}
</script>

View File

@ -0,0 +1,153 @@
<template>
<table class="table is-stripped is-fullwidth">
<thead>
<a-row :context="context" :columns="columnNames"
:orderable="columnsOrderable" cellTag="th"
@move="moveColumn">
<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>
<template v-for="column of columns" v-bind:key="column.name"
v-slot:[column.name]="data">
<slot :name="'header-' + column.name" v-bind="data">
{{ column.label }}
<span v-if="column.help" class="icon small"
:title="column.help">
<i class="fa fa-circle-question"/>
</span>
</slot>
</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 :context="context" :item="item" :cell="{row}" :columns="columnNames" :data-index="row"
:data-row="row"
:draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
@cell="onCellEvent(row, $event)">
<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 },
//! Event:
//! - cell(event): an event occured inside cell
//! - colmove({from,to}), colmove(): columns moved
emits: ['cell', 'colmove'],
props: {
...AList.props,
//! Context object
context: {type: Object, default: () => ({})},
//! Ordered list of columns, as objects with:
//! - name: item attribute value
//! - label: display label
//! - help: help text
//! - hidden: if true, field is hidden
columns: Array,
//! If True, columns are orderable
columnsOrderable: Boolean,
},
data() {
return {
...super.data,
// TODO: add observer
columns_: [...this.columns],
extraItem: new this.set.model(),
}
},
computed: {
columnNames() { return this.columns_.map(c => c.name) },
columnLabels() { return this.columns_.reduce(
(labels, c) => ({...labels, [c.name]: c.label}),
{}
)},
rowSlots() {
return Object.keys(this.$slots).filter(x => x.startsWith('row-'))
.map(x => [x, x.slice(4)])
},
},
methods: {
// TODO: use in tracklist
sortColumns(names) {
const ordered = names.map(n => this.columns_.find(c => c.name == n)).filter(c => !!c);
const remaining = this.columns_.filter(c => names.indexOf(c.name) == -1)
this.columns_ = [...ordered, ...remaining]
this.$emit('colmove')
},
/**
* Move column using provided event object (as `{from, to}`)
*/
moveColumn(event) {
const {from, to} = event
const value = this.columns_[from]
this.columns_.splice(from, 1)
this.columns_.splice(to, 0, value)
this.$emit('colmove', event)
},
/**
* 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) {
if(event.name == 'focus')
this.focus(event.data, event.cell)
this.$emit('cell', {
...event, row,
set: this.set
})
},
/**
* Return row component at provided index
*/
getRow(row) {
const els = this.$el.querySelectorAll('tr')
for(var el of els)
if(el.__row && row == Number(el.dataset.row))
return el.__row
},
/**
* Focus on a cell
*/
focus(row, col, from=null) {
if(from)
row += from.row
row = this.getRow(row)
row && row.focus(col, from)
},
},
}
Component.props.itemTag.default = 'tr'
Component.props.listTag.default = 'tbody'
export default Component
</script>

View File

@ -0,0 +1,167 @@
<template>
<a-modal ref="modal" :title="title">
<template #bar>
<button type="button" class="button small mr-3" v-if="panel == LIST"
@click="showPanel(UPLOAD)">
<span class="icon">
<i class="fa fa-upload"></i>
</span>
<span>{{ labels.upload }}</span>
</button>
<button type="button" class="button small mr-3" v-else
@click="showPanel(LIST)">
<span class="icon">
<i class="fa fa-list"></i>
</span>
<span>{{ labels.list }}</span>
</button>
</template>
<template #default>
<a-file-upload ref="upload" v-if="panel == UPLOAD"
:url="uploadUrl"
:label="uploadLabel" :field-name="uploadFieldName"
@load="uploadDone">
<template #form="data">
<slot name="upload-form" v-bind="data"></slot>
</template>
<template #preview="data">
<slot name="upload-preview" v-bind="data"></slot>
</template>
</a-file-upload>
<div class="a-select-file" v-else>
<div ref="list"
:class="['a-select-file-list', listClass]">
<!-- tiles -->
<div v-if="prevUrl">
<a href="#" @click="load(prevUrl)">
{{ labels.show_previous }}
</a>
</div>
<template v-for="item in items" v-bind:key="item.id">
<div :class="['file-preview', this.item && item.id == this.item.id && 'active']" @click="select(item)">
<slot :item="item" :load="load" :lastUrl="lastUrl"></slot>
<a-action-button v-if="deleteUrl"
class="has-text-danger small float-right"
icon="fa fa-trash"
:confirm="labels.confirm_delete"
method="DELETE"
:url="deleteUrl.replace('123', item.id)"
@done="load(lastUrl)">
</a-action-button>
</div>
</template>
<div v-if="nextUrl">
<a href="#" @click="load(nextUrl)">
{{ labels.show_next }}
</a>
</div>
</div>
</div>
</template>
<template #footer>
<slot name="footer" :item="item">
<span class="mr-3" v-if="item">{{ item.name }}</span>
</slot>
<button type="button" v-if="panel == LIST" class="button align-right"
@click="selected">
{{ labels.select_file }}
</button>
</template>
</a-modal>
</template>
<script>
import AModal from "./AModal"
import AActionButton from "./AActionButton"
import AFileUpload from "./AFileUpload"
export default {
emit: ["select"],
components: {AActionButton, AFileUpload, AModal},
props: {
title: { type: String },
labels: Object,
listClass: {type: String, default: ""},
// List url
listUrl: { type: String },
// URL to delete an item, where "123" is replaced by
// the item id.
deleteUrl: {type: String },
uploadUrl: { type: String },
uploadFieldName: { type: String, default: "file" },
uploadLabel: { type: String, default: "Upload a file" },
},
data() {
return {
LIST: 0,
UPLOAD: 1,
panel: 0,
item: null,
items: [],
nextUrl: "",
prevUrl: "",
lastUrl: "",
}
},
methods: {
open() {
this.$refs.modal.open()
},
close() {
this.$refs.modal.close()
},
showPanel(panel) {
this.panel = panel
},
load(url) {
return fetch(url || this.listUrl).then(
response => response.ok ? response.json() : Promise.reject(response)
).then(data => {
this.lastUrl = url
this.nextUrl = data.next
this.prevUrl = data.previous
this.items = data.results
this.showPanel(this.LIST)
this.$forceUpdate()
this.$refs.list.scroll(0, 0)
return this.items
})
},
//! Select an item
select(item) {
this.item = item;
},
//! User click on select button (confirm selection)
selected() {
this.$emit("select", this.item)
this.close()
},
uploadDone(reload=false) {
reload && this.load().then(items => {
this.item = items[0]
})
},
},
mounted() {
this.load()
},
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<div :class="['a-sound-item m-0 button-group', playing && 'playing' || '']">
<slot name="title" :player="player" :item="item" :loaded="loaded">
<span :class="['label is-flex-grow-1 align-left', playing && 'blink' || '']" @click.stop="$emit('togglePlay')">
{{ name || item.name }}
</span>
</slot>
<slot name="after-title" :player="player" :item="item" :loaded="loaded">
</slot>
<div class="button-group actions">
<a class="button action" v-if="hasAction('page')"
:href="item.data.page_url">
<span class="icon is-small">
<i class="fa fa-external-link"></i>
</span>
</a>
<a class="button action"
v-if="hasAction('download') && item.data.is_downloadable"
:href="item.data.url" target="_blank">
<span class="icon is-small">
<span class="fa fa-download"></span>
</span>
</a>
<button :class="['button action', pinned ? 'selected' : 'not-selected']"
v-if="hasAction('pin') && player && player.sets.pin != $parent.set" @click.stop="player.togglePlaylist('pin', item)">
<span class="icon is-small">
<span class="fa fa-star"></span>
</span>
</button>
<slot name="actions" :player="player" :item="item" :loaded="loaded"></slot>
</div>
<slot name="extra-right" :player="player" :item="item" :loaded="loaded"></slot>
</div>
</template>
<script>
import Model from '../model';
import Sound from '../sound';
export default {
props: {
data: {type: Object, default: () => {}},
name: String,
player: Object,
page_url: String,
actions: {type:Array, default: () => []},
index: {type:Number, default: null},
},
computed: {
item() { return this.data instanceof Model ? this.data : new Sound(this.data || {}); },
loaded() { return this.player && this.player.isLoaded(this.item) },
playing() { return this.player && this.player.isPlaying(this.item) },
paused() { return this.player && this.player.paused && this.loaded },
pinned() { return this.player && this.player.sets.pin.find(this.item) },
},
methods: {
hasAction(action) {
return this.actions && this.actions.indexOf(action) != -1;
},
}
}
</script>

View File

@ -0,0 +1,83 @@
<template>
<div class="a-playlist-editor">
<a-select-file ref="select-file"
:title="labels && labels.add_sound"
:labels="labels"
:list-url="soundListUrl"
:deleteUrl="soundDeleteUrl"
:uploadUrl="soundUploadUrl"
:uploadLabel="labels.select_file"
@select="selected"
>
<template #upload-preview="{upload}">
<slot name="upload-preview" :upload="upload"></slot>
</template>
<template #upload-form>
<slot name="upload-form"></slot>
</template>
<template #default="{item}">
<audio controls :src="item.url"></audio>
<label class="label small flex-grow-1">{{ item.name }}</label>
</template>
</a-select-file>
<a-form-set ref="formset" :form-data="formData" :labels="labels"
:initials="initData.items"
order-by="position"
:action-add="actionAdd">
<template v-for="[name,slot] of rowsSlots" :key="slot"
v-slot:[slot]="data">
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
</template>
<template #row-sound="{item,inputName}">
<label>{{ item.data.name }}</label><br>
<audio controls :src="item.data.url"/>
<input type="hidden" :name="inputName" :value="item.data.sound"/>
</template>
</a-form-set>
</div>
</template>
<script>
import AFormSet from './AFormSet'
import ASelectFile from "./ASelectFile"
export default {
components: {AFormSet, ASelectFile},
props: {
formData: Object,
labels: Object,
// initial datas
initData: Object,
soundListUrl: String,
soundUploadUrl: String,
soundDeleteUrl: String,
},
computed: {
rowsSlots() {
return Object.keys(this.$slots)
.filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-'))
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
},
},
methods: {
actionAdd() {
this.$refs['select-file'].open()
},
selected(item) {
const data = {
"sound": item.id,
"name": item.name,
"url": item.url,
"broadcast": item.broadcast,
}
this.$refs.formset.set.push(data)
},
},
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<form ref="form">
<slot :counts="counts"></slot>
</form>
</template>
<script>
const splitReg = new RegExp(',\\s*|\\s+', 'g');
export default {
data() {
return {
counts: {},
}
},
methods: {
update() {
const items = this.$el.querySelectorAll('input[name="data"]:checked')
const counts = {};
for(var item of items)
if(item.value)
for(var tag of item.value.split(splitReg))
if(tag.trim())
counts[tag.trim()] = (counts[tag.trim()] || 0) + 1;
this.counts = counts;
},
onclick() {
// TODO: row click => check checkbox
}
},
mounted() {
console.log(this.counts)
this.$refs.form.addEventListener('change', () => this.update())
this.update()
}
}
</script>

View File

@ -0,0 +1,57 @@
<template>
<div>
<slot :streamer="streamer" :streamers="streamers" :Sound="Sound"
:sources="sources" :fetchStreamers="fetchStreamers"></slot>
</div>
</template>
<script>
import Sound from '../sound';
import {setEcoInterval} from '../utils';
import Streamer from '../streamer';
export default {
props: {
apiUrl: String,
},
data() {
return {
// current streamer
streamer: null,
// all streamers
streamers: [],
// fetch interval id
fetchInterval: null,
Sound: Sound,
}
},
computed: {
sources() {
var sources = this.streamer ? this.streamer.sources : [];
return sources.filter(s => s.data)
},
},
methods: {
fetchStreamers() {
Streamer.fetch(this.apiUrl, {many:true}).then(streamers => {
this.streamers = streamers
this.streamer = streamers ? streamers[0] : null
})
},
},
mounted() {
this.fetchStreamers();
this.fetchInterval = setEcoInterval(() => this.streamer && this.streamer.fetch(), 5000)
},
unmounted() {
if(this.fetchInterval !== null)
clearInterval(this.fetchInterval)
}
}
</script>

View File

@ -0,0 +1,80 @@
<template>
<button :title="ariaLabel"
type="button"
:aria-label="ariaLabel || label" :aria-description="ariaDescription"
@click="toggle" :class="buttonClass">
<slot name="default" :active="active">
<span class="icon">
<i :class="icon"></i>
</span>
<label v-if="label">{{ label }}</label>
</slot>
</button>
</template>
<script>
export default {
props: {
initialActive: {type: Boolean, default: null},
el: {type: String, default: ""},
label: {type: String, default: ""},
icon: {type: String, default: "fa fa-bars"},
ariaLabel: {type: String, default: ""},
ariaDescription: {type: String, default: ""},
activeClass: {type: String, default:"active"},
/// switch toggle of all items of this group.
group: {type: String, default: ""},
},
data() {
return {
active: this.initialActive,
}
},
computed: {
groupClass() {
return this.group && "a-switch-" + this.group || ''
},
buttonClass() {
return [
this.active && 'active' || '',
this.groupClass
]
}
},
methods: {
toggle() {
this.set(!this.active)
},
set(active) {
if(this.el) {
const el = document.querySelector(this.el)
if(active)
el.classList.add(this.activeClass)
else
el.classList.remove(this.activeClass)
}
this.active = active
if(active)
this.resetGroup()
},
resetGroup() {
if(!this.groupClass)
return
const els = document.querySelectorAll("." + this.groupClass)
for(var el of els)
if(el != this.$el)
el.__vnode.ctx.ctx.set(false)
},
},
mounted() {
if(this.initialActive !== null)
this.set(this.initialActive)
},
}
</script>

View File

@ -0,0 +1,288 @@
<template>
<div class="a-tracklist-editor">
<div class="flex-row">
<div class="flex-grow-1">
<slot name="title" />
</div>
<div class="flex-row align-right">
<div class="field has-addons">
<p class="control">
<button type="button" :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>
<span>{{ labels.text }}</span>
</button>
</p>
<p class="control">
<button type="button" :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>
<span>{{ labels.list }}</span>
</button>
</p>
<p class="control ml-3">
<button type="button" class="button is-info square"
:title="labels.settings"
@click="$refs.settings.open()">
<span class="icon is-small">
<i class="fa fa-cog"></i>
</span>
</button>
</p>
</div>
</div>
</div>
<section v-show="page == Page.Text" class="panel">
<textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
@change="updateList"
/>
</section>
<section v-show="page == Page.List" class="panel">
<a-form-set ref="formset"
:form-data="formData" :initials="initData.items"
:columnsOrderable="true" :labels="labels"
order-by="position"
@load="updateInput" @colmove="onColumnMove" @move="updateInput"
@cell="onCellEvent">
<template v-for="[name,slot] of rowsSlots" :key="slot"
v-slot:[slot]="data">
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
</template>
</a-form-set>
</section>
<a-modal ref="settings" :title="labels.settings">
<template #default>
<div class="field">
<label class="label" style="vertical-align: middle">
{{ labels.columns }}
</label>
<table class="table is-bordered"
style="vertical-align: middle">
<tr v-if="$refs.formset">
<a-row :columns="$refs.formset.rows.columnNames"
:item="$refs.formset.rows.columnLabels"
@move="$refs.formset.rows.moveColumn"
>
<template v-slot:cell-after="{cell}">
<td style="cursor:pointer;" v-if="cell.col < $refs.formset.rows.columns_.length-1">
<span class="icon" @click="$refs.formset.rows.moveColumn({from: cell.col, to: cell.col+1})"
><i class="fa fa-left-right"/>
</span>
</td>
</template>
</a-row>
</tr>
</table>
</div>
<div class="flex-row">
<div class="field is-inline-block is-vcentered flex-grow-1">
<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>
</template>
<template #footer>
<div class="flex-row align-right">
<a-action-button icon="fa fa-floppy-disk"
v-if="settingsChanged"
class="button control p-2 mr-3 is-secondary" run-class="blink"
:url="settingsUrl" method="POST"
:data="settings"
:aria-label="labels.save_settings"
@done="settingsSaved()">
{{ labels.save_settings }}
</a-action-button>
<button class="button" type="button" @click="$refs.settings.close()">
Fermer
</button>
</div>
</template>
</a-modal>
</div>
</template>
<script>
import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
import AActionButton from './AActionButton'
import AFormSet from './AFormSet'
import ARow from './ARow'
import AModal from "./AModal"
/// Page display
export const Page = {
Text: 0, List: 1, Settings: 2,
}
export default {
components: { AActionButton, AFormSet, ARow, AModal },
props: {
formData: Object,
labels: Object,
///! initial data as: {items: [], fields: {column_name: label, settings: {}}
initData: Object,
dataPrefix: String,
settingsUrl: String,
defaultColumns: {
type: Array,
default: () => ['artist', 'title', 'tags', 'album', 'year', 'timestamp']},
},
data() {
const settings = {
// tracklist_editor_columns: this.columns,
tracklist_editor_sep: ' -- ',
}
return {
Page: Page,
page: Page.Text,
extraData: {},
settings,
savedSettings: cloneDeep(settings),
}
},
computed: {
rows() { return this.$refs.formset && this.$refs.formset.rows },
columns() { return this.rows && this.rows.columns_ || [] },
settingsChanged() {
var k = Object.keys(this.savedSettings)
.findIndex(k => !isEqual(this.settings[k], this.savedSettings[k]))
return k != -1
},
separator: {
set(value) {
this.settings.tracklist_editor_sep = value
if(this.page == Page.List)
this.updateInput()
},
get() { return this.settings.tracklist_editor_sep }
},
rowsSlots() {
return Object.keys(this.$slots)
.filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-'))
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
},
},
methods: {
onCellEvent(event) {
switch(event.name) {
case 'change': this.updateInput();
break;
}
},
onColumnMove() {
this.settings.tracklist_editor_columns = this.$refs.formset.rows.columnNames
if(this.page == this.Page.List)
this.updateInput()
else
this.updateList()
},
updateList() {
const items = this.toList(this.$refs.textarea.value)
this.$refs.formset.set.reset(items)
},
updateInput() {
const input = this.toText(this.$refs.formset.items)
this.$refs.textarea.value = input
},
/**
* From input and separator, return list of items.
*/
toList(input) {
const columns = this.$refs.formset.rows.columns_
var lines = input.split('\n')
var items = []
for(let line of lines) {
line = line.trimLeft()
if(!line)
continue
var lineBits = line.split(this.separator)
var item = {}
for(var col in columns) {
if(col >= lineBits.length)
break
const column = columns[col]
item[column.name] = lineBits[col].trim()
}
item && items.push(item)
}
return items
},
/**
* From items and separator return a string
*/
toText(items) {
const columns = this.$refs.formset.rows.columns_
const sep = ` ${this.separator.trim()} `
const lines = []
for(let item of items) {
if(!item)
continue
var line = []
for(var col of columns)
line.push(item.data[col.name] || '')
line = dropRightWhile(line, x => !x || !('' + x).trim())
line = line.join(sep).trimRight()
lines.push(line)
}
return lines.join('\n')
},
_data_key(key) {
key = key.slice(this.dataPrefix.length)
try {
var [index, attr] = key.split('-', 1)
return [Number(index), attr]
}
catch(err) {
return [null, key]
}
},
//! Update saved settings from this.settings
settingsSaved(settings=null) {
if(settings !== null)
this.settings = settings
if(this.$refs.settings)
this.$refs.settings.close()
this.savedSettings = cloneDeep(this.settings)
},
},
mounted() {
const settings = this.initData && this.initData.settings
if(settings) {
this.settingsSaved(settings)
this.rows.sortColumns(settings.tracklist_editor_columns)
}
this.page = this.initData.items.length ? Page.List : Page.Text
},
}
</script>

View File

@ -0,0 +1,24 @@
import AFileUpload from "./AFileUpload.vue"
import ASelectFile from "./ASelectFile.vue"
import AStatistics from './AStatistics.vue'
import AStreamer from './AStreamer.vue'
import AFormSet from './AFormSet.vue'
import ATrackListEditor from './ATrackListEditor.vue'
import ASoundListEditor from './ASoundListEditor.vue'
import AEditor from './AEditor.vue'
import AManyToManyEdit from "./AManyToManyEdit.vue"
import base from "./index.js"
export const admin = {
...base,
AManyToManyEdit,
AFileUpload, ASelectFile, AEditor,
AFormSet, ATrackListEditor, ASoundListEditor,
AStatistics, AStreamer,
}
export default admin

View File

@ -0,0 +1,26 @@
import AAutocomplete from './AAutocomplete.vue'
import AModal from "./AModal.vue"
import AActionButton from './AActionButton.vue'
import ADropdown from "./ADropdown.vue"
import ACarousel from './ACarousel.vue'
import AEpisode from './AEpisode.vue'
import AList from './AList.vue'
import APage from './APage.vue'
import APlayer from './APlayer.vue'
import APlaylist from './APlaylist.vue'
import AProgress from './AProgress.vue'
import ASoundItem from './ASoundItem.vue'
import ASwitch from './ASwitch.vue'
/**
* Core components
*/
export const base = {
AActionButton, AAutocomplete, AModal,
ACarousel, ADropdown, AEpisode, AList, APage, APlayer, APlaylist,
AProgress, ASoundItem, ASwitch,
}
export default base