diff --git a/aircox/filters.py b/aircox/filters.py index be6a9a5..b3b0987 100644 --- a/aircox/filters.py +++ b/aircox/filters.py @@ -1,5 +1,7 @@ -import django_filters as filters +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 . import models @@ -10,6 +12,8 @@ __all__ = ( "ImageFilterSet", "SoundFilterSet", "TrackFilterSet", + "UserFilterSet", + "UserGroupFilterSet", ) @@ -67,3 +71,26 @@ 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"] diff --git a/aircox/forms/__init__.py b/aircox/forms/__init__.py index c15fee5..9faf451 100644 --- a/aircox/forms/__init__.py +++ b/aircox/forms/__init__.py @@ -1,6 +1,5 @@ from . import widgets -from .auth import UserGroupFormSet from .episode import EpisodeForm, EpisodeSoundFormSet from .program import ProgramForm from .page import CommentForm, ImageForm, PageForm, ChildPageForm @@ -19,5 +18,4 @@ __all__ = ( ChildPageForm, SoundForm, SoundCreateForm, - UserGroupFormSet, ) diff --git a/aircox/forms/auth.py b/aircox/forms/auth.py deleted file mode 100644 index 1c8ba33..0000000 --- a/aircox/forms/auth.py +++ /dev/null @@ -1,20 +0,0 @@ -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, -) diff --git a/aircox/templates/aircox/base.html b/aircox/templates/aircox/base.html index 87f3a58..94fb483 100644 --- a/aircox/templates/aircox/base.html +++ b/aircox/templates/aircox/base.html @@ -30,17 +30,10 @@ Usefull context: - - + + - {% comment %} - - - - - - - {% endcomment %} + {% endblock %} diff --git a/aircox/templates/aircox/dashboard/base.html b/aircox/templates/aircox/dashboard/base.html index 18e554a..3b9813b 100644 --- a/aircox/templates/aircox/dashboard/base.html +++ b/aircox/templates/aircox/dashboard/base.html @@ -2,9 +2,9 @@ {% load static i18n %} {% block assets %} +{% static "aircox/admin.js" as app_js_url %} {{ block.super }} -<script src="{% static "aircox/js/dashboard.js" %}"></script> -<link rel="stylesheet" type="text/css" href="{% static "aircox/css/admin.css" %}"/> +<link rel="stylesheet" type="text/css" href="{% static "aircox/admin.css" %}"/> {% endblock %} {% block head-title %} diff --git a/aircox/templates/aircox/dashboard/widgets/group_users.html b/aircox/templates/aircox/dashboard/widgets/group_users.html new file mode 100644 index 0000000..c6a87e0 --- /dev/null +++ b/aircox/templates/aircox/dashboard/widgets/group_users.html @@ -0,0 +1,18 @@ +{% 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> diff --git a/aircox/templates/aircox/forms/formset.html b/aircox/templates/aircox/forms/formset.html index d843dc0..47a807d 100644 --- a/aircox/templates/aircox/forms/formset.html +++ b/aircox/templates/aircox/forms/formset.html @@ -18,11 +18,10 @@ Context: {{ formset.non_form_errors }} <!-- formset.management_form --> - <{{ tag|default:"a-form-set" }} + <{{ tag|default:"a-form-set" }} ref="formset" {% 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 }}-" @@ -40,7 +39,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 }}="{item,cell,value,attr,emit,inputName}"> + <template v-slot:control-{{ name }}="{context,item,cell,value,attr,emit,inputName}"> {% block row-control %} {% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %} {% endblock %} diff --git a/aircox/templates/aircox/page_form.html b/aircox/templates/aircox/page_form.html index b6d30c4..4890890 100644 --- a/aircox/templates/aircox/page_form.html +++ b/aircox/templates/aircox/page_form.html @@ -1,11 +1,6 @@ {% 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 }} @@ -86,9 +81,11 @@ aircox.labels = {% inline_labels %} {% endblock %} <hr/> - <div class="has-text-right"> - <button type="submit" class="button">{% translate "Update" %}</button> - </div> + <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> </form> </section> {% endblock %} diff --git a/aircox/templates/aircox/program_form.html b/aircox/templates/aircox/program_form.html index 413cd33..3734c38 100644 --- a/aircox/templates/aircox/program_form.html +++ b/aircox/templates/aircox/program_form.html @@ -6,13 +6,13 @@ {{ form.media }} {% endblock %} -{% block page-form %} -////// -{{ block.super }} +{% 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> -{% 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" %} +{% include "./dashboard/widgets/group_users.html" %} +{{ block.super }} {% endif %} {% endblock %} diff --git a/aircox/templates/aircox/widgets/autocomplete.html b/aircox/templates/aircox/widgets/autocomplete.html index e74e0ce..d4b8ffa 100644 --- a/aircox/templates/aircox/widgets/autocomplete.html +++ b/aircox/templates/aircox/widgets/autocomplete.html @@ -1,4 +1,4 @@ <a-autocomplete url="{{url}}" - name="{{ widget.name }}"{% if widget.value != None %} model-value="{{ widget.value|stringformat:'s' }}"{% endif %} - {% include "django/forms/widgets/attrs.html" %} /> + {% 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:"" }}/> diff --git a/aircox/templates/aircox/widgets/usergroup_formset.html b/aircox/templates/aircox/widgets/usergroup_formset.html index b900de2..57c6b75 100644 --- a/aircox/templates/aircox/widgets/usergroup_formset.html +++ b/aircox/templates/aircox/widgets/usergroup_formset.html @@ -3,7 +3,7 @@ {% block row-control %} {% if name == 'user' %} - {% form_field field name value %} + {% form_field field "inputName" value %} {% else %} {{ block.super }} {% endif %} diff --git a/aircox/templatetags/aircox_admin.py b/aircox/templatetags/aircox_admin.py index b21365b..55986c5 100644 --- a/aircox/templatetags/aircox_admin.py +++ b/aircox/templatetags/aircox_admin.py @@ -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"): + if sound := getattr(form.instance, "sound", None): 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} + data = {"items": items, "fields": fields, "initial": formset.initial and formset.initial[0]} user = context["request"].user settings = getattr(user, "aircox_settings", None) data["settings"] = settings and UserSettingsSerializer(settings).data diff --git a/aircox/urls.py b/aircox/urls.py index 690d1d1..bdadc1e 100755 --- a/aircox/urls.py +++ b/aircox/urls.py @@ -21,11 +21,13 @@ 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 = [ diff --git a/aircox/views/mixins.py b/aircox/views/mixins.py index 72e9aa3..6190d01 100644 --- a/aircox/views/mixins.py +++ b/aircox/views/mixins.py @@ -115,6 +115,9 @@ 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 @@ -140,5 +143,7 @@ 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, } diff --git a/aircox/views/program.py b/aircox/views/program.py index 7df25b8..2e1e214 100644 --- a/aircox/views/program.py +++ b/aircox/views/program.py @@ -1,6 +1,5 @@ import random -from django.contrib.auth.models import User from django.contrib.auth.mixins import UserPassesTestMixin from django.urls import reverse @@ -61,31 +60,3 @@ 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 diff --git a/aircox/viewsets.py b/aircox/viewsets.py index 00fe67f..7dc2af2 100644 --- a/aircox/viewsets.py +++ b/aircox/viewsets.py @@ -40,6 +40,49 @@ 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,) @@ -84,7 +127,6 @@ 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() @@ -96,10 +138,19 @@ class CommentViewSet(viewsets.ModelViewSet): # --- admin -class UserGroupViewSet(AutocompleteMixin, viewsets.ModelViewSet): +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): serializer_class = serializers.auth.UserGroupSerializer permission_classes = (permissions.IsAdminUser,) - queryset = User.groups.through.objects.all().distinct().order_by("user__username") + filterset_class = filters.UserGroupFilterSet + model = User.groups.through + queryset = model.objects.all().distinct().order_by("user__username") class UserSettingsViewSet(viewsets.ViewSet): diff --git a/assets/src/admin.js b/assets/src/admin.js index ff11096..5b9fa68 100644 --- a/assets/src/admin.js +++ b/assets/src/admin.js @@ -2,7 +2,7 @@ import './styles/admin.scss' import './index.js' import App from './app'; -import {admin as components} from './components' +import components from './components/admin.js' const AdminApp = { ...App, diff --git a/assets/src/app.js b/assets/src/app.js index 3de02bf..881db6f 100644 --- a/assets/src/app.js +++ b/assets/src/app.js @@ -17,10 +17,22 @@ 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 + } + }, } } diff --git a/assets/src/components/AAutocomplete.vue b/assets/src/components/AAutocomplete.vue index 44805cc..d46d001 100644 --- a/assets/src/components/AAutocomplete.vue +++ b/assets/src/components/AAutocomplete.vue @@ -20,24 +20,23 @@ <span class="is-inline-block" v-if="selected"> <slot name="button" :index="selectedIndex" :item="selected" :value-field="valueField" :labelField="labelField"> - {{ labelField && selected.data[labelField] || selected }} + {{ selectedLabel }} </slot> </span> </a> <div :class="dropdownClass"> <div class="dropdown-menu is-fullwidth"> <div class="dropdown-content" style="overflow: hidden"> - <a v-for="(item, index) in items" :key="item.id" - href="#" :data-autocomplete-index="index" + <span v-for="(item, index) in items" :key="item.id" + :data-autocomplete-index="index" @click="select(index, false, false)" :class="['dropdown-item', (index == this.cursor) ? 'is-active':'']" - :title="labelField && item.data[labelField] || item" tabindex="-1"> <slot name="item" :index="index" :item="item" :value-field="valueField" :labelField="labelField"> - {{ labelField && item.data[labelField] || item }} + {{ getValue(item, labelField) || item }} </slot> - </a> + </span> </div> </div> </div> @@ -56,12 +55,14 @@ export default { props: { //! Search URL (where `${query}` is replaced by search term) url: String, + //! Extra GET url parameters + urlParams: Object, //! Items' model model: Function, //! Input tag class inputClass: Array, //! input text placeholder - placeholder: String, + placeholder: Object, //! input form field name name: String, //! Field on items to use as label @@ -105,6 +106,20 @@ 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() { @@ -136,12 +151,34 @@ export default { }, methods: { + reset() { + this.inputValue = "" + this.selectedIndex = -1 + this.items = [] + }, + + // TODO: move to utils/data + getValue(data, path=null) { + if(!data) + return null + if(!path) + return data + + const paths = path.split('.') + for(const key of paths) { + if(key in data) + data = data[key] + else return null; + } + return data + }, + itemValue(item) { - return this.valueField ? item && item[this.valueField] : item; + return this.valueField ? this.getValue(item, this.valueField) : item; }, itemLabel(item) { - return this.labelField ? item && item[this.labelField] : item; + return this.labelField ? this.getValue(item, this.labelField) : item; }, hide() { @@ -183,7 +220,7 @@ export default { if(!this.items.length) return - var index = event.relatedTarget && Math.parseInt(event.relatedTarget.dataset.autocompleteIndex); + var index = event.relatedTarget && Math.floor(event.relatedTarget.dataset.autocompleteIndex); if(index !== undefined && index !== null) this.select(index, false, false) this.cursor = -1; @@ -227,7 +264,7 @@ export default { return this.query = query - var url = this.url.replace('${query}', query) + var url = this.fullUrl.replace('${query}', query).replace('%24%7Bquery%7D', query) var promise = this.model ? this.model.fetch(url, {many:true}) : fetch(url, Model.getOptions()).then(d => d.json()) diff --git a/assets/src/components/AFormSet.vue b/assets/src/components/AFormSet.vue index fcf03fb..7121550 100644 --- a/assets/src/components/AFormSet.vue +++ b/assets/src/components/AFormSet.vue @@ -6,7 +6,7 @@ :value="value"/> </template> - <a-rows ref="rows" :set="set" + <a-rows ref="rows" :set="set" :context="this" :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,8 +118,6 @@ formData: Object, //! Model class used for item's set model: {type: Function, default: Model}, - //! initial data set load at mount - initials: Array, }, data() { @@ -184,7 +182,7 @@ //! Reset forms to initials reset() { - this.load(this.initials || [], true) + this.load(this.formData?.initials || [], true) }, }, diff --git a/assets/src/components/AGroupUsers.vue b/assets/src/components/AGroupUsers.vue new file mode 100644 index 0000000..34862d9 --- /dev/null +++ b/assets/src/components/AGroupUsers.vue @@ -0,0 +1,102 @@ +<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> + — + <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> diff --git a/assets/src/components/AModal.vue b/assets/src/components/AModal.vue index 700e4d7..664447d 100644 --- a/assets/src/components/AModal.vue +++ b/assets/src/components/AModal.vue @@ -4,9 +4,9 @@ <div class="modal-card"> <header class="modal-card-head"> <div class="modal-card-title"> - <slot name="title">{{ title }}</slot> + <slot name="title" :item="item">{{ title }}</slot> </div> - <slot name="bar"></slot> + <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> diff --git a/assets/src/components/ARow.vue b/assets/src/components/ARow.vue index c21618a..171734c 100644 --- a/assets/src/components/ARow.vue +++ b/assets/src/components/ARow.vue @@ -1,25 +1,25 @@ <template> <tr> - <slot name="head" :item="item" :row="row"/> + <slot name="head" :context="context" :item="item" :row="row"/> <template v-for="(attr,col) in columns" :key="col"> - <slot name="cell-before" :item="item" :cell="cells[col]" + <slot name="cell-before" :context="context" :item="item" :cell="cells[col]" :attr="attr"/> <component :is="cellTag" :class="['cell', 'cell-' + attr]" :data-col="col" :draggable="orderable" @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"> - <slot :name="attr" :item="item" :cell="cells[col]" + <slot :name="attr" :context="context" :item="item" :cell="cells[col]" :data="itemData" :attr="attr" :emit="cellEmit" :value="itemData && itemData[attr]"> {{ itemData && itemData[attr] }} </slot> - <slot name="cell" :item="item" :cell="cells[col]" + <slot name="cell" :context="context" :item="item" :cell="cells[col]" :data="itemData" :attr="attr" :emit="cellEmit" :value="itemData && itemData[attr]"/> </component> - <slot name="cell-after" :item="item" :col="col" :cell="cells[col]" + <slot name="cell-after" :context="context" :item="item" :col="col" :cell="cells[col]" :attr="attr"/> </template> - <slot name="tail" :item="item" :row="row"/> + <slot name="tail" :context="context" :item="item" :row="row"/> </tr> </template> <script> @@ -30,6 +30,8 @@ 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 diff --git a/assets/src/components/ARows.vue b/assets/src/components/ARows.vue index d31c054..d214bc9 100644 --- a/assets/src/components/ARows.vue +++ b/assets/src/components/ARows.vue @@ -1,7 +1,7 @@ <template> <table class="table is-stripped is-fullwidth"> <thead> - <a-row :columns="columnNames" + <a-row :context="context" :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 :item="item" :cell="{row}" :columns="columnNames" :data-index="row" + <a-row :context="context" :item="item" :cell="{row}" :columns="columnNames" :data-index="row" :data-row="row" :draggable="orderable" @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop" @@ -54,6 +54,9 @@ 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 diff --git a/assets/src/components/admin.js b/assets/src/components/admin.js new file mode 100644 index 0000000..357b916 --- /dev/null +++ b/assets/src/components/admin.js @@ -0,0 +1,23 @@ +import AFileUpload from "./AFileUpload.vue" +import ASelectFile from "./ASelectFile.vue" +import AStatistics from './AStatistics.vue' +import AStreamer from './AStreamer.vue' + +import AFormSet from './AFormSet.vue' +import ATrackListEditor from './ATrackListEditor.vue' +import ASoundListEditor from './ASoundListEditor.vue' + +import AGroupUsers from "./AGroupUsers.vue" + +import base from "./index.js" + + +export const admin = { + ...base, + AGroupUsers, + AFileUpload, ASelectFile, + AFormSet, ATrackListEditor, ASoundListEditor, + AStatistics, AStreamer, +} + +export default admin diff --git a/assets/src/components/index.js b/assets/src/components/index.js index 3309e70..9160578 100644 --- a/assets/src/components/index.js +++ b/assets/src/components/index.js @@ -1,7 +1,8 @@ -import AActionButton from './AActionButton.vue' import AAutocomplete from './AAutocomplete.vue' -import ACarousel from './ACarousel.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' @@ -11,31 +12,15 @@ import AProgress from './AProgress.vue' import ASoundItem from './ASoundItem.vue' import ASwitch from './ASwitch.vue' -import AModal from "./AModal.vue" -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' /** * Core components */ export const base = { - AAutocomplete, ACarousel, ADropdown, AEpisode, AList, APage, APlayer, APlaylist, + AActionButton, AAutocomplete, AModal, + ACarousel, ADropdown, AEpisode, AList, APage, APlayer, APlaylist, AProgress, ASoundItem, ASwitch, } export default base - - -export const admin = { - ...base, - AActionButton, AFileUpload, ASelectFile, AModal, - AFormSet, ATrackListEditor, ASoundListEditor, - AStatistics, AStreamer, -} diff --git a/assets/src/model.js b/assets/src/model.js index 6b80592..ae803ff 100644 --- a/assets/src/model.js +++ b/assets/src/model.js @@ -113,7 +113,7 @@ export default class Model { } /** - * Update instance's data with provided data. Return None + * Set instance's data with provided data. Return None */ commit(data) { this.data = data; @@ -121,11 +121,17 @@ export default class Model { } /** - * Update model data, without reset previous value + * Update model data, without reset previous value. + * Item is marked as updated. */ update(data) { this.data = {...this.data, ...data} this.id = this.constructor.getId(this.data) + this.updated = true + } + + delete() { + this.deleted = true } /** @@ -177,8 +183,24 @@ export class Set { this.push(item, {args: args, save: false}); } + //! Return total items count get length() { return this.items.length } + //! Return a list of items marked as deleted + get deletedItems() { + return this.items.filter(i => i.deleted) + } + + //! Return a list of created items + get createdItems() { + return this.items.filter(i => !i.deleted && !i.id) + } + + //! Return a list of updated items + get updatedItems() { + return this.items.filter(i => i.updated) + } + /** * Fetch multiple items from server */ @@ -190,6 +212,58 @@ 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 */ @@ -234,22 +308,30 @@ export class Set { : this.items.findIndex(x => x.id == pred.id); } + extend(items, options) { + items.forEach(i => this.push(i, options)) + } + /** * Add item to set, return index. + * If item already exists, replace it. */ push(item, {args={},save=true}={}) { item = item instanceof this.model ? item : new this.model(item, args); - if(this.unique) { - let index = this.findIndex(item); + let index = -1 + if(this.unique && item.id) { + index = this.findIndex(item); if(index > -1) - return index; + this.items[index] = item } - if(this.max && this.items.length >= this.max) - this.items.splice(0,this.items.length-this.max) - - this.items.push(item); - save && this.save(); - return this.items.length-1; + if(index == -1) { + if(this.max && this.items.length >= this.max) + this.items.splice(0,this.items.length-this.max) + this.items.push(item) + index = this.items.length-1 + } + save && this.save() + return index; } /** diff --git a/assets/src/styles/components.scss b/assets/src/styles/components.scss index f77354d..fdac627 100644 --- a/assets/src/styles/components.scss +++ b/assets/src/styles/components.scss @@ -309,9 +309,9 @@ .preview-header { width: 100%; - &:not(.no-cover) { + /*&:not(.no-cover) { min-height: var(--header-height); - } + }*/ &.no-cover { height: unset; diff --git a/assets/src/styles/helpers.scss b/assets/src/styles/helpers.scss index bcaffeb..3b08d77 100644 --- a/assets/src/styles/helpers.scss +++ b/assets/src/styles/helpers.scss @@ -21,6 +21,10 @@ &.x { padding-right: 0px !important; } } +.align-center { + text-align: center !important; + justify-content: center; +} .clear-left { clear: left !important } .clear-right { clear: right !important } diff --git a/assets/vite.config.js b/assets/vite.config.js index 7af9c95..0cf8b45 100644 --- a/assets/vite.config.js +++ b/assets/vite.config.js @@ -25,6 +25,8 @@ export default defineConfig({ globals: { vue: 'Vue', }, + assetFileNames: "[name].[ext]", + chunkFileNames: "[name].js", entryFileNames: "[name].js", }, plugins: [commonjs()], diff --git a/instance/settings/base.py b/instance/settings/base.py index 453dafe..1fd2974 100755 --- a/instance/settings/base.py +++ b/instance/settings/base.py @@ -254,4 +254,8 @@ WSGI_APPLICATION = "instance.wsgi.application" LOGOUT_REDIRECT_URL = "/" -REST_FRAMEWORK = {"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", "PAGE_SIZE": 50} +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 50, + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], +}