- various __all__

- serializer: track search, reorder module files
- autocomplete: allow simple string value selection
- playlist editor:
    - ui & flow improve
    - init data
    - save user settings
    - autocomplete
    - fix bugs
    - discard changes
This commit is contained in:
bkfox 2022-12-12 00:25:57 +01:00
parent 61af53eecb
commit 180cc8bc02
30 changed files with 708 additions and 259 deletions

View File

@ -1,10 +1,11 @@
from .article import Article from .article import *
from .page import Category, Page, StaticPage, Comment, NavItem from .page import *
from .program import Program, Stream, Schedule from .program import *
from .episode import Episode, Diffusion from .episode import *
from .log import Log from .log import *
from .sound import Sound, Track from .sound import *
from .station import Station, Port from .station import *
from .user_settings import *
from . import signals from . import signals

View File

@ -1,8 +1,10 @@
from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .page import Page, PageQuerySet from .page import Page
from .program import Program, ProgramChildQuerySet from .program import ProgramChildQuerySet
__all__ = ('Article',)
class Article(Page): class Article(Page):

View File

@ -9,12 +9,12 @@ from django.utils.functional import cached_property
from easy_thumbnails.files import get_thumbnailer from easy_thumbnails.files import get_thumbnailer
from aircox import settings, utils from aircox import settings, utils
from .program import Program, ProgramChildQuerySet, \ from .program import ProgramChildQuerySet, \
BaseRerun, BaseRerunQuerySet, Schedule BaseRerun, BaseRerunQuerySet, Schedule
from .page import Page, PageQuerySet from .page import Page
__all__ = ['Episode', 'Diffusion', 'DiffusionQuerySet'] __all__ = ('Episode', 'Diffusion', 'DiffusionQuerySet')
class Episode(Page): class Episode(Page):
@ -31,9 +31,9 @@ class Episode(Page):
""" Return serialized data about podcasts. """ """ Return serialized data about podcasts. """
from ..serializers import PodcastSerializer from ..serializers import PodcastSerializer
podcasts = [PodcastSerializer(s).data podcasts = [PodcastSerializer(s).data
for s in self.sound_set.public().order_by('type') ] for s in self.sound_set.public().order_by('type')]
if self.cover: if self.cover:
options = {'size': (128,128), 'crop':'scale'} options = {'size': (128, 128), 'crop': 'scale'}
cover = get_thumbnailer(self.cover).get_thumbnail(options).url cover = get_thumbnailer(self.cover).get_thumbnail(options).url
else: else:
cover = None cover = None
@ -84,7 +84,7 @@ class DiffusionQuerySet(BaseRerunQuerySet):
def episode(self, episode=None, id=None): def episode(self, episode=None, id=None):
""" Diffusions for this episode """ """ Diffusions for this episode """
return self.filter(episode=episode) if id is None else \ return self.filter(episode=episode) if id is None else \
self.filter(episode__id=id) self.filter(episode__id=id)
def on_air(self): def on_air(self):
""" On air diffusions """ """ On air diffusions """
@ -104,13 +104,13 @@ class DiffusionQuerySet(BaseRerunQuerySet):
end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999)) end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
# start = tz.get_current_timezone().localize(start) # start = tz.get_current_timezone().localize(start)
# end = tz.get_current_timezone().localize(end) # end = tz.get_current_timezone().localize(end)
qs = self.filter(start__range = (start, end)) qs = self.filter(start__range=(start, end))
return qs.order_by('start') if order else qs return qs.order_by('start') if order else qs
def at(self, date, order=True): def at(self, date, order=True):
""" Return diffusions at specified date or datetime """ """ Return diffusions at specified date or datetime """
return self.now(date, order) if isinstance(date, tz.datetime) else \ return self.now(date, order) if isinstance(date, tz.datetime) else \
self.date(date, order) self.date(date, order)
def after(self, date=None): def after(self, date=None):
""" """
@ -201,7 +201,7 @@ class Diffusion(BaseRerun):
def __str__(self): def __str__(self):
str_ = '{episode} - {date}'.format( str_ = '{episode} - {date}'.format(
self=self, episode=self.episode and self.episode.title, episode=self.episode and self.episode.title,
date=self.local_start.strftime('%Y/%m/%d %H:%M%z'), date=self.local_start.strftime('%Y/%m/%d %H:%M%z'),
) )
if self.initial: if self.initial:
@ -324,5 +324,3 @@ class Diffusion(BaseRerun):
'end': self.end, 'end': self.end,
'episode': getattr(self, 'episode', None), 'episode': getattr(self, 'episode', None),
} }

View File

@ -20,7 +20,7 @@ from .station import Station
logger = logging.getLogger('aircox') logger = logging.getLogger('aircox')
__all__ = ['Log', 'LogQuerySet', 'LogArchiver'] __all__ = ('Log', 'LogQuerySet', 'LogArchiver')
class LogQuerySet(models.QuerySet): class LogQuerySet(models.QuerySet):
@ -31,7 +31,7 @@ class LogQuerySet(models.QuerySet):
def date(self, date): def date(self, date):
start = tz.datetime.combine(date, datetime.time()) start = tz.datetime.combine(date, datetime.time())
end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999)) end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
return self.filter(date__range = (start, end)) return self.filter(date__range=(start, end))
# this filter does not work with mysql # this filter does not work with mysql
# return self.filter(date__date=date) # return self.filter(date__date=date)

View File

@ -1,4 +1,3 @@
from enum import IntEnum
import re import re
from django.db import models from django.db import models
@ -18,7 +17,8 @@ from model_utils.managers import InheritanceQuerySet
from .station import Station from .station import Station
__all__ = ['Category', 'PageQuerySet', 'Page', 'Comment', 'NavItem'] __all__ = ('Category', 'PageQuerySet',
'Page', 'StaticPage', 'Comment', 'NavItem')
headline_re = re.compile(r'(<p>)?' headline_re = re.compile(r'(<p>)?'

View File

@ -1,6 +1,5 @@
import calendar import calendar
from collections import OrderedDict from collections import OrderedDict
import datetime
from enum import IntEnum from enum import IntEnum
import logging import logging
import os import os
@ -10,7 +9,7 @@ import pytz
from django.conf import settings as conf from django.conf import settings as conf
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F, Q from django.db.models import F
from django.db.models.functions import Concat, Substr from django.db.models.functions import Concat, Substr
from django.utils import timezone as tz from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -24,8 +23,8 @@ from .station import Station
logger = logging.getLogger('aircox') logger = logging.getLogger('aircox')
__all__ = ['Program', 'ProgramQuerySet', 'Stream', 'Schedule', __all__ = ('Program', 'ProgramQuerySet', 'Stream', 'Schedule',
'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet'] 'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet')
class ProgramQuerySet(PageQuerySet): class ProgramQuerySet(PageQuerySet):

View File

@ -2,7 +2,7 @@ import pytz
from django.contrib.auth.models import User, Group, Permission from django.contrib.auth.models import User, Group, Permission
from django.db import transaction from django.db import transaction
from django.db.models import F, signals from django.db.models import signals
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone as tz from django.utils import timezone as tz

View File

@ -1,17 +1,14 @@
from enum import IntEnum
import logging import logging
import os import os
from django.conf import settings as conf from django.conf import settings as conf
from django.db import models from django.db import models
from django.db.models import Q, Value as V from django.db.models import Q
from django.db.models.functions import Concat
from django.utils import timezone as tz from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from aircox import settings
from .program import Program from .program import Program
from .episode import Episode from .episode import Episode
@ -19,7 +16,7 @@ from .episode import Episode
logger = logging.getLogger('aircox') logger = logging.getLogger('aircox')
__all__ = ['Sound', 'SoundQuerySet', 'Track'] __all__ = ('Sound', 'SoundQuerySet', 'Track')
class SoundQuerySet(models.QuerySet): class SoundQuerySet(models.QuerySet):

View File

@ -8,7 +8,7 @@ from filer.fields.image import FilerImageField
from .. import settings from .. import settings
__all__ = ['Station', 'StationQuerySet', 'Port'] __all__ = ('Station', 'StationQuerySet', 'Port')
class StationQuerySet(models.QuerySet): class StationQuerySet(models.QuerySet):

View File

@ -0,0 +1,16 @@
from django.db import models
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
class UserSettings(models.Model):
"""
Store user's settings.
"""
user = models.OneToOneField(
User, models.CASCADE, verbose_name=_('User'),
related_name='aircox_settings')
playlist_editor_columns = models.JSONField(
_('Playlist Editor Columns'))
playlist_editor_sep = models.CharField(
_('Playlist Editor Separator'), max_length=16)

View File

@ -0,0 +1,3 @@
from .log import *
from .sound import *
from .admin import *

View File

@ -0,0 +1,30 @@
from rest_framework import serializers
from taggit.serializers import TagListSerializerField, TaggitSerializer
from ..models import Track, UserSettings
__all__ = ('TrackSerializer', 'UserSettingsSerializer')
class TrackSerializer(TaggitSerializer, serializers.ModelSerializer):
tags = TagListSerializerField()
class Meta:
model = Track
fields = ('pk', 'artist', 'title', 'album', 'year', 'position',
'info', 'tags', 'episode', 'sound')
class UserSettingsSerializer(serializers.ModelSerializer):
# TODO: validate fields values (playlist_editor_columns at least)
class Meta:
model = UserSettings
fields = ('playlist_editor_columns', 'playlist_editor_sep')
def create(self, validated_data):
user = self.context.get('user')
if user:
validated_data['user_id'] = user.id
return super().create(validated_data)

View File

@ -1,12 +1,9 @@
from rest_framework import serializers from rest_framework import serializers
from taggit.serializers import TagListSerializerField, TaggitSerializer
from .models import Diffusion, Log, Sound, Track from ..models import Diffusion, Log
__all__ = ['LogInfo', 'LogInfoSerializer', 'SoundSerializer', __all__ = ('LogInfo', 'LogInfoSerializer')
'PodcastSerializer',
'AdminTrackSerializer']
class LogInfo: class LogInfo:
@ -54,30 +51,3 @@ class LogInfoSerializer(serializers.Serializer):
info = serializers.CharField(max_length=200, required=False) info = serializers.CharField(max_length=200, required=False)
url = serializers.URLField(required=False) url = serializers.URLField(required=False)
cover = serializers.URLField(required=False) cover = serializers.URLField(required=False)
class SoundSerializer(serializers.ModelSerializer):
file = serializers.FileField(use_url=False)
class Meta:
model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type', 'file',
'duration', 'mtime', 'is_good_quality', 'is_public', 'url']
class PodcastSerializer(serializers.ModelSerializer):
# serializers.HyperlinkedIdentityField(view_name='sound', format='html')
class Meta:
model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type',
'duration', 'mtime', 'url', 'is_downloadable']
class AdminTrackSerializer(TaggitSerializer, serializers.ModelSerializer):
tags = TagListSerializerField()
class Meta:
model = Track
fields = ('pk', 'artist', 'title', 'album', 'year', 'position',
'info', 'tags', 'episode', 'sound')

View File

@ -0,0 +1,21 @@
from rest_framework import serializers
from ..models import Sound
class SoundSerializer(serializers.ModelSerializer):
file = serializers.FileField(use_url=False)
class Meta:
model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type', 'file',
'duration', 'mtime', 'is_good_quality', 'is_public', 'url']
class PodcastSerializer(serializers.ModelSerializer):
# serializers.HyperlinkedIdentityField(view_name='sound', format='html')
class Meta:
model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type',
'duration', 'mtime', 'url', 'is_downloadable']

View File

@ -16,7 +16,7 @@
\**********************/ \**********************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { /***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _assets_styles_scss__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./assets/styles.scss */ \"./src/assets/styles.scss\");\n/* harmony import */ var _assets_admin_scss__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./assets/admin.scss */ \"./src/assets/admin.scss\");\n/* harmony import */ var _index_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./index.js */ \"./src/index.js\");\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./src/app.js\");\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n\n\n\n\n\nconst AdminApp = {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n components: {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"].components,\n ..._components__WEBPACK_IMPORTED_MODULE_4__.admin\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (AdminApp);\nwindow.App = AdminApp;\n\n//# sourceURL=webpack://aircox-assets/./src/admin.js?"); eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _assets_styles_scss__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./assets/styles.scss */ \"./src/assets/styles.scss\");\n/* harmony import */ var _assets_admin_scss__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./assets/admin.scss */ \"./src/assets/admin.scss\");\n/* harmony import */ var _index_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./index.js */ \"./src/index.js\");\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./src/app.js\");\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n/* harmony import */ var _track__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./track */ \"./src/track.js\");\n\n\n\n\n\n\nconst AdminApp = {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n components: {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"].components,\n ..._components__WEBPACK_IMPORTED_MODULE_4__.admin\n },\n data() {\n return {\n ...super.data,\n Track: _track__WEBPACK_IMPORTED_MODULE_5__[\"default\"]\n };\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (AdminApp);\nwindow.App = AdminApp;\n\n//# sourceURL=webpack://aircox-assets/./src/admin.js?");
/***/ }), /***/ }),

File diff suppressed because one or more lines are too long

View File

@ -1,17 +1,16 @@
{% comment %}Inline block to edit playlists{% endcomment %} {% comment %}Inline block to edit playlists{% endcomment %}
{% load aircox aircox_admin static i18n %} {% load aircox aircox_admin static i18n %}
{# include "adminsortable2/edit_inline/tabular-django-4.1.html" #}
{% with inline_admin_formset as admin_formset %} {% with inline_admin_formset as admin_formset %}
{% with admin_formset.formset as formset %} {% with admin_formset.formset as formset %}
<script id="{{ formset.prefix }}-init-data">
{{ formset|inline_data|json }}
</script>
<div id="inline-tracks" class="box mb-5"> <div id="inline-tracks" class="box mb-5">
{{ admin_formset.non_form_errors }} {{ admin_formset.non_form_errors }}
<a-playlist-editor data-el="{{ formset.prefix }}-init-data" <a-playlist-editor
:labels="{% track_inline_labels %}"
:init-data="{% track_inline_data formset=formset %}"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-"> data-prefix="{{ formset.prefix }}-">
<template #title> <template #title>
<h5 class="title is-4">{% trans "Playlist" %}</h5> <h5 class="title is-4">{% trans "Playlist" %}</h5>
@ -57,13 +56,21 @@
{% if not field.widget.is_hidden and not field.is_readonly %} {% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:row-{{ field.name }}="{item,col,row,value,attr,emit}"> <template v-slot:row-{{ field.name }}="{item,col,row,value,attr,emit}">
<div class="field"> <div class="field">
{% if field.name in 'artist,title,album' %}
<a-autocomplete
:input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
url="{% url 'api:track-autocomplete' %}?{{ field.name }}=${query}&field={{ field.name }}"
{% else %}
<div class="control"> <div class="control">
<input type="{{ widget.type }}" <input type="{{ widget.type }}"
:class="['input', item.error(attr) ? 'is-danger' : 'half-field']" :class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
{% endif %}
:name="'{{ formset.prefix }}-' + row + '-{{ field.name }}'" :name="'{{ formset.prefix }}-' + row + '-{{ field.name }}'"
v-model="item.data[attr]" v-model="item.data[attr]"
@change="emit('change', col)"/> @change="emit('change', col)"/>
{% if field.name not in 'artist,title,album' %}
</div> </div>
{% endif %}
<p v-for="error in item.error(attr)" class="help is-danger"> <p v-for="error in item.error(attr)" class="help is-danger">
[[ error ]] ! [[ error ]] !
</p> </p>

View File

@ -1,5 +1,14 @@
import json
from django import template from django import template
from django.contrib import admin from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, gettext as __
from aircox.serializers.admin import UserSettingsSerializer
__all__ = ('register', 'do_get_admin_tools', 'do_track_inline_data',
'do_track_inline_column_labels')
register = template.Library() register = template.Library()
@ -10,8 +19,14 @@ def do_get_admin_tools():
return admin.site.get_tools() return admin.site.get_tools()
@register.filter(name='inline_data') @register.simple_tag(name='track_inline_data', takes_context=True)
def do_inline_data(formset): def do_track_inline_data(context, formset, safe_string=False):
"""
Return initial data for playlist editor as dict. Keys are:
- ``items``: list of items. Extra keys:
- ``__error__``: dict of form fields errors
- ``settings``: user's settings
"""
items = [] items = []
for form in formset.forms: for form in formset.forms:
item = {name: form[name].value() item = {name: form[name].value()
@ -23,5 +38,23 @@ def do_inline_data(formset):
if tags and not isinstance(tags, str): if tags and not isinstance(tags, str):
item['tags'] = ', '.join(tag.name for tag in tags) item['tags'] = ', '.join(tag.name for tag in tags)
items.append(item) items.append(item)
return {"items": items}
data = {"items": items}
user = context['request'].user
settings = getattr(user, 'aircox_settings', None)
data['settings'] = settings and UserSettingsSerializer(settings).data
source = json.dumps(data)
return safe_string and mark_safe(source) or source
@register.simple_tag(name='track_inline_labels')
def do_track_inline_labels():
""" Return labels for columns in playlist editor as dict """
return json.dumps({
'artist': __('Artist'), 'album': __('Album'), 'title': __('Title'),
'tags': __('Tags'), 'year': __('Year'),
'save_settings': __('Save Settings'),
'discard_changes': __('Discard changes'),
'columns': __('Columns'),
})

View File

@ -24,10 +24,14 @@ register_converter(WeekConverter, 'week')
router = DefaultRouter() router = DefaultRouter()
router.register('sound', viewsets.SoundViewSet, basename='sound') router.register('sound', viewsets.SoundViewSet, basename='sound')
router.register('track', viewsets.TrackROViewSet, basename='track')
api = [ api = [
path('logs/', views.LogListAPIView.as_view(), name='live'), path('logs/', views.LogListAPIView.as_view(), name='live'),
path('user/settings/', viewsets.UserSettingsViewSet.as_view(
{'get': 'retrieve', 'post': 'update', 'put': 'update'}),
name='user-settings'),
] + router.urls ] + router.urls

View File

@ -62,6 +62,7 @@ class BaseView(TemplateResponseMixin, ContextMixin):
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
# FIXME: rename to sth like [Base]?StationAPIView
class BaseAPIView: class BaseAPIView:
@property @property
def station(self): def station(self):

View File

@ -1,13 +1,18 @@
from django.db.models import Q from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from .models import Sound from .models import Sound, Track
from .serializers import SoundSerializer from .serializers import SoundSerializer, admin
from .views import BaseAPIView from .views import BaseAPIView
__all__ = ('SoundFilter', 'SoundViewSet', 'TrackFilter', 'TrackROViewSet',
'UserSettingsViewSet')
class SoundFilter(filters.FilterSet): class SoundFilter(filters.FilterSet):
station = filters.NumberFilter(field_name='program__station__id') station = filters.NumberFilter(field_name='program__station__id')
program = filters.NumberFilter(field_name='program_id') program = filters.NumberFilter(field_name='program_id')
@ -24,3 +29,63 @@ class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
filter_backends = (filters.DjangoFilterBackend,) filter_backends = (filters.DjangoFilterBackend,)
filterset_class = SoundFilter filterset_class = SoundFilter
# --- admin
class TrackFilter(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 TrackROViewSet(viewsets.ReadOnlyModelViewSet):
""" Track viewset used for auto completion """
serializer_class = admin.TrackSerializer
permission_classes = [IsAuthenticated]
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = TrackFilter
queryset = Track.objects.all()
@action(name='autocomplete', detail=False)
def autocomplete(self, request):
field = request.GET.get('field', None)
if field:
queryset = self.filter_queryset(self.get_queryset())
values = queryset.values_list(field, flat=True).distinct()
return Response(values)
return self.list(request)
class UserSettingsViewSet(viewsets.ViewSet):
"""
User's settings specific to aircox. Allow only to create and edit
user's own settings.
"""
serializer_class = admin.UserSettingsSerializer
permission_classes = [IsAuthenticated]
def get_serializer(self, instance=None, **kwargs):
return self.serializer_class(
instance=instance, context={'user': self.request.user},
**kwargs)
@action(detail=False, methods=['GET'])
def retrieve(self, request):
user = self.request.user
settings = getattr(user, 'aircox_settings', None)
data = settings and self.get_serializer(settings) or None
return Response(data)
@action(detail=False, methods=['POST', 'PUT'])
def update(self, request):
user = self.request.user
settings = getattr(user, 'aircox_settings', None)
data = dict(request.data)
data['user_id'] = self.request.user
serializer = self.get_serializer(instance=settings, data=request.data)
if serializer.is_valid():
serializer.save()
return Response({'status': 'ok'})
else:
return Response({'errors': serializer.errors},
status=status.HTTP_400_BAD_REQUEST)

View File

@ -53,7 +53,7 @@
{# TODO: select station => change the shit #} {# TODO: select station => change the shit #}
<a-autocomplete class="control is-expanded" <a-autocomplete class="control is-expanded"
url="{% url "aircox:sound-list" %}?station={{ station.pk }}&search=${query}" url="{% url "aircox:sound-list" %}?station={{ station.pk }}&search=${query}"
name="sound_id" :model="Sound" label-field="name" name="sound_id" :model="Sound" value-field="id" label-field="name"
placeholder="{% translate "Select a sound" %}"> placeholder="{% translate "Select a sound" %}">
<template v-slot:item="{item}"> <template v-slot:item="{item}">
[[ item.data.name ]] [[ item.data.name ]]

View File

@ -4,10 +4,18 @@ import './index.js'
import App from './app'; import App from './app';
import {admin as components} from './components' import {admin as components} from './components'
import Track from './track'
const AdminApp = { const AdminApp = {
...App, ...App,
components: {...App.components, ...components}, components: {...App.components, ...components},
data() {
return {
...super.data,
Track,
}
}
} }
export default AdminApp; export default AdminApp;

View File

@ -0,0 +1,78 @@
<template>
<component :is="tag" @click="call" :class="buttonClass">
<span v-if="promise && runIcon">
<i :class="runIcon"></i>
</span>
<span v-else-if="icon" class="icon">
<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'},
//! 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
const options = Model.getOptions({
...this.fetchOptions,
method: this.method,
body: JSON.stringify(this.item.data),
})
this.promise = fetch(this.url, options).then(data => {
const response = data.json();
this.promise = null;
this.$emit('done', response)
return response
}, data => { this.promise = null; return data })
return this.promise
},
},
}
</script>

View File

@ -1,37 +1,44 @@
<template> <template>
<div :class="dropdownClass"> <div class="control">
<div class="dropdown-trigger is-fullwidth"> <input type="hidden" :name="name" :value="selectedValue"
<input type="hidden" :name="name" @change="$emit('change', $event)"/>
:value="selectedValue" /> <input type="text" ref="input" class="input is-fullwidth" :class="inputClass"
<div v-show="!selected" class="control is-expanded"> v-show="!button || !selected"
<input type="text" :placeholder="placeholder" v-model="inputValue"
ref="input" class="input is-fullwidth" :placeholder="placeholder"
@keydown.capture="onKeyPress" @keydown.capture="onKeyDown"
@keyup="onKeyUp" @focus="this.cursor < 0 && move(0)"/> @keyup="onKeyUp($event); $emit('keyup', $event)"
</div> @keydown="$emit('keydown', $event)"
<button v-if="selected" class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden" @keypress="$emit('keypress', $event)"
@click="select(-1, false, true)"> @focus="onInputFocus" @blur="onBlur" />
<span class="icon is-small ml-1"> <a v-if="selected && button"
<i class="fa fa-pen"></i> class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
</span> @click="select(-1, false, true)">
<span class="is-inline-block" v-if="selected"> <span class="icon is-small ml-1">
<slot name="button" :index="selectedIndex" :item="selected" <i class="fa fa-pen"></i>
:value-field="valueField" :labelField="labelField"> </span>
{{ selected.data[labelField] }} <span class="is-inline-block" v-if="selected">
</slot> <slot name="button" :index="selectedIndex" :item="selected"
</span> :value-field="valueField" :labelField="labelField">
</button> {{ labelField && selected.data[labelField] || selected }}
</div> </slot>
<div class="dropdown-menu is-fullwidth"> </span>
<div class="dropdown-content" style="overflow: hidden"> </a>
<a v-for="(item, index) in items" :key="item.id" <div :class="dropdownClass">
:class="['dropdown-item', (index == this.cursor) ? 'is-active':'']" <div class="dropdown-menu is-fullwidth">
@click.capture.prevent="select(index, false, false)" :title="item.data[labelField]"> <div class="dropdown-content" style="overflow: hidden">
<slot name="item" :index="index" :item="item" :value-field="valueField" <a v-for="(item, index) in items" :key="item.id"
:labelField="labelField"> href="#" :data-autocomplete-index="index"
{{ item.data[labelField] }} @click="select(index, false, false)"
</slot> :class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
</a> :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 }}
</slot>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -39,29 +46,63 @@
<script> <script>
// import debounce from 'lodash/debounce' // import debounce from 'lodash/debounce'
import Model from '../model'
export default { export default {
emit: ['change', 'keypress', 'keydown', 'keyup', 'select', 'unselect',
'update:modelValue'],
props: { props: {
//! Search URL (where `${query}` is replaced by search term)
url: String, url: String,
//! Items' model
model: Function, model: Function,
//! Input tag class
inputClass: Array,
//! input text placeholder
placeholder: String, placeholder: String,
//! input form field name
name: String, name: String,
//! Field on items to use as label
labelField: String, labelField: String,
//! Field on selected item to get selectedValue from, if any
valueField: {type: String, default: null}, valueField: {type: String, default: null},
count: {type: Number, count: 10}, 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() { data() {
return { return {
value: '', inputValue: this.modelValue || '',
query: '',
items: [], items: [],
selectedIndex: -1, selectedIndex: -1,
cursor: -1, cursor: -1,
isFetching: false, promise: null,
} }
}, },
watch: {
modelValue(value) {
this.inputValue = value
},
inputValue(value) {
if(value != this.inputValue && value != this.modelValue)
this.$emit('update:modelValue', value)
},
},
computed: { computed: {
isFetching() { return !!this.promise },
selected() { selected() {
let index = this.selectedIndex let index = this.selectedIndex
if(index<0) if(index<0)
@ -71,23 +112,40 @@ export default {
}, },
selectedValue() { selectedValue() {
const sel = this.selected let value = this.itemValue(this.selected)
return sel && (this.valueField ? if(!value && !this.mustExist)
sel.data[this.valueField] : sel.id) value = this.inputValue
return value
}, },
selectedLabel() { selectedLabel() {
const sel = this.selected return this.itemLabel(this.selected)
return sel && sel.data[this.labelField]
}, },
dropdownClass() { dropdownClass() {
const active = this.cursor > -1 && this.items.length; var active = this.cursor > -1 && this.items.length;
return ['dropdown', active ? 'is-active':''] if(active && this.items.length == 1 &&
this.itemValue(this.items[0]) == this.inputValue)
active = false
return ['dropdown is-fullwidth', active ? 'is-active':'']
}, },
}, },
methods: { methods: {
itemValue(item) {
return this.valueField ? item && item[this.valueField] : item;
},
itemLabel(item) {
return this.labelField ? item && item[this.labelField] : item;
},
hide() {
this.cursor = -1;
this.selectedIndex = -1;
},
move(index=-1, relative=false) { move(index=-1, relative=false) {
if(relative) if(relative)
index += this.cursor index += this.cursor
@ -100,9 +158,9 @@ export default {
else if(index == this.selectedIndex) else if(index == this.selectedIndex)
return return
this.selectedIndex = Math.max(-1, Math.min(index, this.items.length-1)) this.selectedIndex = Math.max(-1, Math.min(index, this.items.length-1))
if(index >= 0) { if(index >= 0) {
this.$refs.input.value = this.selectedLabel this.inputValue = this.selectedLabel
this.$refs.input.focus() this.$refs.input.focus()
} }
if(this.selectedIndex < 0) if(this.selectedIndex < 0)
@ -114,11 +172,24 @@ export default {
active && this.move(0) || this.move(-1) active && this.move(0) || this.move(-1)
}, },
onKeyPress: function(event) { onInputFocus() {
this.cursor < 0 && this.move(0)
},
onBlur(event) {
var index = event.relatedTarget && event.relatedTarget.dataset.autocompleteIndex;
if(index !== undefined)
this.select(index, false, false)
this.cursor = -1;
},
onKeyDown(event) {
if(event.ctrlKey || event.altKey || event.metaKey)
return
switch(event.keyCode) { switch(event.keyCode) {
case 13: this.select(this.cursor, false, false) case 13: this.select(this.cursor, false, false)
break break
case 27: this.select() case 27: this.hide(); this.select()
break break
case 38: this.move(-1, true) case 38: this.move(-1, true)
break break
@ -130,35 +201,47 @@ export default {
event.stopPropagation() event.stopPropagation()
}, },
onKeyUp: function(event) { onKeyUp(event) {
const value = event.target.value if(event.ctrlKey || event.altKey || event.metaKey)
if(value === this.value)
return return
this.value = value; const value = event.target.value
if(value === this.query)
return
this.inputValue = value;
if(!value) if(!value)
return this.selected && this.select(-1) return this.selected && this.select(-1)
if(!this.minFetchLength || value.length >= this.minFetchLength)
this.fetch(value) this.fetch(value)
}, },
fetch: function(query) { fetch(query) {
if(!query || this.isFetching) if(!query || this.promise)
return return
this.isFetching = true this.query = query
return this.model.fetch(this.url.replace('${query}', query), {many:true}) var url = this.url.replace('${query}', query)
.then(items => { this.items = items || [] var promise = this.model ? this.model.fetch(url, {many:true})
this.isFetching = false : fetch(url, Model.getOptions()).then(d => d.json())
this.move(0)
return items }, promise = promise.then(items => {
data => {this.isFetching = false; Promise.reject(data)}) this.items = items || []
this.promise = null;
this.move(0)
return items
}, data => {this.promise = null; Promise.reject(data)})
this.promise = promise
return promise
}, },
}, },
mounted() { mounted() {
const form = this.$el.closest('form') const form = this.$el.closest('form')
form.addEventListener('reset', () => { this.value=''; this.select(-1) }) form.addEventListener('reset', () => {
this.inputValue = this.value;
this.select(-1)
})
} }
} }

View File

@ -5,7 +5,7 @@
<component :is="listTag" :class="listClass"> <component :is="listTag" :class="listClass">
<template v-for="(item,index) in items" :key="index"> <template v-for="(item,index) in items" :key="index">
<component :is="itemTag" :class="itemClass" @click="select(index)" <component :is="itemTag" :class="itemClass" @click="select(index)"
:draggable="orderable" :draggable="orderable" :data-index="index"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"> @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
<slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot> <slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot>
</component> </component>
@ -70,7 +70,7 @@ export default {
onDragStart(ev) { onDragStart(ev) {
const dataset = ev.target.dataset; const dataset = ev.target.dataset;
const data = `cell:${dataset.index}` const data = `row:${dataset.index}`
ev.dataTransfer.setData("text/cell", data) ev.dataTransfer.setData("text/cell", data)
ev.dataTransfer.dropEffect = 'move' ev.dataTransfer.dropEffect = 'move'
}, },
@ -82,11 +82,11 @@ export default {
onDrop(ev) { onDrop(ev) {
const data = ev.dataTransfer.getData("text/cell") const data = ev.dataTransfer.getData("text/cell")
if(!data || !data.startsWith('cell:')) if(!data || !data.startsWith('row:'))
return return
ev.preventDefault() ev.preventDefault()
const from = Number(data.slice(5)) const from = Number(data.slice(4))
const target = ev.target.tagName == this.itemTag ? ev.target const target = ev.target.tagName == this.itemTag ? ev.target
: ev.target.closest(this.itemTag) : ev.target.closest(this.itemTag)
this.$emit('move', { this.$emit('move', {

View File

@ -7,8 +7,8 @@
<div class="column has-text-right"> <div class="column has-text-right">
<div class="float-right field has-addons"> <div class="float-right field has-addons">
<p class="control"> <p class="control">
<a :class="['button','p-2', mode == Modes.Text ? 'is-primary' : 'is-light']" <a :class="['button','p-2', page == Page.Text ? 'is-primary' : 'is-light']"
@click="mode = Modes.Text"> @click="page = Page.Text">
<span class="icon is-small"> <span class="icon is-small">
<i class="fa fa-pencil"></i> <i class="fa fa-pencil"></i>
</span> </span>
@ -16,8 +16,8 @@
</a> </a>
</p> </p>
<p class="control"> <p class="control">
<a :class="['button','p-2', mode == Modes.List ? 'is-primary' : 'is-light']" <a :class="['button','p-2', page == Page.List ? 'is-primary' : 'is-light']"
@click="mode = Modes.List"> @click="page = Page.List">
<span class="icon is-small"> <span class="icon is-small">
<i class="fa fa-list"></i> <i class="fa fa-list"></i>
</span> </span>
@ -28,43 +28,16 @@
</div> </div>
</div> </div>
<slot name="top" :set="set" :columns="columns" :items="items"/> <slot name="top" :set="set" :columns="columns" :items="items"/>
<section class="page" v-show="mode == Modes.Text"> <section class="page" v-show="page == Page.Text">
<textarea ref="textarea" class="is-fullwidth" rows="20" <textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
@change="updateList" @change="updateList"
/> />
<div class="columns mt-2">
<div class="column field is-vcentered">
<label class="label is-inline mr-2"
style="vertical-align: middle">
Ordre</label>
<table class="table is-bordered is-inline-block"
style="vertical-align: middle">
<tr>
<a-row :cell="{columns}" :item="FormatLabels"
@move="formatMove" :orderable="true">
</a-row>
</tr>
</table>
</div>
<div class="column field is-vcentered">
<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" value="--" class="input is-inline"
@change="updateList()"/>
</div>
</div>
<div class="column"/>
</div>
</section> </section>
<section class="page" v-show="mode == Modes.List"> <section class="page" v-show="page == Page.List">
<a-rows :set="set" :columns="columns" :labels="FormatLabels" <a-rows :set="set" :columns="columns" :labels="labels"
:allow-create="true" :allow-create="true"
:list-class="listClass" :item-class="itemClass" :orderable="true" @move="listItemMove" @colmove="columnMove"
:orderable="true" @move="listItemMove"
@cell="onCellEvent"> @cell="onCellEvent">
<template v-for="[name,slot] of rowsSlots" :key="slot" <template v-for="[name,slot] of rowsSlots" :key="slot"
v-slot:[slot]="data"> v-slot:[slot]="data">
@ -72,51 +45,128 @@
</template> </template>
</a-rows> </a-rows>
</section> </section>
<section class="page" v-show="mode == Modes.Settings">
</section> <div class="mt-2">
<div class="field is-inline-block is-vcentered mr-3">
<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 class="field is-inline-block is-vcentered mr-5">
<label class="label is-inline mr-2"
style="vertical-align: middle">
{{ labels.columns }}</label>
<table class="table is-bordered is-inline-block"
style="vertical-align: middle">
<tr>
<a-row :columns="columns" :item="labels"
@move="formatMove" :orderable="true">
<template v-slot:cell-after="{cell}">
<td style="cursor:pointer;" v-if="cell.col < columns.length-1">
<span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})"
><i class="fa fa-left-right"/>
</span>
</td>
</template>
</a-row>
</tr>
</table>
</div>
<div class="field is-vcentered is-inline-block"
v-if="settingsChanged">
<a-action-button icon="fa fa-floppy-disk"
class="button control p-3 is-info" run-class="blink"
:url="settingsUrl" method="POST"
:data="settings"
:aria-label="labels.save_settings"
@done="settingsSaved()">
{{ labels.save_settings }}
</a-action-button>
</div>
<div class="float-right">
<a class="button is-warning p-2 ml-2"
@click="loadData({items: this.initData.items},true)">
<span class="icon"><i class="fa fa-rotate" /></span>
<span>{{ labels.discard_changes }}</span>
</a>
</div>
</div>
<slot name="bottom" :set="set" :columns="columns" :items="items"/> <slot name="bottom" :set="set" :columns="columns" :items="items"/>
</div> </div>
</template> </template>
<script> <script>
import {dropRightWhile} from 'lodash' import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
import {Set} from '../model' import {Set} from '../model'
import Track from '../track' import Track from '../track'
import AActionButton from './AActionButton'
import ARow from './ARow.vue' import ARow from './ARow.vue'
import ARows from './ARows.vue' import ARows from './ARows.vue'
/// Page display
export const Modes = { export const Page = {
Text: 0, List: 1, Settings: 2, Text: 0, List: 1, Settings: 2,
} }
const FormatLabels = {
artist: 'Artiste', album: 'Album', year: 'Année', tags: 'Tags',
title: 'Titre',
}
export default { export default {
components: { ARow, ARows }, components: { AActionButton, ARow, ARows },
props: { props: {
dataEl: String, initData: Object,
dataPrefix: String, dataPrefix: String,
listClass: String, labels: Object,
itemClass: String, settingsUrl: String,
defaultColumns: {
type: Array,
default: () => ['artist', 'title', 'tags', 'album', 'year']},
}, },
data() { data() {
const settings = {
playlist_editor_columns: this.defaultColumns,
playlist_editor_sep: ' -- ',
}
return { return {
Modes: Modes, Page: Page,
FormatLabels: FormatLabels, page: Page.Text,
mode: Modes.Text,
set: new Set(Track), set: new Set(Track),
columns: ['artist', 'title', 'tags', 'album', 'year'],
extraData: {}, extraData: {},
settings,
savedSettings: cloneDeep(settings),
} }
}, },
computed: { computed: {
settingsChanged() {
var k = Object.keys(this.savedSettings)
.findIndex(k => !isEqual(this.settings[k], this.savedSettings[k]))
return k != -1
},
separator: {
set(value) {
this.settings.playlist_editor_sep = value
if(this.page == Page.List)
this.updateInput()
},
get() { return this.settings.playlist_editor_sep }
},
columns: {
set(value) {
var cols = value.filter(x => x in this.defaultColumns)
var left = this.defaultColumns.filter(x => !(x in cols))
value = cols.concat(left)
this.settings.playlist_editor_columns = value
},
get() { return this.settings.playlist_editor_columns }
},
items() { items() {
return this.set.items return this.set.items
}, },
@ -140,7 +190,17 @@ export default {
const value = this.columns[from] const value = this.columns[from]
this.columns.splice(from, 1) this.columns.splice(from, 1)
this.columns.splice(to, 0, value) this.columns.splice(to, 0, value)
this.updateList() if(this.page == Page.Text)
this.updateList()
else
this.updateText()
},
columnMove({from, to}) {
const value = this.columns[from]
this.columns.splice(from, 1)
this.columns.splice(to, 0, value)
this.updateInput()
}, },
listItemMove({from, to, set}) { listItemMove({from, to, set}) {
@ -149,29 +209,28 @@ export default {
}, },
updateList() { updateList() {
const items = this.toList(this.$refs.textarea.value, const items = this.toList(this.$refs.textarea.value)
this.$refs.sep.value)
this.set.reset(items) this.set.reset(items)
}, },
updateInput() { updateInput() {
const input = this.toText(this.items, this.$refs.sep.value) const input = this.toText(this.items)
this.$refs.textarea.value = input this.$refs.textarea.value = input
}, },
/** /**
* From input and separator, return list of items. * From input and separator, return list of items.
*/ */
toList(input, sep) { toList(input) {
var lines = input.split('\n') var lines = input.split('\n')
var items = [] var items = []
for(let line of lines) { for(let line of lines) {
line = line.trim() line = line.trimLeft()
if(!line) if(!line)
continue continue
var lineBits = line.split(sep) var lineBits = line.split(this.separator)
var item = {} var item = {}
for(var col in this.columns) { for(var col in this.columns) {
if(col >= lineBits.length) if(col >= lineBits.length)
@ -187,17 +246,18 @@ export default {
/** /**
* From items and separator return a string * From items and separator return a string
*/ */
toText(items, sep) { toText(items) {
var lines = [] const sep = ` ${this.separator.trim()} `
sep = ` ${(sep || this.$refs.sep.value).trim()} ` const lines = []
for(let item of items) { for(let item of items) {
if(!item) if(!item)
continue continue
var line = [] var line = []
for(var col of this.columns) for(var col of this.columns)
line.push(item.data[col] || '') line.push(item.data[col] || '')
line = dropRightWhile(line, x => !x) line = dropRightWhile(line, x => !x || !('' + x).trim())
lines.push(line.join(sep)) line = line.join(sep).trimRight()
lines.push(line)
} }
return lines.join('\n') return lines.join('\n')
}, },
@ -213,26 +273,38 @@ export default {
return [null, key] return [null, key]
} }
}, },
//! Update saved settings from this.settings
settingsSaved(settings=null) {
if(settings !== null)
this.settings = settings
this.savedSettings = cloneDeep(this.settings)
},
/** /**
* Load initial data * Load initial data
*/ */
loadData({items=[]}) { loadData({items=[], settings=null}, reset=false) {
if(reset) {
this.set.items = []
}
for(var index in items) for(var index in items)
this.set.push(items[index]) this.set.push(cloneDeep(items[index]))
if(settings)
this.settingsSaved(settings)
this.updateInput() this.updateInput()
}, },
}, },
watch: {
initData(val) {
this.loadData(val)
},
},
mounted() { mounted() {
if(this.dataEl) { this.initData && this.loadData(this.initData)
const el = document.getElementById(this.dataEl) this.page = (this.items) ? Page.List : Page.Text
if(el) {
const data = JSON.parse(el.textContent)
this.loadData(data)
}
}
this.mode = (this.items) ? Modes.List : Modes.Text
}, },
} }
</script> </script>

View File

@ -2,7 +2,9 @@
<tr> <tr>
<slot name="head" :item="item" :row="row"/> <slot name="head" :item="item" :row="row"/>
<template v-for="(attr,col) in columns" :key="col"> <template v-for="(attr,col) in columns" :key="col">
<td :class="['cell', 'cell-' + attr]" :data-col="col" <slot name="cell-before" :item="item" :cell="cells[col]"
:attr="attr"/>
<component :is="cellTag" :class="['cell', 'cell-' + attr]" :data-col="col"
:draggable="orderable" :draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"> @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
<slot :name="attr" :item="item" :cell="cells[col]" <slot :name="attr" :item="item" :cell="cells[col]"
@ -10,9 +12,11 @@
:value="itemData && itemData[attr]"> :value="itemData && itemData[attr]">
{{ itemData && itemData[attr] }} {{ itemData && itemData[attr] }}
</slot> </slot>
</td> </component>
<slot name="cell-after" :item="item" :col="col" :cell="cells[col]"
:attr="attr"/>
</template> </template>
<slot name="tail" :item="item" :row="cell.row"/> <slot name="tail" :item="item" :row="row"/>
</tr> </tr>
</template> </template>
<script> <script>
@ -24,20 +28,21 @@ export default {
props: { props: {
item: Object, item: Object,
cell: Object, columns: Array,
cell: {type: Object, default() { return {row: 0}}},
cellTag: {type: String, default: 'td'},
orderable: {type: Boolean, default: false}, orderable: {type: Boolean, default: false},
}, },
computed: { computed: {
row() { return this.cell.row || 0 }, row() { return this.cell && this.cell.row },
columns() { return this.cell.columns },
itemData() { itemData() {
return this.item instanceof Model ? this.item.data : this.item; return this.item instanceof Model ? this.item.data : this.item;
}, },
cells() { cells() {
const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell || {}
const cells = [] const cells = []
for(var col in this.columns) for(var col in this.columns)
cells.push({...cell, col: Number(col)}) cells.push({...cell, col: Number(col)})
@ -45,7 +50,7 @@ export default {
}, },
cellEls() { cellEls() {
return [...this.$el.querySelectorAll('td')].filter(x => x.dataset.col) return [...this.$el.querySelectorAll(self.cellTag)].filter(x => x.dataset.col)
}, },
}, },

View File

@ -1,27 +1,30 @@
<template> <template>
<table class="table is-stripped is-fullwidth"> <table class="table is-stripped is-fullwidth">
<thead> <thead>
<tr> <a-row :item="labels" :columns="columns" :orderable="orderable"
<slot name="header-head"/> @move="$emit('colmove', $event)">
<th v-for="col in columns" :key="col" <template v-if="$slots['header-head']" v-slot:head="data">
style="vertical-align: middle">{{ labels[col] }}</th> <slot name="header-head" v-bind="data"/>
<slot name="header-tail"/> </template>
</tr> <template v-if="$slots['header-tail']" v-slot:tail="data">
<slot name="header-tail" v-bind="data"/>
</template>
</a-row>
</thead> </thead>
<tbody> <tbody>
<slot name="head"/> <slot name="head"/>
<template v-for="(item,row) in items" :key="row"> <template v-for="(item,row) in items" :key="row">
<!-- data-index comes from AList component drag & drop --> <!-- data-index comes from AList component drag & drop -->
<a-row :item="item" :cell="{row, columns}" :data-index="row" <a-row :item="item" :cell="{row}" :columns="columns" :data-index="row"
:draggable="orderable" :draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop" @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
@cell="onCellEvent(index, $event)"> @cell="onCellEvent(row, $event)">
<template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data"> <template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
<template v-if="slot == 'head' || slot == 'tail'"> <template v-if="slot == 'head' || slot == 'tail'">
<slot :name="name" v-bind="data"/> <slot :name="name" v-bind="data"/>
</template> </template>
<template v-else> <template v-else>
<div @keydown.capture.ctrl="onControlKey($event, data.cell)"> <div @keydown.ctrl="onControlKey($event, data.cell)">
<slot :name="name" v-bind="data"/> <slot :name="name" v-bind="data"/>
</div> </div>
</template> </template>
@ -47,7 +50,7 @@ import ARow from './ARow.vue'
const Component = { const Component = {
extends: AList, extends: AList,
components: { ARow }, components: { ARow },
emit: ['cell'], emit: ['cell', 'colmove'],
props: { props: {
...AList.props, ...AList.props,
@ -67,7 +70,7 @@ const Component = {
rowCells() { rowCells() {
const cells = [] const cells = []
for(var row in this.items) for(var row in this.items)
cells.push({row, columns: this.columns,}) cells.push({row})
}, },
rows() { rows() {

View File

@ -13,12 +13,15 @@ import AStreamer from './AStreamer.vue'
/** /**
* Core components * Core components
*/ */
export default { export const base = {
AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist, AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist,
AProgress, ASoundItem, AProgress, ASoundItem,
} }
export default base
export const admin = { export const admin = {
...base,
AStatistics, AStreamer, APlaylistEditor AStatistics, AStreamer, APlaylistEditor
} }