#132 | #121: backoffice / dev-1.0-121 (#131)

cfr #121

Co-authored-by: Christophe Siraut <d@tobald.eu.org>
Co-authored-by: bkfox <thomas bkfox net>
Co-authored-by: Thomas Kairos <thomas@bkfox.net>
Reviewed-on: rc/aircox#131
Co-authored-by: Chris Tactic <ctactic@noreply.git.radiocampus.be>
Co-committed-by: Chris Tactic <ctactic@noreply.git.radiocampus.be>
This commit is contained in:
2024-04-28 22:02:09 +02:00
committed by Thomas Kairos
parent 1e17a1334a
commit 55123c386d
348 changed files with 124397 additions and 17879 deletions

View File

@ -1,24 +1,29 @@
# aircox-assets
# aircox
## Project setup
```
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compiles and minifies for production
```
### Compile and Minify for Production
```sh
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View File

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@ -1,19 +1,8 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

View File

@ -1,36 +1,37 @@
{
"name": "aircox-assets",
"version": "0.1.0",
"name": "aircox",
"version": "0.0.0",
"private": true,
"sideEffects": true,
"type": "module",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"dev": "vite",
"build": "vite build",
"watch": "vite build --watch",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.0.0",
"@popperjs/core": "^2.11.8",
"@rollup/plugin-commonjs": "^25.0.7",
"core-js": "^3.8.3",
"lodash": "^4.17.21",
"vue": "^3.2.13"
"v-calendar": "^3.1.2",
"vite-plugin-babel-macros": "^1.0.6",
"vue": "^3.4.21"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"bulma": "^0.9.3",
"@vitejs/plugin-vue": "^5.0.4",
"bulma": "^0.9.4",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.49.9",
"sass-loader": "^12.6.0",
"vue-cli": "^2.9.6"
"vite": "^5.2.8"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
"node": true,
"es2022": true
},
"extends": [
"plugin:vue/vue3-essential",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -1 +0,0 @@
../node_modules/vue/dist/vue.esm-browser.js

View File

@ -1 +0,0 @@
../node_modules/vue/dist/vue.esm-browser.prod.js

View File

@ -1,10 +1,8 @@
import './assets/styles.scss'
import './assets/admin.scss'
import './styles/admin.scss'
import './index.js'
import App from './app';
import {admin as components} from './components'
import Track from './track'
import components from './components/admin.js'
const AdminApp = {
...App,
@ -13,8 +11,21 @@ const AdminApp = {
data() {
return {
...super.data,
Track,
modalItem: null,
}
},
methods: {
...App.methods,
fileSelected(select, input, preview) {
const item = this.$refs[select].item
if(item) {
this.$refs[input].value = item.id
if(preview)
preview.src = item.file
}
},
}
}
export default AdminApp;

View File

@ -1,13 +1,39 @@
import {Calendar, DatePicker} from 'v-calendar';
import components from './components'
const App = {
el: '#app',
delimiters: ['[[', ']]'],
components: {...components},
components: {
...components,
...{
VCalendar: Calendar,
VDatepicker: DatePicker
},
},
computed: {
player() { return window.aircox.player; },
},
methods: {
//! Delete elements from DOM using provided selector.
deleteElements(sel) {
for(var el of document.querySelectorAll(sel))
el.parentNode.removeChild(el)
},
//! File has been selected
//! TODO: replace using regular ref and bindings.
fileSelected(select, input, preview) {
const item = this.$refs[select].item
if(item) {
this.$refs[input].value = item.id
if(preview)
preview.src = item.file
}
},
}
}
export const PlayerApp = {

View File

@ -1,139 +0,0 @@
import {createApp} from 'vue'
/**
* Utility class used to handle Vue applications. It provides way to load
* remote application and update history.
*/
export default class Builder {
constructor(config={}) {
this.config = config
this.title = null
this.app = null
this.vm = null
}
/**
* Fetch app from remote and mount application.
*/
fetch(url, {el='#app', ...options}={}) {
return fetch(url, options).then(response => response.text())
.then(content => {
let doc = new DOMParser().parseFromString(content, 'text/html')
let app = doc.querySelector(el)
content = app ? app.innerHTML : content
return this.mount({content, title: doc.title, reset:true, url })
})
}
/**
* Mount application, using `create_app` if required.
*
* @param {String} options.content: replace app container content with it
* @param {String} options.title: set DOM document title.
* @param {String} [options.el=this.config.el]: mount application on this element (querySelector argument)
* @param {Boolean} [reset=False]: if True, force application recreation.
* @return `app.mount`'s result.
*/
mount({content=null, title=null, el=null, reset=false, props=null}={}) {
try {
this.unmount()
let config = this.config
if(el === null)
el = config.el
if(reset || !this.app)
this.app = this.createApp({title,content,el,...config}, props)
this.vm = this.app.mount(el)
window.scroll(0, 0)
return this.vm
} catch(error) {
this.unmount()
throw error
}
}
createApp({el, title=null, content=null, ...config}, props) {
const container = document.querySelector(el)
if(!container)
return
if(content)
container.innerHTML = content
if(title)
document.title = title
return createApp(config, props)
}
unmount() {
this.app && this.app.unmount()
this.app = null
this.vm = null
}
/**
* Enable hot reload: catch page change in order to fetch them and
* load page without actually leaving current one.
*/
enableHotReload(node=null, historySave=true) {
if(historySave)
this.historySave(document.location, true)
node.addEventListener('click', event => this.pageChanged(event), true)
node.addEventListener('submit', event => this.pageChanged(event), true)
node.addEventListener('popstate', event => this.statePopped(event), true)
}
pageChanged(event) {
let submit = event.type == 'submit';
let target = submit || event.target.tagName == 'A'
? event.target : event.target.closest('a');
if(!target || target.hasAttribute('target'))
return;
let url = submit ? target.getAttribute('action') || ''
: target.getAttribute('href');
let domain = window.location.protocol + '//' + window.location.hostname
let stay = (url === '' || url.startsWith('/') || url.startsWith('?') ||
url.startsWith(domain)) && url.indexOf('wp-admin') == -1
if(url===null || !stay) {
return;
}
let options = {};
if(submit) {
let formData = new FormData(event.target);
if(target.method == 'get')
url += '?' + (new URLSearchParams(formData)).toString();
else
options = {...options, method: target.method, body: formData}
}
this.fetch(url, options).then(() => this.historySave(url))
event.preventDefault();
event.stopPropagation();
}
statePopped(event) {
const state = event.state
if(state && state.content)
// document.title = this.title;
this.historyLoad(state);
}
/// Save application state into browser history
historySave(url,replace=false) {
const el = document.querySelector(this.config.el)
const state = {
content: el.innerHTML,
title: document.title,
}
if(replace)
history.replaceState(state, '', url)
else
history.pushState(state, '', url)
}
/// Load application from browser history's state
historyLoad(state) {
return this.mount({ content: state.content, title: state.title })
}
}

View File

@ -1,30 +0,0 @@
.admin {
.navbar .navbar-brand {
padding-right: 1em;
}
.navbar .navbar-brand img {
margin: 0em 0.4em;
margin-top: 0.3em;
max-height: 3em;
}
.breadcrumbs {
margin-bottom: 1em;
}
.results > #result_list {
width: 100%;
margin: 1em 0em;
}
ul.menu-list li {
list-style-type: none;
}
.submit-row a.deletelink {
height: 35px;
}
}

View File

@ -1,327 +0,0 @@
@charset "utf-8";
@import "~bulma/sass/utilities/_all.sass";
@import "~bulma/sass/components/dropdown.sass";
$body-background-color: $light;
$menu-item-hover-background-color: #dfdfdf;
$menu-item-active-background-color: #d2d2d2;
@import "~bulma";
//-- helpers/modifiers
.is-fullwidth { width: 100%; }
.is-fullheight { height: 100%; }
.is-fixed-bottom {
position: fixed;
bottom: 0;
margin-bottom: 0px;
border-radius: 0;
}
.is-borderless { border: none; }
.has-text-nowrap {
white-space: nowrap;
}
.has-background-transparent {
background-color: transparent;
}
.is-opacity-light {
opacity: 0.7;
&:hover {
opacity: 1;
}
}
.float-right { float: right }
.float-left { float: left }
.overflow-hidden { overflow: hidden }
.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; }
}
.blink {
animation: 1s ease-in-out 3s infinite alternate blink;
}
//-- navbar
.navbar + .container {
margin-top: 1em;
}
.navbar.has-shadow, .navbar.is-fixed-bottom.has-shadow {
box-shadow: 0em 0em 1em rgba(0,0,0,0.1);
}
a.navbar-item.is-active {
border-bottom: 1px grey solid;
}
.navbar {
.navbar-dropdown {
z-index: 2000;
}
.navbar-split {
margin: 0.2em 0em;
margin-right: 1em;
padding-right: 1em;
border-right: 1px $grey-light solid;
display: inline-block;
}
form {
margin: 0em;
padding: 0em;
}
&.toolbar {
margin: 1em 0em;
background-color: transparent;
margin-bottom: 1em;
.title {
padding-right: 2em;
margin-right: 1em;
border-right: 1px $grey-light solid;
font-size: $size-5;
color: $text-light;
font-weight: $weight-light;
}
}
}
//-- cards
.card {
.title {
a {
color: $dark;
}
padding: 0.2em;
font-size: $size-5;
font-weight: $weight-medium;
}
&.is-primary {
box-shadow: 0em 0em 0.5em $black
}
}
.card-super-title {
position: absolute;
z-index: 1000;
font-size: $size-6;
font-weight: $weight-bold;
padding: 0.2em;
top: 1em;
background-color: #ffffffc7;
max-width: 90%;
.fas {
padding: 0.1em;
font-size: 0.8em;
}
}
//-- page
.page {
& > .cover {
float: right;
max-width: 45%;
}
.header {
margin-bottom: 1.5em;
}
.headline {
font-size: 1.4em;
padding: 0.2em 0em;
}
p { padding: 0.4em 0em; }
hr { background-color: $grey-light; }
.page-content {
h1 { font-size: $size-1; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; }
h2 { font-size: $size-3; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; }
h3 { font-size: $size-4; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; }
h4 { font-size: $size-5; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; }
h5 { font-size: $size-6; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; }
h6 { font-size: $size-6; margin-top:0.4em; margin-bottom:0.2em; }
}
}
.media.item .headline {
line-height: 1.2em;
max-height: calc(1.2em * 3);
overflow: hidden;
& + .headline-overflow {
position: relative;
width: 100%;
height: 2em;
margin-top: -2em;
}
& + .headline-overflow:before {
content:'';
width:100%;
height:100%;
position:absolute;
left:0;
bottom:0;
background:linear-gradient(transparent 1em, $body-background-color);
}
}
//-- player
.player {
z-index: 10000;
box-shadow: 0em 1.5em 2.5em rgba(0, 0, 0, 0.6);
.player-panels {
height: 0%;
transition: height 3s;
}
.player-panels.is-open {
height: auto;
}
.player-panel {
margin: 0.4em;
max-height: 80%;
overflow-y: auto;
}
.progress {
margin: 0em;
padding: 0em;
border-color: $info;
border-style: 'solid';
}
.player-bar {
border-top: 1px $grey-light solid;
> div {
height: 3.75em !important;
}
> .media-left:not(:last-child) {
margin-right: 0em;
}
> .media-cover {
border-left: 1px black solid;
}
.cover {
font-size: 1.5rem !important;
height: 2.5em !important;
}
> .media-content {
padding-top: 0.4em;
padding-left: 0.4em;
}
.button {
font-size: 1.5rem !important;
height: 100%;
padding: auto 0.2em !important;
min-width: 2.5em;
border-radius: 0px;
transition: background-color 1s;
}
.title {
margin: 0em;
}
}
}
//-- media
.media {
.subtitle {
margin-bottom: 0.4em;
}
.media-content .headline {
font-size: 1em;
font-weight: 400;
}
}
//-- general
body {
background-color: $body-background-color;
}
section > .toolbar {
background-color: rgba(0,0,0,0.05);
padding: 1em;
margin-bottom: 1.5em;
}
main {
.cover.is-small { width: 10em; }
.cover.is-tiny { height: 2em; }
}
aside {
& > section {
margin-bottom: 2em;
}
.cover.is-small { width: 10em; }
.cover.is-tiny { height: 2em; }
.media .subtitle {
font-size: 1em;
}
}
.sound-item {
.cover { height: 5em; }
.media-content a { padding: 0em; }
margin-bottom: 0.2em;
}
.sound-item .media-right .button {
margin-right: 0.2em;
min-width: 2.5em;
display: inline-block;
}
.timetable {
width: 100%;
border: none;
}

View File

@ -1,9 +1,9 @@
<template>
<component :is="tag" @click="call" :class="buttonClass">
<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">
<span v-else-if="icon" class="icon is-small">
<i :class="icon"></i>
</span>
<span v-if="$slots.default"><slot name="default"/></span>
@ -27,6 +27,8 @@ export default {
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
@ -60,16 +62,19 @@ export default {
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 => {
const response = data.json();
this.promise = fetch(this.url, options).then(data => data.text()).then(data => {
data = data && JSON.parse(data) || null
this.promise = null;
this.$emit('done', response)
return response
this.$emit('done', data)
return data
}, data => { this.promise = null; return data })
return this.promise
},

View File

@ -20,24 +20,23 @@
<span class="is-inline-block" v-if="selected">
<slot name="button" :index="selectedIndex" :item="selected"
:value-field="valueField" :labelField="labelField">
{{ labelField && selected.data[labelField] || selected }}
{{ selectedLabel }}
</slot>
</span>
</a>
<div :class="dropdownClass">
<div class="dropdown-menu is-fullwidth">
<div class="dropdown-content" style="overflow: hidden">
<a v-for="(item, index) in items" :key="item.id"
href="#" :data-autocomplete-index="index"
<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':'']"
:title="labelField && item.data[labelField] || item"
tabindex="-1">
<slot name="item" :index="index" :item="item" :value-field="valueField"
:labelField="labelField">
{{ labelField && item.data[labelField] || item }}
{{ getValue(item, labelField) || item }}
</slot>
</a>
</span>
</div>
</div>
</div>
@ -56,12 +55,14 @@ export default {
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: String,
placeholder: Object,
//! input form field name
name: String,
//! Field on items to use as label
@ -94,13 +95,31 @@ export default {
this.inputValue = value
},
inputValue(value) {
if(value != this.inputValue && value != this.modelValue)
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() {
@ -132,12 +151,34 @@ export default {
},
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 ? item && item[this.valueField] : item;
return this.valueField ? this.getValue(item, this.valueField) : item;
},
itemLabel(item) {
return this.labelField ? item && item[this.labelField] : item;
return this.labelField ? this.getValue(item, this.labelField) : item;
},
hide() {
@ -176,8 +217,11 @@ export default {
},
onBlur(event) {
var index = event.relatedTarget && event.relatedTarget.dataset.autocompleteIndex;
if(index !== undefined)
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;
},
@ -220,12 +264,14 @@ export default {
return
this.query = query
var url = this.url.replace('${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 => {
this.items = items || []
if(items.results)
items = items.results
this.items = items.filter((i) => i) || []
this.promise = null;
this.move(0)
return items
@ -237,7 +283,7 @@ export default {
mounted() {
const form = this.$el.closest('form')
form.addEventListener('reset', () => {
form && form.addEventListener('reset', () => {
this.inputValue = this.value;
this.select(-1)
})

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

@ -1,13 +1,11 @@
<template>
<div>
<slot :page="page" :podcasts="podcasts"></slot>
</div>
<slot :page="page" :podcasts="podcasts"></slot>
</template>
<script>
import {Set} from '../model';
import Sound from '../sound';
import APage from './APage';
import {Set} from '../model.js';
import Sound from '../sound.js';
import APage from './APage.vue';
export default {
extends: APage,

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

@ -1,69 +1,63 @@
<template>
<div class="player">
<div :class="['player-panels', panel ? 'is-open' : '']">
<APlaylist ref="pin" class="player-panel menu" v-show="panel == 'pin' && sets.pin.length"
name="Pinned"
:actions="['page']"
:editable="true" :player="self" :set="sets.pin" @select="togglePlay('pin', $event.index)"
listClass="menu-list" itemClass="menu-item">
<template v-slot:header="">
<p class="menu-label">
<span class="icon"><span class="fa fa-thumbtack"></span></span>
Pinned
</p>
</template>
</APlaylist>
<APlaylist ref="queue" class="player-panel menu" v-show="panel == 'queue' && sets.queue.length"
:actions="['page']"
:editable="true" :player="self" :set="sets.queue" @select="togglePlay('queue', $event.index)"
listClass="menu-list" itemClass="menu-item">
<template v-slot:header="">
<p class="menu-label">
<span class="icon"><span class="fa fa-list"></span></span>
Playlist
</p>
</template>
</APlaylist>
<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="player-bar media">
<div class="media-left">
<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>
<div class="media-left media-cover" v-if="current && current.data.cover">
<img :src="current.data.cover" class="cover" />
</div>
<div class="media-content">
<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>
<AProgress v-if="loaded && duration" :value="currentTime" :max="this.duration"
:format="displayTime" class="pt-1 is-size-7"
@select="audio.currentTime = $event"></AProgress>
</div>
<div class="media-right">
<button class="button has-text-weight-bold" v-if="loaded" @click="play()">
<span class="icon is-size-6 has-text-danger">
<span class="fa fa-circle"></span>
</span>
<span>Live</span>
</button>
<button ref="pinPlaylistButton" :class="playlistButtonClass('pin')"
@click="togglePanel('pin')" v-show="sets.pin.length">
<span class="is-size-6" v-if="sets.pin.length">
{{ sets.pin.length }}</span>
<span class="icon"><span class="fa fa-thumbtack"></span></span>
</button>
<button :class="playlistButtonClass('queue')"
@click="togglePanel('queue')" v-show="sets.queue.length">
<span class="is-size-6" v-if="sets.queue.length">
{{ sets.queue.length }}</span>
<span class="icon"><span class="fa fa-list"></span></span>
</button>
</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>
@ -101,6 +95,11 @@ export default {
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,
@ -112,16 +111,15 @@ export default {
//! current playing playlist name
playlistName: null,
//! players' playlists' sets
sets: {
queue: Set.storeLoad(Sound, "playlist.queue", { max: 30, unique: true }),
pin: Set.storeLoad(Sound, "player.pin", { max: 30, unique: true }),
}
sets,
}
},
props: {
buttonTitle: String,
liveArgs: Object,
///! dict of {'slug': ['Label', 'icon']}
playlists: Object,
},
computed: {
@ -131,7 +129,7 @@ export default {
loading() { return this.state == State.loading; },
playlist() {
return this.playlistName ? this.$refs[this.playlistName] : null;
return this.playlistName ? this.$refs[this.playlistName][0] : null;
},
current() {
@ -156,10 +154,9 @@ export default {
playlistButtonClass(name) {
let set = this.sets[name];
return (set ? (set.length ? "" : "has-text-grey-light ")
+ (this.panel == name ? "is-info "
: this.playlistName == name ? 'is-primary '
: '') : '')
+ "button has-text-weight-bold";
+ (this.panel == name ? "open"
: this.playlistName == name ? 'active' : '') : '')
+ " button";
},
/// Show/hide panel
@ -172,8 +169,8 @@ export default {
_setPlaylist(playlist) {
this.playlistName = playlist;
for(var p in this.sets)
if(p != playlist)
this.$refs[p].unselect();
if(p != playlist && this.$refs[p])
this.$refs[p][0].unselect();
},
/// Load a sound from playlist or live
@ -182,7 +179,7 @@ export default {
// from playlist
if(playlist !== null && index != -1) {
let item = this.$refs[playlist].get(index);
let item = this.$refs[playlist][0].get(index);
if(!item)
throw `No sound at index ${index} for playlist ${playlist}`;
this.loaded = item
@ -226,7 +223,7 @@ export default {
/// Push and play items
playItems(playlist, ...items) {
let index = this.push(playlist, ...items);
this.$refs[playlist].selectedIndex = index;
this.$refs[playlist][0].selectedIndex = index;
this.play(playlist, index);
},
@ -244,6 +241,7 @@ export default {
//! 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);
@ -257,13 +255,14 @@ export default {
},
//! Pin/Unpin an item
togglePin(item) {
let index = this.sets.pin.findIndex(item);
togglePlaylist(playlist, item) {
const set = this.sets[playlist]
let index = set.findIndex(item);
if(index > -1)
this.sets.pin.remove(index);
set.remove(index);
else {
this.sets.pin.push(item);
this.$refs.pinPlaylistButton.focus();
set.push(item);
// this.$refs.pinPlaylistButton.focus();
}
},

View File

@ -1,21 +1,23 @@
<template>
<div class="playlist">
<slot name="header"></slot>
<div class="a-playlist">
<div class="header"><slot name="header"></slot></div>
<ul :class="listClass">
<li v-for="(item,index) in items" :class="itemClass" @click="!hasAction('play') && select(index)"
<li v-for="(item,index) in items" :class="[itemClass, player.isPlaying(item) ? 'is-active' : '']" @click="!hasAction('play') && select(index)"
:key="index">
<a :class="player.isPlaying(item) ? 'is-active' : ''">
<ASoundItem
:data="item" :index="index" :set="set" :player="player_"
@togglePlay="togglePlay(index)"
:actions="actions">
<template v-slot:extra-right="{}">
<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>
</a>
<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>
@ -32,9 +34,11 @@ export default {
props: {
actions: Array,
// FIXME: remove
name: String,
player: Object,
editable: Boolean,
withLink: Boolean
},
computed: {

View File

@ -1,329 +0,0 @@
<template>
<div class="playlist-editor">
<div class="columns">
<div class="column">
<slot name="title" />
</div>
<div class="column has-text-right">
<div class="float-right field has-addons">
<p class="control">
<a :class="['button','p-2', page == Page.Text ? 'is-primary' : 'is-light']"
@click="page = Page.Text">
<span class="icon is-small">
<i class="fa fa-pencil"></i>
</span>
<span>Texte</span>
</a>
</p>
<p class="control">
<a :class="['button','p-2', page == Page.List ? 'is-primary' : 'is-light']"
@click="page = Page.List">
<span class="icon is-small">
<i class="fa fa-list"></i>
</span>
<span>Liste</span>
</a>
</p>
</div>
</div>
</div>
<slot name="top" :set="set" :columns="columns" :items="items"/>
<section class="page" v-show="page == Page.Text">
<textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
@change="updateList"
/>
</section>
<section class="page" v-show="page == Page.List">
<a-rows :set="set" :columns="columns" :labels="labels"
:allow-create="true"
:orderable="true" @move="listItemMove" @colmove="columnMove"
@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>
<template v-slot:row-tail="data">
<slot v-if="$slots['row-tail']" :name="row-tail" v-bind="data"/>
<td>
<a class="button is-danger is-outlined p-3 is-size-6"
@click="items.splice(data.row,1)"
:title="labels.remove_track"
:aria-label="labels.remove_track">
<span class="icon"><i class="fa fa-trash" /></span>
</a>
</td>
</template>
</a-rows>
</section>
<div class="mt-2">
<div class="float-right">
<a class="button is-warning p-2 ml-2"
@click="loadData({items: this.initData.items},true)">
<span class="icon"><i class="fa fa-rotate" /></span>
<span>{{ labels.discard_changes }}</span>
</a>
<a class="button is-primary p-2 ml-2" t-if="page == page.List"
@click="this.set.push(new this.set.model())">
<span class="icon"><i class="fa fa-plus"/></span>
<span>{{ labels.add_track }}</span>
</a>
</div>
<div class="field is-inline-block is-vcentered mr-3">
<label class="label is-inline mr-2"
style="vertical-align: middle">
Séparateur</label>
<div class="control is-inline-block"
style="vertical-align: middle;">
<input type="text" ref="sep" class="input is-inline is-text-centered is-small"
style="max-width: 5em;"
v-model="separator" @change="updateList()"/>
</div>
</div>
<div class="field is-inline-block is-vcentered mr-3">
<label class="label is-inline mr-2"
style="vertical-align: middle">
{{ labels.columns }}</label>
<table class="table is-bordered is-inline-block"
style="vertical-align: middle">
<tr>
<a-row :columns="columns" :item="labels"
@move="formatMove" :orderable="true">
<template v-slot:cell-after="{cell}">
<td style="cursor:pointer;" v-if="cell.col < columns.length-1">
<span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})"
><i class="fa fa-left-right"/>
</span>
</td>
</template>
</a-row>
</tr>
</table>
</div>
<div class="field is-vcentered is-inline-block"
v-if="settingsChanged">
<a-action-button icon="fa fa-floppy-disk"
class="button control p-3 is-info" run-class="blink"
:url="settingsUrl" method="POST"
:data="settings"
:aria-label="labels.save_settings"
@done="settingsSaved()">
{{ labels.save_settings }}
</a-action-button>
</div>
</div>
<slot name="bottom" :set="set" :columns="columns" :items="items"/>
</div>
</template>
<script>
import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
import {Set} from '../model'
import Track from '../track'
import AActionButton from './AActionButton'
import ARow from './ARow.vue'
import ARows from './ARows.vue'
/// Page display
export const Page = {
Text: 0, List: 1, Settings: 2,
}
export default {
components: { AActionButton, ARow, ARows },
props: {
initData: Object,
dataPrefix: String,
labels: Object,
settingsUrl: String,
defaultColumns: {
type: Array,
default: () => ['artist', 'title', 'tags', 'album', 'year', 'timestamp']},
},
data() {
const settings = {
playlist_editor_columns: this.defaultColumns,
playlist_editor_sep: ' -- ',
}
return {
Page: Page,
page: Page.Text,
set: new Set(Track),
extraData: {},
settings,
savedSettings: cloneDeep(settings),
}
},
computed: {
settingsChanged() {
var k = Object.keys(this.savedSettings)
.findIndex(k => !isEqual(this.settings[k], this.savedSettings[k]))
return k != -1
},
separator: {
set(value) {
this.settings.playlist_editor_sep = value
if(this.page == Page.List)
this.updateInput()
},
get() { return this.settings.playlist_editor_sep }
},
columns: {
set(value) {
var cols = value.filter(x => x in this.defaultColumns)
var left = this.defaultColumns.filter(x => !(x in cols))
value = cols.concat(left)
this.settings.playlist_editor_columns = value
},
get() {
return this.settings.playlist_editor_columns
}
},
items() {
return this.set.items
},
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.settings.playlist_editor_columns.splice(from, 1)
this.settings.playlist_editor_columns.splice(to, 0, value)
if(this.page == Page.Text)
this.updateList()
else
this.updateInput()
},
columnMove({from, to}) {
const value = this.columns[from]
this.columns.splice(from, 1)
this.columns.splice(to, 0, value)
this.updateInput()
},
listItemMove({from, to, set}) {
set.move(from, to);
this.updateInput()
},
updateList() {
const items = this.toList(this.$refs.textarea.value)
this.set.reset(items)
},
updateInput() {
const input = this.toText(this.items)
this.$refs.textarea.value = input
},
/**
* From input and separator, return list of items.
*/
toList(input) {
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 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) {
const sep = ` ${this.separator.trim()} `
const lines = []
for(let item of items) {
if(!item)
continue
var line = []
for(var col of this.columns)
line.push(item.data[col] || '')
line = dropRightWhile(line, x => !x || !('' + 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
this.savedSettings = cloneDeep(this.settings)
},
/**
* Load initial data
*/
loadData({items=[], settings=null}, reset=false) {
if(reset) {
this.set.items = []
}
for(var index in items)
this.set.push(cloneDeep(items[index]))
if(settings)
this.settingsSaved(settings)
this.updateInput()
},
},
watch: {
initData(val) {
this.loadData(val)
},
},
mounted() {
this.initData && this.loadData(this.initData)
this.page = this.items.length ? Page.List : Page.Text
},
}
</script>

View File

@ -1,15 +1,20 @@
<template>
<div class="media">
<div class="media-left">
<slot name="value" :value="valueDisplay" :max="max">{{ format(valueDisplay) }}</slot>
</div>
<div ref="bar" class="media-content" @click.stop="onClick" @mouseleave.stop="onMouseMove"
<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">&nbsp;</div>
<div :class="progressClass" :style="progressStyle">
<time v-if="hoverValue">
{{ format(hoverValue) }}
</time>
<template v-else>&nbsp;</template>
</div>
</div>
<div class="media-right">
<time class="time-total">
<slot name="value" :value="valueDisplay" :max="max">{{ format(max) }}</slot>
</div>
</time>
</div>
</template>
@ -25,7 +30,7 @@ export default {
value: Number,
max: Number,
format: { type: Function, default: x => x },
progressClass: { default: 'has-background-primary' },
progressClass: { default: 'a-progress-bar' },
vertical: { type: Boolean, default: false },
},

View File

@ -1,25 +1,25 @@
<template>
<tr>
<slot name="head" :item="item" :row="row"/>
<slot name="head" :context="context" :item="item" :row="row"/>
<template v-for="(attr,col) in columns" :key="col">
<slot name="cell-before" :item="item" :cell="cells[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" :item="item" :cell="cells[col]"
<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" :item="item" :cell="cells[col]"
<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" :item="item" :col="col" :cell="cells[col]"
<slot name="cell-after" :context="context" :item="item" :col="col" :cell="cells[col]"
:attr="attr"/>
</template>
<slot name="tail" :item="item" :row="row"/>
<slot name="tail" :context="context" :item="item" :row="row"/>
</tr>
</template>
<script>
@ -27,12 +27,17 @@ import {isReactive, toRefs} from 'vue'
import Model from '../model'
export default {
emit: ['move', 'cell'],
emits: ['move', 'cell'],
props: {
//! Context object
context: {type: Object, default: () => ({})},
//! Item to display in row
item: Object,
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}}},

View File

@ -1,34 +1,38 @@
<template>
<table class="table is-stripped is-fullwidth">
<thead>
<a-row :item="labels" :columns="columns" :orderable="orderable"
@move="$emit('colmove', $event)">
<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 :item="item" :cell="{row}" :columns="columns" :data-index="row"
<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">
<template v-if="slot == 'head' || slot == 'tail'">
<slot :name="name" v-bind="data"/>
</template>
<template v-else>
<div>
<slot :name="name" v-bind="data"/>
</div>
</template>
<slot :name="name" v-bind="data"/>
</template>
</a-row>
</template>
@ -43,29 +47,41 @@ import ARow from './ARow.vue'
const Component = {
extends: AList,
components: { ARow },
emit: ['cell', 'colmove'],
//! 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,
labels: Object,
allowCreate: Boolean,
//! If True, columns are orderable
columnsOrderable: Boolean,
},
data() {
return {
...super.data,
// TODO: add observer
columns_: [...this.columns],
extraItem: new this.set.model(),
}
},
computed: {
rowCells() {
const cells = []
for(var row in this.items)
cells.push({row})
},
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)])
@ -73,6 +89,25 @@ const Component = {
},
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

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

@ -1,32 +1,30 @@
<template>
<div class="media sound-item">
<div class="media-left" @click.stop="$emit('togglePlay')">
<img class="cover is-tiny" :src="item.data.cover" v-if="item.data.cover">
</div>
<div class="media-content">
<slot name="content" :player="player" :item="item" :loaded="loaded">
<h4 class="title is-5" @click.stop="$emit('togglePlay')">
<span class="icon is-small is-size-7 blink" v-if="playing">
<span class="fa fa-play"></span>
</span>
{{ name || item.name }}
</h4>
<a class="subtitle is-6 is-inline-block" v-if="hasAction('page') && item.data.page_url"
<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">
{{ item.data.page_title }}
</a>
</slot>
</div>
<div class="media-right">
<a class="button" v-if="item.data.is_downloadable"
<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" v-if="player && player.sets.pin != $parent.set" @click.stop="player.togglePin(item)">
<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="(pinned ? '' : 'has-text-grey-light ') + 'fa fa-thumbtack'"></span>
<span class="fa fa-star"></span>
</span>
</button>
<slot name="actions" :player="player" :item="item" :loaded="loaded"></slot>

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

@ -5,7 +5,7 @@
</template>
<script>
const splitReg = new RegExp(',\\s*', 'g');
const splitReg = new RegExp(',\\s*|\\s+', 'g');
export default {
data() {
@ -22,7 +22,8 @@ export default {
for(var item of items)
if(item.value)
for(var tag of item.value.split(splitReg))
counts[tag.trim()] = (counts[tag.trim()] || 0) + 1;
if(tag.trim())
counts[tag.trim()] = (counts[tag.trim()] || 0) + 1;
this.counts = counts;
},

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,23 @@
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 AManyToManyEdit from "./AManyToManyEdit.vue"
import base from "./index.js"
export const admin = {
...base,
AManyToManyEdit,
AFileUpload, ASelectFile,
AFormSet, ATrackListEditor, ASoundListEditor,
AStatistics, AStreamer,
}
export default admin

View File

@ -1,26 +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 APlaylistEditor from './APlaylistEditor.vue'
import AProgress from './AProgress.vue'
import ASoundItem from './ASoundItem.vue'
import AStatistics from './AStatistics.vue'
import AStreamer from './AStreamer.vue'
import ASwitch from './ASwitch.vue'
/**
* Core components
*/
export const base = {
AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist,
AProgress, ASoundItem,
AActionButton, AAutocomplete, AModal,
ACarousel, ADropdown, AEpisode, AList, APage, APlayer, APlaylist,
AProgress, ASoundItem, ASwitch,
}
export default base
export const admin = {
...base,
AStatistics, AStreamer, APlaylistEditor
}

View File

@ -2,28 +2,27 @@
* This module includes code available for both the public website and
* administration interface)
*/
//-- vendor
import '@fortawesome/fontawesome-free/css/all.min.css';
import 'vue'
//-- aircox
import App, {PlayerApp} from './app'
import Builder from './appBuilder'
import VueLoader from './vueLoader'
import Sound from './sound'
import {Set} from './model'
import './assets/styles.scss'
import './styles/common.scss'
window.aircox = {
// main application
builder: new Builder(App),
get app() { return this.builder.app },
loader: null,
get app() { return this.loader.app },
// player application
playerBuilder: new Builder(PlayerApp),
get playerApp() { return this.playerBuilder && this.playerBuilder.app },
get player() { return this.playerBuilder.vm && this.playerBuilder.vm.$refs.player },
playerLoader: null,
get playerApp() { return this.playerLoader && this.playerLoader.app },
get player() { return this.playerLoader.vm && this.playerLoader.vm.$refs.player },
Set, Sound,
@ -31,30 +30,38 @@ window.aircox = {
/**
* Initialize main application and player.
*/
init(props=null, {config=null, builder=null, initBuilder=true,
initPlayer=true, hotReload=false, el=null}={})
init(props=null, {hotReload=false, el=null,
config=null, playerConfig=null,
initApp=true, initPlayer=true,
loader=null, playerLoader=null}={})
{
if(initPlayer) {
let playerBuilder = this.playerBuilder
playerBuilder.mount()
playerConfig = playerConfig || PlayerApp
playerLoader = playerLoader || new VueLoader(playerConfig)
playerLoader.enable(false)
this.playerLoader = playerLoader
document.addEventListener("keyup", e => this.onKeyPress(e), false)
}
if(initBuilder) {
builder = builder || this.builder
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})
if(hotReload)
builder.enableHotReload(hotReload)
if(initApp) {
config = config || window.App || App
config.el = el || config.el
loader = loader || new VueLoader({el, props, ...config})
loader.enable(hotReload)
this.loader = loader
}
},
onKeyPress(/*event*/) {
/*
if(event.key == " ") {
this.player.togglePlay()
event.stopPropagation()
}
*/
},
/**
* Filter navbar dropdown menu items
*/
@ -68,5 +75,10 @@ window.aircox = {
else
for(let item of container.querySelectorAll('a.navbar-item'))
item.style.display = null;
},
pickDate(url, date) {
url = `${url}?date=${date.id}`
this.loader.pageLoad.load(url)
}
}

View File

@ -30,6 +30,7 @@ export default class Live {
response.ok ? response.json()
: Promise.reject(response)
).then(data => {
data = data.results
data.forEach(item => {
if(item.start) item.start = new Date(item.start)
if(item.end) item.end = new Date(item.end)

View File

@ -41,9 +41,8 @@ export default class Model {
this.commit(data);
}
get errors() {
return this.data && this.data.__errors__
}
get created() { return !this.id }
get errors() { return this.data && this.data.__errors__ }
/**
* Get instance id from its data
@ -113,7 +112,7 @@ export default class Model {
}
/**
* Update instance's data with provided data. Return None
* Set instance's data with provided data. Return None
*/
commit(data) {
this.data = data;
@ -121,11 +120,17 @@ export default class Model {
}
/**
* Update model data, without reset previous value
* Update model data, without reset previous value.
* Item is marked as updated.
*/
update(data) {
this.data = {...this.data, ...data}
this.id = this.constructor.getId(this.data)
this.updated = true
}
delete() {
this.deleted = true
}
/**
@ -177,8 +182,24 @@ export class Set {
this.push(item, {args: args, save: false});
}
//! Return total items count
get length() { return this.items.length }
//! Return a list of items marked as deleted
get deletedItems() {
return this.items.filter(i => i.deleted)
}
//! Return a list of created items
get createdItems() {
return this.items.filter(i => !i.deleted && !i.id)
}
//! Return a list of updated items
get updatedItems() {
return this.items.filter(i => i.updated)
}
/**
* Fetch multiple items from server
*/
@ -190,6 +211,63 @@ export class Set {
.map(d => new model(d, {url: url, ...args})))
}
fetch({url=null, reset=false, ...options}={}, args=null) {
url = url || this.url
options = this.model.getOptions(options)
return fetch(url, options)
.then(response => response.json())
.then(data =>
(data instanceof Array ? data : data.results)
.map(d => new this.model(d, {url: url, ...args}))
)
.then(data => {
if(reset)
this.items = data
else
// TODO: remove duplicate
this.items = [...this.items, ...data]
return data
})
}
/**
* Commit changes to server.
* py-ref: `views.mixin.ListCommitMixin`
*/
commit(url, {getData=null, fields=null, ...options}={}) {
if(!getData && fields)
getData = (i) => fields.reduce((r, f) => {
r[f] = i.data[f]
return r
}, {})
const createdItems = this.createdItems
const body = {
delete: this.deletedItems.map(i => i.id),
update: this.updatedItems.map(getData),
create: createdItems.map(getData),
}
if(!body.delete && !body.update && !body.create)
return
getData = getData || ((i) => i.data);
options = this.model.getOptions(options)
options.method = "POST"
options.body = JSON.stringify(body)
return fetch(url, options)
.then(response => response.json())
.then(data => {
const {created, updated, deleted} = data
if(createdItems)
this.items = this.items.filter(i => createdItems.indexOf(i) == -1)
if(deleted)
this.items = this.items.filter(i => deleted.indexOf(i.id) == -1)
this.extend(created)
this.extend(updated)
return data
})
}
/**
* Load list from localStorage
*/
@ -234,22 +312,30 @@ export class Set {
: this.items.findIndex(x => x.id == pred.id);
}
extend(items, options) {
items.forEach(i => this.push(i, options))
}
/**
* Add item to set, return index.
* If item already exists, replace it.
*/
push(item, {args={},save=true}={}) {
item = item instanceof this.model ? item : new this.model(item, args);
if(this.unique) {
let index = this.findIndex(item);
let index = -1
if(this.unique && item.id) {
index = this.findIndex(item);
if(index > -1)
return index;
this.items[index] = item
}
if(this.max && this.items.length >= this.max)
this.items.splice(0,this.items.length-this.max)
this.items.push(item);
save && this.save();
return this.items.length-1;
if(index == -1) {
if(this.max && this.items.length >= this.max)
this.items.splice(0,this.items.length-this.max)
this.items.push(item)
index = this.items.length-1
}
save && this.save()
return index;
}
/**

174
assets/src/pageLoad.js Normal file
View File

@ -0,0 +1,174 @@
/**
* Load page without leaving current one (hot-reload).
*/
export default class PageLoad {
constructor(el, {loadingClass="loading", append=false}={}) {
this.el = el
this.append = append
this.loadingClass = loadingClass
}
get target() {
if(!this._target)
this._target = document.querySelector(this.el)
return this._target
}
reset() {
this._target = null
}
/**
* Enable hot reload: catch page change in order to fetch them and
* load page without actually leaving current one.
*/
enable(target=null) {
if(this._pageChanged)
throw "Already enabled, please disable me"
if(!target)
target = this.target || document.body
this.historySave(document.location, true)
this._pageChanged = event => this.pageChanged(event)
this._statePopped = event => this.statePopped(event)
target.addEventListener('click', this._pageChanged, true)
target.addEventListener('submit', this._pageChanged, true)
window.addEventListener('popstate', this._statePopped, true)
}
/**
* Disable hot reload, remove listeners.
*/
disable() {
this.target.removeEventListener('click', this._pageChanged, true)
this.target.removeEventListener('submit', this._pageChanged, true)
window.removeEventListener('popstate', this._statePopped, true)
this._pageChanged = null
this._statePopped = null
}
/**
* Fetch url, return promise, similar to standard Fetch API.
* Default implementation just forward argument to it.
*/
fetch(url, options) {
return fetch(url, options)
}
/**
* Fetch app from remote and mount application.
*/
load(url, {mount=true, scroll=[0,0], ...options}={}) {
if(this.loadingClass)
this.target.classList.add(this.loadingClass)
if(this.onLoad)
this.onLoad({url, el: this.el, options})
if(scroll)
window.scroll(...scroll)
return this.fetch(url, options).then(response => response.text())
.then(content => {
if(this.loadingClass)
this.target.classList.remove(this.loadingClass)
var doc = new DOMParser().parseFromString(content, 'text/html')
var dom = doc.querySelectorAll(this.el)
var result = {url,
content: dom || [document.createTextNode(content)],
title: doc.title,
append: this.append}
mount && this.mount(result)
return result
})
}
/**
* Mount the page on provided target element
*/
mount({content, title=null, ...options}={}) {
if(this.onPreMount)
this.onPreMount({target: this.target, content, items, title})
var items = null;
if(content)
items = this.mountContent(content, options)
if(title)
document.title = title
if(this.onMount)
this.onMount({target: this.target, content, items, title})
}
/**
* Mount page content
*/
mountContent(content, {append=false}={}) {
if(typeof content == "string") {
this.target.innerHTML = append ? this.target.innerHTML + content
: content;
// TODO
return []
}
if(!append)
this.target.innerHTML = ""
var fragment = document.createDocumentFragment()
var items = []
for(var node of content)
while(node.firstChild) {
items.push(node.firstChild)
fragment.appendChild(node.firstChild)
}
this.target.append(fragment)
return items
}
/// Save application state into browser history
historySave(url,replace=false) {
const state = { content: this.target.innerHTML,
title: document.title, }
if(replace)
history.replaceState(state, '', url)
else
history.pushState(state, '', url)
}
// --- events
pageChanged(event) {
let submit = event.type == 'submit';
let target = submit || event.target.tagName == 'A'
? event.target : event.target.closest('a');
if(!target || target.hasAttribute('target') || target.data.forceReload)
return;
let url = submit ? target.getAttribute('action') || ''
: target.getAttribute('href');
let domain = window.location.protocol + '//' + window.location.hostname
let stay = (url === '' || url.startsWith('/') || url.startsWith('?') ||
url.startsWith(domain)) && url.indexOf('wp-admin') == -1
if(url===null || !stay) {
return;
}
let options = {};
if(submit) {
let formData = new FormData(event.target);
if(target.method == 'get')
url += '?' + (new URLSearchParams(formData)).toString();
else
options = {...options, method: target.method, body: formData}
}
this.load(url, options).then(() => this.historySave(url))
event.preventDefault();
event.stopPropagation();
}
statePopped(event) {
const state = event.state
if(state && state.content)
this.mount({ content: state.content, title: state.title });
}
}

View File

@ -1,6 +1,5 @@
import "./styles/public.scss"
import './index.js'
import App from './app.js'
export default App
window.App = App

View File

@ -2,8 +2,11 @@ import Model from './model';
export default class Sound extends Model {
constructor({sound={}, ...data}={}, options={}) {
// flatten EpisodeSound and sound data
super({...sound, ...data}, options)
}
get name() { return this.data.name }
get src() { return this.data.url }
static getId(data) { return data.pk }
}

View File

@ -0,0 +1,101 @@
@use "./vars";
@use "./components";
@import "bulma/sass/utilities/_all.sass";
@import "bulma/sass/elements/button";
@import "bulma/sass/components/navbar";
// enforce button usage inside custom application
#player, .ax {
@include components.button;
}
.admin {
.navbar.has-shadow, .navbar.is-fixed-bottom.has-shadow {
box-shadow: 0em 0em 1em rgba(0,0,0,0.1);
}
a.navbar-item.is-active {
border-bottom: 1px grey solid;
}
.navbar {
& + .container {
margin-top: 1em;
}
.navbar-dropdown {
z-index: 2000;
}
.navbar-split {
margin: 0.2em 0em;
margin-right: 1em;
padding-right: 1em;
border-right: 1px vars.$grey-light solid;
display: inline-block;
}
form {
margin: 0em;
padding: 0em;
}
&.toolbar {
margin: 1em 0em;
background-color: transparent;
margin-bottom: 1em;
.title {
padding-right: 2em;
margin-right: 1em;
border-right: 1px vars.$grey-light solid;
font-size: vars.$text-size;
font-weight: vars.$weight-light;
}
}
.navbar-dropdown {
max-height: 40rem;
overflow-y: auto;
input {
z-index: 10000;
position: sticky;
top: 0;
}
}
}
.navbar .navbar-brand {
padding-right: 1em;
}
.navbar .navbar-brand img {
margin: 0em 0.4em;
margin-top: 0.3em;
max-height: 3em;
}
.breadcrumbs {
margin-bottom: 1em;
}
.results > #result_list {
width: 100%;
margin: 1em 0em;
}
ul.menu-list li {
list-style-type: none;
}
.submit-row a.deletelink {
height: 35px;
}
}

View File

@ -0,0 +1,98 @@
@use "./vars" as v;
@import "./vendor";
@import "./helpers";
//-- helpers/modifiers
//-- forms
input.half-field:not(:active):not(:hover) {
border: none;
background-color: rgba(0,0,0,0);
cursor: pointer;
}
//-- general
:root {
--body-bg: #fff;
--text-color: black;
--text-color-light: #555;
--break-color: rgb(225, 225, 225);
--main-color: #EFCA08;
--main-color-light: #F4da51;
--main-color-dark: #F49F0A;
--secondary-color: #00A6A6;
--secondary-color-light: #4cc0c0;
--secondary-color-dark: #007ba8;
--disabled-color: #aaa;
--disabled-bg: #eee;
--link-fg: #00A6A6;
--link-hv-fg: var(--text-color);
--nav-primary-height: 3rem;
--nav-secondary-height: 2.5rem;
--nav-fg: var(--text-color);
--nav-bg: var(--main-color);
--nav-secondary-bg: var(--main-color-light);
--nav-hv-fg: var(--button-hv-fg);
--nav-hv-bg: var(--button-hv-bg);
--nav-active-fg: var(--button-active-fg);
--nav-active-bg: var(--button-active-bg);
--nav-fs: 1rem;
--nav-2-fs: 0.9rem;
}
:root {
font-size: 14px;
}
body {
background-color: var(--body-bg);
}
@mixin mobile-small {
.grid { @include grid-1; }
}
body.mobile {
@include mobile-small;
}
@media screen and (max-width: v.$screen-smaller) {
@include mobile-small;
}
@media screen and (max-width: v.$screen-normal) {
html { font-size: 18px !important; }
}
@media screen and (max-width: v.$screen-wider) {
html { font-size: 20px !important; }
}
@media screen and (min-width: v.$screen-wider) {
html { font-size: 24px !important; }
}
h1, h2, h3, h4, h5, h6, .heading, .title, .subtitle {
font-family: var(--heading-font-family);
}
.container:empty {
display: none;
}
.header-cover {
display: flex;
flex-direction: column;
}
.modal .dropdown-menu {
z-index: 50,
}

View File

@ -0,0 +1,757 @@
@use "vars" as v;
:root {
--title-1-sz: 1.6rem;
--title-2-sz: 1.4rem;
--title-3-sz: 1.2rem;
--subtitle-1-sz: 1.6rem;
--subtitle-2-sz: 1.4rem;
--subtitle-3-sz: 1.2rem;
--heading-font-family: default;
--heading-bg: var(--main-color);
--heading-fg: var(--text-color);
--heading-hg-fg: var(--text-color);
--heading-hg-bg: var(--secondary-color);
--heading-link-hv-fg: var(--link-fg);
--cover-w: 14rem;
--cover-h: 14rem;
--cover-small-w: 10rem;
--cover-small-h: 10rem;
--cover-tiny-w: 10rem;
--cover-tiny-h: 10rem;
--card-w: var(--cover-w);
--preview-bg: var(--body-bg);
--preview-title-sz: var(--title-3-sz);
--preview-subtitle-sz: var(--title-3-sz);
--preview-cover-size: 14rem;
--preview-cover-small-size: 10rem;
--preview-cover-tiny-size: 4rem;
--preview-wide-content-sz: #{v.$text-size-2};
--preview-heading-bg-color: var(--main-color);
--header-height: var(--cover-h);
--a-carousel-p: #{v.$text-size-medium};
--a-carousel-ml: calc(#{v.$mp-4} - 0.5rem);
--a-carousel-gap: #{v.$mp-4};
--a-carousel-nav-x: -#{v.$mp-3e};
--a-carousel-bg: none; // var(--secondary-color-light);
--a-progress-bg: transparent;
--a-progress-bar-bg: var(--secondary-color);
--a-progress-bar-color: var(--text-color);
--a-progress-bar-pd: #{v.$mp-2};
--a-playlist-header-bg: var(--secondary-color);
--a-playlist-header-fg: var(--text-color);
--a-playlist-title-sz: #{v.$text-size};
--a-playlist-title-pd: #{v.$mp-3};
--a-playlist-item-border: 1px var(--secondary-color) solid;
--a-sound-bg: var(--main-color);
--a-sound-hv-bg: var(--main-color);
--a-sound-hv-fg: var(--secondary-color);
--a-sound-playing-fg: var(--secondary-color-dark);
--a-sound-text-sz: #{v.$text-size};
--a-player-url-fg: var(--text-color);
--a-player-panel-bg: var(--main-color);
--a-player-bar-height: var(--nav-primary-height);
--a-player-bar-bg: var(--main-color);
--a-player-bar-title-alone-sz: #{v.$text-size-medium};
--a-player-bar-button-fg: var(--button-fg);
--a-player-bar-button-fg: var(--button-bg);
--a-player-bar-button-hv-fg: var(--button-hv-fg);
--a-player-bar-button-hv-bg: var(--button-hv-bg);
--button-fg: var(--text-color);
--button-bg: var(--main-color);
--button-sec-bg: var(--main-color-light);
--button-hv-fg: var(--text-color);
--button-hv-bg: var(--secondary-color-light);
--button-active-fg: var(--text-color);
--button-active-bg: var(--secondary-color);
}
@media screen and (max-width: v.$screen-wide) {
:root {
--cover-w: 10rem;
--cover-h: 10rem;
--cover-small-w: 6rem;
--cover-small-h: 6rem;
--cover-tiny-w: 4rem;
--cover-tiny-h: 4rem;
--section-content-sz: 1rem;
// --preview-title-sz: #{v.$text-size};
// --preview-subtitle-sz: #{v.$text-size-smaller};
// --preview-wide-content-sz: #{v.$text-size};
}
}
// ---- headings
.title, .header.preview .title {
&.is-1 { font-size: var(--title-1-sz); }
&.is-2 { font-size: var(--title-2-sz); }
&.is-3 { font-size: var(--title-3-sz); }
}
.subtitle, .header.preview .subtitle {
color: var(--text-color-light);
&.is-1 { font-size: var(--subtitle-1-sz); }
&.is-2 { font-size: var(--subtitle-2-sz); }
&.is-3 { font-size: var(--subtitle-3-sz); }
}
.title + .subtitle {
padding-top: 0em !important;
}
.headings a, a.heading, a.subtitle {
text-decoration: none !important;
}
.heading {
display: inline-block;
&:not(:empty) {
// border-bottom: 1px var(--heading-bg) solid;
// color: var(--heading-fg);
padding: v.$mp-2;
margin-top: 0em !important;
vertical-align: top;
&.highlight, &.active,
.preview.active &,
{
// border-color: var(--heading-hg-bg);
color: var(--heading-hg-fg);
}
}
}
// ---- bulma overrides
.modal-card {
max-width: v.$screen-wide;
}
.modal-card {
max-height: calc(100% - 10rem);
}
// ---- button
@mixin button {
.button, a.button, button.button {
font-size: v.$text-size;
display: inline-block;
padding: v.$mp-2e;
border: none;
justify-content: center;
text-align: center;
cursor: pointer;
text-decoration: none;
color: var(--button-fg);
background-color: var(--button-bg);
&.square { min-width: 2.5em; }
&.secondary { background-color: var(--button-sec-bg); }
.label, label {
cursor: pointer;
}
.icon {
vertical-align: middle;
&:not(:only-child) {
&:first-child { margin: 0 v.$mp-3e 0 v.$mp-1e; }
&:last-child { margin: 0 v.$mp-3e 0 v.$mp-1e; }
}
}
&:hover {
color: var(--button-hv-fg);
background-color: var(--button-hv-bg);
opacity: 1 !important;
}
&.active:not(:hover) {
color: var(--button-active-fg);
background-color: var(--button-active-bg);
}
&:not([disabled]), &:not(.disabled) {
cursor: pointer;
}
&[disabled], &.disabled {
background-color: var(--text-color-light);
color: var(--secondary-color);
border-color: var(--secondary-color-light);
}
.dropdown-trigger {
border-radius: 1.5em;
}
}
.button-group, .nav {
.button {
border-radius: 0px;
background-color: transparent;
border-top: 0px;
border-bottom: 0px;
height: 100%;
&:not(:first-child) { border-left: 0px; }
&:last-child { border-right: 0px; }
}
}
}
// ---- preview
.preview {
position: relative;
background-size: cover;
background-color: var(--preview-bg) !important;
&.preview-item {
width: 100%;
}
// FIXME: remove
&.columns, .headings.columns {
margin-left: 0em;
margin-right: 0em;
.column { padding: 0em; }
}
.title, .title:not(:last-child) {
// second is bulma reset
font-weight: v.$weight-bold;
font-size: var(--preview-title-sz);
margin-bottom: unset;
}
.subtitle {
font-weight: v.$weight-bolder;
font-size: var(--preview-subtitle-sz);
margin-bottom: unset;
}
//.content, .actions {
// font-size: v.$text-size-bigger;
//}
.headings {
background-size: cover;
> * { margin: 0em; }
.column { padding: 0em; }
a { color: var(--text-color); }
a:hover { color: var(--heading-link-hv-fg) !important; }
}
&.tiny {
.title { font-size: calc(var(--preview-title-sz) * 0.8); }
.subtitle { font-size: calc(var(--preview-subtitle-sz) * 0.8); }
.content {
font-size: v.$text-size;
max-height: 3rem;
overflow: hidden;
}
}
}
.preview-cover {
background: var(--preview-bg);
background-size: cover;
background-repeat: no-repeat;
height: var(--cover-h);
max-width: calc( var(--cover-w) * 1.5 );
min-width: var(--cover-w);
overflow: hidden;
border: 1px #c4c4c4 solid;
img {
height: var(--cover-h);
max-width: calc( var(--cover-w) * 1.5 );
min-width: var(--cover-w);
}
img.hide { visibility: hidden; }
&.small, .preview.small & {
min-width: unset;
height: var(--preview-cover-small-size);
width: var(--preview-cover-small-size) !important;
min-width: var(--preview-cover-small-size);
}
&.tiny, .preview.tiny & {
min-width: unset;
height: var(--preview-cover-tiny-size);
width: var(--preview-cover-tiny-size) !important;
min-width: var(--preview-cover-tiny-size);
}
}
.preview-header {
width: 100%;
/*&:not(.no-cover) {
min-height: var(--header-height);
}*/
&.no-cover {
height: unset;
}
.headings {
padding-top: v.$mp-6;
}
.headings, > .container {
width: 100%;
}
> .container, {
height: 100%;
}
}
// ---- list
.list-item {
display: flex;
flex-direction: column;
width: 100%;
// padding: v.$mp-3;
.headings {
display: flex;
flex-direction: row;
padding: 0em;
margin-bottom: v.$mp-2 !important;
.heading {
// background-color: var(--preview-heading-bg-color);
padding: 0rem;
}
}
.title { flex-grow: 1; }
.subtitle {
font-size: var(--preview-title-sz);
// background-color: var(--preview-heading-bg-color);
text-align: right;
&:not(:empty) { min-width: 9rem; }
}
.media-content {
height: 100%;
margin-bottom: unset;
.list-item:not(.no-cover) & {
min-height: var(--preview-cover-small-size);
}
}
.actions {
text-align: right;
align-items: center;
}
&:not(.wide) .media {
padding: v.$mp-3;
// border-radius: v.$mp-2;
border: 1px solid var(--break-color) !important;
}
}
@media screen and (max-width: v.$screen-very-small) {
.list-item .headings {
flex-direction: column;
.heading {
display: inline;
text-align: left;
}
.subtitle {
color: unset !important;
background: none !important;
}
}
}
// ---- wide
.list-item.wide {
& .preview-cover {
box-shadow: 0em 0em 1em rgba(0,0,0,0.2);
}
& .content {
font-size: var(--preview-wide-content-sz);
flex-grow: 1;
}
}
// ---- card
.preview-card {
display: flex;
flex-direction: column;
width: var(--card-w);
padding: 0rem !important;
margin-bottom: auto;
background-color: var(--preview-bg) !important;
transition: box-shadow 0.2s;
&:hover {
figure {
// box-shadow: 0em 0em 1.2em rgba(0, 0, 0, 0.4) !important;
box-shadow: 0em 0em 1em rgba(0,0,0,0.2);
}
a {
color: var(--heading-link-hv-fg);
}
}
.headings {
margin-top: v.$mp-2;
.heading {
display: block !important;
}
.subtitle {
font-size: v.$text-size-2;
}
}
.card-content {
flex-grow: 1;
position: relative;
figure {
// box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.2);
height: var(--cover-h);
width: var(--cover-w);
}
.actions {
position: absolute;
padding: v.$mp-2;
bottom: 0rem;
right: 0rem;
}
}
}
// ---- ---- Carousel
.a-carousel {
.a-carousel-viewport {
box-shadow: inset 0em 0em 20rem var(--a-carousel-bg);
// background-color: var(--a-carousel-bg);
padding: 0rem;
padding-top: var(--a-carousel-p);
margin-top: calc( 0rem - var(--a-carousel-p) );
}
}
.a-carousel-container {
width: 100%;
gap: var(--a-carousel-gap);
transition: margin-left 1s;
> * {
flex-shrink: 0;
}
}
.a-carousel-bullets-container {
// due to a-carousel margin-left
padding-left: var(--a-carousel-ml);
.bullet {
margin: v.$mp-1;
cursor: pointer;
&:hover { color: var(--link-fg); }
}
}
// ---- ---- progress bar
.a-progress {
display: flex;
flex-direction: row;
margin: 0em;
padding: 0em;
&:hover {
background-color: var(--a-progress-bg);
}
.a-progress-bar-container {
flex-grow: 1;
margin: 0em;
}
> time, .a-progress-bar {
height: 100%;
padding: var(--a-progress-bar-pd);
}
.a-progress-bar {
background-color: var(--a-progress-bar-bg);
color: var(--a-progress-bar-color)
}
}
// ---- ---- player
// ---- playlist
.playlist, .a-playlist {
.header {
display: flex;
flex-direction: row;
.title, .button {
background-color: var(--a-playlist-header-bg);
color: var(--a-playlist-header-fg);
}
.title {
font-size: var(--a-playlist-title-sz);
margin: 0;
padding: var(--a-playlist-title-pd);
}
}
li {
list-style: none;
border-bottom: var(--a-playlist-item-border);
&:last-child {
border-bottom: 0px;
}
}
}
// ---- sound item
.a-sound-item {
display: flex;
align-items: center;
flex-direction: row;
height: 3rem;
background-color: var(--a-sound-bg);
&.playing .label {
color: var(--a-sound-playing-fg) !important;
}
&:hover {
background-color: var(--a-sound-hv-bg);
.label {
color: var(--a-sound-hv-fg) !important;
}
}
.label:hover::before, &.playing .label::before {
content: "\f04b";
font-family: "Font Awesome 6 Free";
margin-right: v.$mp-3e;
}
&.playing .label:hover::before {
content: '';
margin: 0;
}
.headings > * {
}
.label {
cursor: pointer;
.icon {
padding: 0em v.$mp-3;
}
margin: 0em !important;
padding: v.$mp-3e;
font-size: var(--a-sound-text-sz);
font-family: var(--heading-font-family);
}
.button {
width: 3em;
font-size: var(--a-sound-text-sz);
&:hover {
color: var(--a-sound-hv-fg) !important;
background-color: unset;
}
}
}
// ---- player
.player-container {
z-index: 1000000;
}
.a-player {
box-shadow: 0em -0.5em 0.5em rgba(0, 0, 0, 0.05);
a { color: var(--a-player-url-fg); }
.button {
color: var(--text-black);
&:hover { color: var(--button-fg); }
}
}
.a-player-panels {
background: var(--a-player-panel-bg);
height: 0%;
transition: height 1s;
}
.a-player-panels.is-open {
height: auto;
}
.a-player-panel {
padding-bottom: v.$mp-3;
max-height: 80%;
overflow-y: auto;
.a-sound-item:not(:hover) {
background-color: transparent;
}
}
.a-player-progress {
height: 0.4em;
overflow: hidden;
time { display: none; }
&:hover, .a-player-panels.is-open + & {
background: var(--a-player-bar-bg);
height: 2em;
time { display: unset; }
}
}
.a-player-bar {
display: flex;
flex-direction: row;
justify-content: center;
height: var(--a-player-bar-height);
border-top: 1px v.$grey-light solid;
background: var(--a-player-bar-bg);
> * { height: 100%; }
.cover { height: 100%; }
.title {
font-size: v.$text-size;
margin: 0em;
&:last-child {
font-size: var(--a-player-bar-title-alone-sz);
}
}
.button {
font-size: v.$text-size-medium;
height: 100%;
padding: v.$mp-2 !important;
min-width: calc(var(--a-player-bar-height) + v.$mp-2 * 2);
border-radius: 0px;
&.open {
background-color: var(--button-active-bg);
color: var(--button-active-fg);
}
}
}
.a-player-bar-content {
display: flex;
flex-direction: vertical;
align-items: center;
flex-grow: 1;
padding: 0 v.$mp-3;
border-right: 1px black solid;
.title {
max-height: calc( var(--a-player-bar-height) - v.$mp-3 );
overflow: hidden;
}
}
/// ---- playlist editor
.a-tracklist-editor {
.dropdown {
display: unset !important;
}
}
/// ----------------
.a-select-file {
> *:not(:last-child) {
margin-bottom: v.$mp-3;
}
.upload-preview {
max-width: 100%;
}
.a-select-file-list {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: v.$mp-3;
}
.file-preview {
width: 100%;
overflow: hidden;
&:hover {
box-shadow: 0em 0em 1em rgba(0,0,0,0.2);
}
&.active {
box-shadow: 0em 0em 1em rgba(0,0,0,0.4);
}
img {
width: 100%;
max-height: 10rem;
}
}
}

View File

@ -0,0 +1,162 @@
@use "./vars" as v;
// ---- text
.text-light { font-weight: 400; color: var(--text-color-light); }
.bigger { font-size: v.$text-size-bigger !important; }
.big { font-size: v.$text-size-big !important; }
.smaller { font-size: v.$text-size-smaller !important; }
.small { font-size: v.$text-size-small !important; }
// ---- layout
.align-left {
text-align: left;
justify-content: left;
&.x { padding-left: 0px !important; }
}
.align-right {
text-align: right;
justify-content: right;
&.x { padding-right: 0px !important; }
}
.align-center {
text-align: center !important;
justify-content: center;
}
.clear-left { clear: left !important }
.clear-right { clear: right !important }
.clear-both { clear: both !important }
.clear-unset { clear: unset !important }
.d-inline { display: inline !important; }
.d-block { display: block !important; }
.d-inline-block { display: inline-block !important; }
.p-relative { position: relative !important }
.p-absolute { position: absolute !important }
.p-fixed { position: fixed !important }
.p-sticky { position: sticky !important }
.p-static { position: static !important }
.ws-nowrap { white-space: nowrap; }
.height-1 { height: 1em; }
.height-2 { height: 2em; }
.height-3 { height: 3em; }
.height-4 { height: 4em; }
.height-5 { height: 5em; }
.height-6 { height: 6em; }
.height-7 { height: 7em; }
.height-8 { height: 8em; }
.height-9 { height: 9em; }
.height-10 { height: 10em; }
.height-15 { height: 15em; }
.height-20 { height: 20em; }
.height-25 { height: 25em; }
// ---- grid / flex
.gap-1 { gap: v.$mp-1 !important; }
.gap-2 { gap: v.$mp-2 !important; }
.gap-3 { gap: v.$mp-3 !important; }
.gap-4 { gap: v.$mp-4 !important; }
.gap-5 { gap: v.$mp-5 !important; }
// ---- ---- grid
@mixin grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-flow: dense;
gap: v.$mp-4;
}
@mixin grid-1 { grid-template-columns: 1fr; }
@mixin grid-2 { grid-template-columns: 1fr 1fr; }
@mixin grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.grid { @include grid; }
.grid-1 { @include grid; @include grid-1; }
.grid-2 { @include grid; @include grid-2; }
.grid-3 { @include grid; @include grid-3; }
// ---- ---- flex
.flex-row { display: flex; flex-direction: row }
.flex-column { display: flex; flex-direction: column }
.flex-grow-0 { flex-grow: 0 !important; }
.flex-grow-1 { flex-grow: 1 !important; }
.flex-grow-2 { flex-grow: 2 !important; }
.flex-grow-3 { flex-grow: 3 !important; }
.flex-grow-4 { flex-grow: 4 !important; }
.flex-grow-5 { flex-grow: 5 !important; }
.flex-grow-6 { flex-grow: 6 !important; }
.float-right { float: right }
.float-left { float: left }
// ---- boxing
.is-fullwidth { width: 100%; }
.is-fullheight { height: 100%; }
.is-fixed-bottom {
position: fixed;
bottom: 0;
margin-bottom: 0px;
border-radius: 0;
}
.no-border { border: 0px !important; }
.overflow-hidden { overflow: hidden }
.overflow-hidden.is-fullwidth { max-width: 100%; }
.height-full { height: 100%; }
*[draggable="true"] {
cursor: move;
}
// ---- animations
@keyframes blink {
from { opacity: 1; }
to { opacity: 0.4; }
}
.blink { animation: 1s ease-in-out 3s infinite alternate blink; }
.loading { animation: 1s ease-in-out 1s infinite alternate blink; }
// -- colors
.main-color { color: var(--main-color); }
.secondary-color { color: var(--secondary-color); }
.bg-main { background-color: var(--main-color); }
.bg-main-light { background-color: var(--main-color-light); }
.bg-secondary { background-color: var(--secondary-color); }
.bg-secondary-light { background-color: var(--secondary-color-light); }
.bg-transparent { background-color: transparent; }
.border-bottom-main { border-bottom: 1px solid var(--main-color); }
.border-bottom-secondary { border-bottom: 1px solid var(--secondary-color); }
.is-success {
background-color: v.$green !important;
border-color: v.$green-dark !important;
}
.is-danger {
background-color: v.$red !important;
border-color: v.$red-dark !important;
}
.box-shadow {
&:hover {
box-shadow: 0em 0em 1em rgba(0,0,0,0.2);
}
&.active {
box-shadow: 0em 0em 1em rgba(0,0,0,0.4);
}
}

View File

@ -0,0 +1,477 @@
@use "./vars" as v;
@use "./components";
// ---- main theme & layout
.page {
padding-bottom: 5rem;
a {
color: var(--link-fg);
text-decoration: underline;
&:hover {
color: var(--link-hv-fg);
}
}
section.container {
margin-top: v.$mp-3;
margin-bottom: v.$mp-4;
&:not(:last-child) {
padding-bottom: calc(v.$mp-4 / 2);
border-bottom: 2px var(--break-color) solid;
}
> .title, h3.title {
font-size: var(--title-2-sz);
clear: both;
margin: v.$mp-3 0;
}
}
*[data-oembed-url] {
clear: both;
}
}
// ---- components
.dropdown-item {
font-size: unset !important
}
.vc-weekday-1, .vc-weekday-7 {
color: var(--secondary-color) !important;
}
.schedules {
padding-top: 0;
margin-bottom: calc(0rem - v.$mp-3) !important;
}
.schedule {
display: inline-block;
margin: v.$mp-3;
margin-left: 0rem;
padding: v.$mp-2;
border-bottom: 1px var(--main-color) solid;
.heading {
padding: 0em;
}
.day {
font-weight: v.$weight-bold;
margin-right: v.$mp-3;
}
}
// -- buttons, forms
@include components.button;
.actions {
display: flex;
flex-direction: row;
gap: v.$mp-3;
justify-content: right;
&.no-label label {
display: none;
}
button, .action, a {
justify-content: center;
min-width: 2rem;
padding: v.$mp-2;
.not-selected { opacity: 0.6; }
.icon { margin: 0em !important; }
label { margin-left: v.$mp-2; }
}
}
.label, .textarea, .input, .select {
font-size: v.$text-size;
}
.field.is-horizontal {
display: flex;
flex-direction: horizontal;
.label { min-width: 7rem }
.control {
flex: 1;
> * {
width: 100%;
}
}
}
@media screen and (min-width: v.$screen-small) {
comment.textarea {
height: calc( v.$text-size * 7 ) !important;
}
}
.navbar-item.active, .table tr.is-selected {
color: var(--secondary-color);
background-color: var(--main-color);
}
// -- headings
.title {
text-transform: uppercase;
&.is-3 { margin-top: v.$mp-3; }
}
// ---- main navigation
.navs {
position: relative;
}
.nav {
display: flex;
background-color: var(--nav-bg);
&:empty {
display: none;
}
.burger {
display: none;
background-color: var(--nav-bg);
}
.nav-item {
padding: v.$mp-2;
flex-grow: 1;
flex-shrink: 1;
text-align: center;
font-family: var(--heading-font-family);
text-transform: uppercase;
color: var(--nav-fg) !important;
.icon:first-child, .icon + span {
text-align: center;
vertical-align: top;
display: inline-block;
}
&:hover {
background-color: var(--nav-hv-bg);
color: var(--nav-hv-fg);
}
&.active {
background-color: var(--nav-active-bg);
color: var(--nav-active-fg) !important;
}
}
.nav-menu {
display: flex;
flex-grow: 1;
.dropdown-content {
font-size: v.$text-size;
min-width: 15rem;
}
}
&.primary {
height: var(--nav-primary-height);
.nav-menu {
flex-grow: 1;
}
.nav-brand {
display: inline-block;
padding: v.$mp-3;
flex-grow: 0;
flex-shrink: 1;
img {
height: 100%;
}
}
.nav-item {
font-size: var(--nav-fs);
font-weight: v.$weight-bold;
white-space: nowrap;
}
}
&.secondary {
background-color: var(--nav-secondary-bg);
//position: absolute;
//width: 100%;
//box-shadow: 0em 0.5em 0.5em rgba(0, 0, 0, 0.05);
justify-content: right;
//display: none;
.nav.primary:hover + &,
&:hover {
display: flex;
top: var(--nav-primary-height);
left: 0rem;
}
.nav-item {
font-size: var(--nav-2-fs);
}
}
}
// ---- breadcrumbs
.breadcrumbs {
text-align: right;
padding: v.$mp-3 0rem;
font-size: v.$text-size-smaller;
padding-bottom: 0;
margin-bottom: 0;
&:empty { display: none; }
a + a {
padding-left: 0;
&:before {
content: "/";
margin: 0 v.$mp-2;
}
}
}
@media screen and (max-width: v.$screen-normal) {
.page {
margin-top: var(--nav-primary-height);
}
.navs {
z-index: 100000;
position: fixed;
display: flex;
left: 0;
right: 0;
top: 0;
.nav:first-child {
flex-grow: 1;
}
.nav + .nav {
flex-grow: 0 !important;
}
}
.nav {
justify-content: space-between;
.burger {
display: unset;
margin-left: auto;
}
.nav-menu {
display: block;
position: absolute;
background-color: var(--nav-secondary-bg);
left: 0;
top: 100%;
width: 100%;
box-shadow: 0em 0.5em 0.5em rgba(0,0,0,0.05);
.nav-item {
display: block;
font-weight: v.$weight-normal;
font-size: var(--nav-fs);
}
}
.nav-menu:not(.active) {
display: none !important
}
}
}
nav li {
list-style: none;
a, .button {
font-size: v.$text-size-medium;
}
}
.nav-urls {
display: flex;
flex-direction: row;
margin-top: v.$mp-3;
text-align: right;
> a:only-child {
margin-left: auto;
}
li {
list-style: none;
}
.urls {
flex-grow: 1;
display: flex;
flex-direction: row;
gap: v.$mp-3;
justify-content: center;
a:not(:last-child) {
margin-right: v.$mp-3;
}
}
.left {
flex-grow: 0;
text-align: left;
}
.right {
flex-grow: 0;
text-align: right;
}
}
// ---- page header
.header {
&.preview-header {
//display: flex;
align-items: start;
gap: v.$mp-3;
min-height: unset;
padding-top: v.$mp-3 !important;
}
.headings {
width: unset;
flex-grow: 1;
padding-top: 0 !important;
display: flex;
flex-direction: column;
}
&.has-cover {
min-height: calc( var(--header-height) / 3 );
}
}
.header-cover:not(:only-child) {
float: right;
position: relative;
z-index: 30;
background-color: var(--body-bg);
margin: 0 0 v.$mp-4 v.$mp-4;
.cover {
max-width: calc(var(--header-height) * 2);
height: var(--header-height);
}
}
.header-cover:only-child {
width: 100%;
}
@media screen and (max-width: v.$screen-small) {
.container.header {
width: calc( 100% - v.$mp-2 );
.headings {
width: 100%;
clear: both;
}
.header-cover {
float: none;
margin: 0;
text-align: center;
}
.cover {
margin-left: auto;
margin-right: auto;
max-height: calc(var(--cover-h) * 1);
max-width: calc(var(--cover-w) * 2);
}
}
}
// ---- ---- detail
.page-content {
margin-top: v.$mp-6;
&:not(:last-child) {
margin-bottom: v.$mp-6;
}
}
// ---- ---- list
.list-item {
&.logs {
.track {
margin-right: v.$mp-3;
.icon {
margin-right: v.$mp-2;
color: var(--secondary-color-dark);
}
}
}
&:nth-child(3n):not(.wide) .media,
{
border-color: var(--main-color-dark) !important;
}
&:nth-child(3n+1):not(.wide) .media,
{
border-color: var(--secondary-color-dark) !important;
}
}
// ---- responsive
@media screen and (max-width: v.$screen-normal) {
.page .container {
margin-left: v.$mp-4;
margin-right: v.$mp-4;
}
}
@media screen and (max-width: v.$screen-small) {
.page .container {
margin-left: v.$mp-2;
margin-right: v.$mp-2;
}
}

View File

@ -0,0 +1,52 @@
@charset "utf-8";
$black: #000;
$white: #fff;
$red: #e00;
$red-dark: #b00;
$green: #0e0;
$green-dark: #0b0;
$grey-light: #ddd;
$mp-1: 0.2rem;
$mp-1e: 0.2em;
$mp-2: 0.4rem;
$mp-2e: 0.4em;
$mp-3: 0.6rem;
$mp-3e: 0.6em;
$mp-4: 1.2rem;
$mp-4e: 1.2em;
$mp-5: 1.6rem;
$mp-5e: 1.6em;
$mp-6: 2rem;
$mp-6e: 2em;
$mp-7: 4rem;
$mp-7e: 4em;
$text-size-small: 0.6rem;
$text-size-smaller: 0.8rem;
$text-size: 1rem;
$text-size-2: 1.2rem;
$text-size-medium: 1.4rem;
$text-size-bigger: 1.6rem;
$text-size-big: 2rem;
$h1-size: 40px;
$h2-size: 32px;
$h3-size: 28px;
$h4-size: 24px;
$h5-size: 20px;
$h6-size: 14px;
$weight-light: 100;
$weight-lighter: 300;
$weight-normal: 400;
$weight-bolder: 500;
$weight-bold: 700;
$screen-very-small: 400px;
$screen-small: 600px;
$screen-smaller: 900px;
$screen-normal: 1024px;
$screen-wider: 1280px;
$screen-wide: 1380px;

View File

@ -0,0 +1,35 @@
@import 'v-calendar/style.css';
// @import '@fortawesome/fontawesome-free/css/all.min.css';
// ---- bulma
$body-color: #000;
$title-color: #000;
$modal-content-width: 80%;
@import "bulma/sass/utilities/_all.sass";
@import "bulma/sass/base/_all";
@import "bulma/sass/components/dropdown";
// @import "bulma/sass/components/card";
@import "bulma/sass/components/media";
@import "bulma/sass/components/message";
@import "bulma/sass/components/modal";
//@import "bulma/sass/components/pagination";
@import "bulma/sass/form/_all";
@import "bulma/sass/grid/_all";
@import "bulma/sass/helpers/_all";
@import "bulma/sass/layout/_all";
@import "bulma/sass/elements/box";
// @import "bulma/sass/elements/button";
@import "bulma/sass/elements/container";
// @import "bulma/sass/elements/content";
@import "bulma/sass/elements/icon";
// @import "bulma/sass/elements/image";
// @import "bulma/sass/elements/notification";
// @import "bulma/sass/elements/progress";
@import "bulma/sass/elements/table";
@import "bulma/sass/elements/tag";
//@import "bulma/sass/elements/title";

View File

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

47
assets/src/vueLoader.js Normal file
View File

@ -0,0 +1,47 @@
import {createApp} from 'vue'
import PageLoad from './pageLoad'
/**
* Handles loading Vue js app on page load.
*/
export default class VueLoader {
constructor({el=null, props={}, ...appConfig}={}, loaderOptions={}) {
this.appConfig = appConfig
this.appConfig.el = el
this.props = props
this.pageLoad = new PageLoad(el, loaderOptions)
this.pageLoad.onPreMount = event => this.onPreMount(event)
this.pageLoad.onMount = event => this.onMount(event)
}
enable(hotReload=true) {
hotReload && this.pageLoad.enable(document.body)
this.mount()
}
mount() {
if(this.app)
this.unmount()
const app = createApp(this.appConfig, this.props)
app.config.globalProperties.window = window
this.vm = app.mount(this.pageLoad.el)
this.app = app
}
unmount() {
if(!this.app)
return
try { this.app.unmount() }
catch(_) { null }
this.app = null
this.vm = null
this.pageLoad.reset()
}
onPreMount() { this.unmount() }
onMount() { this.mount() }
}

44
assets/vite.config.js Normal file
View File

@ -0,0 +1,44 @@
import { resolve } from 'path'
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import commonjs from '@rollup/plugin-commonjs';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
build: {
outDir: "../aircox/static/aircox/",
sourcemap: true,
rollupOptions: {
external: ['vue',],
input: {
public: "src/public.js",
admin: "src/admin.js",
},
output: {
globals: {
vue: 'Vue',
},
assetFileNames: "[name].[ext]",
chunkFileNames: "[name].js",
entryFileNames: "[name].js",
},
plugins: [commonjs()],
},
},
css: {
devSourcemap: true,
},
resolve: {
extensions: ['.js', '.ts', '.json', '.vue'],
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

View File

@ -1,22 +0,0 @@
const path = require('path');
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
outputDir: path.resolve('../aircox/static/aircox'),
publicPath: './',
runtimeCompiler: true,
filenameHashing: false,
css: {
extract: true,
loaderOptions: {
sass: { sourceMap: true },
}
},
pages: {
core: { entry: 'src/core.js', },
admin: { entry: 'src/admin.js' },
}
})