- 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:
parent
61af53eecb
commit
180cc8bc02
|
@ -1,10 +1,11 @@
|
|||
from .article import Article
|
||||
from .page import Category, Page, StaticPage, Comment, NavItem
|
||||
from .program import Program, Stream, Schedule
|
||||
from .episode import Episode, Diffusion
|
||||
from .log import Log
|
||||
from .sound import Sound, Track
|
||||
from .station import Station, Port
|
||||
from .article import *
|
||||
from .page import *
|
||||
from .program import *
|
||||
from .episode import *
|
||||
from .log import *
|
||||
from .sound import *
|
||||
from .station import *
|
||||
from .user_settings import *
|
||||
|
||||
from . import signals
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .page import Page, PageQuerySet
|
||||
from .program import Program, ProgramChildQuerySet
|
||||
from .page import Page
|
||||
from .program import ProgramChildQuerySet
|
||||
|
||||
|
||||
__all__ = ('Article',)
|
||||
|
||||
|
||||
class Article(Page):
|
||||
|
|
|
@ -9,12 +9,12 @@ from django.utils.functional import cached_property
|
|||
from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
from aircox import settings, utils
|
||||
from .program import Program, ProgramChildQuerySet, \
|
||||
from .program import ProgramChildQuerySet, \
|
||||
BaseRerun, BaseRerunQuerySet, Schedule
|
||||
from .page import Page, PageQuerySet
|
||||
from .page import Page
|
||||
|
||||
|
||||
__all__ = ['Episode', 'Diffusion', 'DiffusionQuerySet']
|
||||
__all__ = ('Episode', 'Diffusion', 'DiffusionQuerySet')
|
||||
|
||||
|
||||
class Episode(Page):
|
||||
|
@ -31,9 +31,9 @@ class Episode(Page):
|
|||
""" Return serialized data about podcasts. """
|
||||
from ..serializers import PodcastSerializer
|
||||
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:
|
||||
options = {'size': (128,128), 'crop':'scale'}
|
||||
options = {'size': (128, 128), 'crop': 'scale'}
|
||||
cover = get_thumbnailer(self.cover).get_thumbnail(options).url
|
||||
else:
|
||||
cover = None
|
||||
|
@ -84,7 +84,7 @@ class DiffusionQuerySet(BaseRerunQuerySet):
|
|||
def episode(self, episode=None, id=None):
|
||||
""" Diffusions for this episode """
|
||||
return self.filter(episode=episode) if id is None else \
|
||||
self.filter(episode__id=id)
|
||||
self.filter(episode__id=id)
|
||||
|
||||
def on_air(self):
|
||||
""" On air diffusions """
|
||||
|
@ -104,13 +104,13 @@ class DiffusionQuerySet(BaseRerunQuerySet):
|
|||
end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
|
||||
# start = tz.get_current_timezone().localize(start)
|
||||
# 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
|
||||
|
||||
def at(self, date, order=True):
|
||||
""" Return diffusions at specified date or datetime """
|
||||
return self.now(date, order) if isinstance(date, tz.datetime) else \
|
||||
self.date(date, order)
|
||||
self.date(date, order)
|
||||
|
||||
def after(self, date=None):
|
||||
"""
|
||||
|
@ -201,7 +201,7 @@ class Diffusion(BaseRerun):
|
|||
|
||||
def __str__(self):
|
||||
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'),
|
||||
)
|
||||
if self.initial:
|
||||
|
@ -324,5 +324,3 @@ class Diffusion(BaseRerun):
|
|||
'end': self.end,
|
||||
'episode': getattr(self, 'episode', None),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ from .station import Station
|
|||
logger = logging.getLogger('aircox')
|
||||
|
||||
|
||||
__all__ = ['Log', 'LogQuerySet', 'LogArchiver']
|
||||
__all__ = ('Log', 'LogQuerySet', 'LogArchiver')
|
||||
|
||||
|
||||
class LogQuerySet(models.QuerySet):
|
||||
|
@ -31,7 +31,7 @@ class LogQuerySet(models.QuerySet):
|
|||
def date(self, date):
|
||||
start = tz.datetime.combine(date, datetime.time())
|
||||
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
|
||||
# return self.filter(date__date=date)
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
from enum import IntEnum
|
||||
import re
|
||||
|
||||
from django.db import models
|
||||
|
@ -18,7 +17,8 @@ from model_utils.managers import InheritanceQuerySet
|
|||
from .station import Station
|
||||
|
||||
|
||||
__all__ = ['Category', 'PageQuerySet', 'Page', 'Comment', 'NavItem']
|
||||
__all__ = ('Category', 'PageQuerySet',
|
||||
'Page', 'StaticPage', 'Comment', 'NavItem')
|
||||
|
||||
|
||||
headline_re = re.compile(r'(<p>)?'
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import calendar
|
||||
from collections import OrderedDict
|
||||
import datetime
|
||||
from enum import IntEnum
|
||||
import logging
|
||||
import os
|
||||
|
@ -10,7 +9,7 @@ import pytz
|
|||
from django.conf import settings as conf
|
||||
from django.core.exceptions import ValidationError
|
||||
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.utils import timezone as tz
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -24,8 +23,8 @@ from .station import Station
|
|||
logger = logging.getLogger('aircox')
|
||||
|
||||
|
||||
__all__ = ['Program', 'ProgramQuerySet', 'Stream', 'Schedule',
|
||||
'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet']
|
||||
__all__ = ('Program', 'ProgramQuerySet', 'Stream', 'Schedule',
|
||||
'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet')
|
||||
|
||||
|
||||
class ProgramQuerySet(PageQuerySet):
|
||||
|
|
|
@ -2,7 +2,7 @@ import pytz
|
|||
|
||||
from django.contrib.auth.models import User, Group, Permission
|
||||
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.utils import timezone as tz
|
||||
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
from enum import IntEnum
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings as conf
|
||||
from django.db import models
|
||||
from django.db.models import Q, Value as V
|
||||
from django.db.models.functions import Concat
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone as tz
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from aircox import settings
|
||||
from .program import Program
|
||||
from .episode import Episode
|
||||
|
||||
|
@ -19,7 +16,7 @@ from .episode import Episode
|
|||
logger = logging.getLogger('aircox')
|
||||
|
||||
|
||||
__all__ = ['Sound', 'SoundQuerySet', 'Track']
|
||||
__all__ = ('Sound', 'SoundQuerySet', 'Track')
|
||||
|
||||
|
||||
class SoundQuerySet(models.QuerySet):
|
||||
|
|
|
@ -8,7 +8,7 @@ from filer.fields.image import FilerImageField
|
|||
from .. import settings
|
||||
|
||||
|
||||
__all__ = ['Station', 'StationQuerySet', 'Port']
|
||||
__all__ = ('Station', 'StationQuerySet', 'Port')
|
||||
|
||||
|
||||
class StationQuerySet(models.QuerySet):
|
||||
|
|
16
aircox/models/user_settings.py
Normal file
16
aircox/models/user_settings.py
Normal 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)
|
3
aircox/serializers/__init__.py
Normal file
3
aircox/serializers/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .log import *
|
||||
from .sound import *
|
||||
from .admin import *
|
30
aircox/serializers/admin.py
Normal file
30
aircox/serializers/admin.py
Normal 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)
|
||||
|
|
@ -1,12 +1,9 @@
|
|||
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',
|
||||
'PodcastSerializer',
|
||||
'AdminTrackSerializer']
|
||||
__all__ = ('LogInfo', 'LogInfoSerializer')
|
||||
|
||||
|
||||
class LogInfo:
|
||||
|
@ -54,30 +51,3 @@ class LogInfoSerializer(serializers.Serializer):
|
|||
info = serializers.CharField(max_length=200, required=False)
|
||||
url = 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')
|
21
aircox/serializers/sound.py
Normal file
21
aircox/serializers/sound.py
Normal 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']
|
|
@ -16,7 +16,7 @@
|
|||
\**********************/
|
||||
/***/ (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
|
@ -1,17 +1,16 @@
|
|||
{% comment %}Inline block to edit playlists{% endcomment %}
|
||||
{% load aircox aircox_admin static i18n %}
|
||||
|
||||
{# include "adminsortable2/edit_inline/tabular-django-4.1.html" #}
|
||||
{% with inline_admin_formset as admin_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">
|
||||
{{ 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 }}-">
|
||||
<template #title>
|
||||
<h5 class="title is-4">{% trans "Playlist" %}</h5>
|
||||
|
@ -57,13 +56,21 @@
|
|||
{% if not field.widget.is_hidden and not field.is_readonly %}
|
||||
<template v-slot:row-{{ field.name }}="{item,col,row,value,attr,emit}">
|
||||
<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">
|
||||
<input type="{{ widget.type }}"
|
||||
:class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
|
||||
{% endif %}
|
||||
:name="'{{ formset.prefix }}-' + row + '-{{ field.name }}'"
|
||||
v-model="item.data[attr]"
|
||||
@change="emit('change', col)"/>
|
||||
{% if field.name not in 'artist,title,album' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p v-for="error in item.error(attr)" class="help is-danger">
|
||||
[[ error ]] !
|
||||
</p>
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
import json
|
||||
from django import template
|
||||
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()
|
||||
|
@ -10,8 +19,14 @@ def do_get_admin_tools():
|
|||
return admin.site.get_tools()
|
||||
|
||||
|
||||
@register.filter(name='inline_data')
|
||||
def do_inline_data(formset):
|
||||
@register.simple_tag(name='track_inline_data', takes_context=True)
|
||||
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 = []
|
||||
for form in formset.forms:
|
||||
item = {name: form[name].value()
|
||||
|
@ -23,5 +38,23 @@ def do_inline_data(formset):
|
|||
if tags and not isinstance(tags, str):
|
||||
item['tags'] = ', '.join(tag.name for tag in tags)
|
||||
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'),
|
||||
})
|
||||
|
||||
|
|
|
@ -24,10 +24,14 @@ register_converter(WeekConverter, 'week')
|
|||
|
||||
router = DefaultRouter()
|
||||
router.register('sound', viewsets.SoundViewSet, basename='sound')
|
||||
router.register('track', viewsets.TrackROViewSet, basename='track')
|
||||
|
||||
|
||||
api = [
|
||||
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
|
||||
|
||||
|
||||
|
|
|
@ -62,6 +62,7 @@ class BaseView(TemplateResponseMixin, ContextMixin):
|
|||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
# FIXME: rename to sth like [Base]?StationAPIView
|
||||
class BaseAPIView:
|
||||
@property
|
||||
def station(self):
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
from django.db.models import Q
|
||||
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django_filters import rest_framework as filters
|
||||
|
||||
from .models import Sound
|
||||
from .serializers import SoundSerializer
|
||||
from .models import Sound, Track
|
||||
from .serializers import SoundSerializer, admin
|
||||
from .views import BaseAPIView
|
||||
|
||||
|
||||
__all__ = ('SoundFilter', 'SoundViewSet', 'TrackFilter', 'TrackROViewSet',
|
||||
'UserSettingsViewSet')
|
||||
|
||||
|
||||
class SoundFilter(filters.FilterSet):
|
||||
station = filters.NumberFilter(field_name='program__station__id')
|
||||
program = filters.NumberFilter(field_name='program_id')
|
||||
|
@ -24,3 +29,63 @@ class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
|
|||
filter_backends = (filters.DjangoFilterBackend,)
|
||||
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)
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
{# TODO: select station => change the shit #}
|
||||
<a-autocomplete class="control is-expanded"
|
||||
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" %}">
|
||||
<template v-slot:item="{item}">
|
||||
[[ item.data.name ]]
|
||||
|
|
|
@ -4,10 +4,18 @@ import './index.js'
|
|||
|
||||
import App from './app';
|
||||
import {admin as components} from './components'
|
||||
import Track from './track'
|
||||
|
||||
const AdminApp = {
|
||||
...App,
|
||||
components: {...App.components, ...components},
|
||||
|
||||
data() {
|
||||
return {
|
||||
...super.data,
|
||||
Track,
|
||||
}
|
||||
}
|
||||
}
|
||||
export default AdminApp;
|
||||
|
||||
|
|
78
assets/src/components/AActionButton.vue
Normal file
78
assets/src/components/AActionButton.vue
Normal 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>
|
|
@ -1,37 +1,44 @@
|
|||
<template>
|
||||
<div :class="dropdownClass">
|
||||
<div class="dropdown-trigger is-fullwidth">
|
||||
<input type="hidden" :name="name"
|
||||
:value="selectedValue" />
|
||||
<div v-show="!selected" class="control is-expanded">
|
||||
<input type="text" :placeholder="placeholder"
|
||||
ref="input" class="input is-fullwidth"
|
||||
@keydown.capture="onKeyPress"
|
||||
@keyup="onKeyUp" @focus="this.cursor < 0 && move(0)"/>
|
||||
</div>
|
||||
<button v-if="selected" class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
|
||||
@click="select(-1, false, true)">
|
||||
<span class="icon is-small ml-1">
|
||||
<i class="fa fa-pen"></i>
|
||||
</span>
|
||||
<span class="is-inline-block" v-if="selected">
|
||||
<slot name="button" :index="selectedIndex" :item="selected"
|
||||
:value-field="valueField" :labelField="labelField">
|
||||
{{ selected.data[labelField] }}
|
||||
</slot>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu is-fullwidth">
|
||||
<div class="dropdown-content" style="overflow: hidden">
|
||||
<a v-for="(item, index) in items" :key="item.id"
|
||||
:class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
|
||||
@click.capture.prevent="select(index, false, false)" :title="item.data[labelField]">
|
||||
<slot name="item" :index="index" :item="item" :value-field="valueField"
|
||||
:labelField="labelField">
|
||||
{{ item.data[labelField] }}
|
||||
</slot>
|
||||
</a>
|
||||
<div class="control">
|
||||
<input type="hidden" :name="name" :value="selectedValue"
|
||||
@change="$emit('change', $event)"/>
|
||||
<input type="text" ref="input" class="input is-fullwidth" :class="inputClass"
|
||||
v-show="!button || !selected"
|
||||
v-model="inputValue"
|
||||
:placeholder="placeholder"
|
||||
@keydown.capture="onKeyDown"
|
||||
@keyup="onKeyUp($event); $emit('keyup', $event)"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
@keypress="$emit('keypress', $event)"
|
||||
@focus="onInputFocus" @blur="onBlur" />
|
||||
<a v-if="selected && button"
|
||||
class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
|
||||
@click="select(-1, false, true)">
|
||||
<span class="icon is-small ml-1">
|
||||
<i class="fa fa-pen"></i>
|
||||
</span>
|
||||
<span class="is-inline-block" v-if="selected">
|
||||
<slot name="button" :index="selectedIndex" :item="selected"
|
||||
:value-field="valueField" :labelField="labelField">
|
||||
{{ labelField && selected.data[labelField] || selected }}
|
||||
</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"
|
||||
@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 }}
|
||||
</slot>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -39,29 +46,63 @@
|
|||
|
||||
<script>
|
||||
// import debounce from 'lodash/debounce'
|
||||
import Model from '../model'
|
||||
|
||||
|
||||
export default {
|
||||
emit: ['change', 'keypress', 'keydown', 'keyup', 'select', 'unselect',
|
||||
'update:modelValue'],
|
||||
|
||||
props: {
|
||||
//! Search URL (where `${query}` is replaced by search term)
|
||||
url: String,
|
||||
//! Items' model
|
||||
model: Function,
|
||||
//! Input tag class
|
||||
inputClass: Array,
|
||||
//! input text placeholder
|
||||
placeholder: String,
|
||||
//! input form field name
|
||||
name: String,
|
||||
//! Field on items to use as label
|
||||
labelField: String,
|
||||
//! Field on selected item to get selectedValue from, if any
|
||||
valueField: {type: String, default: null},
|
||||
count: {type: Number, count: 10},
|
||||
//! If true, show button when value has been selected
|
||||
button: Boolean,
|
||||
//! If true, value must come from a selection
|
||||
mustExist: {type: Boolean, default: false},
|
||||
//! Minimum input size before fetching
|
||||
minFetchLength: {type: Number, default: 3},
|
||||
modelValue: {default: ''},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
value: '',
|
||||
inputValue: this.modelValue || '',
|
||||
query: '',
|
||||
items: [],
|
||||
selectedIndex: -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: {
|
||||
isFetching() { return !!this.promise },
|
||||
|
||||
selected() {
|
||||
let index = this.selectedIndex
|
||||
if(index<0)
|
||||
|
@ -71,23 +112,40 @@ export default {
|
|||
},
|
||||
|
||||
selectedValue() {
|
||||
const sel = this.selected
|
||||
return sel && (this.valueField ?
|
||||
sel.data[this.valueField] : sel.id)
|
||||
let value = this.itemValue(this.selected)
|
||||
if(!value && !this.mustExist)
|
||||
value = this.inputValue
|
||||
return value
|
||||
},
|
||||
|
||||
selectedLabel() {
|
||||
const sel = this.selected
|
||||
return sel && sel.data[this.labelField]
|
||||
return this.itemLabel(this.selected)
|
||||
},
|
||||
|
||||
dropdownClass() {
|
||||
const active = this.cursor > -1 && this.items.length;
|
||||
return ['dropdown', active ? 'is-active':'']
|
||||
var active = this.cursor > -1 && this.items.length;
|
||||
if(active && this.items.length == 1 &&
|
||||
this.itemValue(this.items[0]) == this.inputValue)
|
||||
active = false
|
||||
return ['dropdown is-fullwidth', active ? 'is-active':'']
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
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) {
|
||||
if(relative)
|
||||
index += this.cursor
|
||||
|
@ -100,9 +158,9 @@ export default {
|
|||
else if(index == this.selectedIndex)
|
||||
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) {
|
||||
this.$refs.input.value = this.selectedLabel
|
||||
this.inputValue = this.selectedLabel
|
||||
this.$refs.input.focus()
|
||||
}
|
||||
if(this.selectedIndex < 0)
|
||||
|
@ -114,11 +172,24 @@ export default {
|
|||
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) {
|
||||
case 13: this.select(this.cursor, false, false)
|
||||
break
|
||||
case 27: this.select()
|
||||
case 27: this.hide(); this.select()
|
||||
break
|
||||
case 38: this.move(-1, true)
|
||||
break
|
||||
|
@ -130,35 +201,47 @@ export default {
|
|||
event.stopPropagation()
|
||||
},
|
||||
|
||||
onKeyUp: function(event) {
|
||||
const value = event.target.value
|
||||
if(value === this.value)
|
||||
onKeyUp(event) {
|
||||
if(event.ctrlKey || event.altKey || event.metaKey)
|
||||
return
|
||||
|
||||
this.value = value;
|
||||
const value = event.target.value
|
||||
if(value === this.query)
|
||||
return
|
||||
|
||||
this.inputValue = value;
|
||||
if(!value)
|
||||
return this.selected && this.select(-1)
|
||||
|
||||
this.fetch(value)
|
||||
if(!this.minFetchLength || value.length >= this.minFetchLength)
|
||||
this.fetch(value)
|
||||
},
|
||||
|
||||
fetch: function(query) {
|
||||
if(!query || this.isFetching)
|
||||
fetch(query) {
|
||||
if(!query || this.promise)
|
||||
return
|
||||
|
||||
this.isFetching = true
|
||||
return this.model.fetch(this.url.replace('${query}', query), {many:true})
|
||||
.then(items => { this.items = items || []
|
||||
this.isFetching = false
|
||||
this.move(0)
|
||||
return items },
|
||||
data => {this.isFetching = false; Promise.reject(data)})
|
||||
this.query = 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())
|
||||
|
||||
promise = promise.then(items => {
|
||||
this.items = items || []
|
||||
this.promise = null;
|
||||
this.move(0)
|
||||
return items
|
||||
}, data => {this.promise = null; Promise.reject(data)})
|
||||
this.promise = promise
|
||||
return promise
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const form = this.$el.closest('form')
|
||||
form.addEventListener('reset', () => { this.value=''; this.select(-1) })
|
||||
form.addEventListener('reset', () => {
|
||||
this.inputValue = this.value;
|
||||
this.select(-1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<component :is="listTag" :class="listClass">
|
||||
<template v-for="(item,index) in items" :key="index">
|
||||
<component :is="itemTag" :class="itemClass" @click="select(index)"
|
||||
:draggable="orderable"
|
||||
:draggable="orderable" :data-index="index"
|
||||
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
|
||||
<slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot>
|
||||
</component>
|
||||
|
@ -70,7 +70,7 @@ export default {
|
|||
|
||||
onDragStart(ev) {
|
||||
const dataset = ev.target.dataset;
|
||||
const data = `cell:${dataset.index}`
|
||||
const data = `row:${dataset.index}`
|
||||
ev.dataTransfer.setData("text/cell", data)
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
},
|
||||
|
@ -82,11 +82,11 @@ export default {
|
|||
|
||||
onDrop(ev) {
|
||||
const data = ev.dataTransfer.getData("text/cell")
|
||||
if(!data || !data.startsWith('cell:'))
|
||||
if(!data || !data.startsWith('row:'))
|
||||
return
|
||||
|
||||
ev.preventDefault()
|
||||
const from = Number(data.slice(5))
|
||||
const from = Number(data.slice(4))
|
||||
const target = ev.target.tagName == this.itemTag ? ev.target
|
||||
: ev.target.closest(this.itemTag)
|
||||
this.$emit('move', {
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
<div class="column has-text-right">
|
||||
<div class="float-right field has-addons">
|
||||
<p class="control">
|
||||
<a :class="['button','p-2', mode == Modes.Text ? 'is-primary' : 'is-light']"
|
||||
@click="mode = Modes.Text">
|
||||
<a :class="['button','p-2', page == Page.Text ? 'is-primary' : 'is-light']"
|
||||
@click="page = Page.Text">
|
||||
<span class="icon is-small">
|
||||
<i class="fa fa-pencil"></i>
|
||||
</span>
|
||||
|
@ -16,8 +16,8 @@
|
|||
</a>
|
||||
</p>
|
||||
<p class="control">
|
||||
<a :class="['button','p-2', mode == Modes.List ? 'is-primary' : 'is-light']"
|
||||
@click="mode = Modes.List">
|
||||
<a :class="['button','p-2', page == Page.List ? 'is-primary' : 'is-light']"
|
||||
@click="page = Page.List">
|
||||
<span class="icon is-small">
|
||||
<i class="fa fa-list"></i>
|
||||
</span>
|
||||
|
@ -28,43 +28,16 @@
|
|||
</div>
|
||||
</div>
|
||||
<slot name="top" :set="set" :columns="columns" :items="items"/>
|
||||
<section class="page" v-show="mode == Modes.Text">
|
||||
<textarea ref="textarea" class="is-fullwidth" rows="20"
|
||||
<section class="page" v-show="page == Page.Text">
|
||||
<textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
|
||||
@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 class="page" v-show="mode == Modes.List">
|
||||
<a-rows :set="set" :columns="columns" :labels="FormatLabels"
|
||||
<section class="page" v-show="page == Page.List">
|
||||
<a-rows :set="set" :columns="columns" :labels="labels"
|
||||
:allow-create="true"
|
||||
:list-class="listClass" :item-class="itemClass"
|
||||
:orderable="true" @move="listItemMove"
|
||||
:orderable="true" @move="listItemMove" @colmove="columnMove"
|
||||
@cell="onCellEvent">
|
||||
<template v-for="[name,slot] of rowsSlots" :key="slot"
|
||||
v-slot:[slot]="data">
|
||||
|
@ -72,51 +45,128 @@
|
|||
</template>
|
||||
</a-rows>
|
||||
</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"/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import {dropRightWhile} from 'lodash'
|
||||
import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
|
||||
import {Set} from '../model'
|
||||
import Track from '../track'
|
||||
|
||||
import AActionButton from './AActionButton'
|
||||
import ARow from './ARow.vue'
|
||||
import ARows from './ARows.vue'
|
||||
|
||||
|
||||
export const Modes = {
|
||||
/// Page display
|
||||
export const Page = {
|
||||
Text: 0, List: 1, Settings: 2,
|
||||
}
|
||||
const FormatLabels = {
|
||||
artist: 'Artiste', album: 'Album', year: 'Année', tags: 'Tags',
|
||||
title: 'Titre',
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { ARow, ARows },
|
||||
components: { AActionButton, ARow, ARows },
|
||||
props: {
|
||||
dataEl: String,
|
||||
initData: Object,
|
||||
dataPrefix: String,
|
||||
listClass: String,
|
||||
itemClass: String,
|
||||
labels: Object,
|
||||
settingsUrl: String,
|
||||
defaultColumns: {
|
||||
type: Array,
|
||||
default: () => ['artist', 'title', 'tags', 'album', 'year']},
|
||||
},
|
||||
|
||||
data() {
|
||||
const settings = {
|
||||
playlist_editor_columns: this.defaultColumns,
|
||||
playlist_editor_sep: ' -- ',
|
||||
}
|
||||
return {
|
||||
Modes: Modes,
|
||||
FormatLabels: FormatLabels,
|
||||
mode: Modes.Text,
|
||||
Page: Page,
|
||||
page: Page.Text,
|
||||
set: new Set(Track),
|
||||
columns: ['artist', 'title', 'tags', 'album', 'year'],
|
||||
extraData: {},
|
||||
settings,
|
||||
savedSettings: cloneDeep(settings),
|
||||
}
|
||||
},
|
||||
|
||||
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() {
|
||||
return this.set.items
|
||||
},
|
||||
|
@ -140,7 +190,17 @@ export default {
|
|||
const value = this.columns[from]
|
||||
this.columns.splice(from, 1)
|
||||
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}) {
|
||||
|
@ -149,29 +209,28 @@ export default {
|
|||
},
|
||||
|
||||
updateList() {
|
||||
const items = this.toList(this.$refs.textarea.value,
|
||||
this.$refs.sep.value)
|
||||
const items = this.toList(this.$refs.textarea.value)
|
||||
this.set.reset(items)
|
||||
},
|
||||
|
||||
updateInput() {
|
||||
const input = this.toText(this.items, this.$refs.sep.value)
|
||||
const input = this.toText(this.items)
|
||||
this.$refs.textarea.value = input
|
||||
},
|
||||
|
||||
/**
|
||||
* From input and separator, return list of items.
|
||||
*/
|
||||
toList(input, sep) {
|
||||
toList(input) {
|
||||
var lines = input.split('\n')
|
||||
var items = []
|
||||
|
||||
for(let line of lines) {
|
||||
line = line.trim()
|
||||
line = line.trimLeft()
|
||||
if(!line)
|
||||
continue
|
||||
|
||||
var lineBits = line.split(sep)
|
||||
var lineBits = line.split(this.separator)
|
||||
var item = {}
|
||||
for(var col in this.columns) {
|
||||
if(col >= lineBits.length)
|
||||
|
@ -187,17 +246,18 @@ export default {
|
|||
/**
|
||||
* From items and separator return a string
|
||||
*/
|
||||
toText(items, sep) {
|
||||
var lines = []
|
||||
sep = ` ${(sep || this.$refs.sep.value).trim()} `
|
||||
toText(items) {
|
||||
const sep = ` ${this.separator.trim()} `
|
||||
const lines = []
|
||||
for(let item of items) {
|
||||
if(!item)
|
||||
continue
|
||||
var line = []
|
||||
for(var col of this.columns)
|
||||
line.push(item.data[col] || '')
|
||||
line = dropRightWhile(line, x => !x)
|
||||
lines.push(line.join(sep))
|
||||
line = dropRightWhile(line, x => !x || !('' + x).trim())
|
||||
line = line.join(sep).trimRight()
|
||||
lines.push(line)
|
||||
}
|
||||
return lines.join('\n')
|
||||
},
|
||||
|
@ -213,26 +273,38 @@ export default {
|
|||
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
|
||||
*/
|
||||
loadData({items=[]}) {
|
||||
loadData({items=[], settings=null}, reset=false) {
|
||||
if(reset) {
|
||||
this.set.items = []
|
||||
}
|
||||
for(var index in items)
|
||||
this.set.push(items[index])
|
||||
this.set.push(cloneDeep(items[index]))
|
||||
if(settings)
|
||||
this.settingsSaved(settings)
|
||||
this.updateInput()
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
initData(val) {
|
||||
this.loadData(val)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if(this.dataEl) {
|
||||
const el = document.getElementById(this.dataEl)
|
||||
if(el) {
|
||||
const data = JSON.parse(el.textContent)
|
||||
this.loadData(data)
|
||||
}
|
||||
}
|
||||
this.mode = (this.items) ? Modes.List : Modes.Text
|
||||
this.initData && this.loadData(this.initData)
|
||||
this.page = (this.items) ? Page.List : Page.Text
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
<tr>
|
||||
<slot name="head" :item="item" :row="row"/>
|
||||
<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"
|
||||
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
|
||||
<slot :name="attr" :item="item" :cell="cells[col]"
|
||||
|
@ -10,9 +12,11 @@
|
|||
:value="itemData && itemData[attr]">
|
||||
{{ itemData && itemData[attr] }}
|
||||
</slot>
|
||||
</td>
|
||||
</component>
|
||||
<slot name="cell-after" :item="item" :col="col" :cell="cells[col]"
|
||||
:attr="attr"/>
|
||||
</template>
|
||||
<slot name="tail" :item="item" :row="cell.row"/>
|
||||
<slot name="tail" :item="item" :row="row"/>
|
||||
</tr>
|
||||
</template>
|
||||
<script>
|
||||
|
@ -24,20 +28,21 @@ export default {
|
|||
|
||||
props: {
|
||||
item: Object,
|
||||
cell: Object,
|
||||
columns: Array,
|
||||
cell: {type: Object, default() { return {row: 0}}},
|
||||
cellTag: {type: String, default: 'td'},
|
||||
orderable: {type: Boolean, default: false},
|
||||
},
|
||||
|
||||
computed: {
|
||||
row() { return this.cell.row || 0 },
|
||||
columns() { return this.cell.columns },
|
||||
row() { return this.cell && this.cell.row },
|
||||
|
||||
itemData() {
|
||||
return this.item instanceof Model ? this.item.data : this.item;
|
||||
},
|
||||
|
||||
cells() {
|
||||
const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell
|
||||
const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell || {}
|
||||
const cells = []
|
||||
for(var col in this.columns)
|
||||
cells.push({...cell, col: Number(col)})
|
||||
|
@ -45,7 +50,7 @@ export default {
|
|||
},
|
||||
|
||||
cellEls() {
|
||||
return [...this.$el.querySelectorAll('td')].filter(x => x.dataset.col)
|
||||
return [...this.$el.querySelectorAll(self.cellTag)].filter(x => x.dataset.col)
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -1,27 +1,30 @@
|
|||
<template>
|
||||
<table class="table is-stripped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<slot name="header-head"/>
|
||||
<th v-for="col in columns" :key="col"
|
||||
style="vertical-align: middle">{{ labels[col] }}</th>
|
||||
<slot name="header-tail"/>
|
||||
</tr>
|
||||
<a-row :item="labels" :columns="columns" :orderable="orderable"
|
||||
@move="$emit('colmove', $event)">
|
||||
<template v-if="$slots['header-head']" v-slot:head="data">
|
||||
<slot name="header-head" v-bind="data"/>
|
||||
</template>
|
||||
<template v-if="$slots['header-tail']" v-slot:tail="data">
|
||||
<slot name="header-tail" v-bind="data"/>
|
||||
</template>
|
||||
</a-row>
|
||||
</thead>
|
||||
<tbody>
|
||||
<slot name="head"/>
|
||||
<template v-for="(item,row) in items" :key="row">
|
||||
<!-- data-index comes from AList component drag & drop -->
|
||||
<a-row :item="item" :cell="{row, columns}" :data-index="row"
|
||||
<a-row :item="item" :cell="{row}" :columns="columns" :data-index="row"
|
||||
:draggable="orderable"
|
||||
@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-if="slot == 'head' || slot == 'tail'">
|
||||
<slot :name="name" v-bind="data"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div @keydown.capture.ctrl="onControlKey($event, data.cell)">
|
||||
<div @keydown.ctrl="onControlKey($event, data.cell)">
|
||||
<slot :name="name" v-bind="data"/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -47,7 +50,7 @@ import ARow from './ARow.vue'
|
|||
const Component = {
|
||||
extends: AList,
|
||||
components: { ARow },
|
||||
emit: ['cell'],
|
||||
emit: ['cell', 'colmove'],
|
||||
|
||||
props: {
|
||||
...AList.props,
|
||||
|
@ -67,7 +70,7 @@ const Component = {
|
|||
rowCells() {
|
||||
const cells = []
|
||||
for(var row in this.items)
|
||||
cells.push({row, columns: this.columns,})
|
||||
cells.push({row})
|
||||
},
|
||||
|
||||
rows() {
|
||||
|
|
|
@ -13,12 +13,15 @@ import AStreamer from './AStreamer.vue'
|
|||
/**
|
||||
* Core components
|
||||
*/
|
||||
export default {
|
||||
export const base = {
|
||||
AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist,
|
||||
AProgress, ASoundItem,
|
||||
}
|
||||
|
||||
export default base
|
||||
|
||||
export const admin = {
|
||||
...base,
|
||||
AStatistics, AStreamer, APlaylistEditor
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user