Compare commits

..

No commits in common. "a2a399e531f1bd32009960aece157bed4556816d" and "07d72d799d4247f53ce17799b6af0cf75333d2f1" have entirely different histories.

59 changed files with 20412 additions and 577 deletions

View File

@ -1,7 +1,5 @@
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
import django_filters as filters
from django.utils.translation import gettext_lazy as _
from . import models
@ -12,8 +10,6 @@ __all__ = (
"ImageFilterSet",
"SoundFilterSet",
"TrackFilterSet",
"UserFilterSet",
"UserGroupFilterSet",
)
@ -71,26 +67,3 @@ class TrackFilterSet(filters.FilterSet):
artist = filters.CharFilter(field_name="artist", lookup_expr="icontains")
album = filters.CharFilter(field_name="album", lookup_expr="icontains")
title = filters.CharFilter(field_name="title", lookup_expr="icontains")
class UserFilterSet(filters.FilterSet):
search = filters.CharFilter(field_name="search", method="search_filter")
in_group = filters.NumberFilter(field_name="in_group", method="in_group_filter")
not_in_group = filters.NumberFilter(field_name="not_in_group", method="not_in_group_filter")
def in_group_filter(self, queryset, name, value):
return queryset.filter(groups__in=[value])
def not_in_group_filter(self, queryset, name, value):
return queryset.exclude(groups__in=[value])
def search_filter(self, queryset, name, value):
return queryset.filter(
Q(username__icontains=value) | Q(first_name__icontains=value) | Q(last_name__icontains=value)
)
class UserGroupFilterSet(filters.FilterSet):
class Meta:
model = User.groups.through
fields = ["group", "user"]

View File

@ -1,5 +1,6 @@
from . import widgets
from .auth import UserGroupFormSet
from .episode import EpisodeForm, EpisodeSoundFormSet
from .program import ProgramForm
from .page import CommentForm, ImageForm, PageForm, ChildPageForm
@ -18,4 +19,5 @@ __all__ = (
ChildPageForm,
SoundForm,
SoundCreateForm,
UserGroupFormSet,
)

20
aircox/forms/auth.py Normal file
View File

@ -0,0 +1,20 @@
from django import forms
from django.forms.models import modelformset_factory
from django.contrib.auth.models import User
from aircox.forms import widgets
__all__ = ("UserGroupFormSet",)
UserGroupFormSet = modelformset_factory(
User.groups.through,
fields=("group", "user"),
widgets={
"group": forms.HiddenInput(),
"user": widgets.VueAutoComplete("api:usergroup-autocomplete", lookup="username"),
},
extra=0,
can_delete=True,
)

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vue App</title>
<script defer src="js/chunk-vendors.js"></script><script defer src="js/chunk-common.js"></script><script defer src="js/admin.js"></script><link href="css/chunk-vendors.css" rel="stylesheet"><link href="css/chunk-common.css" rel="stylesheet"></head>
<body>
<div id="app"></div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -19,21 +19,12 @@ Usefull context:
<link rel="icon" href="{% thumbnail site.favicon 32x32 crop %}" />
{% block assets %}
{% static "vue/vue.esm-browser.js" as vue_url %}
<script type="importmap">
{
"imports": {
"vue": "{{vue_url}}"
}
}
</script>
<script type="module" src="{{vue_url}}"></script>
<link rel="stylesheet" type="text/css" href="{% static "fontawesome-free/css/all.min.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/index.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/public.css" %}"/>
<script type="module" src="{% if app_js_url %}{{ app_js_url }}{% else %}{% static "aircox/public.js" %}{% endif %}"></script>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/chunk-common.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/chunk-vendors.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/public.css" %}"/>
<script src="{% static "aircox/js/chunk-common.js" %}"></script>
<script src="{% static "aircox/js/chunk-vendors.js" %}"></script>
<script src="{% static "aircox/js/public.js" %}"></script>
{% endblock %}
<title>
@ -56,7 +47,6 @@ Usefull context:
})
</script>
<div id="app">
{% block app %}
<div class="navs">
{% block nav %}
<nav class="nav primary" role="navigation" aria-label="main navigation">
@ -168,7 +158,6 @@ Usefull context:
<div id="player">{% include "aircox/widgets/player.html" %}</div>
{% endblock %}
{% endblock %}
{% endblock %}
</body>
</html>

View File

@ -2,9 +2,9 @@
{% load static i18n %}
{% block assets %}
{% static "aircox/admin.js" as app_js_url %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "aircox/admin.css" %}"/>
<script src="{% static "aircox/js/dashboard.js" %}"></script>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/admin.css" %}"/>
{% endblock %}
{% block head-title %}

View File

@ -1,18 +0,0 @@
{% load i18n %}
<a-modal ref="group-users-modal">
<template #title="{item}">[[ item?.name ]]</template>
<template #default="{item}">
<a-group-users v-if="item" ref="group-users"
:url="'{% url 'api:usergroup-list' %}?group=' + item.id"
commit-url="{% url 'api:usergroup-commit' %}"
:search-url="'{% url 'api:user-autocomplete' %}?search=${query}&group=' + item.id"
:initials="{group_id: item.id }"
/>
</template>
<template #footer="{item, close}">
<button type="button" class="button" @click="$refs['group-users'].save(); close()">
Save
</button>
</template>
</a-modal>

View File

@ -18,10 +18,11 @@ Context:
{{ formset.non_form_errors }}
<!-- formset.management_form -->
<{{ tag|default:"a-form-set" }} ref="formset"
<{{ tag|default:"a-form-set" }}
{% block tag-attrs %}
:form-data="{{ formset_data|json }}"
:labels="window.aircox.labels"
:init-data="{% formset_inline_data formset=formset %}"
:columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-"
@ -39,7 +40,7 @@ Context:
</template>
{% for name, field in fields.items %}
{% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:control-{{ name }}="{context,item,cell,value,attr,emit,inputName}">
<template v-slot:control-{{ name }}="{item,cell,value,attr,emit,inputName}">
{% block row-control %}
{% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %}
{% endblock %}

View File

@ -1,7 +1,6 @@
{% extends "./public.html" %}
{% load i18n aircox %}
{% block head_title %}{{ station.name }}{% endblock %}
{% block title %}{% if page %}{{ block.super }}{% endif %}{% endblock %}

View File

@ -1,6 +1,11 @@
{% extends "./dashboard/base.html" %}
{% load static aircox_admin i18n %}
{% block assets %}
{{ block.super }}
<script src="{% static "aircox/js/dashboard.js" %}"></script>
{% endblock %}
{% block init-scripts %}
aircox.labels = {% inline_labels %}
{{ block.super }}
@ -81,11 +86,9 @@ aircox.labels = {% inline_labels %}
{% endblock %}
<hr/>
<div class="flex-row">
<div class="flex-grow-1">{% block page-form-actions %}{% endblock %}</div>
<div class="has-text-right">
<button type="submit" class="button">{% translate "Update" %}</button>
</div>
<div class="has-text-right">
<button type="submit" class="button">{% translate "Update" %}</button>
</div>
</form>
</section>
{% endblock %}

View File

@ -6,13 +6,13 @@
{{ form.media }}
{% endblock %}
{% block page-form-actions %}
{% if request.user.is_superuser %}
<button type="button"
class="button secondary"
@click="$refs['group-users-modal'].open({id: {{ object.editors_group_id }}, name: '{{ object.editors_group.name }}' })">Editors</button>
{% include "./dashboard/widgets/group_users.html" %}
{% block page-form %}
//////
{{ block.super }}
{% if editors_formset %}
<hr/>
<h2 class="title is-2">{% translate "Editors" %}</h2>
{% include "./widgets/usergroup_formset.html" with formset=editors_formset formset_data=editors_formset_data tag_id="usergroup_formset" %}
{% endif %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "./base.html" %}
{% extends base_template|default:"./base.html" %}
{% comment %}

View File

@ -1,4 +1,4 @@
<a-autocomplete
url="{{url}}"
{% if ":name" not in widget.attrs %}name="{{ name|default:widget.name }}"{% endif %}{% if widget.value != None %} model-value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% include "django/forms/widgets/attrs.html" %} {{ extra|default:"" }}/>
name="{{ widget.name }}"{% if widget.value != None %} model-value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% include "django/forms/widgets/attrs.html" %} />

View File

@ -3,7 +3,7 @@
{% block row-control %}
{% if name == 'user' %}
{% form_field field "inputName" value %}
{% form_field field name value %}
{% else %}
{{ block.super }}
{% endif %}

View File

@ -45,7 +45,7 @@ def do_formset_inline_data(context, formset):
# hack for sound list
if duration := item.get("duration"):
item["duration"] = duration.strftime("%H:%M")
if sound := getattr(form.instance, "sound", None):
if sound := getattr(form.instance, "sound"):
item["name"] = sound.name
fields["name"] = str(_("Sound")).capitalize()
@ -55,7 +55,7 @@ def do_formset_inline_data(context, formset):
item["tags"] = ", ".join(tag.name for tag in tags)
items.append(item)
data = {"items": items, "fields": fields, "initial": formset.initial and formset.initial[0]}
data = {"items": items, "fields": fields}
user = context["request"].user
settings = getattr(user, "aircox_settings", None)
data["settings"] = settings and UserSettingsSerializer(settings).data

View File

@ -21,13 +21,11 @@ register_converter(WeekConverter, "week")
router = DefaultRouter()
router.register("user", viewsets.UserViewSet, basename="user")
router.register("usergroup", viewsets.UserGroupViewSet, basename="usergroup")
router.register("images", viewsets.ImageViewSet, basename="image")
router.register("sound", viewsets.SoundViewSet, basename="sound")
router.register("track", viewsets.TrackROViewSet, basename="track")
router.register("comment", viewsets.CommentViewSet, basename="comment")
router.register("usergroup", viewsets.UserGroupViewSet, basename="usergroup")
api = [

View File

@ -115,9 +115,6 @@ class VueFormDataMixin:
# Note: values corresponds to AFormSet expected one
def get_form_items(self, formset):
return [form.initial for form in formset.forms]
def get_form_field_data(self, form, values=None):
"""Return form fields as data."""
model = form.Meta.model
@ -143,7 +140,5 @@ class VueFormDataMixin:
"max_num_forms": formset.max_num,
},
"fields": self.get_form_field_data(formset.form, field_values),
"initial_extra": formset.initial_extra and formset.initial_extra[0],
"initials": self.get_form_items(formset),
**kwargs,
}

View File

@ -1,5 +1,6 @@
import random
from django.contrib.auth.models import User
from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse
@ -60,3 +61,31 @@ class ProgramUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
def test_func(self):
obj = self.get_object()
return permissions.program.can(self.request.user, "update", obj)
def get_editors_queryset(self, program):
# TODO: provide username in formset initials
return User.groups.through.objects.filter(group_id=program.editors_group_id).order_by("user__username")
def get_editors_formset(self, program, **kwargs):
return forms.UserGroupFormSet(
**{
**kwargs,
"prefix": "editors",
"queryset": self.get_editors_queryset(program),
"initial": {
"group": program.editors_group_id,
},
}
)
def get_context_data(self, editors_formset=None, **kwargs):
# TODO: use group and permission system
if self.request.user.is_superuser:
if editors_formset is None:
editors_formset = self.get_editors_formset(self.object)
kwargs["editors_formset_data"] = self.get_formset_data(
editors_formset, {"group": self.object.editors_group_id}
)
context = super().get_context_data(editors_formset=editors_formset, **kwargs)
return context

View File

@ -40,49 +40,6 @@ class AutocompleteMixin:
return self.list(request)
class ListCommitMixin:
@action(name="commit", detail=False, methods=["POST"])
def commit(self, request):
"""
Data:
{
"delete": [pk],
"update": [{pk, **object}],
"create": [object_data]
}
Return:
{
"deleted": [pk],
"updated": [object],
"created": [object],
}
"""
queryset = self.get_queryset()
resp = {"deleted": [], "updated": [], "created": []}
if ids := request.data.get("delete"):
q = queryset.filter(id__in=ids)
resp["deleted"] = list(q.values_list("id", flat=True))
q.delete()
# TODO: bulk save and update
if items := request.data.get("update"):
resp["updated"] = self._commit_save_many(items)
if items := request.data.get("create"):
resp["created"] = self._commit_save_many(items)
return Response(data=resp)
def _commit_save_many(self, data):
ser = self.get_serializer(data=data, many=True)
ser.is_valid(raise_exception=True)
items = ser.save()
ser = self.get_serializer(items, many=True)
return ser.data
class ImageViewSet(viewsets.ModelViewSet):
parsers = (parsers.MultiPartParser,)
permissions = (permissions.IsAuthenticatedOrReadOnly,)
@ -127,6 +84,7 @@ class TrackROViewSet(AutocompleteMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = serializers.admin.TrackSerializer
permission_classes = (permissions.IsAuthenticated,)
filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.TrackFilterSet
queryset = models.Track.objects.all()
@ -138,19 +96,10 @@ class CommentViewSet(viewsets.ModelViewSet):
# --- admin
class UserViewSet(AutocompleteMixin, viewsets.ModelViewSet):
serializer_class = serializers.auth.UserSerializer
permission_classes = (permissions.IsAdminUser,)
filterset_class = filters.UserFilterSet
queryset = User.objects.all().distinct().order_by("username")
class UserGroupViewSet(ListCommitMixin, viewsets.ModelViewSet):
class UserGroupViewSet(AutocompleteMixin, viewsets.ModelViewSet):
serializer_class = serializers.auth.UserGroupSerializer
permission_classes = (permissions.IsAdminUser,)
filterset_class = filters.UserGroupFilterSet
model = User.groups.through
queryset = model.objects.all().distinct().order_by("user__username")
queryset = User.groups.through.objects.all().distinct().order_by("user__username")
class UserSettingsViewSet(viewsets.ViewSet):

View File

@ -1,29 +1,24 @@
# aircox
# aircox-assets
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
## Project setup
```
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
### Compiles and hot-reloads for development
```
npm run serve
```
### Compile and Minify for Production
```sh
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
assets/babel.config.js Normal file
View File

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

View File

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

View File

@ -1,37 +1,39 @@
{
"name": "aircox",
"version": "0.0.0",
"name": "aircox-assets",
"version": "0.1.0",
"private": true,
"type": "module",
"sideEffects": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"watch": "vite build --watch",
"preview": "vite preview"
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"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"
"vue": "^3.2.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"bulma": "^0.9.4",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.49.9",
"vite": "^5.2.8"
"sass-loader": "^12.6.0",
"vue-cli": "^2.9.6",
"webpack-cli": "^5.1.4"
},
"eslintConfig": {
"root": true,
"env": {
"node": true,
"es2022": true
"node": true
},
"extends": [
"plugin:vue/vue3-essential",

BIN
assets/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import './styles/admin.scss'
import './index.js'
import App from './app';
import components from './components/admin.js'
import {admin as components} from './components'
const AdminApp = {
...App,
@ -11,21 +11,7 @@ const AdminApp = {
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;

View File

@ -17,22 +17,10 @@ const App = {
},
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
}
},
}
}
}

View File

@ -20,23 +20,24 @@
<span class="is-inline-block" v-if="selected">
<slot name="button" :index="selectedIndex" :item="selected"
:value-field="valueField" :labelField="labelField">
{{ selectedLabel }}
{{ labelField && selected.data[labelField] || selected }}
</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"
<a v-for="(item, index) in items" :key="item.id"
href="#" :data-autocomplete-index="index"
@click="select(index, false, false)"
:class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
:title="labelField && item.data[labelField] || item"
tabindex="-1">
<slot name="item" :index="index" :item="item" :value-field="valueField"
:labelField="labelField">
{{ getValue(item, labelField) || item }}
{{ labelField && item.data[labelField] || item }}
</slot>
</span>
</a>
</div>
</div>
</div>
@ -55,14 +56,12 @@ export default {
props: {
//! Search URL (where `${query}` is replaced by search term)
url: String,
//! Extra GET url parameters
urlParams: Object,
//! Items' model
model: Function,
//! Input tag class
inputClass: Array,
//! input text placeholder
placeholder: Object,
placeholder: String,
//! input form field name
name: String,
//! Field on items to use as label
@ -106,20 +105,6 @@ export default {
},
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() {
@ -151,34 +136,12 @@ export default {
},
methods: {
reset() {
this.inputValue = ""
this.selectedIndex = -1
this.items = []
},
// TODO: move to utils/data
getValue(data, path=null) {
if(!data)
return null
if(!path)
return data
const paths = path.split('.')
for(const key of paths) {
if(key in data)
data = data[key]
else return null;
}
return data
},
itemValue(item) {
return this.valueField ? this.getValue(item, this.valueField) : item;
return this.valueField ? item && item[this.valueField] : item;
},
itemLabel(item) {
return this.labelField ? this.getValue(item, this.labelField) : item;
return this.labelField ? item && item[this.labelField] : item;
},
hide() {
@ -220,7 +183,7 @@ export default {
if(!this.items.length)
return
var index = event.relatedTarget && Math.floor(event.relatedTarget.dataset.autocompleteIndex);
var index = event.relatedTarget && Math.parseInt(event.relatedTarget.dataset.autocompleteIndex);
if(index !== undefined && index !== null)
this.select(index, false, false)
this.cursor = -1;
@ -264,7 +227,7 @@ export default {
return
this.query = query
var url = this.fullUrl.replace('${query}', query).replace('%24%7Bquery%7D', query)
var url = this.url.replace('${query}', query)
var promise = this.model ? this.model.fetch(url, {many:true})
: fetch(url, Model.getOptions()).then(d => d.json())

View File

@ -3,9 +3,9 @@
</template>
<script>
import {Set} from '../model.js';
import Sound from '../sound.js';
import APage from './APage.vue';
import {Set} from '../model';
import Sound from '../sound';
import APage from './APage';
export default {
extends: APage,

View File

@ -26,7 +26,7 @@
</div>
</template>
<script>
import {getCsrf} from "../model.js"
import {getCsrf} from "../model"
export default {
emit: ["fileChange", "load", "abort", "error"],

View File

@ -6,7 +6,7 @@
:value="value"/>
</template>
<a-rows ref="rows" :set="set" :context="this"
<a-rows ref="rows" :set="set"
:columns="visibleFields" :columnsOrderable="columnsOrderable"
:orderable="orderable" @move="moveItem" @colmove="onColumnMove"
@cell="e => $emit('cell', e)">
@ -45,7 +45,7 @@
<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">
<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"/>
@ -118,6 +118,8 @@
formData: Object,
//! Model class used for item's set
model: {type: Function, default: Model},
//! initial data set load at mount
initials: Array,
},
data() {
@ -182,7 +184,7 @@
//! Reset forms to initials
reset() {
this.load(this.formData?.initials || [], true)
this.load(this.initials || [], true)
},
},

View File

@ -1,102 +0,0 @@
<template>
<div class="a-group-users">
<table class="table is-fullwidth">
<thead>
<tr>
<th>
Members
</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>
<td>
<b class="mr-3">{{ item.data.user.username }}</b>
<span class="text-light">{{ item.data.user.first_name }} {{ item.data.user.last_name }}</span>
</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-user"/>
</span>
Add user
</label>
<a-autocomplete ref="autocomplete" :url="searchUrl"
label-field="username" value-field="id"
@select="onUserSelect">
<template #item="{item}">
<b class="mr-3">{{ item.username }}</b>
<span class="text-light">{{ item.first_name }} {{ item.last_name }}</span>
&mdash;
<i>{{ item.email }}</i>
</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,
// User autocomplete url
searchUrl: String,
// POST url
commitUrl: String,
// default values
initials: {type: Object, default: () => ({})},
},
data() {
return {
set: new Set(this.model, {url: this.url, unique: true}),
}
},
computed: {
items() { return this.set?.items || [] },
user_ids() { return this.set?.items.map(i => i.data.user.id) },
},
methods: {
onUserSelect(index, item, value) {
if(this.user_ids.indexOf(item.id) != -1)
return
this.set.push({
...this.initials,
user: {...item},
})
this.$refs.autocomplete.reset()
},
save() {
this.set.commit(this.commitUrl, {
getData: i => ({...this.initials, user_id: i.data.user.id})
})
},
},
mounted() {
this.set.fetch()
},
}
</script>

View File

@ -4,9 +4,9 @@
<div class="modal-card">
<header class="modal-card-head">
<div class="modal-card-title">
<slot name="title" :item="item">{{ title }}</slot>
<slot name="title">{{ title }}</slot>
</div>
<slot name="bar" :item="item"></slot>
<slot name="bar"></slot>
<button type="button" class="delete square" aria-label="close" @click="close">
<span class="icon">
<i class="fa fa-close"></i>

View File

@ -1,25 +1,25 @@
<template>
<tr>
<slot name="head" :context="context" :item="item" :row="row"/>
<slot name="head" :item="item" :row="row"/>
<template v-for="(attr,col) in columns" :key="col">
<slot name="cell-before" :context="context" :item="item" :cell="cells[col]"
<slot name="cell-before" :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]"
<slot :name="attr" :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]"
<slot name="cell" :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]"
<slot name="cell-after" :item="item" :col="col" :cell="cells[col]"
:attr="attr"/>
</template>
<slot name="tail" :context="context" :item="item" :row="row"/>
<slot name="tail" :item="item" :row="row"/>
</tr>
</template>
<script>
@ -30,8 +30,6 @@ 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

View File

@ -1,7 +1,7 @@
<template>
<table class="table is-stripped is-fullwidth">
<thead>
<a-row :context="context" :columns="columnNames"
<a-row :columns="columnNames"
:orderable="columnsOrderable" cellTag="th"
@move="moveColumn">
<template v-if="$slots['header-head']" v-slot:head="data">
@ -26,7 +26,7 @@
<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"
<a-row :item="item" :cell="{row}" :columns="columnNames" :data-index="row"
:data-row="row"
:draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
@ -54,9 +54,6 @@ const Component = {
props: {
...AList.props,
//! Context object
context: {type: Object, default: () => ({})},
//! Ordered list of columns, as objects with:
//! - name: item attribute value
//! - label: display label

View File

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

View File

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

33
assets/src/dashboard.js Normal file
View File

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

View File

@ -3,8 +3,6 @@
* administration interface)
*/
import 'vue'
//-- aircox
import App, {PlayerApp} from './app'
import VueLoader from './vueLoader'

View File

@ -113,7 +113,7 @@ export default class Model {
}
/**
* Set instance's data with provided data. Return None
* Update instance's data with provided data. Return None
*/
commit(data) {
this.data = data;
@ -121,17 +121,11 @@ export default class Model {
}
/**
* Update model data, without reset previous value.
* Item is marked as updated.
* Update model data, without reset previous value
*/
update(data) {
this.data = {...this.data, ...data}
this.id = this.constructor.getId(this.data)
this.updated = true
}
delete() {
this.deleted = true
}
/**
@ -183,24 +177,8 @@ export class Set {
this.push(item, {args: args, save: false});
}
//! Return total items count
get length() { return this.items.length }
//! Return a list of items marked as deleted
get deletedItems() {
return this.items.filter(i => i.deleted)
}
//! Return a list of created items
get createdItems() {
return this.items.filter(i => !i.deleted && !i.id)
}
//! Return a list of updated items
get updatedItems() {
return this.items.filter(i => i.updated)
}
/**
* Fetch multiple items from server
*/
@ -212,58 +190,6 @@ export class Set {
.map(d => new model(d, {url: url, ...args})))
}
fetch({url=null, reset=false, ...options}={}, args=null) {
url = url || this.url
options = this.model.getOptions(options)
return fetch(url, options)
.then(response => response.json())
.then(data =>
(data instanceof Array ? data : data.results)
.map(d => new this.model(d, {url: url, ...args}))
)
.then(data => {
if(reset)
this.items = data
else
// TODO: remove duplicate
this.items = [...this.items, ...data]
return data
})
}
/**
* Commit changes to server.
* ref: `views.mixin.ListCommitMixin`
*/
commit(url, {getData=null, ...options}={}) {
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
*/
@ -308,30 +234,22 @@ export class Set {
: this.items.findIndex(x => x.id == pred.id);
}
extend(items, options) {
items.forEach(i => this.push(i, options))
}
/**
* Add item to set, return index.
* If item already exists, replace it.
*/
push(item, {args={},save=true}={}) {
item = item instanceof this.model ? item : new this.model(item, args);
let index = -1
if(this.unique && item.id) {
index = this.findIndex(item);
if(this.unique) {
let index = this.findIndex(item);
if(index > -1)
this.items[index] = item
return index;
}
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;
if(this.max && this.items.length >= this.max)
this.items.splice(0,this.items.length-this.max)
this.items.push(item);
save && this.save();
return this.items.length-1;
}
/**

View File

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

View File

@ -309,9 +309,9 @@
.preview-header {
width: 100%;
/*&:not(.no-cover) {
&:not(.no-cover) {
min-height: var(--header-height);
}*/
}
&.no-cover {
height: unset;

View File

@ -1,7 +1,7 @@
@use "./vars" as v;
// ---- text
.text-light { font-weight: 400; color: var(--text-color-light); }
.text-light { weight: 400; color: var(--text-color-light); }
.bigger { font-size: v.$text-size-bigger !important; }
.big { font-size: v.$text-size-big !important; }
@ -21,10 +21,6 @@
&.x { padding-right: 0px !important; }
}
.align-center {
text-align: center !important;
justify-content: center;
}
.clear-left { clear: left !important }
.clear-right { clear: right !important }

View File

@ -398,7 +398,7 @@ nav li {
}
}
.header-cover:only-child {
width: 100%;
with: 100%;
}
@media screen and (max-width: v.$screen-small) {

View File

@ -1,5 +1,5 @@
@import 'v-calendar/style.css';
// @import '@fortawesome/fontawesome-free/css/all.min.css';
@import '@fortawesome/fontawesome-free/css/all.min.css';
// ---- bulma
$body-color: #000;

View File

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

23
assets/vue.config.js Normal file
View File

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

View File

@ -254,8 +254,4 @@ WSGI_APPLICATION = "instance.wsgi.application"
LOGOUT_REDIRECT_URL = "/"
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"PAGE_SIZE": 50,
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}
REST_FRAMEWORK = {"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", "PAGE_SIZE": 50}