- Refactored webpanel style to use flex and dynamic element heights

instead of ugly fixed/absolute positioning.

- New media webpanel plugin WIP
This commit is contained in:
Fabio Manganiello 2019-06-14 00:54:20 +02:00
parent 076d766745
commit 9d4511577f
48 changed files with 833 additions and 178 deletions

View file

@ -58,6 +58,7 @@ def index():
scripts=enabled_scripts, styles=enabled_styles,
utils=HttpUtils, token=Config.get('token'),
websocket_port=get_websocket_port(),
plugins=Config.get_plugins(), backends=Config.get_backends(),
has_ssl=http_conf.get('ssl_cert') is not None)

View file

@ -1,13 +1,12 @@
.fade-in {
--duration: $fade-in-transition-duration;
opacity: 1;
animation-name: fadeInOpacity;
animation-iteration-count: 1;
animation-name: fadeIn;
animation-timing-function: ease-in;
animation-duration: var(--duration);
animation-fill-mode:both;
}
@keyframes fadeInOpacity {
@keyframes fadeIn {
0% {
opacity: 0;
}
@ -16,22 +15,22 @@
}
}
.fade-out {
--duration: $fade-out-transition-duration;
opacity: 1;
animation-name: fadeOutOpacity;
animation-iteration-count: 1;
animation-timing-function: ease-out;
.roll-in {
--duration: $roll-in-transition-duration;
animation-name: rollIn;
animation-timing-function: ease-in;
animation-duration: var(--duration);
animation-fill-mode:both;
}
@keyframes fadeOutOpacity {
@keyframes rollIn {
0% {
opacity: 1;
opacity: 0;
transform:translateX(-100%);
}
100% {
opacity: 0;
display: none;
opacity: 1;
transform:translateX(0);
}
}

View file

@ -23,15 +23,20 @@ $selected-bg: #c8ffd0 !default;
$hover-bg: #def6ea !default;
$header-bg: $default_bg !default;
$nav-height: 4.5rem !default;
$nav-bg: #e8e8e8 !default;
$nav-fg: $default-link-fg;
$nav-date-time-shadow: 2px 2px 2px #ccc !default;
$nav-margin: .2rem;
$nav-date-time-shadow: .2rem .2rem .2rem #ccc !default;
//// Animations defaults
$transition-duration: .5s !default;
$fade-transition-duration: .5s !default;
$fade-transition-duration: $transition-duration !default;
$roll-transition-duration: $transition-duration !default;
$fade-in-transition-duration: $fade-transition-duration !default;
$fade-out-transition-duration: $fade-transition-duration !default;
$roll-in-transition-duration: $roll-transition-duration !default;
$roll-out-transition-duration: $roll-transition-duration !default;
//// Notifications
$notification-bg: rgba(185, 255, 193, 0.9) !default;

View file

@ -11,17 +11,42 @@
body {
width: 100%;
height: 100%;
margin: 0;
overflow-x: hidden;
font-family: $default-font-family;
font-size: $default-font-size;
}
main {
padding: 4.9rem 0;
#app {
display: flex;
flex-flow: column;
height: 100%;
main {
margin: 0;
}
flex: 1 1 auto;
overflow: hidden;
a {
// nav height hardcoded, calc won't support either CSS4 nor SASS vars
height: calc(100vh - 4.8rem);
.plugins-container {
height: inherit;
.plugin-container {
overflow: auto;
height: inherit;
.plugin {
overflow: auto;
}
}
}
}
a {
color: $default-link-fg;
}
}

View file

@ -1,10 +1,13 @@
:root {
--nav-height: $nav-height;
}
nav {
margin-bottom: 1.2rem;
position: fixed;
top: 0;
width: 100%;
z-index: 10;
opacity: 0.95;
height: var(--nav-height);
margin-bottom: $nav-margin;
flex: 0 1 auto;
z-index: 2;
ul {
position: relative;

View file

@ -0,0 +1 @@
media

View file

@ -0,0 +1 @@
media

View file

@ -0,0 +1 @@
media

View file

@ -0,0 +1,87 @@
.media-plugin {
.controls {
@extend .vertical-center;
width: 100%;
border-top: $default-border-2;
box-shadow: $control-panel-shadow;
flex: 0 0 $control-panel-height;
.item-container {
@extend .vertical-center;
padding-left: 1rem;
line-height: 2.6rem;
}
button {
&:hover {
.fa {
color: $button-hover-color;
}
}
}
.playback-controls {
.row {
@extend .vertical-center;
justify-content: center;
}
button {
padding: 0 1.5rem;
.fa-play, .fa-pause {
color: $button-hover-color;
font-size: $font-size * 2;
margin-top: .3rem;
&:hover {
color: $play-button-hover-color;
}
}
}
}
.pull-right {
button {
&:not(last-child) {
padding: 0 .7rem;
}
&:last-child {
padding: 0;
}
}
.volume-container {
button {
padding: 0 .3rem 0 0;
background: none;
}
}
}
.seek-slider {
width: 75%;
}
.volume-slider {
width: 75%;
margin-right: 1rem;
}
.elapsed-time,
.total-time {
font-size: .7em;
color: .7em;
}
.elapsed-time {
margin-right: 1.5rem;
}
.total-time {
margin-left: 1.5rem;
}
}
}

View file

@ -0,0 +1,22 @@
.media-plugin {
.devices {
display: inline-block;
button {
padding: .5rem;
}
.dropdown {
.item {
display: flex;
align-items: center;
cursor: pointer;
&:hover {
background: $hover-bg
}
}
}
}
}

View file

@ -0,0 +1,30 @@
@import 'common/vars';
@import 'common/mixins';
@import 'common/layout';
@import 'common/animations';
@import 'webpanel/plugins/media/vars';
@import 'webpanel/plugins/media/search';
@import 'webpanel/plugins/media/devices';
@import 'webpanel/plugins/media/results';
@import 'webpanel/plugins/media/controls';
.media-plugin {
display: flex;
flex-direction: column;
height: inherit;
letter-spacing: .03rem;
input[type=text] {
width: 100%;
&:hover {
border-color: $default-hover-fg;
}
}
button {
border: 0;
}
}

View file

@ -0,0 +1,30 @@
.media-plugin {
.results {
height: calc(100% - 16rem);
overflow: auto;
.empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5em;
letter-spacing: .1rem;
color: $empty-results-color;
}
.media-item {
display: flex;
align-items: center;
cursor: pointer;
border-radius: 1rem;
padding: .5rem;
&:nth-child(odd) { background: rgba(255, 255, 255, 0.0); }
&:nth-child(even) { background: $default-bg-3; }
&:hover { background: $hover-bg !important; }
&.selected { background: $selected-bg !important; }
}
}
}

View file

@ -0,0 +1,53 @@
.media-plugin {
.search {
display: flex;
align-items: center;
width: 100%;
background: $default-bg-3;
border-bottom: $default-border-3;
input[type=text] {
width: 80%;
max-width: 600px;
}
[type=submit] {
color: $default-hover-fg;
font-size: 1.2em;
&:hover {
border: $default-border-2;
border-radius: 5rem;
}
}
.types {
.type {
display: inline-block;
padding: 1rem 1rem 0 1rem;
}
label {
display: inline-block;
font-weight: normal;
margin: 0;
}
}
button {
padding: 0 2rem;
border: 0;
&:hover {
color: $default-hover-fg;
}
}
form {
width: 100%;
margin: 0;
padding: 1rem 0;
}
}
}

View file

@ -0,0 +1,11 @@
$button-enabled-color: #59df3e;
$button-hover-color: $button-enabled-color;
$play-button-hover-color: #64ef4a;
$control-panel-bg: rgba(245,245,245,0.95);
$control-panel-height: 10rem !default;
$control-panel-shadow: 0 -2.5px 4px 0 #c0c0c0;
$control-time-color: #666;
$empty-results-color: #506050;

View file

@ -4,11 +4,14 @@
@import 'webpanel/plugins/music.mpd/vars';
.music-mpd-container {
display: flex;
flex-direction: column;
line-height: 3rem;
letter-spacing: .03rem;
height: inherit;
overflow: hidden;
* > .item {
.item {
display: flex;
align-items: center;
cursor: pointer;
@ -25,12 +28,12 @@
}
}
* > .duration {
.duration {
color: $duration-color;
font-size: .7em;
}
* > button {
button {
border: 0;
&:disabled {
@ -55,16 +58,17 @@
.panels {
display: flex;
}
.browser, .playlist {
overflow: auto;
flex-direction: row;
flex: 0 1 auto;
order: 0;
height: calc(100% - 10.1rem);
}
.browser {
width: 40%;
min-width: 20rem;
max-width: 35rem;
background: $browser-panel-bg;
border-right: $default-border-2;
padding: .3rem 1rem 6rem 1rem;
font-size: .9em;
.item {
@ -103,28 +107,27 @@
.browser,
.search,
.playlist {
.results {
position: relative; // For the dropdown menu
padding: .5rem 1rem 6rem 1rem;
height: calc(100% - 5.1rem);
overflow: auto;
}
.browser-controls,
.results-controls,
.playlist-controls {
position: fixed;
height: 5rem;
width: 100%;
height: 4rem;
background: $playlist-controls-bg;
border-bottom: $playlist-controls-border;
margin: -.5rem 0 0 -1rem;
padding: .5rem;
padding: .5rem 0;
input[type=text] {
width: 100%;
}
* > button {
@extend %ctrl-button;
}
button {
@extend %ctrl-button;
padding: 0 .75rem;
}
}
@ -157,6 +160,9 @@
}
}
.playlist {
width: 100%;
}
.playlist-add,
.editor {
.editor-controls,
@ -202,24 +208,19 @@
.controls {
@extend .vertical-center;
position: fixed;
width: 100%;
bottom: 0;
border-top: $default-border-2;
padding: 1rem;
background: $control-panel-bg;
box-shadow: $control-panel-shadow;
z-index: 2;
order: 1;
flex: 0 0 $control-panel-height;
.track-container {
@extend .vertical-center;
padding-left: 1rem;
line-height: 2.6rem;
.track-info {
.artist {
font-weight: bold;
}
a {
color: initial;
text-decoration: none;
@ -228,6 +229,11 @@
color: $track-info-hover-color;
}
}
.track-info {
.artist {
font-weight: bold;
}
}
}
@ -280,37 +286,37 @@
}
}
* > .seek-slider {
.seek-slider {
width: 75%;
}
* > .volume-slider {
.volume-slider {
width: 75%;
margin-right: 1rem;
}
* > .elapsed-time,
* > .total-time {
.elapsed-time,
.total-time {
font-size: .7em;
color: .7em;
}
* > .elapsed-time {
.elapsed-time {
margin-right: 1.5rem;
}
* > .total-time {
.total-time {
margin-left: 1.5rem;
}
}
.search {
--width: 90vw;
position: relative;
padding: 0;
form {
margin-bottom: 0;
padding: 2.7rem;
.row {
padding: .5rem;
@ -318,7 +324,7 @@
.footer {
padding-top: 1.5rem;
margin-top: 1.5rem;
margin: 2.5rem 0;
border-top: $search-modal-footer-border;
.left {
@ -333,24 +339,22 @@
}
.results-controls {
position: fixed;
padding: 0;
margin: -2.45rem auto 0 -2rem;
border-bottom: $default-border-2;
width: var(--width);
height: 3em;
height: 4.5rem;
display: flex;
align-items: center;
z-index: 502;
}
.results {
padding-top: 2.7rem;
}
form, .results {
position: relative;
}
.results {
height: calc(100% - 4.7rem);
}
}
.dropdown {
@ -394,6 +398,17 @@
}
}
#music-mpd-search-modal {
.header {
height: 3.8rem;
}
.body {
display: flex;
padding: 0;
}
}
#music-mpd-playlist-add {
.modal {
min-width: 50rem;

View file

@ -4,6 +4,7 @@ $play-button-hover-color: #64ef4a;
$duration-color: #666;
$control-panel-height: 10rem !default;
$control-panel-bg: rgba(245,245,245,0.95);
$control-panel-shadow: 0 -2.5px 4px 0 #c0c0c0;
$control-time-color: #666;

View file

@ -39,7 +39,6 @@ window.vm = new Vue({
};
},
mounted: function() {},
created: function() {
const self = this;
setInterval(() => {
@ -48,8 +47,5 @@ window.vm = new Vue({
initEvents();
},
updated: function() {},
destroyed: function() {},
});

View file

@ -100,5 +100,13 @@ function openDropdown(element) {
element.style.top = (parseFloat(element.style.top) - parseFloat(getComputedStyle(element).height)) + 'px';
}
}
if (parseFloat(element.style.left) < 0) {
element.style.left = 0;
}
if (parseFloat(element.style.top) < 0) {
element.style.top = 0;
}
}

View file

@ -0,0 +1,5 @@
Vue.component('media-mplayer', {
template: '#tmpl-media-mplayer',
props: ['config'],
});

View file

@ -0,0 +1,5 @@
Vue.component('media-mpv', {
template: '#tmpl-media-mpv',
props: ['config'],
});

View file

@ -0,0 +1,5 @@
Vue.component('media-omxplayer', {
template: '#tmpl-media-omxplayer',
props: ['config'],
});

View file

@ -0,0 +1,5 @@
Vue.component('media-vlc', {
template: '#tmpl-media-vlc',
props: ['config'],
});

View file

@ -0,0 +1,14 @@
Vue.component('media-controls', {
template: '#tmpl-media-controls',
props: {
bus: { type: Object },
item: {
type: Object,
default: () => {},
}
},
methods: {
},
});

View file

@ -0,0 +1,39 @@
Vue.component('media-devices', {
template: '#tmpl-media-devices',
props: {
bus: { type: Object },
},
data: function() {
return {
showDevicesMenu: false,
};
},
computed: {
dropdownItems: function() {
var items = [
{
text: 'Local player',
icon: 'desktop',
},
{
text: 'Browser',
icon: 'laptop',
},
];
return items;
},
},
methods: {
openDevicesMenu: function() {
openDropdown(this.$refs.menu);
},
},
created: function() {
},
});

View file

@ -0,0 +1,46 @@
Vue.component('media', {
template: '#tmpl-media',
props: ['config','player'],
data: function() {
return {
bus: new Vue({}),
results: [],
currentItem: {},
loading: {
results: false,
},
};
},
computed: {
types: function() {
return {
file: {},
torrent: {},
youtube: {},
};
},
},
methods: {
refresh: async function() {
},
onResultsLoading: function() {
this.loading.results = true;
},
onResultsReady: function(results) {
this.loading.results = false;
this.results = results;
},
},
created: function() {
this.refresh();
this.bus.$on('results-loading', this.onResultsLoading);
this.bus.$on('results-ready', this.onResultsReady);
},
});

View file

@ -0,0 +1,11 @@
Vue.component('media-item', {
template: '#tmpl-media-item',
props: {
bus: { type: Object },
item: {
type: Object,
default: () => {},
}
},
});

View file

@ -0,0 +1,18 @@
Vue.component('media-results', {
template: '#tmpl-media-results',
props: {
bus: { type: Object },
loading: {
type: Boolean,
default: false,
},
results: {
type: Array,
default: () => [],
},
},
methods: {
},
});

View file

@ -0,0 +1,44 @@
Vue.component('media-search', {
template: '#tmpl-media-search',
props: {
bus: { type: Object },
supportedTypes: { type: Object },
},
data: function() {
return {
searching: false,
showFilter: false,
query: '',
types: Object.keys(this.supportedTypes).reduce((obj, type) => {
obj[type] = true;
return obj;
}, {}),
};
},
methods: {
search: async function(event) {
const types = Object.entries(this.types).filter(t => t[1]).map(t => t[0]);
var results = [];
this.searching = true;
this.bus.$emit('results-loading');
try {
results = await request('media.search', {
query: this.query,
types: types,
});
} finally {
this.searching = false;
this.bus.$emit('results-ready', results);
}
},
},
created: function() {
},
});

View file

@ -468,8 +468,8 @@ Vue.component('music-mpd', {
}
};
adjust(this)();
setInterval(adjust(this), 2000);
// adjust(this)();
// setInterval(adjust(this), 2000);
},
_parseStatus: async function(status) {
@ -1200,18 +1200,6 @@ Vue.component('music-mpd', {
} else {
return;
}
const self = this;
setTimeout(() => {
var parent = self.$refs.activePlaylistTrack[0].$el.parentElement;
if (parent.clientHeight + parent.scrollTop < parent.scrollHeight) {
if (parent.scrollTop-50 > 0) {
parent.scrollTop -= 50;
} else {
parent.scrollTop = 0;
}
}
}, 750);
},
addToPlaylistPrompt: async function() {

View file

@ -27,11 +27,15 @@
window.config = { ...window.config,
websocket_port: {{ websocket_port }},
has_ssl: {% print('true' if has_ssl else 'false') %},
templates: JSON.parse('{% print(utils.to_json(templates))|safe %}'),
scripts: JSON.parse('{% print(utils.to_json(scripts))|safe %}'),
has_ssl: {{ 'true' if has_ssl else 'false' }},
templates: JSON.parse('{{ utils.to_json(templates)|safe }}'),
scripts: JSON.parse('{{ utils.to_json(scripts)|safe }}'),
};
var __plugins__ = JSON.parse('{{ utils.to_json(plugins)|safe }}');
var __backends__ = JSON.parse('{{ utils.to_json(backends)|safe }}');
{% if token %}
window.config.token = '{{ token }}';
{% else %}

View file

@ -7,7 +7,7 @@
{% include 'plugins/light.hue/animations.html' %}
<script type="text/x-template" id="tmpl-light-hue">
<div class="row light-hue-container">
<div class="row plugin light-hue-container">
<div class="groups col-no-margin-3 col-s-12">
<div class="title">Rooms</div>
<light-hue-group

View file

@ -0,0 +1,7 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/index.js') }}"></script>
{% include 'plugins/media/index.html' %}
<script type="text/x-template" id="tmpl-media-mplayer">
<media :config="config" player="mplayer"></media>
</script>

View file

@ -0,0 +1,7 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/index.js') }}"></script>
{% include 'plugins/media/index.html' %}
<script type="text/x-template" id="tmpl-media-mpv">
<media :config="config" player="mpv"></media>
</script>

View file

@ -0,0 +1,7 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/index.js') }}"></script>
{% include 'plugins/media/index.html' %}
<script type="text/x-template" id="tmpl-media-omxplayer">
<media :config="config" player="omxplayer"></media>
</script>

View file

@ -0,0 +1,7 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/index.js') }}"></script>
{% include 'plugins/media/index.html' %}
<script type="text/x-template" id="tmpl-media-vlc">
<media :config="config" player="vlc"></media>
</script>

View file

@ -0,0 +1,44 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/controls.js') }}"></script>
<script type="text/x-template" id="tmpl-media-controls">
<div class="controls">
<div class="col-3 item-container">
<div class="item-info">
<span v-text="item.name"></span>
</div>
</div>
<div class="col-6 playback-controls">
<div class="row">
<button>
<i class="fa fa-play"></i>
</button>
<button>
<i class="fa fa-stop"></i>
</button>
</div>
<div class="row">
<span class="elapsed-time" v-text="'-:--'"></span>
<input type="range"
class="slider seek-slider"
min="0"
max="100">
<span class="total-time" v-text="'-:--'"></span>
</div>
</div>
<div class="col-3 pull-right">
<div class="row volume-container">
<button disabled>
<i class="fa fa-volume-up"></i>
</button>
<input type="range"
class="slider volume-slider"
min="0"
max="100">
</div>
</div>
</div>
</script>

View file

@ -0,0 +1,13 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/devices.js') }}"></script>
<script type="text/x-template" id="tmpl-media-devices">
<div class="devices">
<button type="button" title="Select target player" @click="openDevicesMenu">
<i class="fa fa-podcast"></i>
</button>
<dropdown ref="menu" :items="dropdownItems">
</dropdown>
</div>
</script>

View file

@ -0,0 +1,22 @@
{% include 'plugins/media/search.html' %}
{% include 'plugins/media/controls.html' %}
{% include 'plugins/media/results.html' %}
{% include 'plugins/media/item.html' %}
<script type="text/x-template" id="tmpl-media">
<div class="plugin media-plugin">
<media-search :bus="bus"
:supportedTypes="types">
</media-search>
<media-results :bus="bus"
:loading="loading.results"
:results="results">
</media-results>
<media-controls :bus="bus"
:item="currentItem">
</media-controls>
</div>
</script>

View file

@ -0,0 +1,8 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/item.js') }}"></script>
<script type="text/x-template" id="tmpl-media-item">
<div class="media-item">
<span v-text="item.title"></span>
</div>
</script>

View file

@ -0,0 +1,18 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/results.js') }}"></script>
<script type="text/x-template" id="tmpl-media-results">
<div class="results">
<div class="empty" v-if="loading || !results.length">
<div class="loading" v-if="loading">Loading</div>
<div class="no-results" v-else-if="!results.length">No results</div>
</div>
<media-item v-for="item in results"
:key="item.url"
:bus="bus"
:item="item"
v-else>
</media-item>
</div>
</script>

View file

@ -0,0 +1,39 @@
{% include 'plugins/media/devices.html' %}
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/search.js') }}"></script>
<script type="text/x-template" id="tmpl-media-search">
<div class="search">
<form @submit.prevent="search">
<div class="row">
<div class="col-11 query-container">
<button type="button" title="Media type filter" class="filter" @click="showFilter = !showFilter">
<i class="fa fa-filter"></i>
</button>
<input type="text" name="query" v-model.lazy.trim="query"
:disabled="searching" placeholder="Search query or video URL">
<button type="submit" :disabled="searching" title="Search">
<i class="fa fa-search"></i>
</button>
</div>
<div class="col-1 pull-right">
<media-devices></media-devices>
</div>
</div>
<div class="row types fade-in" :class="{hidden: !showFilter}">
<div class="type" v-for="config,type in types">
<input type="checkbox"
name="type"
:id="'media-type-' + type"
v-model.lazy="types[type]">
<label :for="'media-type-' + type" v-text="type"></label>
</div>
</div>
</form>
</div>
</script>

View file

@ -5,7 +5,7 @@
{% include 'plugins/music.mpd/search.html' %}
<script type="text/x-template" id="tmpl-music-mpd">
<div class="row music-mpd-container">
<div class="row plugin music-mpd-container">
<music-mpd-search ref="search" @info="info" :mpd="this">
</music-mpd-search>
@ -136,8 +136,8 @@
<div class="row panels">
<!-- Browser section -->
<div class="col-no-margin-l-3 col-no-margin-m-3 s-hidden panel browser">
<div class="col-s-12 col-no-margin-m-3 col-no-margin-l-3 browser-controls">
<div class="s-hidden panel browser">
<div class="browser-controls">
<div class="col-7 filter-container">
<i class="fa fa-filter input-icon"></i>
<input type="text" class="with-icon" v-model="browserFilter">
@ -155,14 +155,13 @@
</div>
</div>
<div class="results">
<dropdown id="music-mpd-browser-dropdown"
v-if="browserItems.length > 0"
ref="browserDropdown"
:items="browserDropdownItems">
</dropdown>
<div class="spacer"></div>
<music-mpd-browser-item
v-if="browserPath.length > 0"
key=".."
@ -193,10 +192,11 @@
@input="onBrowserItemClick">
</music-mpd-browser-item>
</div>
</div>
<!-- Playlist section -->
<div class="col-s-12 col-no-margin-m-9 col-no-margin-l-9 panel playlist">
<div class="col-s-12 col-m-9 col-l-9 playlist-controls">
<div class="panel playlist">
<div class="playlist-controls">
<div class="col-7 filter-container">
<i class="fa fa-filter input-icon"></i>
<input type="text" class="with-icon" v-model="playlistFilter">
@ -232,12 +232,11 @@
</div>
</div>
<div class="results">
<div class="row empty" v-if="playlist.length === 0">
<i class="fa fa-list"></i>
</div>
<div class="spacer"></div>
<dropdown id="music-mpd-playlist-dropdown"
v-if="playlist.length > 0"
ref="playlistDropdown"
@ -257,6 +256,7 @@
</music-mpd-playlist-item>
</div>
</div>
</div>
<div class="row controls">
<div class="col-3 track-container">

View file

@ -4,12 +4,6 @@
<modal id="music-mpd-search-modal" title="Search" v-model="visible"
:width="showResults ? '90vw' : 'initial'" ref="modal"
@open="$refs.form.querySelector('input[type=text]:first-child').focus()">
<dropdown id="music-mpd-search-dropdown"
v-if="results.length > 0"
ref="dropdown"
:items="dropdownItems">
</dropdown>
<div class="search">
<form ref="form" @submit.prevent="search" :class="{hidden: showResults}">
<div class="row">
@ -67,8 +61,13 @@
</div>
<div class="results" :class="{hidden: !showResults}">
<dropdown id="music-mpd-search-dropdown"
v-if="results.length > 0"
ref="dropdown"
:items="dropdownItems">
</dropdown>
<div class="no-results" v-if="results.length === 0">No results</div>
<div v-else>
<music-mpd-search-item
v-for="item in results"
v-if="matchesFilter(item)"
@ -79,7 +78,6 @@
</music-mpd-search-item>
</div>
</div>
</div>
</modal>
</script>

View file

@ -1,7 +1,7 @@
{% include 'plugins/music.snapcast/host.html' %}
<script type="text/x-template" id="tmpl-music-snapcast">
<div class="row music-snapcast-container">
<div class="row plugin music-snapcast-container">
<modal id="music-snapcast-host-info" title="Server info" v-model="modal.host.visible" ref="modalHost">
{% include 'plugins/music.snapcast/modals/host.html' %}
</modal>

View file

@ -1,4 +1,4 @@
<div class="row tts-container">
<div class="row plugin tts-container">
<form @submit="talk">
<div class="col-8">
<input type="text" name="text" placeholder="Text to say">

View file

@ -1,4 +1,5 @@
import json
import logging
import os
import re
@ -7,6 +8,8 @@ from platypush.backend.http.app import template_folder
class HttpUtils(object):
log = logging.getLogger(__name__)
@staticmethod
def widget_columns_to_html_class(columns):
if not isinstance(columns, int):
@ -85,10 +88,18 @@ class HttpUtils(object):
@classmethod
def to_json(cls, data):
def json_parse(x):
if type(x) == __import__('datetime').timedelta:
return x.days * 24 * 60 * 60 + x.seconds + x.microseconds / 1e6
# Ignore non-serializable attributes
cls.log.warning('Non-serializable attribute type "{}": {}'.format(type(x), x))
return None
if isinstance(data, type({}.keys())):
# Convert dict_keys to list before serializing
data = list(data)
return json.dumps(data)
return json.dumps(data, default=json_parse)
@classmethod
def from_json(cls, data):

View file

@ -25,7 +25,7 @@ class WebBuildCommand(distutils.cmd.Command):
input_path = path(os.path.join(base_path,'source'))
output_path = path(os.path.join(base_path,'dist'))
for root, dirs, files in os.walk(input_path):
for root, dirs, files in os.walk(input_path, followlinks=True):
scss_file = os.path.join(root, 'index.scss')
if os.path.isfile(scss_file):
css_path = os.path.split(scss_file[len(input_path):])[0][1:] + '.css'