- 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:
		@ -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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user