Compare commits

..

8 Commits

101 changed files with 22305 additions and 45 deletions

View File

@ -34,7 +34,7 @@ class ProgramQuerySet(PageQuerySet):
"""
if user.is_superuser:
return self
groups = self.request.user.groups.all()
groups = user.groups.all()
return self.filter(editors_group__in=groups)

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

File diff suppressed because one or more lines are too long

View File

@ -16,6 +16,7 @@ Usefull context:
<meta name="description" content="{{ site.description }}" />
<meta name="keywords" content="{{ site.tags }}" />
<meta name="generator" content="Aircox" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="{% thumbnail site.favicon 32x32 crop %}" />
{% block assets %}

View File

@ -3,7 +3,7 @@
<div class="dropdown-trigger">
<button class="button square" aria-haspopup="true" aria-controls="dropdown-menu" type="button">
<span class="icon">
<i class="fa fa-user" aria-hidden="true"></i>
<i class="fa-regular fa-user" aria-hidden="true" style="opacity: 0.6"></i>
</span>
</button>
</div>
@ -40,11 +40,16 @@
{% translate "Statistics" %}
</a>
{% endblock %}
<hr class="dropdown-divider" />
{% endif %}
<a class="dropdown-item" href="{% url "logout" %}" data-force-reload="1">
{% if user.is_authenticated %}
<hr class="dropdown-divider" />
<form id="logout" action="{% url 'logout' %}" method="post">
{% csrf_token %}
<a class="dropdown-item" href="#" type="submit" onclick="document.getElementById('logout').submit();">
{% translate "Disconnect" %}
</a>
</form>
{% endif %}
</div>
</div>
</div>

View File

@ -136,6 +136,11 @@ export default class PageLoad {
history.pushState(state, '', url)
}
dispatchPageLoaded(url) {
var evt = new CustomEvent("pageLoaded", {detail: url})
document.dispatchEvent(evt)
}
// --- events
pageChanged(event) {
let submit = event.type == 'submit';
@ -161,7 +166,7 @@ export default class PageLoad {
else
options = {...options, method: target.method, body: formData}
}
this.load(url, options).then(() => this.historySave(url))
this.load(url, options).then(() => this.dispatchPageLoaded(url)).then(() => this.historySave(url))
event.preventDefault();
event.stopPropagation();
}

View File

@ -0,0 +1,29 @@
# aircox
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
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

4324
radiocampus/assets/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,56 @@
{
"name": "aircox",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"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",
"v-calendar": "^3.1.2",
"vite-plugin-babel-macros": "^1.0.6",
"vue": "^3.4.21"
},
"devDependencies": {
"@tiptap/extension-link": "^2.3.0",
"@tiptap/extension-underline": "^2.3.0",
"@tiptap/pm": "^2.3.0",
"@tiptap/starter-kit": "^2.3.0",
"@tiptap/vue-3": "^2.3.0",
"@vitejs/plugin-vue": "^5.0.4",
"bulma": "^0.9.4",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.49.9",
"vite": "^5.2.8"
},
"eslintConfig": {
"root": true,
"env": {
"node": true,
"es2022": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}

View File

@ -0,0 +1,34 @@
import './styles/admin.scss'
import './index.js'
import App from './app';
import components from './components/admin.js'
const AdminApp = {
...App,
components: {...App.components, ...components},
data() {
return {
...super.data,
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;
window.App = AdminApp

View File

@ -0,0 +1,45 @@
import {Calendar, DatePicker} from 'v-calendar';
import components from './components'
const App = {
el: '#app',
delimiters: ['[[', ']]'],
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 = {
el: '#player',
delimiters: ['[[', ']]'],
components: {...components},
}
export default App

View File

@ -0,0 +1,27 @@
// Enable styling body background while using vue hotreload
// Tags with side effect (<script> and <style>) are ignored in client component templates.
const backgrounds = new Map();
backgrounds.set('default', "linear-gradient(#738ef2, white)");
backgrounds.set('/', "url(/static/radiocampus/backgrounds/photo-04-20.jpg) no-repeat center center fixed");
export default class BackgroundLoad {
constructor () {
let url = new URL(document.location)
this.path = url.pathname
this.update()
document.addEventListener("pageLoaded", this.handlePageLoad.bind(this), false)
}
handlePageLoad (e) {
this.path = e.detail
this.update()
}
update () {
let background = backgrounds.get(this.path) || backgrounds.get("default")
document.body.style.background = background;
document.body.style.backgroundSize = "cover";
}
}

View File

@ -0,0 +1,83 @@
<template>
<component :is="tag" @click.capture.stop="call" type="button" :class="[buttonClass, this.promise && 'blink' || '']">
<span v-if="promise && runIcon">
<i :class="runIcon"></i>
</span>
<span v-else-if="icon" class="icon is-small">
<i :class="icon"></i>
</span>
<span v-if="$slots.default"><slot name="default"/></span>
</component>
</template>
<script>
import Model from '../model'
/**
* Button that can be used to call API requests on provided url
*/
export default {
emit: ['start', 'done'],
props: {
//! Component tag, by default, `button`
tag: { type: String, default: 'a'},
//! Button icon
icon: String,
//! Data or model instance to send
data: Object,
//! Action method, by default, `POST`
method: { type: String, default: 'POST'},
//! If provided open confirmation box before proceeding
confirm: { type: String, default: ''},
//! Action url
url: String,
//! Extra request options
fetchOptions: {type: Object, default: () => {return {}}},
//! Component class while action is running
runClass: String,
//! Icon class while action is running
runIcon: String,
},
computed: {
//! Input data as model instance
item() {
return this.data instanceof Model ? this.data
: new Model(this.data)
},
//! Computed button class
buttonClass() {
return this.promise ? this.runClass : ''
}
},
data() {
return {
promise: false
}
},
methods: {
call() {
if(this.promise || !this.url)
return
if(this.confirm && !confirm(this.confirm))
return
const options = Model.getOptions({
...this.fetchOptions,
method: this.method,
body: JSON.stringify(this.item.data),
})
this.promise = fetch(this.url, options).then(data => data.text()).then(data => {
data = data && JSON.parse(data) || null
this.promise = null;
this.$emit('done', data)
return data
}, data => { this.promise = null; return data })
return this.promise
},
},
}
</script>

View File

@ -0,0 +1,293 @@
<template>
<div class="control">
<input type="hidden" :name="name" :value="selectedValue"
@change="$emit('change', $event)"/>
<input type="text" ref="input" class="input is-fullwidth" :class="inputClass"
v-show="!button || !selected"
v-model="inputValue"
:placeholder="placeholder"
@keydown.capture="onKeyDown"
@keyup="onKeyUp($event); $emit('keyup', $event)"
@keydown="$emit('keydown', $event)"
@keypress="$emit('keypress', $event)"
@focus="onInputFocus" @blur="onBlur" />
<a v-if="selected && button"
class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
@click="select(-1, false, true)">
<span class="icon is-small ml-1">
<i class="fa fa-pen"></i>
</span>
<span class="is-inline-block" v-if="selected">
<slot name="button" :index="selectedIndex" :item="selected"
:value-field="valueField" :labelField="labelField">
{{ selectedLabel }}
</slot>
</span>
</a>
<div :class="dropdownClass">
<div class="dropdown-menu is-fullwidth">
<div class="dropdown-content" style="overflow: hidden">
<span v-for="(item, index) in items" :key="item.id"
:data-autocomplete-index="index"
@click="select(index, false, false)"
:class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
tabindex="-1">
<slot name="item" :index="index" :item="item" :value-field="valueField"
:labelField="labelField">
{{ getValue(item, labelField) || item }}
</slot>
</span>
</div>
</div>
</div>
</div>
</template>
<script>
// import debounce from 'lodash/debounce'
import Model from '../model'
export default {
emit: ['change', 'keypress', 'keydown', 'keyup', 'select', 'unselect',
'update:modelValue'],
props: {
//! Search URL (where `${query}` is replaced by search term)
url: String,
//! Extra GET url parameters
urlParams: Object,
//! Items' model
model: Function,
//! Input tag class
inputClass: Array,
//! input text placeholder
placeholder: Object,
//! input form field name
name: String,
//! Field on items to use as label
labelField: String,
//! Field on selected item to get selectedValue from, if any
valueField: {type: String, default: null},
count: {type: Number, count: 10},
//! If true, show button when value has been selected
button: Boolean,
//! If true, value must come from a selection
mustExist: {type: Boolean, default: false},
//! Minimum input size before fetching
minFetchLength: {type: Number, default: 3},
modelValue: {default: ''},
},
data() {
return {
inputValue: this.modelValue || '',
query: '',
items: [],
selectedIndex: -1,
cursor: -1,
promise: null,
}
},
watch: {
modelValue(value) {
this.inputValue = value
},
inputValue(value, old) {
if(value != old && value != this.modelValue) {
this.$emit('update:modelValue', value)
this.$emit('change', {target: this.$refs.input})
}
if(this.selectedLabel != value)
this.selectedIndex = -1
},
},
computed: {
fullUrl() {
if(!this.urlParams)
return this.url
const url = new URL(this.url, window.location.origin)
const params = new URLSearchParams(url.searchParams)
for(var key in this.urlParams)
params.set(key, this.urlParams[key])
const join = this.url.indexOf("?") >= 0 ? "&" : "?"
url.search = params.toString()
return url.href
},
isFetching() { return !!this.promise },
selected() {
let index = this.selectedIndex
if(index<0)
return null
index = Math.min(index, this.items.length-1)
return this.items[index]
},
selectedValue() {
let value = this.itemValue(this.selected)
if(!value && !this.mustExist)
value = this.inputValue
return value
},
selectedLabel() {
return this.itemLabel(this.selected)
},
dropdownClass() {
var active = this.cursor > -1 && this.items.length;
if(active && this.items.length == 1 &&
this.itemValue(this.items[0]) == this.inputValue)
active = false
return ['dropdown is-fullwidth', active ? 'is-active':'']
},
},
methods: {
reset() {
this.inputValue = ""
this.selectedIndex = -1
this.items = []
},
// TODO: move to utils/data
getValue(data, path=null) {
if(!data)
return null
if(!path)
return data
const paths = path.split('.')
for(const key of paths) {
if(key in data)
data = data[key]
else return null;
}
return data
},
itemValue(item) {
return this.valueField ? this.getValue(item, this.valueField) : item;
},
itemLabel(item) {
return this.labelField ? this.getValue(item, this.labelField) : item;
},
hide() {
this.cursor = -1;
this.selectedIndex = -1;
},
move(index=-1, relative=false) {
if(relative)
index += this.cursor
this.cursor = Math.max(-1, Math.min(index, this.items.length-1))
},
select(index=-1, relative=false, active=null) {
if(relative)
index += this.selectedIndex
else if(index == this.selectedIndex)
return
this.selectedIndex = Math.max(-1, Math.min(index, this.items.length-1))
if(index >= 0) {
this.inputValue = this.selectedLabel
this.$refs.input.focus()
}
if(this.selectedIndex < 0)
this.$emit('unselect')
else
this.$emit('select', index, this.selected, this.selectedValue)
if(active!==null)
active && this.move(0) || this.move(-1)
},
onInputFocus() {
this.cursor < 0 && this.move(0)
},
onBlur(event) {
if(!this.items.length)
return
var index = event.relatedTarget && Math.floor(event.relatedTarget.dataset.autocompleteIndex);
if(index !== undefined && index !== null)
this.select(index, false, false)
this.cursor = -1;
},
onKeyDown(event) {
if(event.ctrlKey || event.altKey || event.metaKey)
return
switch(event.keyCode) {
case 13: this.select(this.cursor, false, false)
break
case 27: this.hide(); this.select()
break
case 38: this.move(-1, true)
break
case 40: this.move(1, true)
break
default: return
}
event.preventDefault()
event.stopPropagation()
},
onKeyUp(event) {
if(event.ctrlKey || event.altKey || event.metaKey)
return
const value = event.target.value
if(value === this.query)
return
this.inputValue = value;
if(!value)
return this.selected && this.select(-1)
if(!this.minFetchLength || value.length >= this.minFetchLength)
this.fetch(value)
},
fetch(query) {
if(!query || this.promise)
return
this.query = query
var url = this.fullUrl.replace('${query}', query).replace('%24%7Bquery%7D', query)
var promise = this.model ? this.model.fetch(url, {many:true})
: fetch(url, Model.getOptions()).then(d => d.json())
promise = promise.then(items => {
if(items.results)
items = items.results
this.items = items.filter((i) => i) || []
this.promise = null;
this.move(0)
return items
}, data => {this.promise = null; Promise.reject(data)})
this.promise = promise
return promise
},
},
mounted() {
const form = this.$el.closest('form')
form && form.addEventListener('reset', () => {
this.inputValue = this.value;
this.select(-1)
})
}
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,105 @@
<template>
<div>
<!-- FIXME: header and footer should be inside list tags -->
<slot name="header"></slot>
<component :is="listTag" :class="listClass">
<template v-for="(item,index) in items" :key="index">
<component :is="itemTag" :class="itemClass" @click="select(index)"
:draggable="orderable" :data-index="index"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
<slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot>
</component>
</template>
</component>
<slot name="footer"></slot>
</div>
</template>
<script>
export default {
emits: ['select', 'unselect', 'move', 'remove'],
data() {
return {
selectedIndex: this.defaultIndex,
}
},
props: {
listClass: String,
itemClass: String,
defaultIndex: { type: Number, default: -1},
set: Object,
orderable: { type: Boolean, default: false },
itemTag: { default: 'li' },
listTag: { default: 'ul' },
},
computed: {
model() { return this.set.model },
items() { return this.set.items },
length() { return this.set.length },
selected() {
return this.selectedIndex > -1 && this.items.length > this.selectedIndex > -1
? this.items[this.selectedIndex] : null;
},
},
methods: {
get(index) { return this.set.get(index) },
find(pred) { return this.set.find(pred) },
findIndex(pred) { return this.set.findIndex(pred) },
remove(index, select=false) {
const item = this.set.get(index)
if(!item)
return
this.set.remove(index);
if(index < this.selectedIndex)
this.selectedIndex--;
if(select && this.selectedIndex == index)
this.select(index)
this.$emit('remove', {index, item, set: this.set})
},
select(index) {
this.selectedIndex = index > -1 && this.items.length ? index % this.items.length : -1;
this.$emit('select', { item: this.selected, index: this.selectedIndex });
return this.selectedIndex;
},
unselect() {
this.$emit('unselect', { item: this.selected, index: this.selectedIndex});
this.selectedIndex = -1;
},
onDragStart(ev) {
const dataset = ev.target.dataset;
const data = `row:${dataset.index}`
ev.dataTransfer.setData("text/cell", data)
ev.dataTransfer.dropEffect = 'move'
},
onDragOver(ev) {
ev.preventDefault()
ev.dataTransfer.dropEffect = 'move'
},
onDrop(ev) {
const data = ev.dataTransfer.getData("text/cell")
if(!data || !data.startsWith('row:'))
return
ev.preventDefault()
const from = Number(data.slice(4))
const target = ev.target.tagName == this.itemTag ? ev.target
: ev.target.closest(this.itemTag)
this.$emit('move', {
from, target,
to: Number(target.dataset.index),
set: this.set,
})
},
},
}
</script>

View File

@ -0,0 +1,109 @@
<template>
<div class="a-m2m-edit">
<table class="table is-fullwidth">
<thead>
<tr>
<th>
<slot name="items-title"></slot>
</th>
<th style="width: 1rem">
<span class="icon">
<i class="fa fa-trash"/>
</span>
</th>
</tr>
</thead>
<tbody>
<template v-for="item of items" :key="item.id">
<tr :class="[item.created && 'has-text-info', item.deleted && 'has-text-danger']">
<td>
<slot name="item" :item="item">
{{ item.data }}
</slot>
</td>
<td class="align-center">
<input type="checkbox" class="checkbox" @change="item.deleted = $event.target.checked">
</td>
</tr>
</template>
</tbody>
</table>
<div>
<label>
<span class="icon">
<i class="fa fa-plus"/>
</span>
Add
</label>
<a-autocomplete ref="autocomplete" v-bind="autocomplete"
@select="onSelect">
<template #item="{item}">
<slot name="autocomplete-item" :item="item">{{ item }}</slot>
</template>
</a-autocomplete>
</div>
</div>
</template>
<script>
import Model, { Set } from "../model.js"
import AAutocomplete from "./AAutocomplete.vue"
export default {
components: {AAutocomplete},
props: {
model: {type: Function, default: Model },
// List url
url: String,
// POST url
commitUrl: String,
// v-bind to autocomplete search box
autocomplete: {type: Object },
source_id: Number,
source_field: String,
target_field: String,
},
data() {
return {
set: new Set(this.model, {url: this.url, unique: true}),
}
},
computed: {
items() { return this.set?.items || [] },
initials() {
let obj = {}
obj[this.source_id_attr] = this.source_id
return obj
},
source_id_attr() { return this.source_field + "_id" },
target_id_attr() { return this.target_field + "_id" },
target_ids() { return this.set?.items.map(i => i.data[this.target_id_attr]) },
},
methods: {
onSelect(index, item, value) {
if(this.target_ids.indexOf(item.id) != -1)
return
let obj = {...this.initials}
obj[this.target_field] = {...item}
obj[this.target_id_attr] = item.id
this.set.push(obj)
this.$refs.autocomplete.reset()
},
save() {
this.set.commit(this.commitUrl, {
fields: [...Object.keys(this.initials), this.target_id_attr]
})
},
},
mounted() {
this.set.fetch()
},
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,84 @@
/**
* This module includes code available for both the public website and
* administration interface)
*/
import 'vue'
//-- aircox
import App, {PlayerApp} from './app'
import VueLoader from './vueLoader'
import Sound from './sound'
import {Set} from './model'
import './styles/common.scss'
window.aircox = {
// main application
loader: null,
get app() { return this.loader.app },
// player application
playerLoader: null,
get playerApp() { return this.playerLoader && this.playerLoader.app },
get player() { return this.playerLoader.vm && this.playerLoader.vm.$refs.player },
Set, Sound,
/**
* Initialize main application and player.
*/
init(props=null, {hotReload=false, el=null,
config=null, playerConfig=null,
initApp=true, initPlayer=true,
loader=null, playerLoader=null}={})
{
if(initPlayer) {
playerConfig = playerConfig || PlayerApp
playerLoader = playerLoader || new VueLoader(playerConfig)
playerLoader.enable(false)
this.playerLoader = playerLoader
document.addEventListener("keyup", e => this.onKeyPress(e), false)
}
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
*/
filter_menu(event) {
var filter = new RegExp(event.target.value, 'gi');
var container = event.target.closest('.navbar-dropdown');
if(event.target.value)
for(let item of container.querySelectorAll('a.navbar-item'))
item.style.display = item.innerHTML.search(filter) == -1 ? 'none' : null;
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

@ -0,0 +1,83 @@
import {setEcoInterval} from './utils';
import Model from './model';
export default class Live {
constructor({url,timeout=10,src=""}={}) {
this.url = url;
this.timeout = timeout;
this.src = src;
this.interval = null
this.promise = null
this.items = []
this.current = null
}
//-- data refreshing
drop() {
this.promise = null;
}
/**
* Fetch data from server.
*
* @param {Object} options
* @param {Function} options.then: call this method on fetch, `this` passed as argument.
* @return {Promise} Promise resolving to fetched items.
*/
fetch({then=null}={}) {
const promise = fetch(this.url).then(response =>
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)
})
this.items = data
const now = new Date()
let item = data.find(it => it.start && (it.start <= now < it.end)) ||
data.length ? data[0] : null;
if(item) {
item.src = this.src
this.current = new Model(item)
}
else
this.current = null
if(then)
then(this)
return this.items
})
this.promise = promise;
return promise;
}
_refresh(options={}) {
const promise = this.fetch(options);
promise.then(() => {
if(promise != this.promise)
return [];
})
return promise
}
/**
* Refresh live info every `this.timeout`.
* @param {Object} options: arguments passed to `this.fetch`.
*/
refresh(options={}) {
if(this.interval !== null)
return
this._refresh(options)
this.interval = setEcoInterval(() => this._refresh(options), this.timeout*1000)
return this.interval
}
stopRefresh() {
this.interval !== null && clearInterval(this.interval)
}
}

View File

@ -0,0 +1,371 @@
/**
* Return cookie with provided key
*/
function getCookie(key) {
if(document.cookie && document.cookie !== '') {
const cookie = document.cookie.split(';')
.find(c => c.trim().startsWith(key + '='))
return cookie ? decodeURIComponent(cookie.split('=')[1]) : null;
}
return null;
}
/**
* CSRF token provided by Django
*/
var csrfToken = null;
/**
* Get CSRF token
*/
export function getCsrf() {
if(csrfToken === null)
csrfToken = getCookie('csrftoken')
return csrfToken;
}
// TODO: prevent duplicate simple fetch
/**
* Provide interface used to fetch and manipulate objects.
*/
export default class Model {
/**
* Instanciate model with provided data and options.
* By default `url` is taken from `data.url_`.
*/
constructor(data={}, {url=null, ...options}={}) {
this.url = url || data.url_;
this.options = options;
this.commit(data);
}
get created() { return !this.id }
get errors() { return this.data && this.data.__errors__ }
/**
* Get instance id from its data
*/
static getId(data) {
return 'id' in data ? data.id : data.pk;
}
/**
* Return fetch options
*/
static getOptions(options) {
return {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRFToken': getCsrf(),
},
...options,
}
}
/**
* Return model instances for the provided list of model data.
* @param {Array} items: array of data
* @param {Object} options: options passed down to all model instances
*/
static fromList(items, options={}) {
return items ? items.map(d => new this(d, options)) : []
}
/**
* Fetch item from server
*/
static fetch(url, {many=false, ...options}={}, args={}) {
options = this.getOptions(options)
const request = fetch(url, options).then(response => response.json());
if(many)
return request.then(data => {
if(!(data instanceof Array))
data = data.results
return this.fromList(data, args)
})
else
return request.then(data => new this(data, {url: url, ...args}));
}
/**
* Fetch data from server.
*/
fetch(options) {
options = this.constructor.getOptions(options)
return fetch(this.url, options)
.then(response => response.json())
.then(data => this.commit(data));
}
/**
* Call API action on object.
*/
action(path, options, commit=false) {
options = this.constructor.getOptions(options)
const promise = fetch(this.url + path, options);
return commit ? promise.then(data => data.json())
.then(data => { this.commit(data); this.data })
: promise;
}
/**
* Set instance's data with provided data. Return None
*/
commit(data) {
this.data = data;
this.id = this.constructor.getId(this.data);
}
/**
* 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
}
/**
* Save instance into localStorage.
*/
store(key) {
window.localStorage.setItem(key, JSON.stringify(this.data));
}
/**
* Load model instance from localStorage.
*/
static storeLoad(key) {
let item = window.localStorage.getItem(key);
return item === null ? item : new this(JSON.parse(item));
}
/**
* Return true if model instance has no data
*/
get isEmpty() {
return !this.data || Object.keys(this.data).findIndex(k => !!this.data[k] && this.data[k] !== 0) == -1
}
/**
* Return error for a specific attribute name if any
*/
error(attr=null) {
return attr === null ? this.errors : this.errors && this.errors[attr]
}
}
/**
* List of models
*/
export class Set {
constructor(model, {items=[],url=null,args={},unique=null,max=null,storeKey=null}={}) {
this.items = [];
this.model = model;
this.url = url;
this.unique = unique;
this.max = max;
this.storeKey = storeKey;
for(var item of items)
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
*/
static fetch(model, url, options=null, args=null) {
options = model.getOptions(options)
return fetch(url, options)
.then(response => response.json())
.then(data => (data instanceof Array ? data : data.results)
.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
*/
static storeLoad(model, key, args={}) {
let items = window.localStorage.getItem(key);
return new this(model, {...args, storeKey: key, items: items ? JSON.parse(items) : []});
}
/**
* Store list into localStorage
*/
store() {
this.storeKey && window.localStorage.setItem(this.storeKey, JSON.stringify(
this.items.map(i => i.data)));
}
/**
* Save item
*/
save() {
this.storeKey && this.store();
}
/**
* Get item at index
*/
get(index) { return this.items[index] }
/**
* Find an item by id or using a predicate function
*/
find(pred) {
return pred instanceof Function ? this.items.find(pred)
: this.items.find(x => x.id == pred.id);
}
/**
* Find item index by id or using a predicate function
*/
findIndex(pred) {
return pred instanceof Function ? this.items.findIndex(pred)
: 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);
let index = -1
if(this.unique && item.id) {
index = this.findIndex(item);
if(index > -1)
this.items[index] = item
}
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;
}
/**
* Remove item from set by index
*/
remove(index, {save=true}={}) {
this.items.splice(index,1);
save && this.save();
}
/**
* Clear items, assign new ones
*/
reset(items=[]) {
// TODO: check reactivity
this.items = []
for(var item of items)
this.push(item)
}
move(from, to) {
if(from >= this.length || to > this.length)
throw "source or target index is not in range"
const value = this.items[from]
this.items.splice(from, 1)
this.items.splice(to, 0, value)
}
}
Set[Symbol.iterator] = function () {
return this.items[Symbol.iterator]();
}

View File

@ -0,0 +1,179 @@
/**
* 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)
}
dispatchPageLoaded(url) {
var evt = new CustomEvent("pageLoaded", {detail: url})
document.dispatchEvent(evt)
}
// --- 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.dataset && target.dataset.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.dispatchPageLoaded(url)).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

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

View File

@ -0,0 +1,12 @@
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 }
}

View File

@ -0,0 +1,98 @@
import Model from './model';
import {setEcoInterval} from './utils';
export class Streamer extends Model {
get playlists() { return this.data ? this.data.playlists : []; }
get queues() { return this.data ? this.data.queues : []; }
get sources() { return [...this.queues, ...this.playlists]; }
get source() { return this.sources.find(o => o.id == this.data.source) }
commit(data) {
if(!this.data)
this.data = { id: data.id, playlists: [], queues: [] }
data.playlists = Playlist.fromList(data.playlists, {streamer: this});
data.queues = Queue.fromList(data.queues, {streamer: this});
super.commit(data)
}
}
export default Streamer;
export class Request extends Model {
static getId(data) { return data.rid; }
}
export class Source extends Model {
constructor(data, {streamer=null, ...options}={}) {
super(data, options);
this.streamer = streamer;
setEcoInterval(() => this.tick(), 1000)
}
get isQueue() { return false; }
get isPlaylist() { return false; }
get isPlaying() { return this.data.status == 'playing' }
get isPaused() { return this.data.status == 'paused' }
get remainingString() {
if(!this.remaining)
return '00:00';
const seconds = Math.floor(this.remaining % 60);
const minutes = Math.floor(this.remaining / 60);
return String(minutes).padStart(2, '0') + ':' +
String(seconds).padStart(2, '0');
}
sync() { return this.action('sync/', {method: 'POST'}, true); }
skip() { return this.action('skip/', {method: 'POST'}, true); }
restart() { return this.action('restart/', {method: 'POST'}, true); }
seek(count) {
return this.action('seek/', {
method: 'POST',
body: JSON.stringify({count: count})
}, true)
}
tick() {
if(!this.data.remaining || !this.isPlaying)
return;
const delta = (Date.now() - this.commitDate) / 1000;
this.remaining = this.data.remaining - delta
}
commit(data) {
if(data.air_time)
data.air_time = new Date(data.air_time);
this.commitDate = Date.now()
super.commit(data)
this.remaining = data.remaining
}
}
export class Playlist extends Source {
get isPlaylist() { return true; }
}
export class Queue extends Source {
get isQueue() { return true; }
get queue() { return this.data && this.data.queue; }
commit(data) {
data.queue = Request.fromList(data.queue);
super.commit(data)
}
push(soundId) {
return this.action('push/', {
method: 'POST',
body: JSON.stringify({'sound_id': parseInt(soundId)})
}, true);
}
}

View File

@ -0,0 +1,58 @@
import AdminApp from '../admin';
import Model from '../model';
import Sound from '../sound';
import {setEcoInterval} from '../utils';
import {Streamer, Queue} from './controllers';
export default {
...AdminApp,
props: {
...(AdminApp.props || {}),
apiUrl: String,
},
data() {
return {
// current streamer
streamer: null,
// all streamers
streamers: [],
// fetch interval id
fetchInterval: null,
Sound: Sound,
}
},
computed: {
...(AdminApp.computed || {}),
sources() {
var sources = this.streamer ? this.streamer.sources : [];
return sources.filter(s => s.data)
},
},
methods: {
...(AdminApp.methods || {}),
fetchStreamers() {
Streamer.fetch(this.apiUrl, {many:true}).then(streamers => {
this.streamers = streamers
this.streamer = streamers ? streamers[0] : null
})
},
},
mounted() {
this.fetchStreamers();
this.fetchInterval = setEcoInterval(() => this.streamer && this.streamer.fetch(), 5000)
},
destroyed() {
if(this.fetchInterval !== null)
clearInterval(this.fetchInterval)
}
}

View File

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

View File

@ -0,0 +1 @@
../../../../assets/src/styles/*

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, 0.8);
--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: 16px !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: 20px !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,782 @@
@use "vars" as v;
:root {
--title-1-sz: 1.4rem;
--title-2-sz: 1.3rem;
--title-3-sz: 1.1rem;
--title-4-sz: 1.0rem;
--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: 10rem;
--cover-h: 10rem;
--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-4-sz);
--preview-subtitle-sz: var(--title-4-sz);
--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};
}
}
@media screen and (max-width: v.$screen-wide) {
:root {
--cover-w: 8rem;
--cover-h: 8rem;
--cover-small-w: 4rem;
--cover-small-h: 4rem;
--cover-tiny-w: 2rem;
--cover-tiny-h: 2rem;
--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
.no-reset h1 { font-size: var(--title-1-sz); }
.no-reset h2 { font-size: var(--title-2-sz); }
.no-reset h3 { font-size: var(--title-3-sz); }
.no-reset h3 { font-size: var(--title-3-sz); }
.no-reset h4 { font-size: var(--title-4-sz); }
.no-reset h5 { font-size: var(--title-5-sz); }
.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; }
}
}
.button-group + .button-group {
border-left: 1px solid var(--text-color-light);
}
}
// ---- 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(--cover-small-h);
width: var(--cover-small-w) !important;
min-width: var(--cover-small-w);
}
&.tiny, .preview.tiny & {
min-width: unset;
height: var(--cover-tiny-h);
width: var(--cover-tiny-w) !important;
min-width: var(--cover-tiny-w);
}
}
.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(--cover-small-h);
}
}
.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,165 @@
@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 { border: 1px solid var(--text-color); }
.border-main { border: 1px solid var(--main-color); }
.border-secondary { border: 1px solid var(--secondary-color); }
.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,478 @@
@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;
text-color: var(--main-color);
background-color: var(--main-color-light);
.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

@ -0,0 +1,17 @@
/**
* Run function with provided args only if document is not hidden
*/
export function setEcoTimeout(func, ...args) {
return setTimeout((...args) => {
!document.hidden && func(...args)
}, ...args)
}
/**
* Run function at specific interval only if document is not hidden
*/
export function setEcoInterval(func, ...args) {
return setInterval((...args) => {
!document.hidden && func(...args)
}, ...args)
}

View File

@ -0,0 +1,50 @@
import {createApp} from 'vue'
import PageLoad from './pageLoad'
import BackgroundLoad from './backgroundLoad'
/**
* 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)
this.backgroundLoad = new BackgroundLoad()
}
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() }
}

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: "../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

@ -0,0 +1,48 @@
// Enable styling body background while using vue hotreload
// Tags with side effect (<script> and <style>) are ignored in client component templates.
class BackgroundLoad {
// change background style on load
// and also on vuejs pageLoaded event
constructor () {
let url = new URL(document.location);
this.path = url.pathname;
this.update();
document.addEventListener("pageLoaded", this.handlePageLoad.bind(this), false);
}
handlePageLoad (e) {
this.path = e.detail;
this.update();
}
update () {
let theme = this.get_theme_name();
document.body.className = theme;
// home page uses different theme
if (this.path == "/") {
document.body.classList.add('home');
}
}
get_theme_name () {
var currentTime = new Date().getHours();
if (document.body) {
if (3 <= currentTime && currentTime <15) {
return "yellow";
}
else {
return "blue";
}
}
}
}
window.onload = function() {
let backgroundLoad = new BackgroundLoad();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -0,0 +1,250 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" data-name="Calque 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1190.6 841.9">
<defs>
<style>
.cls-1 {
fill: none;
}
.cls-1, .cls-2 {
stroke-width: 0px;
}
.cls-2 {
fill: #000;
}
</style>
</defs>
<g>
<g>
<path class="cls-2" d="M215.8-748.5c24.6.2,40.9,16,40.9,39.6s-15.6,43-38.9,43h-28.5l59,62.9h-25.2l-56.9-62.9h-12.6v62.9h-18.8v-145.5h81.1ZM153.5-684.1h64c13.3-.2,21.6-9.6,21.6-23.8s-8.3-22.3-21.6-22.5h-64v46.2Z"/>
<path class="cls-2" d="M370.4-651.2h-66.3l-19.5,48.1h-19.3l58.4-145.5h26.7l58.4,145.5h-19.3l-19.3-48.1ZM363.2-669.4l-22.7-56.5-3.2-8.1-3.2,8.1-22.7,56.5h51.8Z"/>
<path class="cls-2" d="M489.9-748.5c36.8.2,60.1,30.2,60.1,70.8s-19,74.7-61.4,74.7h-60.6v-145.5h61.8ZM446.9-621.2h45.6c25.5,0,39.4-22,39.4-55.4s-16.1-53.5-40.2-53.7h-44.7v109.1h0Z"/>
<path class="cls-2" d="M657.2-620.8v17.8h-94.1v-17.8h38.5v-110h-32.1v-17.8h81.3v17.8h-31.5v110h37.9Z"/>
</g>
<path class="cls-2" d="M369.4-476.5h-66.3l-19.5,48.1h-19.3l58.4-145.5h26.7l58.4,145.5h-19.3l-19.3-48.1ZM362.1-494.7l-22.7-56.5-3.2-8.1-3.2,8.1-22.7,56.5h51.8Z"/>
<path class="cls-2" d="M678.2-573.6c24.6.2,40.9,16,40.9,39.6s-15.6,43-38.9,43h-59.9v62.9h-18.8v-145.5h76.8ZM620.2-509.2h59.7c13.3-.2,21.6-9.6,21.6-23.7s-8.3-22.2-21.6-22.5h-59.7v46.2Z"/>
<path class="cls-2" d="M849.6-471.1c-.2,29.8-20.5,47.3-54.8,47.3s-57.8-18.2-57.8-45.4v-104.4h19v102.1c.2,18.8,14.1,29.1,37.7,29.1s36.6-10.3,36.8-29.1v-102.1h19v102.5h0Z"/>
<path class="cls-2" d="M929.4-442c17.8,0,30.8-6.8,30.8-21.4s-10.3-18.8-19.5-22c-11.8-4.1-21.4-5.6-35.1-9.6-17.1-5.1-29.7-19.7-29.7-40s15.6-42.6,47.7-42.6,38.1,4.1,55,22.5l-12.8,13.1c-15.2-14.8-34.2-17.3-43.2-17.3-17.3,0-27.8,11.1-27.8,23.5s6,19.3,20.1,23.3c7.3,2.1,18.6,5.4,34.4,10.1,23.5,7.1,29.7,21.4,29.7,38.7s-19.9,40-49.6,40-43.2-7.7-56.1-25.9l15.6-11.6c10.7,13.9,24.4,19.3,40.4,19.3Z"/>
<path class="cls-2" d="M536.2-272.3v18.2h-103.2v-145.9h18.2v127.7h85Z"/>
<path class="cls-2" d="M253.4-296.5c0,26.3-19.1,43-46,42.4l-9-.2h-63.4v-145.6h69.8c27.2,1.1,39,18.2,39,38.5s-3.9,21-9.2,27c10.7,7.3,18.8,20.8,18.8,37.9ZM153.2-340.8h51.8c14.8,0,20.5-8.6,20.5-20.3s-5.6-20.6-24.2-20.6h-48.2v40.9ZM235.2-298c0-13.5-7.3-24.6-24.8-24.6h-57.2v50.1h55c19.7,0,27-10.5,27-25.5Z"/>
<g>
<path class="cls-2" d="M301.9-381.8c0,10.1-8.2,18.3-18.3,18.3s-18.3-8.2-18.3-18.3,8.2-18.3,18.3-18.3,18.3,8.2,18.3,18.3Z"/>
<circle class="cls-2" cx="393.6" cy="-381.8" r="18.3"/>
<path class="cls-2" d="M338.6-345.3c0,10.1-8.2,18.3-18.3,18.4-10.1,0-18.3-8.2-18.4-18.3,0-10.1,8.2-18.3,18.3-18.4,10.1,0,18.3,8.2,18.4,18.3Z"/>
<circle class="cls-2" cx="356.9" cy="-345.3" r="18.3"/>
<path class="cls-2" d="M338.6-308.6c0,10.1-8.2,18.3-18.3,18.4-10.1,0-18.3-8.2-18.4-18.3,0-10.1,8.2-18.3,18.3-18.4,10.1,0,18.3,8.2,18.4,18.3Z"/>
<circle class="cls-2" cx="357" cy="-308.7" r="18.3"/>
<path class="cls-2" d="M301.9-271.8c0,10.1-8.2,18.3-18.3,18.3s-18.3-8.2-18.3-18.3,8.2-18.3,18.3-18.3c10.1,0,18.3,8.2,18.3,18.3Z"/>
<circle class="cls-2" cx="393.6" cy="-271.8" r="18.3"/>
</g>
<polygon class="cls-2" points="428.4 -573.9 428.4 -428.6 439 -428.6 439 -557.1 480.8 -557.1 480.8 -428.6 501.2 -428.6 501.2 -557.1 544.2 -557.1 544.2 -428.6 569.8 -428.6 569.8 -573.9 428.4 -573.9"/>
<polygon class="cls-2" points="253.5 -428.2 172.8 -428.2 172.8 -463.1 134.9 -463.1 134.9 -549.4 173.3 -549.4 173.3 -574.4 216.5 -574.4 216.5 -557.4 190.3 -557.4 190.3 -532.4 151.9 -532.4 151.9 -480.1 189.8 -480.1 189.8 -445.2 253.5 -445.2 253.5 -428.2"/>
<path class="cls-2" d="M809.9-693.8c6.1,4.5,12.4,8.6,18.8,12.5v-21.3c-2.8-1.8-5.5-3.7-8.1-5.7-16-11.8-29.8-25.3-41.5-40.3h-22.2c14.1,20.9,31.9,39.2,53,54.8Z"/>
<path class="cls-2" d="M695.8-655.8c-7.1-5.4-14.5-10.4-22.1-14.9v21.3c3.8,2.5,7.5,5.2,11.2,7.9,14.8,11.3,27.8,24.1,39,38.4h22.3c-13.7-20-30.6-37.7-50.4-52.8Z"/>
<path class="cls-2" d="M828.7-648.7v-21.6c-3.8,2.4-7.5,4.8-11.1,7.4-24.3,17-44.8,37.5-60.5,60h22.5c13.7-17.2,30.4-32.7,49.1-45.7Z"/>
<path class="cls-2" d="M746.5-748.7h-22.1c-13.9,18.4-31.1,34.2-50.8,47v21.1c29.4-17.1,54.4-40.3,72.8-68.1Z"/>
<path class="cls-2" d="M760-347.3c0,69.6-16.9,97.2-53.1,97.2s-39.4-10.3-47.7-25.7l14.1-10.1c6.6,12.2,17.1,18,34.5,18s32.3-12.4,34.5-44.3c-9,7.9-21.2,13.1-34.7,13.1-31.7,0-52.2-24-52.2-48.2s20.8-56.9,52.2-56.9c31.3,0,52.4,24.2,52.4,56.9ZM743.8-350.5c0-23.1-17.6-34.2-36.2-34.2s-34.7,11.1-34.7,34.2,14.8,33.4,34.7,33.4c20.8,0,36.2-13.5,36.2-33.4Z"/>
<path class="cls-2" d="M875.6-272.8v18.4h-92.5v-18.4c57.4-42.6,71.7-60.8,71.7-83.7s-11.3-28.9-31.7-28.9-33,11.3-26.8,39.2l-17.1,7.9c-9.2-30.2,4.7-65.9,45.6-65.9s47.3,18.2,47.3,43.9-21,59.5-62.9,87.6h66.4Z"/>
<path class="cls-2" d="M907.7-276.4v22h-22v-22h22Z"/>
<path class="cls-2" d="M960.6-399.9h18.4v145.8h-18.4v-122.7l-39,37.9v-22.7l39-38.3Z"/>
</g>
<g>
<path class="cls-2" d="M1448.4-1634.5c24.6.2,40.9,16,40.9,39.6s-15.6,43-38.9,43h-28.5l59,62.9h-25.2l-56.9-62.9h-12.6v62.9h-18.8v-145.5h81.1ZM1386.1-1570.1h64c13.3-.2,21.6-9.6,21.6-23.8s-8.3-22.3-21.6-22.5h-64v46.2Z"/>
<path class="cls-2" d="M1603-1537.1h-66.3l-19.5,48.1h-19.3l58.4-145.5h26.7l58.4,145.5h-19.3l-19.3-48.1ZM1595.7-1555.3l-22.7-56.5-3.2-8.1-3.2,8.1-22.7,56.5h51.8Z"/>
<path class="cls-2" d="M1722.4-1634.5c36.8.2,60.1,30.2,60.1,70.8s-19,74.7-61.4,74.7h-60.6v-145.5h61.8ZM1679.4-1507.2h45.6c25.5,0,39.4-22,39.4-55.4s-16.1-53.5-40.2-53.7h-44.7v109.1h0Z"/>
<path class="cls-2" d="M1889.8-1506.7v17.8h-94.1v-17.8h38.5v-110h-32.1v-17.8h81.3v17.8h-31.5v110h37.9Z"/>
<path class="cls-2" d="M1602-1362.4h-66.3l-19.5,48.1h-19.3l58.4-145.5h26.7l58.4,145.5h-19.3l-19.3-48.1ZM1594.7-1380.6l-22.7-56.5-3.2-8.1-3.2,8.1-22.7,56.5h51.8Z"/>
<path class="cls-2" d="M1910.8-1459.6c24.6.2,40.9,16,40.9,39.6s-15.6,43-38.9,43h-59.9v62.9h-18.8v-145.5h76.8ZM1852.8-1395.2h59.7c13.3-.2,21.6-9.6,21.6-23.8s-8.3-22.2-21.6-22.5h-59.7v46.2Z"/>
<path class="cls-2" d="M2082.2-1357.1c-.2,29.7-20.5,47.3-54.8,47.3s-57.8-18.2-57.8-45.4v-104.4h19v102.1c.2,18.8,14.1,29.1,37.7,29.1s36.6-10.3,36.8-29.1v-102.1h19v102.5h0Z"/>
<path class="cls-2" d="M2162-1328c17.8,0,30.8-6.8,30.8-21.4s-10.3-18.8-19.5-22c-11.8-4.1-21.4-5.6-35.1-9.6-17.1-5.1-29.7-19.7-29.7-40s15.6-42.6,47.7-42.6,38.1,4.1,55,22.5l-12.8,13.1c-15.2-14.8-34.2-17.3-43.2-17.3-17.3,0-27.8,11.1-27.8,23.5s6,19.3,20.1,23.3c7.3,2.1,18.6,5.4,34.4,10.1,23.5,7.1,29.7,21.4,29.7,38.7s-19.9,40-49.6,40-43.2-7.7-56.1-25.9l15.6-11.6c10.7,13.9,24.4,19.3,40.4,19.3Z"/>
<polygon class="cls-2" points="1660.9 -1459.9 1660.9 -1314.5 1671.6 -1314.5 1671.6 -1443.1 1713.3 -1443.1 1713.3 -1314.5 1733.8 -1314.5 1733.8 -1443.1 1776.7 -1443.1 1776.7 -1314.5 1802.4 -1314.5 1802.4 -1459.9 1660.9 -1459.9"/>
<polygon class="cls-2" points="1486.1 -1314.1 1405.3 -1314.1 1405.3 -1349.1 1367.5 -1349.1 1367.5 -1435.3 1405.9 -1435.3 1405.9 -1460.4 1449 -1460.4 1449 -1443.4 1422.9 -1443.4 1422.9 -1418.3 1384.5 -1418.3 1384.5 -1366.1 1422.3 -1366.1 1422.3 -1331.1 1486.1 -1331.1 1486.1 -1314.1"/>
<path class="cls-2" d="M2050.1-1548.9c-24.3,17-44.8,37.5-60.5,60h22.5c13.7-17.2,30.4-32.7,49.1-45.7v-21.6c-3.8,2.4-7.5,4.8-11.1,7.4Z"/>
<path class="cls-2" d="M2042.5-1579.8c6.1,4.5,12.4,8.7,18.8,12.6v-21.3c-2.8-1.8-5.5-3.7-8.1-5.7-16-11.8-29.8-25.3-41.5-40.3h-22.2c14.1,20.9,31.9,39.2,53,54.8Z"/>
<path class="cls-2" d="M1979.1-1634.6h-22.1c-13.9,18.4-31.1,34.2-50.8,47v21.1c29.5-17.1,54.4-40.3,72.9-68.1Z"/>
<path class="cls-2" d="M1928.3-1541.7c-7.1-5.4-14.5-10.3-22.1-14.9v21.3c3.8,2.5,7.5,5.2,11.2,7.9,14.8,11.3,27.8,24.1,39,38.4h22.3c-13.7-20-30.6-37.7-50.4-52.8Z"/>
<path class="cls-2" d="M1408.8-1192.9c0,10.2-8.2,18.5-18.5,18.5-10.2,0-18.5-8.2-18.5-18.5,0-10.2,8.2-18.5,18.5-18.5,10.2,0,18.5,8.3,18.5,18.5Z"/>
<path class="cls-2" d="M1445.8-1193c0,10.2-8.2,18.5-18.5,18.5-10.2,0-18.5-8.2-18.5-18.5,0-10.2,8.3-18.5,18.5-18.5,10.2,0,18.5,8.3,18.5,18.5Z"/>
<path class="cls-2" d="M1408.8-1155.9c0,10.2-8.3,18.5-18.5,18.5-10.2,0-18.5-8.2-18.5-18.5,0-10.2,8.3-18.5,18.5-18.5,10.2,0,18.5,8.2,18.5,18.5Z"/>
<path class="cls-2" d="M1445.8-1156c0,10.2-8.3,18.5-18.5,18.5-10.2,0-18.5-8.2-18.5-18.5,0-10.2,8.2-18.5,18.5-18.5,10.2,0,18.5,8.2,18.5,18.5Z"/>
<path class="cls-2" d="M1584.4-1182.4c0,26.3-19,43-46,42.4l-9-.2h-63.3v-145.5h69.8c27.2,1.1,38.9,18.2,38.9,38.5s-3.9,21-9.2,27c10.7,7.3,18.8,20.8,18.8,37.9ZM1484.2-1226.7h51.8c14.8,0,20.5-8.6,20.5-20.3s-5.6-20.5-24.2-20.5h-48.2v40.9ZM1566.2-1183.9c0-13.5-7.3-24.6-24.8-24.6h-57.1v50.1h55c19.7,0,27-10.5,27-25.5Z"/>
<path class="cls-2" d="M1620.8-1267.5v40.9h77.2v18.2h-77.2v50.1h85.8v18.2h-104v-145.5h104v18.2h-85.8Z"/>
</g>
<g>
<path class="cls-2" d="M1448.8-748.7c24.6.2,40.9,16,40.9,39.6s-15.6,43-38.9,43h-28.5l59,62.9h-25.2l-56.9-62.9h-12.6v62.9h-18.8v-145.5h81.1ZM1386.6-684.3h64c13.3-.2,21.6-9.6,21.6-23.8s-8.3-22.3-21.6-22.5h-64v46.2Z"/>
<path class="cls-2" d="M1603.5-651.4h-66.3l-19.5,48.1h-19.3l58.4-145.5h26.7l58.4,145.5h-19.3l-19.3-48.1ZM1596.2-669.5l-22.7-56.5-3.2-8.1-3.2,8.1-22.7,56.5h51.8Z"/>
<path class="cls-2" d="M1722.9-748.7c36.8.2,60.1,30.2,60.1,70.8s-19,74.7-61.4,74.7h-60.6v-145.5h61.8ZM1679.9-621.4h45.6c25.5,0,39.4-22,39.4-55.4s-16.1-53.5-40.2-53.7h-44.7v109.1h0Z"/>
<path class="cls-2" d="M1890.3-621v17.8h-94.1v-17.8h38.5v-110h-32.1v-17.8h81.3v17.8h-31.5v110h37.9Z"/>
<path class="cls-2" d="M1602.5-476.7h-66.3l-19.5,48.1h-19.3l58.4-145.5h26.7l58.4,145.5h-19.3l-19.3-48.1ZM1595.2-494.9l-22.7-56.5-3.2-8.1-3.2,8.1-22.7,56.5h51.8Z"/>
<path class="cls-2" d="M1911.3-573.8c24.6.2,40.9,16,40.9,39.6s-15.6,43-38.9,43h-59.9v62.9h-18.8v-145.5h76.8ZM1853.3-509.4h59.7c13.3-.2,21.6-9.6,21.6-23.8s-8.3-22.2-21.6-22.5h-59.7v46.2Z"/>
<path class="cls-2" d="M2082.6-471.3c-.2,29.7-20.5,47.3-54.8,47.3s-57.8-18.2-57.8-45.4v-104.4h19v102.1c.2,18.8,14.1,29.1,37.7,29.1s36.6-10.3,36.8-29.1v-102.1h19v102.5h0Z"/>
<path class="cls-2" d="M2162.5-442.2c17.8,0,30.8-6.8,30.8-21.4s-10.3-18.8-19.5-22c-11.8-4.1-21.4-5.6-35.1-9.6-17.1-5.1-29.7-19.7-29.7-40s15.6-42.6,47.7-42.6,38.1,4.1,55,22.5l-12.8,13.1c-15.2-14.8-34.2-17.3-43.2-17.3-17.3,0-27.8,11.1-27.8,23.5s6,19.3,20.1,23.3c7.3,2.1,18.6,5.4,34.4,10.1,23.5,7.1,29.7,21.4,29.7,38.7s-19.9,40-49.6,40-43.2-7.7-56.1-25.9l15.6-11.6c10.7,13.9,24.4,19.3,40.4,19.3Z"/>
<polygon class="cls-2" points="1661.4 -574.1 1661.4 -428.8 1672.1 -428.8 1672.1 -557.3 1713.8 -557.3 1713.8 -428.8 1734.2 -428.8 1734.2 -557.3 1777.2 -557.3 1777.2 -428.8 1802.8 -428.8 1802.8 -574.1 1661.4 -574.1"/>
<polygon class="cls-2" points="1486.6 -428.4 1405.8 -428.4 1405.8 -463.3 1368 -463.3 1368 -549.6 1406.4 -549.6 1406.4 -574.6 1449.5 -574.6 1449.5 -557.6 1423.4 -557.6 1423.4 -532.6 1385 -532.6 1385 -480.3 1422.8 -480.3 1422.8 -445.4 1486.6 -445.4 1486.6 -428.4"/>
<path class="cls-2" d="M2050.6-663.1c-24.3,17-44.8,37.5-60.5,60h22.5c13.7-17.2,30.4-32.7,49.1-45.7v-21.6c-3.8,2.4-7.5,4.8-11.1,7.4Z"/>
<path class="cls-2" d="M2042.9-694c6.1,4.5,12.4,8.7,18.8,12.6v-21.3c-2.8-1.8-5.5-3.7-8.1-5.7-16-11.8-29.8-25.3-41.5-40.3h-22.2c14.1,20.9,31.9,39.2,53,54.8Z"/>
<path class="cls-2" d="M1979.6-748.8h-22.1c-13.9,18.4-31.1,34.2-50.8,47v21.1c29.5-17.1,54.4-40.3,72.9-68.1Z"/>
<path class="cls-2" d="M1928.8-655.9c-7.1-5.4-14.5-10.3-22.1-14.9v21.3c3.8,2.5,7.5,5.2,11.2,7.9,14.8,11.3,27.8,24.1,39,38.4h22.3c-13.7-20-30.6-37.7-50.4-52.8Z"/>
<g>
<path class="cls-2" d="M1409.3-307.2c0,10.2-8.2,18.5-18.5,18.5-10.2,0-18.5-8.2-18.5-18.5,0-10.2,8.3-18.5,18.5-18.5,10.2,0,18.5,8.3,18.5,18.5Z"/>
<path class="cls-2" d="M1446.3-307.2c0,10.2-8.2,18.5-18.5,18.5-10.2,0-18.5-8.2-18.5-18.5,0-10.2,8.3-18.5,18.5-18.5,10.2,0,18.5,8.2,18.5,18.5Z"/>
<path class="cls-2" d="M1409.3-270.2c0,10.2-8.3,18.5-18.5,18.5-10.2,0-18.5-8.2-18.5-18.5s8.2-18.5,18.5-18.5c10.2,0,18.5,8.2,18.5,18.5Z"/>
<circle class="cls-2" cx="1427.8" cy="-270.2" r="18.5"/>
</g>
<g>
<path class="cls-2" d="M1584.9-296.6c0,26.3-19,43-46,42.4l-9-.2h-63.3v-145.5h69.8c27.2,1.1,38.9,18.2,38.9,38.5s-3.9,21-9.2,27c10.7,7.3,18.8,20.8,18.8,37.9ZM1484.7-340.9h51.8c14.8,0,20.5-8.6,20.5-20.3s-5.6-20.5-24.2-20.5h-48.1v40.9ZM1566.7-298.1c0-13.5-7.3-24.6-24.8-24.6h-57.1v50.1h55c19.7,0,27-10.5,27-25.5Z"/>
<path class="cls-2" d="M1621.3-381.8v40.9h77.2v18.2h-77.2v50.1h85.8v18.2h-104v-145.5h104v18.2h-85.8Z"/>
</g>
<g>
<path class="cls-2" d="M1993.6-346.7c0,69.6-16.9,97.2-53.1,97.2s-39.4-10.3-47.7-25.7l14.1-10.1c6.6,12.2,17.1,18,34.5,18s32.3-12.4,34.5-44.3c-9,7.9-21.2,13.1-34.7,13.1-31.7,0-52.2-24-52.2-48.2s20.8-56.9,52.2-56.9c31.3,0,52.4,24.2,52.4,56.9ZM1977.4-349.9c0-23.1-17.6-34.3-36.2-34.3s-34.7,11.1-34.7,34.3,14.8,33.4,34.7,33.4c20.8,0,36.2-13.5,36.2-33.4Z"/>
<path class="cls-2" d="M2109.2-272.2v18.4h-92.5v-18.4c57.4-42.6,71.7-60.8,71.7-83.7s-11.3-28.9-31.7-28.9-33,11.3-26.8,39.2l-17.1,7.9c-9.2-30.2,4.7-65.9,45.6-65.9s47.3,18.2,47.3,43.9-21,59.5-62.9,87.6h66.4Z"/>
<path class="cls-2" d="M2141.3-275.8v22h-22v-22h22Z"/>
<path class="cls-2" d="M2194.2-399.3h18.4v145.8h-18.4v-122.7l-39,37.9v-22.7l39-38.3Z"/>
</g>
</g>
<g>
<path class="cls-2" d="M3011.2-678.5c.2-.1.4-.2.6-.4h-1.2c.2.1.4.2.6.4Z"/>
<path class="cls-2" d="M2744.4-663.2c-24.3,17-44.8,37.5-60.5,60h22.5c13.7-17.2,30.4-32.7,49.1-45.7v-21.6c-3.8,2.4-7.5,4.8-11.1,7.4Z"/>
<path class="cls-2" d="M2736.8-694.1c6.1,4.5,12.4,8.7,18.8,12.6v-21.3c-2.8-1.8-5.5-3.7-8.1-5.7-16-11.8-29.8-25.3-41.5-40.3h-22.2c14.1,20.9,31.9,39.2,53,54.8Z"/>
<path class="cls-2" d="M2673.4-748.9h-22.1c-13.9,18.4-31.1,34.2-50.8,47v21.1c29.5-17.1,54.4-40.3,72.8-68.1Z"/>
<path class="cls-2" d="M2622.6-656c-7.1-5.4-14.5-10.3-22.1-14.9v21.3c3.8,2.5,7.5,5.2,11.2,7.9,14.8,11.3,27.8,24.1,39,38.4h22.3c-13.7-20-30.6-37.7-50.4-52.8Z"/>
<polygon class="cls-2" points="2912.4 -602.8 2831.7 -602.8 2831.7 -637.7 2793.8 -637.7 2793.8 -723.9 2832.2 -723.9 2832.2 -749 2875.4 -749 2875.4 -732 2849.2 -732 2849.2 -706.9 2810.8 -706.9 2810.8 -654.7 2848.7 -654.7 2848.7 -619.8 2912.4 -619.8 2912.4 -602.8"/>
<polygon class="cls-2" points="2944.5 -748 2944.5 -602.6 2955.1 -602.6 2955.1 -731.2 2996.9 -731.2 2996.9 -602.6 3017.3 -602.6 3017.3 -731.2 3060.3 -731.2 3060.3 -602.6 3085.9 -602.6 3085.9 -748 2944.5 -748"/>
<path class="cls-2" d="M3162.6-733.1c0,10.5-8.5,19.1-19.1,19.1s-19.1-8.5-19.1-19.1,8.5-19.1,19.1-19.1,19.1,8.5,19.1,19.1Z"/>
<circle class="cls-2" cx="3258" cy="-733.1" r="19.1"/>
<path class="cls-2" d="M3200.7-695.1c0,10.5-8.5,19.1-19,19.1s-19.1-8.5-19.1-19c0-10.5,8.5-19.1,19-19.1,10.5,0,19.1,8.5,19.1,19Z"/>
<circle class="cls-2" cx="3219.8" cy="-695.1" r="19.1"/>
<path class="cls-2" d="M3200.8-656.9c0,10.5-8.5,19.1-19,19.1-10.5,0-19.1-8.5-19.1-19s8.5-19.1,19-19.1,19.1,8.5,19.1,19Z"/>
<circle class="cls-2" cx="3219.9" cy="-656.9" r="19.1"/>
<path class="cls-2" d="M3162.6-618.6c0,10.5-8.5,19.1-19.1,19.1s-19.1-8.5-19.1-19.1,8.5-19.1,19.1-19.1,19.1,8.5,19.1,19.1Z"/>
<circle class="cls-2" cx="3258" cy="-618.6" r="19.1"/>
</g>
<g>
<path class="cls-2" d="M2765-1536.3c-27.7,19.4-51.1,42.7-69,68.4h25.6c15.6-19.6,34.6-37.3,56-52.1v-24.7c-4.3,2.7-8.5,5.5-12.7,8.4Z"/>
<path class="cls-2" d="M2756.2-1571.5c6.9,5.1,14.1,9.9,21.5,14.3v-24.3c-3.1-2.1-6.2-4.3-9.3-6.5-18.2-13.4-34-28.8-47.3-46h-25.3c16.1,23.8,36.3,44.8,60.4,62.5Z"/>
<path class="cls-2" d="M2683.9-1634h-25.2c-15.9,20.9-35.5,39-57.9,53.6v24.1c33.6-19.5,62-45.9,83.1-77.7Z"/>
<path class="cls-2" d="M2626-1528.1c-8.1-6.1-16.5-11.8-25.2-17v24.3c4.3,2.9,8.6,5.9,12.7,9.1,16.9,12.8,31.7,27.5,44.5,43.8h25.4c-15.7-22.8-34.9-43-57.5-60.2Z"/>
<polygon class="cls-2" points="2951.8 -1466.7 2859.7 -1466.7 2859.7 -1506.5 2816.6 -1506.5 2816.6 -1604.9 2860.3 -1604.9 2860.3 -1633.5 2909.6 -1633.5 2909.6 -1614.1 2879.7 -1614.1 2879.7 -1585.5 2835.9 -1585.5 2835.9 -1525.9 2879.1 -1525.9 2879.1 -1486.1 2951.8 -1486.1 2951.8 -1466.7"/>
<polygon class="cls-2" points="2600.8 -1442.6 2600.8 -1276.8 2612.9 -1276.8 2612.9 -1423.4 2660.5 -1423.4 2660.5 -1276.8 2683.9 -1276.8 2683.9 -1423.4 2732.9 -1423.4 2732.9 -1276.8 2762.1 -1276.8 2762.1 -1442.6 2600.8 -1442.6"/>
<path class="cls-2" d="M2854.6-1421c0,11.5-9.4,20.9-20.9,20.9s-20.9-9.4-20.9-20.9,9.4-20.9,20.9-20.9c11.5,0,20.9,9.4,20.9,20.9Z"/>
<circle class="cls-2" cx="2959.1" cy="-1421" r="20.9"/>
<path class="cls-2" d="M2896.4-1379.4c0,11.5-9.3,20.9-20.9,20.9-11.5,0-20.9-9.3-20.9-20.9,0-11.5,9.3-20.9,20.9-20.9,11.5,0,20.9,9.3,20.9,20.9Z"/>
<path class="cls-2" d="M2938.2-1379.5c0,11.5-9.3,20.9-20.9,20.9-11.5,0-20.9-9.3-20.9-20.9,0-11.5,9.3-20.9,20.9-20.9,11.5,0,20.9,9.3,20.9,20.9Z"/>
<path class="cls-2" d="M2896.5-1337.6c0,11.5-9.3,20.9-20.9,20.9-11.5,0-20.9-9.3-20.9-20.9,0-11.5,9.3-20.9,20.9-20.9,11.5,0,20.9,9.3,20.9,20.9Z"/>
<circle class="cls-2" cx="2917.4" cy="-1337.6" r="20.9"/>
<path class="cls-2" d="M2854.6-1295.6c0,11.5-9.4,20.9-20.9,20.9s-20.9-9.4-20.9-20.9,9.4-20.9,20.9-20.9c11.5,0,20.9,9.4,20.9,20.9Z"/>
<path class="cls-2" d="M2980-1295.6c0,11.5-9.4,20.9-20.9,20.9s-20.9-9.4-20.9-20.9,9.4-20.9,20.9-20.9c11.5,0,20.9,9.4,20.9,20.9Z"/>
</g>
<g>
<g>
<path class="cls-2" d="M215.5-1634c24.6.2,40.9,16,40.9,39.6s-15.6,43-38.9,43h-28.5l59,62.9h-25.2l-56.9-62.9h-12.6v62.9h-18.8v-145.5h81.1ZM153.2-1569.6h64c13.3-.2,21.6-9.6,21.6-23.8s-8.3-22.3-21.6-22.5h-64v46.2Z"/>
<path class="cls-2" d="M370.1-1536.7h-66.3l-19.5,48.1h-19.3l58.4-145.5h26.7l58.4,145.5h-19.3l-19.3-48.1ZM362.8-1554.9l-22.7-56.5-3.2-8.1-3.2,8.1-22.7,56.5h51.8Z"/>
<path class="cls-2" d="M489.5-1634c36.8.2,60.1,30.2,60.1,70.8s-19,74.7-61.4,74.7h-60.6v-145.5h61.8ZM446.5-1506.7h45.6c25.5,0,39.4-22,39.4-55.4s-16-53.5-40.2-53.7h-44.7v109.1h0Z"/>
<path class="cls-2" d="M656.9-1506.3v17.8h-94.1v-17.8h38.5v-110h-32.1v-17.8h81.3v17.8h-31.5v110h37.9Z"/>
</g>
<path class="cls-2" d="M369.1-1362h-66.3l-19.5,48.1h-19.3l58.4-145.5h26.7l58.4,145.5h-19.3l-19.3-48.1ZM361.8-1380.2l-22.7-56.5-3.2-8.1-3.2,8.1-22.7,56.5h51.8Z"/>
<path class="cls-2" d="M677.9-1459.1c24.6.2,40.9,16,40.9,39.6s-15.6,43-38.9,43h-59.9v62.9h-18.8v-145.5h76.8ZM619.9-1394.7h59.7c13.3-.2,21.6-9.6,21.6-23.8s-8.3-22.2-21.6-22.5h-59.7v46.2Z"/>
<path class="cls-2" d="M849.3-1356.6c-.2,29.7-20.5,47.3-54.8,47.3s-57.8-18.2-57.8-45.4v-104.4h19v102.1c.2,18.8,14.1,29.1,37.7,29.1s36.6-10.3,36.8-29.1v-102.1h19v102.5h0Z"/>
<path class="cls-2" d="M929.1-1327.5c17.8,0,30.8-6.8,30.8-21.4s-10.3-18.8-19.5-22c-11.8-4.1-21.4-5.6-35.1-9.6-17.1-5.1-29.7-19.7-29.7-40s15.6-42.6,47.7-42.6,38.1,4.1,55,22.5l-12.8,13.1c-15.2-14.8-34.2-17.3-43.2-17.3-17.3,0-27.8,11.1-27.8,23.5s6,19.3,20.1,23.3c7.3,2.1,18.6,5.4,34.4,10.1,23.5,7.1,29.7,21.4,29.7,38.7s-19.9,40-49.6,40-43.2-7.7-56.1-25.9l15.6-11.6c10.7,13.9,24.4,19.3,40.4,19.3Z"/>
<path class="cls-2" d="M535.8-1157.8v18.2h-103.2v-145.9h18.2v127.7h85Z"/>
<path class="cls-2" d="M253.1-1182c0,26.3-19.1,43-46,42.4l-9-.2h-63.4v-145.6h69.8c27.2,1.1,39,18.2,39,38.5s-3.9,21-9.2,27c10.7,7.3,18.8,20.8,18.8,37.9ZM152.9-1226.3h51.8c14.8,0,20.5-8.6,20.5-20.3s-5.6-20.6-24.2-20.6h-48.2v40.9h0ZM234.9-1183.5c0-13.5-7.3-24.6-24.8-24.6h-57.2v50.1h55c19.7,0,27-10.5,27-25.5Z"/>
<g>
<circle class="cls-2" cx="283.3" cy="-1267.3" r="18.3"/>
<circle class="cls-2" cx="393.3" cy="-1267.3" r="18.3"/>
<path class="cls-2" d="M338.3-1230.8c0,10.1-8.2,18.3-18.3,18.4-10.1,0-18.3-8.2-18.4-18.3,0-10.1,8.2-18.3,18.3-18.4,10.1,0,18.3,8.2,18.4,18.3Z"/>
<circle class="cls-2" cx="356.6" cy="-1230.8" r="18.3"/>
<path class="cls-2" d="M338.3-1194.2c0,10.1-8.2,18.3-18.3,18.4-10.1,0-18.3-8.2-18.4-18.3,0-10.1,8.2-18.3,18.3-18.4,10.1,0,18.3,8.2,18.4,18.3Z"/>
<circle class="cls-2" cx="356.6" cy="-1194.2" r="18.3"/>
<circle class="cls-2" cx="283.3" cy="-1157.3" r="18.3"/>
<circle class="cls-2" cx="393.3" cy="-1157.3" r="18.3"/>
</g>
<polygon class="cls-2" points="253.2 -1313.7 172.4 -1313.7 172.4 -1348.6 134.6 -1348.6 134.6 -1434.9 173 -1434.9 173 -1459.9 216.2 -1459.9 216.2 -1442.9 190 -1442.9 190 -1417.9 151.6 -1417.9 151.6 -1365.6 189.4 -1365.6 189.4 -1330.7 253.2 -1330.7 253.2 -1313.7"/>
<path class="cls-2" d="M756.8-1487.9h22.5c0,0,.1-.2.2-.3h-22.6c0,0-.1.2-.2.3Z"/>
<path class="cls-2" d="M673.4-1587.1v21.2c29.4-17.1,54.3-40.2,72.8-67.9h-22.2c-13.9,18.2-31,34-50.5,46.7Z"/>
<path class="cls-2" d="M828.7-1533.8v-21.7c-3.7,2.3-7.4,4.8-11.1,7.3-24.4,17.1-44.9,37.6-60.6,60.1h22.6c13.7-17.2,30.4-32.6,49.1-45.7Z"/>
<path class="cls-2" d="M695.5-1541c-7.1-5.4-14.5-10.4-22.1-14.9v21.3c3.8,2.5,7.5,5.2,11.2,7.9,14.8,11.3,27.9,24.2,39,38.5h22.4c-13.8-20.1-30.7-37.8-50.5-52.8Z"/>
<path class="cls-2" d="M820.6-1593.7c-15.9-11.7-29.7-25.1-41.3-40.1h-22.3c14.1,20.8,31.8,39.1,52.9,54.6,6.1,4.5,12.3,8.6,18.8,12.5v-21.4c-2.7-1.8-5.4-3.7-8.1-5.7Z"/>
<polygon class="cls-2" points="428 -1459.4 428 -1314.1 438.7 -1314.1 438.7 -1442.2 480.4 -1442.2 480.4 -1314.1 500.8 -1314.1 500.8 -1442.2 543.7 -1442.2 543.7 -1314.1 569.2 -1314.1 569.2 -1459.4 428 -1459.4"/>
</g>
<path class="cls-1" d="M1684.9,411c-11.5,0-20.9-9.3-20.9-20.9,0,11.5-9.3,20.9-20.9,20.9,11.5,0,20.9,9.3,20.9,20.9,0-11.5,9.3-20.9,20.9-20.9Z"/>
<path class="cls-2" d="M1646.7,283.4v-39.9h-43.1v-25.6c.2,0,.4-.1.6-.2-.2,0-.4-.1-.6-.2v-33.6h43.8v-28.6h29.8v-19.4h-49.2v28.6h-43.8v98.3h43.1v39.9h92.1v-19.4h-72.7Z"/>
<path class="cls-2" d="M1462.8,301.6h25.6c15.6-19.6,34.6-37.3,56-52.1v-24.7c-4.3,2.7-8.5,5.5-12.7,8.4-27.7,19.4-51.1,42.7-69,68.4Z"/>
<path class="cls-2" d="M1535.1,181.5c-18.2-13.4-34-28.8-47.3-46h-25.3c16.1,23.8,36.3,44.8,60.4,62.5,3.5,2.6,7.1,5.1,10.8,7.5,3.5,2.3,7.1,4.6,10.7,6.8v-24.3c-3.1-2.1-6.2-4.3-9.3-6.5Z"/>
<path class="cls-2" d="M1450.7,135.5h-25.2c-15.9,20.9-35.5,39-57.9,53.6v24.1c4.2-2.4,8.3-5,12.4-7.6,28.2-18.6,52.3-42.3,70.7-70.1Z"/>
<path class="cls-2" d="M1424.8,301.6h25.4c-15.7-22.8-34.9-43-57.5-60.2-8.1-6.1-16.5-11.8-25.2-17v24.3c4.3,2.9,8.6,5.9,12.7,9.1,16.9,12.8,31.7,27.5,44.5,43.8Z"/>
<polygon class="cls-2" points="1465.6 326.9 1447.3 326.9 1442.1 326.9 1369.8 326.9 1369.8 492.7 1382 492.7 1382 346.1 1429.6 346.1 1429.6 372 1429.6 492.7 1449.8 492.7 1452.9 492.7 1452.9 365.8 1452.9 347.6 1452.9 346.1 1460.7 346.1 1474.7 346.1 1501.9 346.1 1501.9 492.7 1531.1 492.7 1531.1 326.9 1470.8 326.9 1465.6 326.9"/>
<path class="cls-2" d="M1601.3,369.4c11.5,0,20.9-9.4,20.9-20.9s-9.4-20.9-20.9-20.9-20.9,9.4-20.9,20.9,9.4,20.9,20.9,20.9Z"/>
<circle class="cls-2" cx="1726.7" cy="348.5" r="20.9"/>
<path class="cls-2" d="M1664,390.1c0-11.5-9.4-20.9-20.9-20.9-11.5,0-20.9,9.4-20.9,20.9,0,11.5,9.4,20.9,20.9,20.9,11.5,0,20.9-9.4,20.9-20.9Z"/>
<circle class="cls-2" cx="1684.9" cy="390.1" r="20.9"/>
<path class="cls-2" d="M1643.1,411c-11.5,0-20.9,9.4-20.9,20.9,0,11.5,9.4,20.9,20.9,20.9,11.5,0,20.9-9.4,20.9-20.9,0-11.5-9.4-20.9-20.9-20.9Z"/>
<path class="cls-2" d="M1684.9,411c-11.5,0-20.9,9.4-20.9,20.9,0,11.5,9.4,20.9,20.9,20.9,11.5,0,20.9-9.4,20.9-20.9,0-11.5-9.4-20.9-20.9-20.9Z"/>
<path class="cls-2" d="M1601.3,453c-11.5,0-20.9,9.4-20.9,20.9s9.4,20.9,20.9,20.9,20.9-9.4,20.9-20.9c0-11.5-9.4-20.9-20.9-20.9Z"/>
<path class="cls-2" d="M1726.7,453c-11.5,0-20.9,9.4-20.9,20.9s9.4,20.9,20.9,20.9,20.9-9.4,20.9-20.9c0-11.5-9.4-20.9-20.9-20.9Z"/>
<path class="cls-2" d="M1900.6,139.4h-125.5c-5.5,0-10,5-10,11.1v85c0,6.1,4.5,11.1,10,11.1h18.5c.1,0,.2,0,.3,0h0s58.9,0,58.9,0h0c0,0,.1.2.1.3v19.2c0,.1,0,.2,0,.2h-30.1s0,0,0-.2v-10h-3.7v10c0,2.2,1.7,3.9,3.8,3.9h30.1c2.1,0,3.8-1.8,3.8-3.9v-19.2c0-.1,0-.2,0-.3h25.1c.1,0,.2,0,.3,0h18.5c5.5,0,10-5,10-11.1v-85c0-6.1-4.5-11.1-10-11.1ZM1790.1,238.2v-59.5c0-2.5,1.7-4.6,3.8-4.6h87.8c2.1,0,3.8,2.1,3.8,4.6v59.5c0,2.5-1.6,4.5-3.7,4.6h-8c.3-.8.5-1.7.5-2.7v-41.6c0-3.5-2.6-6.4-5.8-6.4h-61.5c-3.2,0-5.8,2.9-5.8,6.4v41.6c0,1,.2,1.9.5,2.7h-8c-2,0-3.7-2.1-3.7-4.6ZM1822.7,242.4v-20.4c0-.2,0-.3,0-.4h30.1s.1.1.1.3v20.4c0,.2,0,.3,0,.3h0s-30.1,0-30.1,0h0s-.1-.2-.1-.4ZM1852.9,218h-30.1c-2.1,0-3.8,1.8-3.8,4.1v20.4c0,.1,0,.2,0,.4h-2.7c-.5,0-.9-.6-.9-1.3v-29.1c0-.7.4-1.3.9-1.3h43c.5,0,.9.6.9,1.3v29.1c0,.7-.4,1.3-.9,1.3h0s-2.7,0-2.7,0c0-.1,0-.2,0-.4v-20.4c0-2.2-1.7-4.1-3.8-4.1ZM1859.4,207.3h-43c-2.6,0-4.6,2.2-4.6,5v29.1c0,.5,0,.9.2,1.3h-4.8c-1.2,0-2.1-1.2-2.1-2.7v-41.6c0-1.5,1-2.6,2.1-2.6h61.5c1.2,0,2.1,1.2,2.1,2.6v41.6c0,1.5-1,2.6-2.1,2.6h0s-4.8,0-4.8,0c.1-.4.2-.9.2-1.3v-29.1c0-2.8-2.1-5-4.6-5ZM1906.9,235.4c0,4.1-2.8,7.3-6.3,7.3h-12.5c.8-1.3,1.3-2.9,1.3-4.6v-59.5c0-4.6-3.4-8.3-7.6-8.3h-87.8c-4.2,0-7.6,3.7-7.6,8.3v59.5c0,1.7.5,3.3,1.3,4.6h-12.5c-3.5,0-6.3-3.3-6.3-7.3v-85c0-4.1,2.8-7.3,6.3-7.3h125.5c3.5,0,6.3,3.3,6.3,7.3v85h0Z"/>
<path class="cls-2" d="M2058,139h-126.3c-5.5,0-10,4.8-10,10.6v42.1h3.7v-42.1c0-3.8,2.8-6.9,6.3-6.9h126.3c3.5,0,6.3,3.1,6.3,6.9v80.9c0,3.3-2.8,7.1-6.3,7.1h-12.6c.8-1.3,1.2-2.8,1.2-4.4v-56.6c0-4.4-3.4-8-7.6-8h-88.4c-4.2,0-7.6,3.6-7.6,8v29.5h3.7v-29.5c0-2.4,1.7-4.3,3.9-4.3h88.4c2.1,0,3.9,1.9,3.9,4.3v56.6c0,2.4-1.8,4.4-3.9,4.4h0s-7.9,0-7.9,0c.3-.8.5-1.6.5-2.5v-39.6c0-3.4-2.6-6.1-5.9-6.1h-61.9c-3.2,0-5.9,2.8-5.9,6.1v20.6h3.7v-20.6c0-1.3,1-2.4,2.2-2.4h61.9c1.2,0,2.2,1.1,2.2,2.4v39.6c0,1.3-.9,2.4-1.9,2.5h-5c0-.4.2-.8.2-1.2v-27.7c0-2.7-2.1-4.9-4.7-4.9h-43.3c-2.6,0-4.7,2.2-4.7,4.9v14.4h3.7v-14.4c0-.6.4-1.1,1-1.1h43.3c.5,0,.9.5.9,1.1v27.7c0,.6-.4,1.2-.9,1.2h0s-2.7,0-2.7,0c0-.1,0-.2,0-.3v-19.4c0-2.2-1.7-4-3.8-4h-30.3c-2.1,0-3.8,1.8-3.8,4v10.1h3.7v-10.1c0-.2,0-.2.1-.2h30.3s.1,0,.1.2v19.4c0,.2,0,.3-.1.3h0s-30.1,0-30.1,0v3.7s45.9,0,45.9,0h0c.2,0,.4,0,.6,0h31.6c5.4,0,10-5,10-10.8v-80.9c0-5.9-4.5-10.6-10-10.6Z"/>
<path class="cls-2" d="M2010.1,261c0,.2,0,.2-.1.2h-30.3s-.1,0-.1-.2v-19.4c0-.2,0-.3.1-.3v-3.7c-2.1,0-3.8,1.8-3.8,4v19.4c0,2.2,1.7,4,3.8,4h30.3c2.1,0,3.8-1.8,3.8-4v-10.1h-3.7v10.1Z"/>
<path class="cls-2" d="M2225,336.8h-3.7v-186c0-3.8-2.8-6.9-6.2-6.9h-124.4c-3.4,0-6.2,3.1-6.2,6.9h-3.7c0-5.8,4.5-10.6,9.9-10.6h124.4c5.5,0,9.9,4.7,9.9,10.6v186Z"/>
<path class="cls-2" d="M2203.9,307.6h-3.7v-130.2c0-2.3-1.7-4.3-3.8-4.3h-87.1c-2.1,0-3.8,1.9-3.8,4.3h-3.7c0-4.4,3.4-8,7.5-8h87.1c4.1,0,7.5,3.6,7.5,8v130.2Z"/>
<path class="cls-2" d="M2189.2,287.2h-3.7v-91.2c0-1.3-.9-2.4-2.1-2.4h-61c-1.2,0-2.1,1.1-2.1,2.4h-3.7c0-3.4,2.6-6.1,5.8-6.1h61c3.2,0,5.8,2.8,5.8,6.1v91.2h0Z"/>
<path class="cls-2" d="M2178.8,272.8h-3.7v-63.8c0-.6-.4-1.1-.9-1.1h-42.7c-.5,0-.9.5-.9,1.1h-3.7c0-2.7,2.1-4.9,4.6-4.9h42.7c2.6,0,4.6,2.2,4.6,4.9v63.8h0Z"/>
<path class="cls-2" d="M2171.6,262.8h-3.7v-44.7c0-.2,0-.2,0-.2h-29.9s0,0,0,.2h-3.7c0-2.2,1.7-4,3.8-4h29.9c2.1,0,3.8,1.8,3.8,4v44.7Z"/>
<path class="cls-2" d="M2137.8,265.9c-2.1,0-3.8-1.8-3.8-4h3.7c0,.2,0,.3.1.3v3.7Z"/>
<path class="cls-2" d="M2131.5,276.2c-2.5,0-4.6-2.2-4.6-4.9h3.7c0,.6.4,1.2.9,1.2v3.7h0Z"/>
<path class="cls-2" d="M2122.3,291.6c-3.2,0-5.8-2.8-5.8-6.3h3.7c0,1.4.9,2.5,2.1,2.5v3.7h0Z"/>
<path class="cls-2" d="M2109.4,312.3c-4.1,0-7.5-3.7-7.5-8.1h3.7c0,2.4,1.7,4.4,3.7,4.4v3.7h0Z"/>
<path class="cls-2" d="M2090.4,344.1c-5.5,0-9.9-4.9-9.9-10.9h3.7c0,4,2.8,7.2,6.2,7.2v3.7Z"/>
<g>
<path class="cls-2" d="M1157.8,206.6v9.8h-55.5v-78.5h9.8v68.7h45.7Z"/>
<path class="cls-2" d="M1005.1,193.6c0,14.2-10.2,23.1-24.8,22.8h-4.8c0-.1-34.1-.1-34.1-.1v-78.3h37.5c14.6.6,21,9.8,21,20.7s-2.1,11.3-5,14.5c5.8,3.9,10.1,11.2,10.1,20.4ZM951.2,169.7h27.9c7.9,0,11.1-4.6,11.1-10.9s-3-11.1-13-11.1h-25.9v22h0ZM995.3,192.8c0-7.3-3.9-13.2-13.4-13.2h-30.8v26.9h29.6c10.6,0,14.5-5.6,14.5-13.7Z"/>
<circle class="cls-2" cx="1021.5" cy="147.7" r="9.9" transform="translate(643.1 1108) rotate(-76.7)"/>
<circle class="cls-2" cx="1080.7" cy="147.7" r="9.9"/>
<circle class="cls-2" cx="1041.3" cy="167.3" r="9.9"/>
<path class="cls-2" d="M1070.8,167.3c0,5.4-4.4,9.9-9.8,9.9-5.4,0-9.9-4.4-9.9-9.8,0-5.4,4.4-9.9,9.8-9.9,5.4,0,9.9,4.4,9.9,9.8Z"/>
<circle class="cls-2" cx="1041.3" cy="187" r="9.9"/>
<circle class="cls-2" cx="1061" cy="187" r="9.9"/>
<circle class="cls-2" cx="1021.5" cy="206.8" r="9.9" transform="translate(585.5 1153.5) rotate(-76.7)"/>
<circle class="cls-2" cx="1080.7" cy="206.8" r="9.9"/>
<path class="cls-2" d="M574.6,189.6h-35.7l-10.5,25.9h-10.4l31.4-78.3h14.4l31.4,78.3h-10.4l-10.4-25.9ZM570.7,179.8l-12.2-30.4-1.7-4.4-1.7,4.4-12.2,30.4h27.9Z"/>
<path class="cls-2" d="M740.7,137.3c13.2.1,22,8.6,22,21.3s-8.4,23.1-21,23.1h-32.2v33.8h-10.1v-78.3h41.3ZM709.5,172h32.1c7.1-.1,11.6-5.2,11.6-12.8s-4.5-12-11.6-12.1h-32.1v24.9Z"/>
<path class="cls-2" d="M832.9,192.5c-.1,16-11.1,25.4-29.5,25.4s-31.1-9.8-31.1-24.4v-56.2h10.2v54.9c.1,10.1,7.6,15.7,20.3,15.7s19.7-5.5,19.8-15.7v-54.9h10.2v55.1Z"/>
<path class="cls-2" d="M875.9,208.1c9.6,0,16.6-3.7,16.6-11.5s-5.5-10.1-10.5-11.9c-6.3-2.2-11.5-3-18.9-5.2-9.2-2.8-16-10.6-16-21.5s8.4-22.9,25.7-22.9,20.5,2.2,29.6,12.1l-6.9,7c-8.2-7.9-18.4-9.3-23.3-9.3-9.3,0-15,6-15,12.7s3.2,10.4,10.8,12.5c3.9,1.2,10,2.9,18.5,5.4,12.7,3.8,16,11.5,16,20.8s-10.7,21.5-26.7,21.5-23.3-4.1-30.2-13.9l8.4-6.2c5.8,7.5,13.1,10.4,21.8,10.4Z"/>
<polygon class="cls-2" points="512.3 215.6 468.8 215.6 468.8 196.8 448.4 196.8 448.4 150.4 469.1 150.4 469.1 136.9 492.3 136.9 492.3 146 478.2 146 478.2 159.5 457.6 159.5 457.6 187.6 477.9 187.6 477.9 206.4 512.3 206.4 512.3 215.6"/>
<path class="cls-2" d="M73.2,137.2c13.2.1,22,8.6,22,21.3s-8.4,23.1-21,23.1h-15.3l31.8,33.9h-13.6l-30.6-33.9h-6.8v33.8h-10.1v-78.3h43.6ZM39.7,171.9h34.4c7.1-.1,11.6-5.2,11.6-12.8s-4.5-12-11.6-12.1h-34.4v24.9Z"/>
<path class="cls-2" d="M156.4,189.6h-35.7l-10.5,25.9h-10.4l31.4-78.3h14.4l31.4,78.3h-10.4l-10.4-25.9ZM152.5,179.8l-12.2-30.4-1.7-4.4-1.7,4.4-12.2,30.4h27.9Z"/>
<path class="cls-2" d="M220.7,137.2c19.8.1,32.3,16.2,32.3,38.1s-10.2,40.2-33,40.2h-32.6v-78.3h33.3ZM197.5,205.7h24.5c13.7,0,21.2-11.9,21.2-29.8s-8.6-28.8-21.6-28.9h-24.1v58.7Z"/>
<path class="cls-2" d="M310.7,205.9v9.6h-50.6v-9.6h20.7v-59.2h-17.3v-9.6h43.7v9.6h-16.9v59.2h20.4Z"/>
<polygon class="cls-2" points="612 215.7 612 146.2 634.5 146.2 634.5 215.7 645.5 215.7 645.5 146.2 668.6 146.2 668.6 215.7 682.4 215.7 682.4 136.7 606.3 136.7 606.3 215.7 612 215.7"/>
<path class="cls-2" d="M319.6,162.4v11.4c15.8-9.2,29.1-21.6,39.1-36.5h-11.9c-7.5,9.8-16.6,18.2-27.1,25.1Z"/>
<path class="cls-2" d="M403,191v-11.7c-2,1.3-4,2.6-5.9,3.9-13.1,9.2-24.2,20.2-32.6,32.4h12.1c7.4-9.3,16.4-17.6,26.5-24.7Z"/>
<path class="cls-2" d="M319.6,190.6c2,1.4,4,2.8,6,4.3,8,6.1,15,13,21,20.7h12c-7.4-10.8-16.5-20.3-27.1-28.4-3.8-2.9-7.8-5.6-11.9-8v11.4h0Z"/>
<path class="cls-2" d="M403,161.9c-1.5-1-2.9-2-4.3-3-8.5-6.3-15.9-13.5-22.2-21.5h-12c7.6,11.1,17.1,21,28.4,29.3,3.2,2.4,6.6,4.6,10.1,6.7v-11.5h0Z"/>
</g>
<g id="small">
<g>
<path class="cls-2" d="M605.8,679.6h-35.7l-10.5,25.9h-10.4l31.4-78.3h14.4l31.4,78.3h-10.4l-10.4-25.9ZM601.8,669.8l-12.2-30.4-1.7-4.4-1.7,4.4-12.2,30.4h27.9Z"/>
<path class="cls-2" d="M771.9,627.3c13.2.1,22,8.6,22,21.3s-8.4,23.1-21,23.1h-32.2v33.8h-10.1v-78.3h41.3ZM740.7,662h32.1c7.1-.1,11.6-5.2,11.6-12.8s-4.5-12-11.6-12.1h-32.1v24.9Z"/>
<path class="cls-2" d="M864.1,682.5c-.1,16-11.1,25.4-29.5,25.4s-31.1-9.8-31.1-24.4v-56.2h10.2v54.9c.1,10.1,7.6,15.7,20.3,15.7s19.7-5.5,19.8-15.7v-54.9h10.2v55.1Z"/>
<path class="cls-2" d="M907,698.1c9.6,0,16.6-3.7,16.6-11.5s-5.5-10.1-10.5-11.9c-6.3-2.2-11.5-3-18.9-5.2-9.2-2.8-16-10.6-16-21.5s8.4-22.9,25.7-22.9,20.5,2.2,29.6,12.1l-6.9,7c-8.2-7.9-18.4-9.3-23.3-9.3-9.3,0-15,6-15,12.7s3.2,10.4,10.8,12.5c3.9,1.2,10,2.9,18.5,5.4,12.7,3.8,16,11.5,16,20.8s-10.7,21.5-26.7,21.5-23.3-4.1-30.2-13.9l8.4-6.2c5.8,7.5,13.1,10.4,21.8,10.4Z"/>
<polygon class="cls-2" points="543.4 705.6 500 705.6 500 686.8 479.6 686.8 479.6 640.4 500.3 640.4 500.3 626.9 523.5 626.9 523.5 636 509.4 636 509.4 649.5 488.7 649.5 488.7 677.6 509.1 677.6 509.1 696.4 543.4 696.4 543.4 705.6"/>
<path class="cls-2" d="M104.4,627.2c13.2.1,22,8.6,22,21.3s-8.4,23.1-21,23.1h-15.3l31.8,33.9h-13.6l-30.6-33.9h-6.8v33.8h-10.1v-78.3h43.6ZM70.9,661.9h34.4c7.1-.1,11.6-5.2,11.6-12.8s-4.5-12-11.6-12.1h-34.4v24.9Z"/>
<path class="cls-2" d="M187.6,679.6h-35.7l-10.5,25.9h-10.4l31.4-78.3h14.4l31.4,78.3h-10.4l-10.4-25.9ZM183.7,669.8l-12.2-30.4-1.7-4.4-1.7,4.4-12.2,30.4h27.9Z"/>
<path class="cls-2" d="M251.8,627.2c19.8.1,32.3,16.2,32.3,38.1s-10.2,40.2-33,40.2h-32.6v-78.3h33.3ZM228.7,695.7h24.5c13.7,0,21.2-11.9,21.2-29.8s-8.6-28.8-21.6-28.9h-24.1v58.7h0Z"/>
<path class="cls-2" d="M341.9,695.9v9.6h-50.6v-9.6h20.7v-59.2h-17.3v-9.6h43.7v9.6h-16.9v59.2h20.4Z"/>
<polygon class="cls-2" points="643.2 705.7 643.2 636.2 665.7 636.2 665.7 705.7 676.7 705.7 676.7 636.2 699.8 636.2 699.8 705.7 713.6 705.7 713.6 626.7 637.4 626.7 637.4 705.7 643.2 705.7"/>
<path class="cls-2" d="M350.8,652.4v11.4c15.8-9.2,29.1-21.6,39.1-36.5h-11.9c-7.5,9.8-16.6,18.2-27.1,25.1Z"/>
<path class="cls-2" d="M434.1,681v-11.7c-2,1.3-4,2.6-5.9,3.9-13.1,9.2-24.2,20.2-32.6,32.4h12.1c7.4-9.3,16.4-17.6,26.5-24.7Z"/>
<path class="cls-2" d="M350.8,680.6c2,1.4,4,2.8,6,4.3,8,6,15,13,21,20.7h12c-7.4-10.8-16.5-20.3-27.1-28.4-3.8-2.9-7.8-5.6-11.9-8v11.5h0Z"/>
<path class="cls-2" d="M434.1,651.9c-1.5-1-2.9-2-4.3-3-8.5-6.3-15.9-13.5-22.2-21.5h-12c7.6,11.1,17.1,21,28.4,29.3,3.2,2.4,6.6,4.6,10.1,6.7v-11.5Z"/>
<g>
<path class="cls-2" d="M963.9,677.9c0,5.5-4.4,10-9.9,10-5.5,0-10-4.4-10-9.9,0-5.5,4.4-10,9.9-10,5.5,0,10,4.4,10,9.9Z"/>
<circle class="cls-2" cx="973.8" cy="677.8" r="10"/>
<circle class="cls-2" cx="954" cy="697.8" r="10"/>
<circle class="cls-2" cx="973.9" cy="697.8" r="10"/>
<path class="cls-2" d="M1058.4,683.5c0,14.2-10.2,23.1-24.8,22.8h-4.8c0-.1-34.1-.1-34.1-.1v-78.3h37.5c14.6.6,21,9.8,21,20.7s-2.1,11.3-5,14.5c5.8,3.9,10.1,11.2,10.1,20.4ZM1004.5,659.7h27.9c7.9,0,11.1-4.6,11.1-10.9s-3-11.1-13-11.1h-25.9v22h0ZM1048.6,682.7c0-7.3-3.9-13.2-13.4-13.2h-30.7v26.9h29.6c10.6,0,14.5-5.6,14.5-13.7Z"/>
<path class="cls-2" d="M1077.9,637.7v22h41.6v9.8h-41.6v26.9h46.2v9.8h-55.9v-78.3h55.9v9.8h-46.2Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" data-name="Calque 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1190.6 841.9">
<defs>
<style>
.cls-1 {
fill: #000;
stroke-width: 0px;
}
</style>
</defs>
<g>
<path class="cls-1" d="M80,318.5c14.5.1,24.2,9.5,24.2,23.4s-9.2,25.4-23,25.4h-16.8l34.9,37.2h-14.9l-33.6-37.2h-7.4v37.2h-11.1v-86h47.9,0ZM43.2,356.6h37.8c7.9-.1,12.8-5.7,12.8-14.1s-4.9-13.2-12.8-13.3h-37.8v27.3h0Z"/>
<path class="cls-1" d="M171.4,376.1h-39.2l-11.5,28.4h-11.4l34.5-86h15.8l34.5,86h-11.4l-11.4-28.4h.1ZM167.1,365.4l-13.4-33.4-1.9-4.8-1.9,4.8-13.4,33.4h30.6,0Z"/>
<path class="cls-1" d="M242,318.5c21.8.1,35.5,17.9,35.5,41.9s-11.2,44.2-36.3,44.2h-35.8v-86h36.6ZM216.6,393.8h27c15.1,0,23.3-13,23.3-32.7s-9.5-31.6-23.8-31.7h-26.4v64.5h0Z"/>
<path class="cls-1" d="M341,394v10.5h-55.6v-10.5h22.8v-65h-19v-10.5h48.1v10.5h-18.6v65h22.4,0Z"/>
<path class="cls-1" d="M170.9,479.3h-39.2l-11.5,28.4h-11.4l34.5-86h15.8l34.5,86h-11.4l-11.4-28.4h.1ZM166.5,468.6l-13.4-33.4-1.9-4.8-1.9,4.8-13.4,33.4h30.6,0Z"/>
<path class="cls-1" d="M353.4,421.9c14.5.1,24.2,9.5,24.2,23.4s-9.2,25.4-23,25.4h-35.4v37.2h-11.1v-86h45.4,0ZM319.1,460h35.3c7.9-.1,12.8-5.7,12.8-14.1s-4.9-13.1-12.8-13.3h-35.3v27.3h0Z"/>
<path class="cls-1" d="M454.7,482.5c-.1,17.6-12.1,28-32.4,28s-34.2-10.8-34.2-26.8v-61.7h11.2v60.4c.1,11.1,8.3,17.2,22.3,17.2s21.6-6.1,21.8-17.2v-60.4h11.2v60.6h0Z"/>
<path class="cls-1" d="M501.9,499.7c10.5,0,18.2-4,18.2-12.7s-6.1-11.1-11.5-13c-7-2.4-12.6-3.3-20.7-5.7-10.1-3-17.6-11.6-17.6-23.6s9.2-25.2,28.2-25.2,22.5,2.4,32.5,13.3l-7.6,7.7c-9-8.7-20.2-10.2-25.5-10.2-10.2,0-16.4,6.6-16.4,13.9s3.5,11.4,11.9,13.8c4.3,1.2,11,3.2,20.3,6,13.9,4.2,17.6,12.7,17.6,22.9s-11.8,23.6-29.3,23.6-25.5-4.6-33.2-15.3l9.2-6.9c6.3,8.2,14.4,11.4,23.9,11.4h0Z"/>
<polygon class="cls-1" points="205.7 421.8 205.7 507.6 212 507.6 212 431.7 236.6 431.7 236.6 507.6 248.7 507.6 248.7 431.7 274.1 431.7 274.1 507.6 289.3 507.6 289.3 421.8 205.7 421.8"/>
<polygon class="cls-1" points="102.3 507.9 54.6 507.9 54.6 487.3 32.2 487.3 32.2 436.2 54.9 436.2 54.9 421.5 80.4 421.5 80.4 431.5 65 431.5 65 446.3 42.3 446.3 42.3 477.2 64.6 477.2 64.6 497.8 102.3 497.8 102.3 507.9"/>
<path class="cls-1" d="M435.7,369.1c-14.4,10-26.5,22.2-35.8,35.5h13.3c8.1-10.2,18-19.3,29-27v-12.8c-2.2,1.4-4.4,2.8-6.6,4.4h0Z"/>
<path class="cls-1" d="M431.2,350.9c3.6,2.7,7.3,5.1,11.1,7.4v-12.6c-1.7-1.1-3.3-2.2-4.8-3.4-9.5-7-17.6-15-24.5-23.8h-13.1c8.3,12.4,18.9,23.2,31.3,32.4h0Z"/>
<path class="cls-1" d="M393.8,318.5h-13.1c-8.2,10.9-18.4,20.2-30,27.8v12.5c17.4-10.1,32.2-23.8,43.1-40.3Z"/>
<path class="cls-1" d="M363.7,373.4c-4.2-3.2-8.6-6.1-13.1-8.8v12.6c2.2,1.5,4.4,3.1,6.6,4.7,8.7,6.7,16.4,14.2,23.1,22.7h13.2c-8.1-11.8-18.1-22.3-29.8-31.2h0Z"/>
<g>
<path class="cls-1" d="M56.7,579.5c0,6-4.8,10.9-10.9,10.9s-10.9-4.8-10.9-10.9,4.9-10.9,10.9-10.9,10.9,4.9,10.9,10.9Z"/>
<path class="cls-1" d="M78.5,579.5c0,6-4.8,10.9-10.9,10.9s-10.9-4.8-10.9-10.9,4.9-10.9,10.9-10.9,10.9,4.8,10.9,10.9Z"/>
<path class="cls-1" d="M56.7,601.4c0,6-4.9,10.9-10.9,10.9s-10.9-4.8-10.9-10.9,4.8-10.9,10.9-10.9,10.9,4.8,10.9,10.9Z"/>
<circle class="cls-1" cx="67.6" cy="601.4" r="10.9"/>
</g>
<g>
<path class="cls-1" d="M160.5,585.8c0,15.5-11.2,25.4-27.2,25.1h-5.3c0-.1-37.4-.1-37.4-.1v-86h41.3c16.1.7,23,10.8,23,22.8s-2.3,12.4-5.4,16c6.3,4.3,11.1,12.3,11.1,22.4h0ZM101.2,559.6h30.6c8.7,0,12.1-5.1,12.1-12s-3.3-12.1-14.3-12.1h-28.4v24.2h0ZM149.7,584.9c0-8-4.3-14.5-14.7-14.5h-33.8v29.6h32.5c11.6,0,16-6.2,16-15.1h0Z"/>
<path class="cls-1" d="M182,535.4v24.2h45.6v10.8h-45.6v29.6h50.7v10.8h-61.5v-86h61.5v10.8h-50.7Z"/>
</g>
<g>
<path class="cls-1" d="M402,556.2c0,41.1-10,57.5-31.4,57.5s-23.3-6.1-28.2-15.2l8.3-6c3.9,7.2,10.1,10.6,20.4,10.6s19.1-7.3,20.4-26.2c-5.3,4.7-12.5,7.7-20.5,7.7-18.7,0-30.9-14.2-30.9-28.5s12.3-33.6,30.9-33.6,31,14.3,31,33.6h0ZM392.5,554.3c0-13.7-10.4-20.3-21.4-20.3s-20.5,6.6-20.5,20.3,8.7,19.7,20.5,19.7,21.4-8,21.4-19.7Z"/>
<path class="cls-1" d="M470.4,600.2v10.9h-54.7v-10.9c33.9-25.2,42.4-35.9,42.4-49.5s-6.7-17.1-18.7-17.1-19.5,6.7-15.8,23.2l-10.1,4.7c-5.4-17.9,2.8-39,27-39s28,10.8,28,26-12.4,35.2-37.2,51.8h39.3Z"/>
<path class="cls-1" d="M489.4,598.1v13h-13v-13h13Z"/>
<path class="cls-1" d="M520.6,525.1h10.9v86.2h-10.9v-72.5l-23.1,22.4v-13.4l23.1-22.6Z"/>
</g>
</g>
<g>
<path class="cls-1" d="M1157.8,206.6v9.8h-55.5v-78.5h9.8v68.7h45.7Z"/>
<path class="cls-1" d="M1005.1,193.6c0,14.2-10.2,23.1-24.8,22.8h-4.8c0-.1-34.1-.1-34.1-.1v-78.3h37.5c14.6.6,21,9.8,21,20.7s-2.1,11.3-5,14.5c5.8,3.9,10.1,11.2,10.1,20.4h0ZM951.2,169.7h27.9c7.9,0,11.1-4.6,11.1-10.9s-3-11.1-13-11.1h-25.9v22h0ZM995.3,192.8c0-7.3-3.9-13.2-13.4-13.2h-30.8v26.9h29.6c10.6,0,14.5-5.6,14.5-13.7h.1Z"/>
<circle class="cls-1" cx="1021.8" cy="147.9" r="9.9" transform="translate(642.9 1108.3) rotate(-76.7)"/>
<circle class="cls-1" cx="1080.7" cy="147.7" r="9.9"/>
<circle class="cls-1" cx="1041.3" cy="167.3" r="9.9"/>
<path class="cls-1" d="M1070.8,167.3c0,5.4-4.4,9.9-9.8,9.9s-9.9-4.4-9.9-9.8,4.4-9.9,9.8-9.9,9.9,4.4,9.9,9.8Z"/>
<circle class="cls-1" cx="1041.3" cy="187" r="9.9"/>
<circle class="cls-1" cx="1061" cy="187" r="9.9"/>
<circle class="cls-1" cx="1021.7" cy="207" r="9.9" transform="translate(585.3 1153.7) rotate(-76.7)"/>
<circle class="cls-1" cx="1080.7" cy="206.8" r="9.9"/>
<path class="cls-1" d="M574.6,189.6h-35.7l-10.5,25.9h-10.4l31.4-78.3h14.4l31.4,78.3h-10.4l-10.4-25.9h.2ZM570.7,179.8l-12.2-30.4-1.7-4.4-1.7,4.4-12.2,30.4h27.9,0Z"/>
<path class="cls-1" d="M740.7,137.3c13.2.1,22,8.6,22,21.3s-8.4,23.1-21,23.1h-32.2v33.8h-10.1v-78.3h41.3ZM709.5,172h32.1c7.1-.1,11.6-5.2,11.6-12.8s-4.5-12-11.6-12.1h-32.1v24.9h0Z"/>
<path class="cls-1" d="M832.9,192.5c0,16-11.1,25.4-29.5,25.4s-31.1-9.8-31.1-24.4v-56.2h10.2v54.9c0,10.1,7.6,15.7,20.3,15.7s19.7-5.5,19.8-15.7v-54.9h10.2v55.1h0Z"/>
<path class="cls-1" d="M875.9,208.1c9.6,0,16.6-3.7,16.6-11.5s-5.5-10.1-10.5-11.9c-6.3-2.2-11.5-3-18.9-5.2-9.2-2.8-16-10.6-16-21.5s8.4-22.9,25.7-22.9,20.5,2.2,29.6,12.1l-6.9,7c-8.2-7.9-18.4-9.3-23.3-9.3-9.3,0-15,6-15,12.7s3.2,10.4,10.8,12.5c3.9,1.2,10,2.9,18.5,5.4,12.7,3.8,16,11.5,16,20.8s-10.7,21.5-26.7,21.5-23.3-4.1-30.2-13.9l8.4-6.2c5.8,7.5,13.1,10.4,21.8,10.4h.1Z"/>
<polygon class="cls-1" points="512.3 215.6 468.8 215.6 468.8 196.8 448.4 196.8 448.4 150.4 469.1 150.4 469.1 136.9 492.3 136.9 492.3 146 478.2 146 478.2 159.5 457.6 159.5 457.6 187.6 477.9 187.6 477.9 206.4 512.3 206.4 512.3 215.6"/>
<path class="cls-1" d="M73.2,137.2c13.2.1,22,8.6,22,21.3s-8.4,23.1-21,23.1h-15.3l31.8,33.9h-13.6l-30.6-33.9h-6.8v33.8h-10.1v-78.3h43.6ZM39.7,171.9h34.4c7.1-.1,11.6-5.2,11.6-12.8s-4.5-12-11.6-12.1h-34.4v24.9h0Z"/>
<path class="cls-1" d="M156.4,189.6h-35.7l-10.5,25.9h-10.4l31.4-78.3h14.4l31.4,78.3h-10.4l-10.4-25.9h.2ZM152.5,179.8l-12.2-30.4-1.7-4.4-1.7,4.4-12.2,30.4h27.9-.1Z"/>
<path class="cls-1" d="M220.7,137.2c19.8.1,32.3,16.2,32.3,38.1s-10.2,40.2-33,40.2h-32.6v-78.3h33.3ZM197.5,205.7h24.5c13.7,0,21.2-11.9,21.2-29.8s-8.6-28.8-21.6-28.9h-24.1v58.7h0Z"/>
<path class="cls-1" d="M310.7,205.9v9.6h-50.6v-9.6h20.7v-59.2h-17.3v-9.6h43.7v9.6h-16.9v59.2h20.4,0Z"/>
<polygon class="cls-1" points="612 215.7 612 146.2 634.5 146.2 634.5 215.7 645.5 215.7 645.5 146.2 668.6 146.2 668.6 215.7 682.4 215.7 682.4 136.7 606.3 136.7 606.3 215.7 612 215.7"/>
<path class="cls-1" d="M319.6,162.4v11.4c15.8-9.2,29.1-21.6,39.1-36.5h-11.9c-7.5,9.8-16.6,18.2-27.1,25.1h-.1Z"/>
<path class="cls-1" d="M403,191v-11.7c-2,1.3-4,2.6-5.9,3.9-13.1,9.2-24.2,20.2-32.6,32.4h12.1c7.4-9.3,16.4-17.6,26.5-24.7h-.1Z"/>
<path class="cls-1" d="M319.6,190.6c2,1.4,4,2.8,6,4.3,8,6.1,15,13,21,20.7h12c-7.4-10.8-16.5-20.3-27.1-28.4-3.8-2.9-7.8-5.6-11.9-8v11.4h0Z"/>
<path class="cls-1" d="M403,161.9c-1.5-1-2.9-2-4.3-3-8.5-6.3-15.9-13.5-22.2-21.5h-12c7.6,11.1,17.1,21,28.4,29.3,3.2,2.4,6.6,4.6,10.1,6.7v-11.5h0Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -0,0 +1,333 @@
:root {
--a-player-bar-bg: #738EF2;
--a-player-bar-fg: #4897c7;
--a-player-url-fg: white;
--a-playlist-header-bg: #F6ED80;;
--a-playlist-header-fg: #222;
--a-player-panel-bg: #738ef2;
--a-player-panel-fg: white;
--a-sound-hv-bg: #f6ed80;
--a-sound-hv-fg: #444;
--a-sound-bg: #f6ed80;
--body-bg: unset;
--break-color: transparent;
--button-bg: #F4F88D;
--button-fg: #222;
--button-hv-bg: #F4F88D;
--button-hv-fg: #1d3cab;
--button-active-fg: white;
--button-active-bg: #738ef2;
--heading-font-family: "campus_grotesk";
--heading-link-hv-fg: #aa217b;
--heading-hg-fg: #fff;
--link-fg: #3b47ff;
--link-hv-fg: #c40c85;
--main-color-light: #F4F881;
--nav-bg: transparent;
--nav-fg: #222;
--nav-secondary-bg: transparent;
--nav-hv-bg: unset;
--nav-active-bg: unset;
--nav-active-fg: white;
--text-color: #75124e;
--text-color-light: #bbb;
}
@font-face {
font-family: "campus_grotesk";
src:
local("campus_grotesk"),
url("/static/radiocampus/fonts/campus_grotesk/CampusGroteskv24-Regular.woff2") format("woff2"),
url("/static/radiocampus/fonts/campus_grotesk/CampusGroteskv24-Regular.woff") format("woff"),
url("/static/radiocampus/fonts/campus_grotesk/CampusGroteskv24-Regular.otf") format("opentype") tech(color-COLRv1);
}
body {
color: #222;
background-size: cover;
}
body.home, body.home .preview .headings a, body.home .page a {
color: #fff;
}
body.home .preview .headings a:hover {
color: #f4f88d !important;
}
body.home .nav.primary {
max-width: 1200px;
margin: 0 auto;
}
body.yellow {
background: url(/static/radiocampus/backgrounds/degrade-jaune.jpg) repeat-x top fixed;
}
body.yellow.home {
background: url(/static/radiocampus/backgrounds/photo-degrade-02.jpg) no-repeat center/cover;
}
body.blue {
background: url(/static/radiocampus/backgrounds/degrade-bleu.jpg) repeat-x top fixed;
}
body.blue.home {
background: url(/static/radiocampus/backgrounds/photo-degrade-01.jpg) no-repeat center/cover;
}
body.blue #grandlogo img {
content: url('/static/radiocampus/logos/logo-RC-blanc1.png');
}
body.yellow #grandlogo img {
content: url('/static/radiocampus/logos/logo-RC-bleu1.png');
}
body.blue.home #grandlogo img {
content: url('/static/radiocampus/logos/logo-RC-blanc2.png');
}
body.yellow.home #grandlogo img {
content: url('/static/radiocampus/logos/logo-RC-bleu2.png');
}
body.yellow .nav .nav-item.active {
color: #7E6B64 !important;
}
body.blue #grandlogo img, body.yellow #grandlogo img {
width: 120px;
margin: 12px 0 0 48px;
}
body.blue.home #grandlogo, body.yellow.home #grandlogo {
text-align: center;
width: 100%;
}
body.blue.home #grandlogo img , body.yellow.home #grandlogo img {
margin: 12px auto 0 auto;
width: 960px;
opacity: 0.8;
}
.a-player-bar {
border-top: 1px solid #555;
}
.a-player .button, .a-player-bar-content {
color: white;
}
#player .a-sound-item .label {
color: white !important;
}
.a-switch-nav span {
color: white;
}
.a-switch-nav span:hover {
color: #333;
}
.button, a.button, button.button {
border: 0;
}
.nav.primary .nav-brand {
display: none;
}
.nav.secondary .nav-item {
color: #7E6B64 !important;
}
.nav-item:hover {
opacity: 0.8;
}
.nav.primary .nav-item {
font-weight: unset;
font-size: 1.4em;
}
.page section.container {
margin-top: 0;
padding-top: 0.6rem;
}
@media screen and (max-width: 400px) {
body {
font-size: 16px;
}
}
@media screen and (max-width: 1024px) {
.page {
margin-top: 0;
padding-top: var(--nav-primary-height);
}
.nav.primary .nav-brand {
display: inline-block;
}
.nav.secondary .nav-item {
color: white !important;
}
.dropdown-trigger .icon, .icon.bullet {
color: white;
}
.dropdown.is-right .dropdown-menu {
left: 0;
}
#grandlogo {
display: none;
}
.navs, .nav-menu.active {
background-color: #7892f1; /*#738ef2;*/
}
.nav .nav-item {
color: white !important;
}
body.yellow .nav .nav-item.active {
color: white !important;
}
/* yellow theme is not implemented for small screens */
body.yellow {
background: url(/static/radiocampus/backgrounds/degrade-bleu.jpg) repeat-x top/auto 520px;
}
body.yellow.home {
background: url(/static/radiocampus/backgrounds/photo-degrade-01.jpg) no-repeat center/cover;
}
.navs .nav + .nav {
flex-grow: 1 !important;
}
}
@media screen and (min-width: 1408px) {
.container:not(.is-max-desktop):not(.is-max-widescreen) {
max-width: unset;
margin: 10px 64px;
}
body.home .container:not(.is-max-desktop):not(.is-max-widescreen) {
max-width: 1400px;
margin: 0 auto;
}
}
@media screen and (min-width: 1216px) {
.container:not(.is-max-desktop):not(.is-max-widescreen) {
max-width: unset;
margin: 10px 64px;
}
body.home .container:not(.is-max-desktop):not(.is-max-widescreen) {
max-width: 1152px;
margin: 0 auto;
}
}
.list-item:nth-child(3n+1):not(.wide) .media {
border-color: transparent !important;
}
.list-item:nth-child(3n):not(.wide) .media {
border-color: transparent !important;
}
/*
.nav-urls .urls a, .nav-urls .urls span {
color: white;
background-color: #444;
border-radius: 5px;
padding: 4px;
}
#background {
background-size: cover;
padding: 80px 0 80px 0;
width: 100%;
}
.button:hover, a.button:hover, button.button:hover {
opacity: 0.9 !important;
}
.container {
background: whitesmoke;
padding: 24px;
border-radius: 5px;
max-width: 960px;
border: 1px solid #929293;
}
.container.breadcrumbs, .container.header {
background: transparent;
border-radius: 0;
padding: 0 24px 0 24px;
border: 0;
}
.container.breadcrumbs a {
color: white;
}
.grid .list-item .headings .heading {
padding-top: 10px;
padding-left: 12px;
}
.nav.secondary {
opacity: 0.9;
}
.nav.secondary .nav-item{
olor: #1d3cab !important;
color: #8c827e !important;
font-size: 1.1em;
}
.nav .nav-item:hover {
opacity: 0.8;
}
.navs {
border-bottom: 1px solid #4d4545;
}
.page {
min-height: 500px;
}
.preview-cover {
border-radius: 5px;
opacity: 0.92;
border: 1px solid #fff;
}
.a-carousel .preview-cover {
border: 1px solid #ccc;
}
.preview-card .headings .heading {
opacity: 0.85;
padding: 12px;
background-color: #242121;
}
.preview-card .headings a {
color: white;
}
.grid .preview .headings a {
color: black;
}
.preview .title, .preview .title:not(:last-child) {
font-weight: normal;
}
@media screen and (max-width: 1024px) {
.nav .nav-menu {
background-color: #6C7ED2;
}
.page {
margin-top: 10px;
}
#background {
padding: 100px 0 50px 0;
}
.container, .page .container {
margin-left: 0;
margin-right: 0;
border-radius: 0;
}
}
*/

Some files were not shown because too many files have changed in this diff Show More