#26: mise à jour #40

Merged
thomas merged 12 commits from fix-1.0-26 into develop-1.0 2022-03-20 11:31:33 +00:00
24 changed files with 6863 additions and 4776 deletions
Showing only changes of commit ab8858154b - Show all commits

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -9,6 +9,7 @@
<script src="{% static "aircox/vendor.js" %}"></script> <script src="{% static "aircox/vendor.js" %}"></script>
<script src="{% static "aircox/admin.js" %}"></script> <script src="{% static "aircox/admin.js" %}"></script>
<script src="{% static "aircox/public.js" %}"></script>
{% block extrastyle %}{% endblock %} {% block extrastyle %}{% endblock %}
@ -172,5 +173,7 @@
</div> </div>
<!-- END Container --> <!-- END Container -->
{% block outside_bottom %}{% endblock %}
</body> </body>
</html> </html>

View File

@ -6,8 +6,9 @@
<script src="{% static "aircox/streamer.js" %}"></script> <script src="{% static "aircox/streamer.js" %}"></script>
{% endblock %} {% endblock %}
{% block content %}{{ block.super }} {% block content %}
<div id="app" data-api-url="{% url "admin:api:streamer-list" %}"> {{ block.super }}
<div id="app" v-if="streamers" data-api-url="{% url "admin:api:streamer-list" %}">
<div class="navbar toolbar"> <div class="navbar toolbar">
<div class="navbar-start"> <div class="navbar-start">
<span class="navbar-item control"> <span class="navbar-item control">
@ -38,3 +39,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% block outside_bottom %}
<div id="player">{% include "aircox/widgets/player.html" %}</div>
{% endblock %}

View File

@ -1,12 +1,8 @@
import Vue from 'vue'; import App from 'public/app';
import 'public';
import './admin.scss'; import './admin.scss';
import AStatistics from './statistics.vue';
import Statistics from './statistics.vue';
Vue.component('a-statistics', Statistics)
import 'public';
window.aircox_admin = { window.aircox_admin = {
/** /**
@ -23,7 +19,11 @@ window.aircox_admin = {
for(var item of container.querySelectorAll('a.navbar-item')) for(var item of container.querySelectorAll('a.navbar-item'))
item.style.display = null; item.style.display = null;
}, },
}
window.aircox.builder.config = {
...App,
components: {...App.components, AStatistics},
} }

View File

@ -1,95 +1,25 @@
import Vue from 'vue'; import AAutocomplete from './autocomplete'
import AEpisode from './episode'
import APlayer from './player'
import APlaylist from './playlist'
import ASoundItem from './soundItem'
export const defaultConfig = { const App = {
el: '#app', el: '#app',
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
computed: { computed: {
player() { return window.aircox.player; }, player() { return window.aircox.player; },
}, },
components: {AAutocomplete, AEpisode, APlayer, APlaylist, ASoundItem},
} }
export const PlayerApp = {
export default class AppBuilder { el: '#player',
constructor(config={}) { components: {APlayer},
this._config = config;
this.title = null;
this.app = null;
} }
get config() { export default App
let config = this._config instanceof Function ? this._config() : this._config;
for(var k of new Set([...Object.keys(config || {}), ...Object.keys(defaultConfig)])) {
if(!config[k] && defaultConfig[k])
config[k] = defaultConfig[k]
else if(config[k] instanceof Object)
config[k] = {...defaultConfig[k], ...config[k]}
}
return config;
}
set config(value) {
this._config = value;
}
destroy() {
self.app && self.app.$destroy();
self.app = null;
}
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.getElementById('app');
content = app ? app.innerHTML : content;
return this.load({sync: true, content, title: doc.title, url })
})
}
load({async=false,content=null,title=null,el='app'}={}) {
var self = this;
return new Promise((resolve, reject) => {
let func = () => {
try {
let config = self.config;
const el = document.querySelector(config.el);
if(!el)
return reject(`Error: can't get element ${config.el}`)
if(content)
el.innerHTML = content
if(title)
document.title = title;
this.app = new Vue(config);
window.scroll(0, 0);
resolve(self.app)
} catch(error) {
self.destroy();
reject(error)
}};
async ? window.addEventListener('load', func) : func();
});
}
/// 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.load({ content: state.content, title: state.title });
}
}

137
assets/public/appBuilder.js Normal file
View File

@ -0,0 +1,137 @@
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.getElementById('app')
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}={}) {
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})
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}) {
const container = document.querySelector(el)
if(!container)
throw `Error: can't get element ${el}`
if(content)
container.innerHTML = content
if(title)
document.title = title
return createApp(config)
}
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._onPageChange(event), true)
node.addEventListener('submit', event => this._onPageChange(event), true)
node.addEventListener('popstate', event => this._onPopState(event), true)
}
_onPageChange(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');
if(url===null || !(url === '' || url.startsWith('/') || url.startsWith('?')))
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();
}
_onPopState(event) {
if(event.state && event.state.content)
// document.title = this.title;
this.historyLoad(event.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

@ -12,8 +12,7 @@
<script> <script>
import debounce from 'lodash/debounce' import debounce from 'lodash/debounce'
import {Autocomplete} from 'buefy/dist/components/autocomplete'; import {Autocomplete} from 'buefy/dist/components/autocomplete'
import Vue from 'vue';
export default { export default {
props: { props: {
@ -31,25 +30,25 @@ export default {
data: [], data: [],
selected: null, selected: null,
isFetching: false, isFetching: false,
}; }
}, },
methods: { methods: {
onSelect(option) { onSelect(option) {
console.log('selected', option) console.log('selected', option)
Vue.set(this, 'selected', option); this.selected = option
this.$emit('select', option); this.$emit('select', option)
}, },
fetch: debounce(function(query) { fetch: debounce(function(query) {
if(!query) if(!query)
return; return
this.isFetching = true; this.isFetching = true
this.model.fetchAll(this.url.replace('${query}', query)) this.model.fetchAll(this.url.replace('${query}', query))
.then(data => { .then(data => {
this.data = data; this.data = data
this.isFetching = false; this.isFetching = false
}, data => { this.isFetching = false; Promise.reject(data) }) }, data => { this.isFetching = false; Promise.reject(data) })
}), }),
}, },

View File

@ -3,93 +3,37 @@
* administration interface) * administration interface)
*/ */
//-- vendor //-- vendor
import Vue from 'vue'; import '@fortawesome/fontawesome-free/css/all.min.css'
import '@fortawesome/fontawesome-free/css/fontawesome.min.css'
import '@fortawesome/fontawesome-free/css/all.min.css';
import '@fortawesome/fontawesome-free/css/fontawesome.min.css';
//-- aircox //-- aircox
import AppBuilder from './app'; import App, {PlayerApp} from './app'
import Sound from './sound'; import Builder from './appBuilder'
import {Set} from './model'; import Sound from './sound'
import {Set} from './model'
import './styles.scss';
import Autocomplete from './autocomplete';
import Episode from './episode';
import Player from './player';
import Playlist from './playlist';
import SoundItem from './soundItem';
Vue.component('a-autocomplete', Autocomplete)
Vue.component('a-episode', Episode)
Vue.component('a-player', Player)
Vue.component('a-playlist', Playlist)
Vue.component('a-sound-item', SoundItem)
import './styles.scss'
window.aircox = { window.aircox = {
// main application // main application
appBuilder: null, builder: new Builder(App),
appConfig: {}, get app() { return this.builder.app },
get app() { return this.appBuilder.app },
// player application // player application
playerBuilder: null, playerBuilder: new Builder(PlayerApp),
get playerApp() { return this.playerBuilder && this.playerBuilder.app }, get playerApp() { return this.playerBuilder && this.playerBuilder.app },
get player() { return this.playerApp && this.playerApp.$refs.player }, get player() { return this.playerBuilder.vm && this.playerBuilder.vm.$refs.player },
// Handle hot-reload (link click and form submits).
onPageFetch(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');
if(url===null || !(url === '' || url.startsWith('/') || url.startsWith('?')))
return;
let options = {};
if(submit) {
let formData = new FormData(event.target);
if(target.method == 'get')
url += '?' + (new URLSearchParams(formData)).toString();
else {
options['method'] = target.method;
options['body'] = formData;
}
}
this.appBuilder.fetch(url, options).then(app => {
this.appBuilder.historySave(url);
});
event.preventDefault();
event.stopPropagation();
},
Set: Set, Sound: Sound, Set: Set, Sound: Sound,
};
window.Vue = Vue;
aircox.playerBuilder = new AppBuilder({el: '#player'});
aircox.playerBuilder.load({async:true});
aircox.appBuilder = new AppBuilder(x => window.aircox.appConfig);
aircox.appBuilder.load({async:true}).then(app => {
aircox.appBuilder.historySave(document.location, true);
//-- load page hooks
window.addEventListener('click', event => aircox.onPageFetch(event), true);
window.addEventListener('submit', event => aircox.onPageFetch(event), true);
window.addEventListener('popstate', event => {
if(event.state && event.state.content) {
document.title = aircox.appBuilder.title;
aircox.appBuilder.historyLoad(event.state);
} }
});
window.addEventListener('load', e => {
const [app, player] = [aircox.builder, aircox.playerBuilder]
app.title = document.title
app.mount()
app.enableHotReload(window)
player.mount()
}) })

View File

@ -1,4 +1,3 @@
import Vue from 'vue';
function getCookie(name) { function getCookie(name) {
if(document.cookie && document.cookie !== '') { if(document.cookie && document.cookie !== '') {
@ -82,7 +81,7 @@ export default class Model {
*/ */
commit(data) { commit(data) {
this.id = this.constructor.getId(data); this.id = this.constructor.getId(data);
Vue.set(this, 'data', data); this.data = data;
} }
/** /**
@ -123,12 +122,12 @@ export class Set {
/** /**
* Fetch multiple items from server * Fetch multiple items from server
*/ */
static fetch(url, options=null, args=null) { static fetch(model, url, options=null, args=null) {
options = this.getOptions(options) options = this.getOptions(options)
return fetch(url, options) return fetch(url, options)
.then(response => response.json()) .then(response => response.json())
.then(data => (data instanceof Array ? data : data.results) .then(data => (data instanceof Array ? data : data.results)
.map(d => new this.model(d, {url: url, ...args}))) .map(d => new model(d, {url: url, ...args})))
} }
/** /**

View File

@ -1,5 +1,3 @@
import Vue from 'vue';
import Model, {Set} from 'public/model'; import Model, {Set} from 'public/model';
import {setEcoInterval} from 'public/utils'; import {setEcoInterval} from 'public/utils';
@ -61,7 +59,7 @@ export class Source extends Model {
if(!this.data.remaining || !this.isPlaying) if(!this.data.remaining || !this.isPlaying)
return; return;
const delta = (Date.now() - this.commitDate) / 1000; const delta = (Date.now() - this.commitDate) / 1000;
Vue.set(this, 'remaining', this.data.remaining - delta) this.remaining = this.data.remaining - delta
} }
commit(data) { commit(data) {
@ -70,7 +68,7 @@ export class Source extends Model {
this.commitDate = Date.now() this.commitDate = Date.now()
super.commit(data) super.commit(data)
Vue.set(this, 'remaining', data.remaining) this.remaining = data.remaining
} }
} }

View File

@ -1,13 +1,13 @@
import Vue from 'vue'; import App from 'public/app';
import Model, {Set} from 'public/model';
import {setAppConfig} from 'public/app';
import Model from 'public/model';
import Sound from 'public/sound'; import Sound from 'public/sound';
import {setEcoInterval} from 'public/utils'; import {setEcoInterval} from 'public/utils';
import {Streamer, Queue} from './controllers'; import {Streamer, Queue} from './controllers';
window.aircox.appConfig = { window.aircox.builder.config = {
...App,
data() { data() {
return { return {
// current streamer // current streamer
@ -16,12 +16,13 @@ window.aircox.appConfig = {
streamers: [], streamers: [],
// fetch interval id // fetch interval id
fetchInterval: null, fetchInterval: null,
Sound: Sound, Sound: Sound,
} }
}, },
computed: { computed: {
...(App.computed || {}),
apiUrl() { apiUrl() {
return this.$el && this.$el.dataset.apiUrl; return this.$el && this.$el.dataset.apiUrl;
}, },
@ -33,11 +34,12 @@ window.aircox.appConfig = {
}, },
methods: { methods: {
...(App.methods || {}),
fetchStreamers() { fetchStreamers() {
Streamer.Set.fetch(this.apiUrl) Set.fetch(Streamer, this.apiUrl).then(streamers => {
.then(streamers => { this.streamers = streamers
Vue.set(this, 'streamers', streamers); this.streamer = streamers ? streamers[0] : null
Vue.set(this, 'streamer', streamers ? streamers[0] : null);
}) })
}, },
}, },

View File

@ -6,26 +6,24 @@
"author": "bkfox", "author": "bkfox",
"license": "AGPL", "license": "AGPL",
"devDependencies": { "devDependencies": {
"@fortawesome/fontawesome-free": "^5.13.0", "@fortawesome/fontawesome-free": "^6.0.0",
"buefy": "^0.9.3", "buefy": "^0.9.19",
"bulma": "^0.7.5", "bulma": "^0.9.3",
"css-loader": "^2.1.1", "css-loader": "^6.7.1",
"extract-text-webpack-plugin": "^4.0.0-beta.0", "file-loader": "^6.2.0",
"file-loader": "^3.0.1", "lodash": "^4.17.21",
"lodash": "^4.17.15", "mini-css-extract-plugin": "^2.6.0",
"mini-css-extract-plugin": "^0.5.0", "node-sass": "^7.0.1",
"node-sass": "^4.14.1", "sass-loader": "^12.6.0",
"sass-loader": "^7.3.1", "style-loader": "^3.3.1",
"style-loader": "^0.23.1", "vue-loader": "^17.0.0",
"ttf-loader": "^1.0.2", "vue-style-loader": "^4.1.3",
"vue-loader": "^15.9.3", "vue-template-compiler": "^2.6.14",
"vue-style-loader": "^4.1.2", "webpack": "^5.70.0",
"vue-template-compiler": "^2.6.12", "webpack-cli": "^4.9.2"
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
}, },
"dependencies": { "dependencies": {
"vue": "^2.6.12" "vue": "^3.2.31"
}, },
"scripts": { "scripts": {
"build": "webpack --config webpack.config.js" "build": "webpack --config webpack.config.js"

View File

@ -86,7 +86,7 @@ module.exports = (env, argv) => Object({
resolve: { resolve: {
alias: { alias: {
vue: 'vue/dist/vue.esm.browser.js', vue: 'vue/dist/vue.esm-browser.js',
}, },
modules: [ modules: [
'./assets', './assets',