forked from rc/aircox
		
	- 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 .article import *
 | 
				
			||||||
from .page import Category, Page, StaticPage, Comment, NavItem
 | 
					from .page import *
 | 
				
			||||||
from .program import Program, Stream, Schedule
 | 
					from .program import *
 | 
				
			||||||
from .episode import Episode, Diffusion
 | 
					from .episode import *
 | 
				
			||||||
from .log import Log
 | 
					from .log import *
 | 
				
			||||||
from .sound import Sound, Track
 | 
					from .sound import *
 | 
				
			||||||
from .station import Station, Port
 | 
					from .station import *
 | 
				
			||||||
 | 
					from .user_settings import *
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from . import signals
 | 
					from . import signals
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,8 +1,10 @@
 | 
				
			|||||||
from django.db import models
 | 
					 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from .page import Page, PageQuerySet
 | 
					from .page import Page
 | 
				
			||||||
from .program import Program, ProgramChildQuerySet
 | 
					from .program import ProgramChildQuerySet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = ('Article',)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Article(Page):
 | 
					class Article(Page):
 | 
				
			||||||
 | 
				
			|||||||
@ -9,12 +9,12 @@ from django.utils.functional import cached_property
 | 
				
			|||||||
from easy_thumbnails.files import get_thumbnailer
 | 
					from easy_thumbnails.files import get_thumbnailer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from aircox import settings, utils
 | 
					from aircox import settings, utils
 | 
				
			||||||
from .program import Program, ProgramChildQuerySet, \
 | 
					from .program import ProgramChildQuerySet, \
 | 
				
			||||||
        BaseRerun, BaseRerunQuerySet, Schedule
 | 
					        BaseRerun, BaseRerunQuerySet, Schedule
 | 
				
			||||||
from .page import Page, PageQuerySet
 | 
					from .page import Page
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__all__ = ['Episode', 'Diffusion', 'DiffusionQuerySet']
 | 
					__all__ = ('Episode', 'Diffusion', 'DiffusionQuerySet')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Episode(Page):
 | 
					class Episode(Page):
 | 
				
			||||||
@ -31,9 +31,9 @@ class Episode(Page):
 | 
				
			|||||||
        """ Return serialized data about podcasts. """
 | 
					        """ Return serialized data about podcasts. """
 | 
				
			||||||
        from ..serializers import PodcastSerializer
 | 
					        from ..serializers import PodcastSerializer
 | 
				
			||||||
        podcasts = [PodcastSerializer(s).data
 | 
					        podcasts = [PodcastSerializer(s).data
 | 
				
			||||||
                    for s in self.sound_set.public().order_by('type') ]
 | 
					                    for s in self.sound_set.public().order_by('type')]
 | 
				
			||||||
        if self.cover:
 | 
					        if self.cover:
 | 
				
			||||||
            options = {'size': (128,128), 'crop':'scale'}
 | 
					            options = {'size': (128, 128), 'crop': 'scale'}
 | 
				
			||||||
            cover = get_thumbnailer(self.cover).get_thumbnail(options).url
 | 
					            cover = get_thumbnailer(self.cover).get_thumbnail(options).url
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            cover = None
 | 
					            cover = None
 | 
				
			||||||
@ -84,7 +84,7 @@ class DiffusionQuerySet(BaseRerunQuerySet):
 | 
				
			|||||||
    def episode(self, episode=None, id=None):
 | 
					    def episode(self, episode=None, id=None):
 | 
				
			||||||
        """ Diffusions for this episode """
 | 
					        """ Diffusions for this episode """
 | 
				
			||||||
        return self.filter(episode=episode) if id is None else \
 | 
					        return self.filter(episode=episode) if id is None else \
 | 
				
			||||||
               self.filter(episode__id=id)
 | 
					            self.filter(episode__id=id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def on_air(self):
 | 
					    def on_air(self):
 | 
				
			||||||
        """ On air diffusions """
 | 
					        """ On air diffusions """
 | 
				
			||||||
@ -104,13 +104,13 @@ class DiffusionQuerySet(BaseRerunQuerySet):
 | 
				
			|||||||
        end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
 | 
					        end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
 | 
				
			||||||
        # start = tz.get_current_timezone().localize(start)
 | 
					        # start = tz.get_current_timezone().localize(start)
 | 
				
			||||||
        # end = tz.get_current_timezone().localize(end)
 | 
					        # end = tz.get_current_timezone().localize(end)
 | 
				
			||||||
        qs = self.filter(start__range = (start, end))
 | 
					        qs = self.filter(start__range=(start, end))
 | 
				
			||||||
        return qs.order_by('start') if order else qs
 | 
					        return qs.order_by('start') if order else qs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def at(self, date, order=True):
 | 
					    def at(self, date, order=True):
 | 
				
			||||||
        """ Return diffusions at specified date or datetime """
 | 
					        """ Return diffusions at specified date or datetime """
 | 
				
			||||||
        return self.now(date, order) if isinstance(date, tz.datetime) else \
 | 
					        return self.now(date, order) if isinstance(date, tz.datetime) else \
 | 
				
			||||||
               self.date(date, order)
 | 
					            self.date(date, order)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def after(self, date=None):
 | 
					    def after(self, date=None):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
@ -201,7 +201,7 @@ class Diffusion(BaseRerun):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        str_ = '{episode} - {date}'.format(
 | 
					        str_ = '{episode} - {date}'.format(
 | 
				
			||||||
            self=self, episode=self.episode and self.episode.title,
 | 
					            episode=self.episode and self.episode.title,
 | 
				
			||||||
            date=self.local_start.strftime('%Y/%m/%d %H:%M%z'),
 | 
					            date=self.local_start.strftime('%Y/%m/%d %H:%M%z'),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        if self.initial:
 | 
					        if self.initial:
 | 
				
			||||||
@ -324,5 +324,3 @@ class Diffusion(BaseRerun):
 | 
				
			|||||||
            'end': self.end,
 | 
					            'end': self.end,
 | 
				
			||||||
            'episode': getattr(self, 'episode', None),
 | 
					            'episode': getattr(self, 'episode', None),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -20,7 +20,7 @@ from .station import Station
 | 
				
			|||||||
logger = logging.getLogger('aircox')
 | 
					logger = logging.getLogger('aircox')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__all__ = ['Log', 'LogQuerySet', 'LogArchiver']
 | 
					__all__ = ('Log', 'LogQuerySet', 'LogArchiver')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class LogQuerySet(models.QuerySet):
 | 
					class LogQuerySet(models.QuerySet):
 | 
				
			||||||
@ -31,7 +31,7 @@ class LogQuerySet(models.QuerySet):
 | 
				
			|||||||
    def date(self, date):
 | 
					    def date(self, date):
 | 
				
			||||||
        start = tz.datetime.combine(date, datetime.time())
 | 
					        start = tz.datetime.combine(date, datetime.time())
 | 
				
			||||||
        end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
 | 
					        end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
 | 
				
			||||||
        return self.filter(date__range = (start, end))
 | 
					        return self.filter(date__range=(start, end))
 | 
				
			||||||
        # this filter does not work with mysql
 | 
					        # this filter does not work with mysql
 | 
				
			||||||
        # return self.filter(date__date=date)
 | 
					        # return self.filter(date__date=date)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,3 @@
 | 
				
			|||||||
from enum import IntEnum
 | 
					 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
@ -18,7 +17,8 @@ from model_utils.managers import InheritanceQuerySet
 | 
				
			|||||||
from .station import Station
 | 
					from .station import Station
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__all__ = ['Category', 'PageQuerySet', 'Page', 'Comment', 'NavItem']
 | 
					__all__ = ('Category', 'PageQuerySet',
 | 
				
			||||||
 | 
					           'Page', 'StaticPage', 'Comment', 'NavItem')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
headline_re = re.compile(r'(<p>)?'
 | 
					headline_re = re.compile(r'(<p>)?'
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,5 @@
 | 
				
			|||||||
import calendar
 | 
					import calendar
 | 
				
			||||||
from collections import OrderedDict
 | 
					from collections import OrderedDict
 | 
				
			||||||
import datetime
 | 
					 | 
				
			||||||
from enum import IntEnum
 | 
					from enum import IntEnum
 | 
				
			||||||
import logging
 | 
					import logging
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
@ -10,7 +9,7 @@ import pytz
 | 
				
			|||||||
from django.conf import settings as conf
 | 
					from django.conf import settings as conf
 | 
				
			||||||
from django.core.exceptions import ValidationError
 | 
					from django.core.exceptions import ValidationError
 | 
				
			||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
from django.db.models import F, Q
 | 
					from django.db.models import F
 | 
				
			||||||
from django.db.models.functions import Concat, Substr
 | 
					from django.db.models.functions import Concat, Substr
 | 
				
			||||||
from django.utils import timezone as tz
 | 
					from django.utils import timezone as tz
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
@ -24,8 +23,8 @@ from .station import Station
 | 
				
			|||||||
logger = logging.getLogger('aircox')
 | 
					logger = logging.getLogger('aircox')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__all__ = ['Program', 'ProgramQuerySet', 'Stream', 'Schedule',
 | 
					__all__ = ('Program', 'ProgramQuerySet', 'Stream', 'Schedule',
 | 
				
			||||||
           'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet']
 | 
					           'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ProgramQuerySet(PageQuerySet):
 | 
					class ProgramQuerySet(PageQuerySet):
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,7 @@ import pytz
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from django.contrib.auth.models import User, Group, Permission
 | 
					from django.contrib.auth.models import User, Group, Permission
 | 
				
			||||||
from django.db import transaction
 | 
					from django.db import transaction
 | 
				
			||||||
from django.db.models import F, signals
 | 
					from django.db.models import signals
 | 
				
			||||||
from django.dispatch import receiver
 | 
					from django.dispatch import receiver
 | 
				
			||||||
from django.utils import timezone as tz
 | 
					from django.utils import timezone as tz
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,17 +1,14 @@
 | 
				
			|||||||
from enum import IntEnum
 | 
					 | 
				
			||||||
import logging
 | 
					import logging
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings as conf
 | 
					from django.conf import settings as conf
 | 
				
			||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
from django.db.models import Q, Value as V
 | 
					from django.db.models import Q
 | 
				
			||||||
from django.db.models.functions import Concat
 | 
					 | 
				
			||||||
from django.utils import timezone as tz
 | 
					from django.utils import timezone as tz
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from taggit.managers import TaggableManager
 | 
					from taggit.managers import TaggableManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from aircox import settings
 | 
					 | 
				
			||||||
from .program import Program
 | 
					from .program import Program
 | 
				
			||||||
from .episode import Episode
 | 
					from .episode import Episode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -19,7 +16,7 @@ from .episode import Episode
 | 
				
			|||||||
logger = logging.getLogger('aircox')
 | 
					logger = logging.getLogger('aircox')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__all__ = ['Sound', 'SoundQuerySet', 'Track']
 | 
					__all__ = ('Sound', 'SoundQuerySet', 'Track')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SoundQuerySet(models.QuerySet):
 | 
					class SoundQuerySet(models.QuerySet):
 | 
				
			||||||
 | 
				
			|||||||
@ -8,7 +8,7 @@ from filer.fields.image import FilerImageField
 | 
				
			|||||||
from .. import settings
 | 
					from .. import settings
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__all__ = ['Station', 'StationQuerySet', 'Port']
 | 
					__all__ = ('Station', 'StationQuerySet', 'Port')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StationQuerySet(models.QuerySet):
 | 
					class StationQuerySet(models.QuerySet):
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										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 rest_framework import serializers
 | 
				
			||||||
from taggit.serializers import TagListSerializerField, TaggitSerializer
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from .models import Diffusion, Log, Sound, Track
 | 
					from ..models import Diffusion, Log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__all__ = ['LogInfo', 'LogInfoSerializer', 'SoundSerializer',
 | 
					__all__ = ('LogInfo', 'LogInfoSerializer')
 | 
				
			||||||
           'PodcastSerializer',
 | 
					 | 
				
			||||||
           'AdminTrackSerializer']
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class LogInfo:
 | 
					class LogInfo:
 | 
				
			||||||
@ -54,30 +51,3 @@ class LogInfoSerializer(serializers.Serializer):
 | 
				
			|||||||
    info = serializers.CharField(max_length=200, required=False)
 | 
					    info = serializers.CharField(max_length=200, required=False)
 | 
				
			||||||
    url = serializers.URLField(required=False)
 | 
					    url = serializers.URLField(required=False)
 | 
				
			||||||
    cover = serializers.URLField(required=False)
 | 
					    cover = serializers.URLField(required=False)
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SoundSerializer(serializers.ModelSerializer):
 | 
					 | 
				
			||||||
    file = serializers.FileField(use_url=False)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        model = Sound
 | 
					 | 
				
			||||||
        fields = ['pk', 'name', 'program', 'episode', 'type', 'file',
 | 
					 | 
				
			||||||
                  'duration', 'mtime', 'is_good_quality', 'is_public', 'url']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class PodcastSerializer(serializers.ModelSerializer):
 | 
					 | 
				
			||||||
    # serializers.HyperlinkedIdentityField(view_name='sound', format='html')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        model = Sound
 | 
					 | 
				
			||||||
        fields = ['pk', 'name', 'program', 'episode', 'type',
 | 
					 | 
				
			||||||
                  'duration', 'mtime', 'url', 'is_downloadable']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class AdminTrackSerializer(TaggitSerializer, serializers.ModelSerializer):
 | 
					 | 
				
			||||||
    tags = TagListSerializerField()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        model = Track
 | 
					 | 
				
			||||||
        fields = ('pk', 'artist', 'title', 'album', 'year', 'position',
 | 
					 | 
				
			||||||
                  'info', 'tags', 'episode', 'sound')
 | 
					 | 
				
			||||||
							
								
								
									
										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__) {
 | 
					/***/ (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 %}
 | 
					{% comment %}Inline block to edit playlists{% endcomment %}
 | 
				
			||||||
{% load aircox aircox_admin static i18n %}
 | 
					{% load aircox aircox_admin static i18n %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{# include "adminsortable2/edit_inline/tabular-django-4.1.html" #}
 | 
					 | 
				
			||||||
{% with inline_admin_formset as admin_formset %}
 | 
					{% with inline_admin_formset as admin_formset %}
 | 
				
			||||||
{% with admin_formset.formset as formset %}
 | 
					{% with admin_formset.formset as formset %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script id="{{ formset.prefix }}-init-data">
 | 
					 | 
				
			||||||
{{ formset|inline_data|json }}
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
<div id="inline-tracks" class="box mb-5">
 | 
					<div id="inline-tracks" class="box mb-5">
 | 
				
			||||||
    {{ admin_formset.non_form_errors }}
 | 
					    {{ admin_formset.non_form_errors }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <a-playlist-editor data-el="{{ formset.prefix }}-init-data"
 | 
					    <a-playlist-editor
 | 
				
			||||||
 | 
					            :labels="{% track_inline_labels %}"
 | 
				
			||||||
 | 
					            :init-data="{% track_inline_data formset=formset %}"
 | 
				
			||||||
 | 
					            settings-url="{% url "api:user-settings" %}"
 | 
				
			||||||
            data-prefix="{{ formset.prefix }}-">
 | 
					            data-prefix="{{ formset.prefix }}-">
 | 
				
			||||||
        <template #title>
 | 
					        <template #title>
 | 
				
			||||||
            <h5 class="title is-4">{% trans "Playlist" %}</h5>
 | 
					            <h5 class="title is-4">{% trans "Playlist" %}</h5>
 | 
				
			||||||
@ -57,13 +56,21 @@
 | 
				
			|||||||
        {% if not field.widget.is_hidden and not field.is_readonly %}
 | 
					        {% if not field.widget.is_hidden and not field.is_readonly %}
 | 
				
			||||||
        <template v-slot:row-{{ field.name }}="{item,col,row,value,attr,emit}">
 | 
					        <template v-slot:row-{{ field.name }}="{item,col,row,value,attr,emit}">
 | 
				
			||||||
            <div class="field">
 | 
					            <div class="field">
 | 
				
			||||||
 | 
					                {% if field.name in 'artist,title,album' %}
 | 
				
			||||||
 | 
					                <a-autocomplete
 | 
				
			||||||
 | 
					                        :input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
 | 
				
			||||||
 | 
					                        url="{% url 'api:track-autocomplete' %}?{{ field.name }}=${query}&field={{ field.name }}"
 | 
				
			||||||
 | 
					                {% else %}
 | 
				
			||||||
                <div class="control">
 | 
					                <div class="control">
 | 
				
			||||||
                    <input type="{{ widget.type }}"
 | 
					                    <input type="{{ widget.type }}"
 | 
				
			||||||
                        :class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
 | 
					                        :class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
 | 
				
			||||||
 | 
					                {% endif %}
 | 
				
			||||||
                        :name="'{{ formset.prefix }}-' + row + '-{{ field.name }}'"
 | 
					                        :name="'{{ formset.prefix }}-' + row + '-{{ field.name }}'"
 | 
				
			||||||
                        v-model="item.data[attr]"
 | 
					                        v-model="item.data[attr]"
 | 
				
			||||||
                        @change="emit('change', col)"/>
 | 
					                        @change="emit('change', col)"/>
 | 
				
			||||||
 | 
					                {% if field.name not in 'artist,title,album' %}
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
 | 
					                {% endif %}
 | 
				
			||||||
                <p v-for="error in item.error(attr)" class="help is-danger">
 | 
					                <p v-for="error in item.error(attr)" class="help is-danger">
 | 
				
			||||||
                    [[ error ]] !
 | 
					                    [[ error ]] !
 | 
				
			||||||
                </p>
 | 
					                </p>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,14 @@
 | 
				
			|||||||
 | 
					import json
 | 
				
			||||||
from django import template
 | 
					from django import template
 | 
				
			||||||
from django.contrib import admin
 | 
					from django.contrib import admin
 | 
				
			||||||
 | 
					from django.utils.safestring import mark_safe
 | 
				
			||||||
 | 
					from django.utils.translation import gettext_lazy as _, gettext as __
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox.serializers.admin import UserSettingsSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = ('register', 'do_get_admin_tools', 'do_track_inline_data',
 | 
				
			||||||
 | 
					           'do_track_inline_column_labels')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
register = template.Library()
 | 
					register = template.Library()
 | 
				
			||||||
@ -10,8 +19,14 @@ def do_get_admin_tools():
 | 
				
			|||||||
    return admin.site.get_tools()
 | 
					    return admin.site.get_tools()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@register.filter(name='inline_data')
 | 
					@register.simple_tag(name='track_inline_data', takes_context=True)
 | 
				
			||||||
def do_inline_data(formset):
 | 
					def do_track_inline_data(context, formset, safe_string=False):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Return initial data for playlist editor as dict. Keys are:
 | 
				
			||||||
 | 
					    - ``items``: list of items. Extra keys:
 | 
				
			||||||
 | 
					        - ``__error__``: dict of form fields errors
 | 
				
			||||||
 | 
					    - ``settings``: user's settings
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
    items = []
 | 
					    items = []
 | 
				
			||||||
    for form in formset.forms:
 | 
					    for form in formset.forms:
 | 
				
			||||||
        item = {name: form[name].value()
 | 
					        item = {name: form[name].value()
 | 
				
			||||||
@ -23,5 +38,23 @@ def do_inline_data(formset):
 | 
				
			|||||||
        if tags and not isinstance(tags, str):
 | 
					        if tags and not isinstance(tags, str):
 | 
				
			||||||
            item['tags'] = ', '.join(tag.name for tag in tags)
 | 
					            item['tags'] = ', '.join(tag.name for tag in tags)
 | 
				
			||||||
        items.append(item)
 | 
					        items.append(item)
 | 
				
			||||||
    return {"items": items}
 | 
					
 | 
				
			||||||
 | 
					    data = {"items": items}
 | 
				
			||||||
 | 
					    user = context['request'].user
 | 
				
			||||||
 | 
					    settings = getattr(user, 'aircox_settings', None)
 | 
				
			||||||
 | 
					    data['settings'] = settings and UserSettingsSerializer(settings).data
 | 
				
			||||||
 | 
					    source = json.dumps(data)
 | 
				
			||||||
 | 
					    return safe_string and mark_safe(source) or source
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@register.simple_tag(name='track_inline_labels')
 | 
				
			||||||
 | 
					def do_track_inline_labels():
 | 
				
			||||||
 | 
					    """ Return labels for columns in playlist editor as dict """
 | 
				
			||||||
 | 
					    return json.dumps({
 | 
				
			||||||
 | 
					        'artist': __('Artist'), 'album': __('Album'), 'title': __('Title'),
 | 
				
			||||||
 | 
					        'tags': __('Tags'), 'year': __('Year'),
 | 
				
			||||||
 | 
					        'save_settings': __('Save Settings'),
 | 
				
			||||||
 | 
					        'discard_changes': __('Discard changes'),
 | 
				
			||||||
 | 
					        'columns': __('Columns'),
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -24,10 +24,14 @@ register_converter(WeekConverter, 'week')
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
router = DefaultRouter()
 | 
					router = DefaultRouter()
 | 
				
			||||||
router.register('sound', viewsets.SoundViewSet, basename='sound')
 | 
					router.register('sound', viewsets.SoundViewSet, basename='sound')
 | 
				
			||||||
 | 
					router.register('track', viewsets.TrackROViewSet, basename='track')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
api = [
 | 
					api = [
 | 
				
			||||||
    path('logs/', views.LogListAPIView.as_view(), name='live'),
 | 
					    path('logs/', views.LogListAPIView.as_view(), name='live'),
 | 
				
			||||||
 | 
					    path('user/settings/', viewsets.UserSettingsViewSet.as_view(
 | 
				
			||||||
 | 
					         {'get': 'retrieve', 'post': 'update', 'put': 'update'}),
 | 
				
			||||||
 | 
					         name='user-settings'),
 | 
				
			||||||
] + router.urls
 | 
					] + router.urls
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -62,6 +62,7 @@ class BaseView(TemplateResponseMixin, ContextMixin):
 | 
				
			|||||||
        return super().get_context_data(**kwargs)
 | 
					        return super().get_context_data(**kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# FIXME: rename to sth like [Base]?StationAPIView
 | 
				
			||||||
class BaseAPIView:
 | 
					class BaseAPIView:
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def station(self):
 | 
					    def station(self):
 | 
				
			||||||
 | 
				
			|||||||
@ -1,13 +1,18 @@
 | 
				
			|||||||
from django.db.models import Q
 | 
					from rest_framework import status, viewsets
 | 
				
			||||||
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework import viewsets
 | 
					from rest_framework.permissions import IsAuthenticated
 | 
				
			||||||
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from django_filters import rest_framework as filters
 | 
					from django_filters import rest_framework as filters
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from .models import Sound
 | 
					from .models import Sound, Track
 | 
				
			||||||
from .serializers import SoundSerializer
 | 
					from .serializers import SoundSerializer, admin
 | 
				
			||||||
from .views import BaseAPIView
 | 
					from .views import BaseAPIView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = ('SoundFilter', 'SoundViewSet', 'TrackFilter', 'TrackROViewSet',
 | 
				
			||||||
 | 
					           'UserSettingsViewSet')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SoundFilter(filters.FilterSet):
 | 
					class SoundFilter(filters.FilterSet):
 | 
				
			||||||
    station = filters.NumberFilter(field_name='program__station__id')
 | 
					    station = filters.NumberFilter(field_name='program__station__id')
 | 
				
			||||||
    program = filters.NumberFilter(field_name='program_id')
 | 
					    program = filters.NumberFilter(field_name='program_id')
 | 
				
			||||||
@ -24,3 +29,63 @@ class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
 | 
				
			|||||||
    filter_backends = (filters.DjangoFilterBackend,)
 | 
					    filter_backends = (filters.DjangoFilterBackend,)
 | 
				
			||||||
    filterset_class = SoundFilter
 | 
					    filterset_class = SoundFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# --- admin
 | 
				
			||||||
 | 
					class TrackFilter(filters.FilterSet):
 | 
				
			||||||
 | 
					    artist = filters.CharFilter(field_name='artist', lookup_expr='icontains')
 | 
				
			||||||
 | 
					    album = filters.CharFilter(field_name='album', lookup_expr='icontains')
 | 
				
			||||||
 | 
					    title = filters.CharFilter(field_name='title', lookup_expr='icontains')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
 | 
				
			||||||
 | 
					    """ Track viewset used for auto completion """
 | 
				
			||||||
 | 
					    serializer_class = admin.TrackSerializer
 | 
				
			||||||
 | 
					    permission_classes = [IsAuthenticated]
 | 
				
			||||||
 | 
					    filter_backends = (filters.DjangoFilterBackend,)
 | 
				
			||||||
 | 
					    filterset_class = TrackFilter
 | 
				
			||||||
 | 
					    queryset = Track.objects.all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @action(name='autocomplete', detail=False)
 | 
				
			||||||
 | 
					    def autocomplete(self, request):
 | 
				
			||||||
 | 
					        field = request.GET.get('field', None)
 | 
				
			||||||
 | 
					        if field:
 | 
				
			||||||
 | 
					            queryset = self.filter_queryset(self.get_queryset())
 | 
				
			||||||
 | 
					            values = queryset.values_list(field, flat=True).distinct()
 | 
				
			||||||
 | 
					            return Response(values)
 | 
				
			||||||
 | 
					        return self.list(request)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UserSettingsViewSet(viewsets.ViewSet):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    User's settings specific to aircox. Allow only to create and edit
 | 
				
			||||||
 | 
					    user's own settings.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    serializer_class = admin.UserSettingsSerializer
 | 
				
			||||||
 | 
					    permission_classes = [IsAuthenticated]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_serializer(self, instance=None, **kwargs):
 | 
				
			||||||
 | 
					        return self.serializer_class(
 | 
				
			||||||
 | 
					            instance=instance, context={'user': self.request.user},
 | 
				
			||||||
 | 
					            **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @action(detail=False, methods=['GET'])
 | 
				
			||||||
 | 
					    def retrieve(self, request):
 | 
				
			||||||
 | 
					        user = self.request.user
 | 
				
			||||||
 | 
					        settings = getattr(user, 'aircox_settings', None)
 | 
				
			||||||
 | 
					        data = settings and self.get_serializer(settings) or None
 | 
				
			||||||
 | 
					        return Response(data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @action(detail=False, methods=['POST', 'PUT'])
 | 
				
			||||||
 | 
					    def update(self, request):
 | 
				
			||||||
 | 
					        user = self.request.user
 | 
				
			||||||
 | 
					        settings = getattr(user, 'aircox_settings', None)
 | 
				
			||||||
 | 
					        data = dict(request.data)
 | 
				
			||||||
 | 
					        data['user_id'] = self.request.user
 | 
				
			||||||
 | 
					        serializer = self.get_serializer(instance=settings, data=request.data)
 | 
				
			||||||
 | 
					        if serializer.is_valid():
 | 
				
			||||||
 | 
					            serializer.save()
 | 
				
			||||||
 | 
					            return Response({'status': 'ok'})
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return Response({'errors': serializer.errors},
 | 
				
			||||||
 | 
					                            status=status.HTTP_400_BAD_REQUEST)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -53,7 +53,7 @@
 | 
				
			|||||||
                    {# TODO: select station => change the shit #}
 | 
					                    {# TODO: select station => change the shit #}
 | 
				
			||||||
                    <a-autocomplete class="control is-expanded"
 | 
					                    <a-autocomplete class="control is-expanded"
 | 
				
			||||||
                        url="{% url "aircox:sound-list" %}?station={{ station.pk }}&search=${query}"
 | 
					                        url="{% url "aircox:sound-list" %}?station={{ station.pk }}&search=${query}"
 | 
				
			||||||
                        name="sound_id" :model="Sound" label-field="name"
 | 
					                        name="sound_id" :model="Sound" value-field="id" label-field="name"
 | 
				
			||||||
                        placeholder="{% translate "Select a sound" %}">
 | 
					                        placeholder="{% translate "Select a sound" %}">
 | 
				
			||||||
                            <template v-slot:item="{item}">
 | 
					                            <template v-slot:item="{item}">
 | 
				
			||||||
                                [[ item.data.name ]]
 | 
					                                [[ item.data.name ]]
 | 
				
			||||||
 | 
				
			|||||||
@ -4,10 +4,18 @@ import './index.js'
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import App from './app';
 | 
					import App from './app';
 | 
				
			||||||
import {admin as components} from './components'
 | 
					import {admin as components} from './components'
 | 
				
			||||||
 | 
					import Track from './track'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const AdminApp = {
 | 
					const AdminApp = {
 | 
				
			||||||
    ...App,
 | 
					    ...App,
 | 
				
			||||||
    components: {...App.components, ...components},
 | 
					    components: {...App.components, ...components},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    data() {
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					            ...super.data,
 | 
				
			||||||
 | 
					            Track,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
export default AdminApp;
 | 
					export default AdminApp;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										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>
 | 
					<template>
 | 
				
			||||||
    <div :class="dropdownClass">
 | 
					    <div class="control">
 | 
				
			||||||
        <div class="dropdown-trigger is-fullwidth">
 | 
					        <input type="hidden" :name="name" :value="selectedValue"
 | 
				
			||||||
            <input type="hidden" :name="name"
 | 
					            @change="$emit('change', $event)"/>
 | 
				
			||||||
                :value="selectedValue" />
 | 
					        <input type="text" ref="input" class="input is-fullwidth" :class="inputClass"
 | 
				
			||||||
            <div v-show="!selected" class="control is-expanded">
 | 
					            v-show="!button || !selected"
 | 
				
			||||||
                <input type="text" :placeholder="placeholder"
 | 
					            v-model="inputValue"
 | 
				
			||||||
                    ref="input" class="input is-fullwidth"
 | 
					            :placeholder="placeholder"
 | 
				
			||||||
                    @keydown.capture="onKeyPress"
 | 
					            @keydown.capture="onKeyDown"
 | 
				
			||||||
                    @keyup="onKeyUp" @focus="this.cursor < 0 && move(0)"/>
 | 
					            @keyup="onKeyUp($event); $emit('keyup', $event)"
 | 
				
			||||||
            </div>
 | 
					            @keydown="$emit('keydown', $event)"
 | 
				
			||||||
            <button v-if="selected" class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
 | 
					            @keypress="$emit('keypress', $event)"
 | 
				
			||||||
                    @click="select(-1, false, true)">
 | 
					            @focus="onInputFocus" @blur="onBlur" />
 | 
				
			||||||
                <span class="icon is-small ml-1">
 | 
					        <a v-if="selected && button"
 | 
				
			||||||
                    <i class="fa fa-pen"></i>
 | 
					                class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
 | 
				
			||||||
                </span>
 | 
					                @click="select(-1, false, true)">
 | 
				
			||||||
                <span class="is-inline-block" v-if="selected">
 | 
					            <span class="icon is-small ml-1">
 | 
				
			||||||
                    <slot name="button" :index="selectedIndex" :item="selected"
 | 
					                <i class="fa fa-pen"></i>
 | 
				
			||||||
                        :value-field="valueField" :labelField="labelField">
 | 
					            </span>
 | 
				
			||||||
                    {{ selected.data[labelField] }}
 | 
					            <span class="is-inline-block" v-if="selected">
 | 
				
			||||||
                    </slot>
 | 
					                <slot name="button" :index="selectedIndex" :item="selected"
 | 
				
			||||||
                </span>
 | 
					                    :value-field="valueField" :labelField="labelField">
 | 
				
			||||||
            </button>
 | 
					                {{ labelField && selected.data[labelField] || selected }}
 | 
				
			||||||
        </div>
 | 
					                </slot>
 | 
				
			||||||
        <div class="dropdown-menu is-fullwidth">
 | 
					            </span>
 | 
				
			||||||
            <div class="dropdown-content" style="overflow: hidden">
 | 
					        </a>
 | 
				
			||||||
                <a v-for="(item, index) in items" :key="item.id"
 | 
					        <div :class="dropdownClass">
 | 
				
			||||||
                    :class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
 | 
					            <div class="dropdown-menu is-fullwidth">
 | 
				
			||||||
                    @click.capture.prevent="select(index, false, false)" :title="item.data[labelField]">
 | 
					                <div class="dropdown-content" style="overflow: hidden">
 | 
				
			||||||
                    <slot name="item" :index="index" :item="item" :value-field="valueField"
 | 
					                    <a v-for="(item, index) in items" :key="item.id"
 | 
				
			||||||
                        :labelField="labelField">
 | 
					                        href="#" :data-autocomplete-index="index"
 | 
				
			||||||
                    {{ item.data[labelField] }}
 | 
					                        @click="select(index, false, false)"
 | 
				
			||||||
                    </slot>
 | 
					                        :class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
 | 
				
			||||||
                </a>
 | 
					                        :title="labelField && item.data[labelField] || item"
 | 
				
			||||||
 | 
					                        tabindex="-1">
 | 
				
			||||||
 | 
					                        <slot name="item" :index="index" :item="item" :value-field="valueField"
 | 
				
			||||||
 | 
					                            :labelField="labelField">
 | 
				
			||||||
 | 
					                        {{ labelField && item.data[labelField] || item }}
 | 
				
			||||||
 | 
					                        </slot>
 | 
				
			||||||
 | 
					                    </a>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
@ -39,29 +46,63 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script>
 | 
					<script>
 | 
				
			||||||
// import debounce from 'lodash/debounce'
 | 
					// import debounce from 'lodash/debounce'
 | 
				
			||||||
 | 
					import Model from '../model'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default {
 | 
					export default {
 | 
				
			||||||
 | 
					    emit: ['change', 'keypress', 'keydown', 'keyup', 'select', 'unselect',
 | 
				
			||||||
 | 
					           'update:modelValue'],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    props: {
 | 
					    props: {
 | 
				
			||||||
 | 
					        //! Search URL (where `${query}` is replaced by search term)
 | 
				
			||||||
        url: String,
 | 
					        url: String,
 | 
				
			||||||
 | 
					        //! Items' model
 | 
				
			||||||
        model: Function,
 | 
					        model: Function,
 | 
				
			||||||
 | 
					        //! Input tag class
 | 
				
			||||||
 | 
					        inputClass: Array,
 | 
				
			||||||
 | 
					        //! input text placeholder
 | 
				
			||||||
        placeholder: String,
 | 
					        placeholder: String,
 | 
				
			||||||
 | 
					        //! input form field name
 | 
				
			||||||
        name: String,
 | 
					        name: String,
 | 
				
			||||||
 | 
					        //! Field on items to use as label
 | 
				
			||||||
        labelField: String,
 | 
					        labelField: String,
 | 
				
			||||||
 | 
					        //! Field on selected item to get selectedValue from, if any
 | 
				
			||||||
        valueField: {type: String, default: null},
 | 
					        valueField: {type: String, default: null},
 | 
				
			||||||
        count: {type: Number, count: 10},
 | 
					        count: {type: Number, count: 10},
 | 
				
			||||||
 | 
					        //! If true, show button when value has been selected
 | 
				
			||||||
 | 
					        button: Boolean,
 | 
				
			||||||
 | 
					        //! If true, value must come from a selection
 | 
				
			||||||
 | 
					        mustExist: {type: Boolean, default: false},
 | 
				
			||||||
 | 
					        //! Minimum input size before fetching
 | 
				
			||||||
 | 
					        minFetchLength: {type: Number, default: 3},
 | 
				
			||||||
 | 
					        modelValue: {default: ''},
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    data() {
 | 
					    data() {
 | 
				
			||||||
        return {
 | 
					        return {
 | 
				
			||||||
            value: '',
 | 
					            inputValue: this.modelValue || '',
 | 
				
			||||||
 | 
					            query: '',
 | 
				
			||||||
            items: [],
 | 
					            items: [],
 | 
				
			||||||
            selectedIndex: -1,
 | 
					            selectedIndex: -1,
 | 
				
			||||||
            cursor: -1,
 | 
					            cursor: -1,
 | 
				
			||||||
            isFetching: false,
 | 
					            promise: null,
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    watch: {
 | 
				
			||||||
 | 
					        modelValue(value) {
 | 
				
			||||||
 | 
					            this.inputValue = value
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        inputValue(value) {
 | 
				
			||||||
 | 
					            if(value != this.inputValue && value != this.modelValue)
 | 
				
			||||||
 | 
					                this.$emit('update:modelValue', value)
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    computed: {
 | 
					    computed: {
 | 
				
			||||||
 | 
					        isFetching() { return !!this.promise },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        selected() {
 | 
					        selected() {
 | 
				
			||||||
            let index = this.selectedIndex
 | 
					            let index = this.selectedIndex
 | 
				
			||||||
            if(index<0)
 | 
					            if(index<0)
 | 
				
			||||||
@ -71,23 +112,40 @@ export default {
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        selectedValue() {
 | 
					        selectedValue() {
 | 
				
			||||||
            const sel = this.selected
 | 
					            let value = this.itemValue(this.selected)
 | 
				
			||||||
            return sel && (this.valueField ?
 | 
					            if(!value && !this.mustExist)
 | 
				
			||||||
                    sel.data[this.valueField] : sel.id)
 | 
					                value = this.inputValue
 | 
				
			||||||
 | 
					            return value
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        selectedLabel() {
 | 
					        selectedLabel() {
 | 
				
			||||||
            const sel = this.selected
 | 
					            return this.itemLabel(this.selected)
 | 
				
			||||||
            return sel && sel.data[this.labelField]
 | 
					 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        dropdownClass() {
 | 
					        dropdownClass() {
 | 
				
			||||||
            const active = this.cursor > -1 && this.items.length;
 | 
					            var active = this.cursor > -1 && this.items.length;
 | 
				
			||||||
            return ['dropdown', active ? 'is-active':'']
 | 
					            if(active && this.items.length == 1 &&
 | 
				
			||||||
 | 
					                    this.itemValue(this.items[0]) == this.inputValue)
 | 
				
			||||||
 | 
					                active = false
 | 
				
			||||||
 | 
					            return ['dropdown is-fullwidth', active ? 'is-active':'']
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    methods: {
 | 
					    methods: {
 | 
				
			||||||
 | 
					        itemValue(item) {
 | 
				
			||||||
 | 
					            return this.valueField ? item && item[this.valueField] : item;
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        itemLabel(item) {
 | 
				
			||||||
 | 
					            return this.labelField ? item && item[this.labelField] : item;
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        hide() {
 | 
				
			||||||
 | 
					            this.cursor = -1;
 | 
				
			||||||
 | 
					            this.selectedIndex = -1;
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
        move(index=-1, relative=false) {
 | 
					        move(index=-1, relative=false) {
 | 
				
			||||||
            if(relative)
 | 
					            if(relative)
 | 
				
			||||||
                index += this.cursor
 | 
					                index += this.cursor
 | 
				
			||||||
@ -100,9 +158,9 @@ export default {
 | 
				
			|||||||
            else if(index == this.selectedIndex)
 | 
					            else if(index == this.selectedIndex)
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
            this.selectedIndex =  Math.max(-1, Math.min(index, this.items.length-1))
 | 
					            this.selectedIndex = Math.max(-1, Math.min(index, this.items.length-1))
 | 
				
			||||||
            if(index >= 0) {
 | 
					            if(index >= 0) {
 | 
				
			||||||
                this.$refs.input.value = this.selectedLabel
 | 
					                this.inputValue = this.selectedLabel
 | 
				
			||||||
                this.$refs.input.focus()
 | 
					                this.$refs.input.focus()
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            if(this.selectedIndex < 0)
 | 
					            if(this.selectedIndex < 0)
 | 
				
			||||||
@ -114,11 +172,24 @@ export default {
 | 
				
			|||||||
                active && this.move(0) || this.move(-1)
 | 
					                active && this.move(0) || this.move(-1)
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        onKeyPress: function(event) {
 | 
					        onInputFocus() {
 | 
				
			||||||
 | 
					            this.cursor < 0 && this.move(0)
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        onBlur(event) {
 | 
				
			||||||
 | 
					            var index = event.relatedTarget && event.relatedTarget.dataset.autocompleteIndex;
 | 
				
			||||||
 | 
					            if(index !== undefined)
 | 
				
			||||||
 | 
					                this.select(index, false, false)
 | 
				
			||||||
 | 
					            this.cursor = -1;
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        onKeyDown(event) {
 | 
				
			||||||
 | 
					            if(event.ctrlKey || event.altKey || event.metaKey)
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
            switch(event.keyCode) {
 | 
					            switch(event.keyCode) {
 | 
				
			||||||
                case 13: this.select(this.cursor, false, false)
 | 
					                case 13: this.select(this.cursor, false, false)
 | 
				
			||||||
                         break
 | 
					                         break
 | 
				
			||||||
                case 27: this.select()
 | 
					                case 27: this.hide(); this.select()
 | 
				
			||||||
                         break
 | 
					                         break
 | 
				
			||||||
                case 38: this.move(-1, true)
 | 
					                case 38: this.move(-1, true)
 | 
				
			||||||
                         break
 | 
					                         break
 | 
				
			||||||
@ -130,35 +201,47 @@ export default {
 | 
				
			|||||||
            event.stopPropagation()
 | 
					            event.stopPropagation()
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        onKeyUp: function(event) {
 | 
					        onKeyUp(event) {
 | 
				
			||||||
            const value = event.target.value
 | 
					            if(event.ctrlKey || event.altKey || event.metaKey)
 | 
				
			||||||
            if(value === this.value)
 | 
					 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            this.value = value;
 | 
					            const value = event.target.value
 | 
				
			||||||
 | 
					            if(value === this.query)
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            this.inputValue = value;
 | 
				
			||||||
            if(!value)
 | 
					            if(!value)
 | 
				
			||||||
                return this.selected && this.select(-1)
 | 
					                return this.selected && this.select(-1)
 | 
				
			||||||
 | 
					            if(!this.minFetchLength || value.length >= this.minFetchLength)
 | 
				
			||||||
            this.fetch(value)
 | 
					                this.fetch(value)
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        fetch: function(query) {
 | 
					        fetch(query) {
 | 
				
			||||||
            if(!query || this.isFetching)
 | 
					            if(!query || this.promise)
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            this.isFetching = true
 | 
					            this.query = query
 | 
				
			||||||
            return this.model.fetch(this.url.replace('${query}', query), {many:true})
 | 
					            var url = this.url.replace('${query}', query)
 | 
				
			||||||
                .then(items => { this.items = items || []
 | 
					            var promise = this.model ? this.model.fetch(url, {many:true})
 | 
				
			||||||
                                 this.isFetching = false
 | 
					                                     : fetch(url, Model.getOptions()).then(d => d.json())
 | 
				
			||||||
                                 this.move(0)
 | 
					
 | 
				
			||||||
                                 return items },
 | 
					            promise = promise.then(items => {
 | 
				
			||||||
                      data => {this.isFetching = false; Promise.reject(data)})
 | 
					                this.items = items || []
 | 
				
			||||||
 | 
					                this.promise = null;
 | 
				
			||||||
 | 
					                this.move(0)
 | 
				
			||||||
 | 
					                return items
 | 
				
			||||||
 | 
					            }, data => {this.promise = null; Promise.reject(data)})
 | 
				
			||||||
 | 
					            this.promise = promise
 | 
				
			||||||
 | 
					            return promise
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    mounted() {
 | 
					    mounted() {
 | 
				
			||||||
        const form = this.$el.closest('form')
 | 
					        const form = this.$el.closest('form')
 | 
				
			||||||
        form.addEventListener('reset', () => { this.value=''; this.select(-1) })
 | 
					        form.addEventListener('reset', () => {
 | 
				
			||||||
 | 
					            this.inputValue = this.value;
 | 
				
			||||||
 | 
					            this.select(-1)
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -5,7 +5,7 @@
 | 
				
			|||||||
        <component :is="listTag" :class="listClass">
 | 
					        <component :is="listTag" :class="listClass">
 | 
				
			||||||
            <template v-for="(item,index) in items" :key="index">
 | 
					            <template v-for="(item,index) in items" :key="index">
 | 
				
			||||||
                <component :is="itemTag" :class="itemClass" @click="select(index)"
 | 
					                <component :is="itemTag" :class="itemClass" @click="select(index)"
 | 
				
			||||||
                        :draggable="orderable"
 | 
					                        :draggable="orderable" :data-index="index"
 | 
				
			||||||
                        @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
 | 
					                        @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
 | 
				
			||||||
                    <slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot>
 | 
					                    <slot name="item" :selected="index == selectedIndex" :set="set" :index="index" :item="item"></slot>
 | 
				
			||||||
                </component>
 | 
					                </component>
 | 
				
			||||||
@ -70,7 +70,7 @@ export default {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        onDragStart(ev) {
 | 
					        onDragStart(ev) {
 | 
				
			||||||
            const dataset = ev.target.dataset;
 | 
					            const dataset = ev.target.dataset;
 | 
				
			||||||
            const data = `cell:${dataset.index}`
 | 
					            const data = `row:${dataset.index}`
 | 
				
			||||||
            ev.dataTransfer.setData("text/cell", data)
 | 
					            ev.dataTransfer.setData("text/cell", data)
 | 
				
			||||||
            ev.dataTransfer.dropEffect = 'move'
 | 
					            ev.dataTransfer.dropEffect = 'move'
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
@ -82,11 +82,11 @@ export default {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        onDrop(ev) {
 | 
					        onDrop(ev) {
 | 
				
			||||||
            const data = ev.dataTransfer.getData("text/cell")
 | 
					            const data = ev.dataTransfer.getData("text/cell")
 | 
				
			||||||
            if(!data || !data.startsWith('cell:'))
 | 
					            if(!data || !data.startsWith('row:'))
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            ev.preventDefault()
 | 
					            ev.preventDefault()
 | 
				
			||||||
            const from = Number(data.slice(5))
 | 
					            const from = Number(data.slice(4))
 | 
				
			||||||
            const target = ev.target.tagName == this.itemTag ? ev.target
 | 
					            const target = ev.target.tagName == this.itemTag ? ev.target
 | 
				
			||||||
                                : ev.target.closest(this.itemTag)
 | 
					                                : ev.target.closest(this.itemTag)
 | 
				
			||||||
            this.$emit('move', {
 | 
					            this.$emit('move', {
 | 
				
			||||||
 | 
				
			|||||||
@ -7,8 +7,8 @@
 | 
				
			|||||||
            <div class="column has-text-right">
 | 
					            <div class="column has-text-right">
 | 
				
			||||||
                <div class="float-right field has-addons">
 | 
					                <div class="float-right field has-addons">
 | 
				
			||||||
                    <p class="control">
 | 
					                    <p class="control">
 | 
				
			||||||
                        <a :class="['button','p-2', mode == Modes.Text ? 'is-primary' : 'is-light']"
 | 
					                        <a :class="['button','p-2', page == Page.Text ? 'is-primary' : 'is-light']"
 | 
				
			||||||
                                @click="mode = Modes.Text">
 | 
					                                @click="page = Page.Text">
 | 
				
			||||||
                            <span class="icon is-small">
 | 
					                            <span class="icon is-small">
 | 
				
			||||||
                                <i class="fa fa-pencil"></i>
 | 
					                                <i class="fa fa-pencil"></i>
 | 
				
			||||||
                            </span>
 | 
					                            </span>
 | 
				
			||||||
@ -16,8 +16,8 @@
 | 
				
			|||||||
                        </a>
 | 
					                        </a>
 | 
				
			||||||
                    </p>
 | 
					                    </p>
 | 
				
			||||||
                    <p class="control">
 | 
					                    <p class="control">
 | 
				
			||||||
                        <a :class="['button','p-2', mode == Modes.List ? 'is-primary' : 'is-light']"
 | 
					                        <a :class="['button','p-2', page == Page.List ? 'is-primary' : 'is-light']"
 | 
				
			||||||
                                @click="mode = Modes.List">
 | 
					                                @click="page = Page.List">
 | 
				
			||||||
                            <span class="icon is-small">
 | 
					                            <span class="icon is-small">
 | 
				
			||||||
                                <i class="fa fa-list"></i>
 | 
					                                <i class="fa fa-list"></i>
 | 
				
			||||||
                            </span>
 | 
					                            </span>
 | 
				
			||||||
@ -28,43 +28,16 @@
 | 
				
			|||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <slot name="top" :set="set" :columns="columns" :items="items"/>
 | 
					        <slot name="top" :set="set" :columns="columns" :items="items"/>
 | 
				
			||||||
        <section class="page" v-show="mode == Modes.Text">
 | 
					        <section class="page" v-show="page == Page.Text">
 | 
				
			||||||
            <textarea ref="textarea" class="is-fullwidth" rows="20"
 | 
					            <textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
 | 
				
			||||||
                @change="updateList"
 | 
					                @change="updateList"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div class="columns mt-2">
 | 
					 | 
				
			||||||
                <div class="column field is-vcentered">
 | 
					 | 
				
			||||||
                    <label class="label is-inline mr-2"
 | 
					 | 
				
			||||||
                            style="vertical-align: middle">
 | 
					 | 
				
			||||||
                        Ordre</label>
 | 
					 | 
				
			||||||
                    <table class="table is-bordered is-inline-block"
 | 
					 | 
				
			||||||
                            style="vertical-align: middle">
 | 
					 | 
				
			||||||
                        <tr>
 | 
					 | 
				
			||||||
                            <a-row :cell="{columns}" :item="FormatLabels"
 | 
					 | 
				
			||||||
                                @move="formatMove" :orderable="true">
 | 
					 | 
				
			||||||
                            </a-row>
 | 
					 | 
				
			||||||
                        </tr>
 | 
					 | 
				
			||||||
                    </table>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div class="column field is-vcentered">
 | 
					 | 
				
			||||||
                    <label class="label is-inline mr-2"
 | 
					 | 
				
			||||||
                            style="vertical-align: middle">
 | 
					 | 
				
			||||||
                        Séparateur</label>
 | 
					 | 
				
			||||||
                    <div class="control is-inline-block"
 | 
					 | 
				
			||||||
                            style="vertical-align: middle">
 | 
					 | 
				
			||||||
                        <input type="text" ref="sep" value="--" class="input is-inline"
 | 
					 | 
				
			||||||
                            @change="updateList()"/>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div class="column"/>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </section>
 | 
					        </section>
 | 
				
			||||||
        <section class="page" v-show="mode == Modes.List">
 | 
					        <section class="page" v-show="page == Page.List">
 | 
				
			||||||
            <a-rows :set="set" :columns="columns" :labels="FormatLabels"
 | 
					            <a-rows :set="set" :columns="columns" :labels="labels"
 | 
				
			||||||
                    :allow-create="true"
 | 
					                    :allow-create="true"
 | 
				
			||||||
                    :list-class="listClass" :item-class="itemClass"
 | 
					                    :orderable="true" @move="listItemMove" @colmove="columnMove"
 | 
				
			||||||
                    :orderable="true" @move="listItemMove"
 | 
					 | 
				
			||||||
                    @cell="onCellEvent">
 | 
					                    @cell="onCellEvent">
 | 
				
			||||||
                <template v-for="[name,slot] of rowsSlots" :key="slot"
 | 
					                <template v-for="[name,slot] of rowsSlots" :key="slot"
 | 
				
			||||||
                        v-slot:[slot]="data">
 | 
					                        v-slot:[slot]="data">
 | 
				
			||||||
@ -72,50 +45,127 @@
 | 
				
			|||||||
                </template>
 | 
					                </template>
 | 
				
			||||||
            </a-rows>
 | 
					            </a-rows>
 | 
				
			||||||
        </section>
 | 
					        </section>
 | 
				
			||||||
        <section class="page" v-show="mode == Modes.Settings">
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        </section>
 | 
					        <div class="mt-2">
 | 
				
			||||||
 | 
					            <div class="field is-inline-block is-vcentered mr-3">
 | 
				
			||||||
 | 
					                <label class="label is-inline mr-2"
 | 
				
			||||||
 | 
					                        style="vertical-align: middle">
 | 
				
			||||||
 | 
					                    Séparateur</label>
 | 
				
			||||||
 | 
					                <div class="control is-inline-block"
 | 
				
			||||||
 | 
					                        style="vertical-align: middle;">
 | 
				
			||||||
 | 
					                    <input type="text" ref="sep" class="input is-inline is-text-centered is-small"
 | 
				
			||||||
 | 
					                        style="max-width: 5em;"
 | 
				
			||||||
 | 
					                        v-model="separator" @change="updateList()"/>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="field is-inline-block is-vcentered mr-5">
 | 
				
			||||||
 | 
					                <label class="label is-inline mr-2"
 | 
				
			||||||
 | 
					                        style="vertical-align: middle">
 | 
				
			||||||
 | 
					                    {{ labels.columns }}</label>
 | 
				
			||||||
 | 
					                <table class="table is-bordered is-inline-block"
 | 
				
			||||||
 | 
					                        style="vertical-align: middle">
 | 
				
			||||||
 | 
					                    <tr>
 | 
				
			||||||
 | 
					                        <a-row :columns="columns" :item="labels"
 | 
				
			||||||
 | 
					                                @move="formatMove" :orderable="true">
 | 
				
			||||||
 | 
					                            <template v-slot:cell-after="{cell}">
 | 
				
			||||||
 | 
					                                <td style="cursor:pointer;" v-if="cell.col < columns.length-1">
 | 
				
			||||||
 | 
					                                    <span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})"
 | 
				
			||||||
 | 
					                                        ><i class="fa fa-left-right"/>
 | 
				
			||||||
 | 
					                                    </span>
 | 
				
			||||||
 | 
					                                </td>
 | 
				
			||||||
 | 
					                            </template>
 | 
				
			||||||
 | 
					                        </a-row>
 | 
				
			||||||
 | 
					                    </tr>
 | 
				
			||||||
 | 
					                </table>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="field is-vcentered is-inline-block"
 | 
				
			||||||
 | 
					                    v-if="settingsChanged">
 | 
				
			||||||
 | 
					                <a-action-button icon="fa fa-floppy-disk"
 | 
				
			||||||
 | 
					                        class="button control p-3 is-info" run-class="blink"
 | 
				
			||||||
 | 
					                        :url="settingsUrl" method="POST"
 | 
				
			||||||
 | 
					                        :data="settings"
 | 
				
			||||||
 | 
					                        :aria-label="labels.save_settings"
 | 
				
			||||||
 | 
					                        @done="settingsSaved()">
 | 
				
			||||||
 | 
					                    {{ labels.save_settings }}
 | 
				
			||||||
 | 
					                </a-action-button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="float-right">
 | 
				
			||||||
 | 
					                <a class="button is-warning p-2 ml-2"
 | 
				
			||||||
 | 
					                        @click="loadData({items: this.initData.items},true)">
 | 
				
			||||||
 | 
					                    <span class="icon"><i class="fa fa-rotate" /></span>
 | 
				
			||||||
 | 
					                    <span>{{ labels.discard_changes }}</span>
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
        <slot name="bottom" :set="set" :columns="columns" :items="items"/>
 | 
					        <slot name="bottom" :set="set" :columns="columns" :items="items"/>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
<script>
 | 
					<script>
 | 
				
			||||||
import {dropRightWhile} from 'lodash'
 | 
					import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
 | 
				
			||||||
import {Set} from '../model'
 | 
					import {Set} from '../model'
 | 
				
			||||||
import Track from '../track'
 | 
					import Track from '../track'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import AActionButton from './AActionButton'
 | 
				
			||||||
import ARow from './ARow.vue'
 | 
					import ARow from './ARow.vue'
 | 
				
			||||||
import ARows from './ARows.vue'
 | 
					import ARows from './ARows.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Page display
 | 
				
			||||||
export const Modes = {
 | 
					export const Page = {
 | 
				
			||||||
    Text: 0, List: 1, Settings: 2,
 | 
					    Text: 0, List: 1, Settings: 2,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
const FormatLabels = {
 | 
					 | 
				
			||||||
    artist: 'Artiste', album: 'Album', year: 'Année', tags: 'Tags',
 | 
					 | 
				
			||||||
    title: 'Titre',
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default {
 | 
					export default {
 | 
				
			||||||
    components: { ARow, ARows },
 | 
					    components: { AActionButton, ARow, ARows },
 | 
				
			||||||
    props: {
 | 
					    props: {
 | 
				
			||||||
        dataEl: String,
 | 
					        initData: Object,
 | 
				
			||||||
        dataPrefix: String,
 | 
					        dataPrefix: String,
 | 
				
			||||||
        listClass: String,
 | 
					        labels: Object,
 | 
				
			||||||
        itemClass: String,
 | 
					        settingsUrl: String,
 | 
				
			||||||
 | 
					        defaultColumns: {
 | 
				
			||||||
 | 
					            type: Array,
 | 
				
			||||||
 | 
					            default: () => ['artist', 'title', 'tags', 'album', 'year']},
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    data() {
 | 
					    data() {
 | 
				
			||||||
 | 
					        const settings = {
 | 
				
			||||||
 | 
					            playlist_editor_columns: this.defaultColumns,
 | 
				
			||||||
 | 
					            playlist_editor_sep: ' -- ',
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        return {
 | 
					        return {
 | 
				
			||||||
            Modes: Modes,
 | 
					            Page: Page,
 | 
				
			||||||
            FormatLabels: FormatLabels,
 | 
					            page: Page.Text,
 | 
				
			||||||
            mode: Modes.Text,
 | 
					 | 
				
			||||||
            set: new Set(Track),
 | 
					            set: new Set(Track),
 | 
				
			||||||
            columns: ['artist', 'title', 'tags', 'album', 'year'],
 | 
					 | 
				
			||||||
            extraData: {},
 | 
					            extraData: {},
 | 
				
			||||||
 | 
					            settings,
 | 
				
			||||||
 | 
					            savedSettings: cloneDeep(settings),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    computed: {
 | 
					    computed: {
 | 
				
			||||||
 | 
					        settingsChanged() {
 | 
				
			||||||
 | 
					            var k = Object.keys(this.savedSettings)
 | 
				
			||||||
 | 
					                          .findIndex(k => !isEqual(this.settings[k], this.savedSettings[k]))
 | 
				
			||||||
 | 
					            return k != -1
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        separator: {
 | 
				
			||||||
 | 
					            set(value) {
 | 
				
			||||||
 | 
					                this.settings.playlist_editor_sep = value
 | 
				
			||||||
 | 
					                if(this.page == Page.List)
 | 
				
			||||||
 | 
					                    this.updateInput()
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            get() { return this.settings.playlist_editor_sep }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        columns: {
 | 
				
			||||||
 | 
					            set(value) {
 | 
				
			||||||
 | 
					                var cols = value.filter(x => x in this.defaultColumns)
 | 
				
			||||||
 | 
					                var left = this.defaultColumns.filter(x => !(x in cols))
 | 
				
			||||||
 | 
					                value = cols.concat(left)
 | 
				
			||||||
 | 
					                this.settings.playlist_editor_columns = value
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            get() { return this.settings.playlist_editor_columns }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        items() {
 | 
					        items() {
 | 
				
			||||||
            return this.set.items
 | 
					            return this.set.items
 | 
				
			||||||
@ -140,7 +190,17 @@ export default {
 | 
				
			|||||||
            const value = this.columns[from]
 | 
					            const value = this.columns[from]
 | 
				
			||||||
            this.columns.splice(from, 1)
 | 
					            this.columns.splice(from, 1)
 | 
				
			||||||
            this.columns.splice(to, 0, value)
 | 
					            this.columns.splice(to, 0, value)
 | 
				
			||||||
            this.updateList()
 | 
					            if(this.page == Page.Text)
 | 
				
			||||||
 | 
					                this.updateList()
 | 
				
			||||||
 | 
					            else
 | 
				
			||||||
 | 
					                this.updateText()
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        columnMove({from, to}) {
 | 
				
			||||||
 | 
					            const value = this.columns[from]
 | 
				
			||||||
 | 
					            this.columns.splice(from, 1)
 | 
				
			||||||
 | 
					            this.columns.splice(to, 0, value)
 | 
				
			||||||
 | 
					            this.updateInput()
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        listItemMove({from, to, set}) {
 | 
					        listItemMove({from, to, set}) {
 | 
				
			||||||
@ -149,29 +209,28 @@ export default {
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
        updateList() {
 | 
					        updateList() {
 | 
				
			||||||
            const items = this.toList(this.$refs.textarea.value,
 | 
					            const items = this.toList(this.$refs.textarea.value)
 | 
				
			||||||
                                      this.$refs.sep.value)
 | 
					 | 
				
			||||||
            this.set.reset(items)
 | 
					            this.set.reset(items)
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        updateInput() {
 | 
					        updateInput() {
 | 
				
			||||||
            const input = this.toText(this.items, this.$refs.sep.value)
 | 
					            const input = this.toText(this.items)
 | 
				
			||||||
            this.$refs.textarea.value = input
 | 
					            this.$refs.textarea.value = input
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        /**
 | 
					        /**
 | 
				
			||||||
         * From input and separator, return list of items.
 | 
					         * From input and separator, return list of items.
 | 
				
			||||||
         */
 | 
					         */
 | 
				
			||||||
        toList(input, sep) {
 | 
					        toList(input) {
 | 
				
			||||||
            var lines = input.split('\n')
 | 
					            var lines = input.split('\n')
 | 
				
			||||||
            var items = []
 | 
					            var items = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            for(let line of lines) {
 | 
					            for(let line of lines) {
 | 
				
			||||||
                line = line.trim()
 | 
					                line = line.trimLeft()
 | 
				
			||||||
                if(!line)
 | 
					                if(!line)
 | 
				
			||||||
                    continue
 | 
					                    continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                var lineBits = line.split(sep)
 | 
					                var lineBits = line.split(this.separator)
 | 
				
			||||||
                var item = {}
 | 
					                var item = {}
 | 
				
			||||||
                for(var col in this.columns) {
 | 
					                for(var col in this.columns) {
 | 
				
			||||||
                    if(col >= lineBits.length)
 | 
					                    if(col >= lineBits.length)
 | 
				
			||||||
@ -187,17 +246,18 @@ export default {
 | 
				
			|||||||
        /**
 | 
					        /**
 | 
				
			||||||
         * From items and separator return a string
 | 
					         * From items and separator return a string
 | 
				
			||||||
         */
 | 
					         */
 | 
				
			||||||
        toText(items, sep) {
 | 
					        toText(items) {
 | 
				
			||||||
            var lines = []
 | 
					            const sep = ` ${this.separator.trim()} `
 | 
				
			||||||
            sep = ` ${(sep || this.$refs.sep.value).trim()} `
 | 
					            const lines = []
 | 
				
			||||||
            for(let item of items) {
 | 
					            for(let item of items) {
 | 
				
			||||||
                if(!item)
 | 
					                if(!item)
 | 
				
			||||||
                    continue
 | 
					                    continue
 | 
				
			||||||
                var line = []
 | 
					                var line = []
 | 
				
			||||||
                for(var col of this.columns)
 | 
					                for(var col of this.columns)
 | 
				
			||||||
                    line.push(item.data[col] || '')
 | 
					                    line.push(item.data[col] || '')
 | 
				
			||||||
                line = dropRightWhile(line, x => !x)
 | 
					                line = dropRightWhile(line, x => !x || !('' + x).trim())
 | 
				
			||||||
                lines.push(line.join(sep))
 | 
					                line = line.join(sep).trimRight()
 | 
				
			||||||
 | 
					                lines.push(line)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            return lines.join('\n')
 | 
					            return lines.join('\n')
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
@ -214,25 +274,37 @@ export default {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        //! Update saved settings from this.settings
 | 
				
			||||||
 | 
					        settingsSaved(settings=null) {
 | 
				
			||||||
 | 
					            if(settings !== null)
 | 
				
			||||||
 | 
					                this.settings = settings
 | 
				
			||||||
 | 
					            this.savedSettings = cloneDeep(this.settings)
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
        /**
 | 
					        /**
 | 
				
			||||||
         * Load initial data
 | 
					         * Load initial data
 | 
				
			||||||
         */
 | 
					         */
 | 
				
			||||||
        loadData({items=[]}) {
 | 
					        loadData({items=[], settings=null}, reset=false) {
 | 
				
			||||||
 | 
					            if(reset) {
 | 
				
			||||||
 | 
					                this.set.items = []
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
            for(var index in items)
 | 
					            for(var index in items)
 | 
				
			||||||
                this.set.push(items[index])
 | 
					                this.set.push(cloneDeep(items[index]))
 | 
				
			||||||
 | 
					            if(settings)
 | 
				
			||||||
 | 
					                this.settingsSaved(settings)
 | 
				
			||||||
            this.updateInput()
 | 
					            this.updateInput()
 | 
				
			||||||
         },
 | 
					         },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    watch: {
 | 
				
			||||||
 | 
					        initData(val) {
 | 
				
			||||||
 | 
					            this.loadData(val)
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    mounted() {
 | 
					    mounted() {
 | 
				
			||||||
        if(this.dataEl) {
 | 
					        this.initData && this.loadData(this.initData)
 | 
				
			||||||
            const el = document.getElementById(this.dataEl)
 | 
					        this.page = (this.items) ? Page.List : Page.Text
 | 
				
			||||||
            if(el) {
 | 
					 | 
				
			||||||
                const data = JSON.parse(el.textContent)
 | 
					 | 
				
			||||||
                this.loadData(data)
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        this.mode = (this.items) ? Modes.List : Modes.Text
 | 
					 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,9 @@
 | 
				
			|||||||
    <tr>
 | 
					    <tr>
 | 
				
			||||||
        <slot name="head" :item="item" :row="row"/>
 | 
					        <slot name="head" :item="item" :row="row"/>
 | 
				
			||||||
        <template v-for="(attr,col) in columns" :key="col">
 | 
					        <template v-for="(attr,col) in columns" :key="col">
 | 
				
			||||||
            <td :class="['cell', 'cell-' + attr]" :data-col="col"
 | 
					            <slot name="cell-before" :item="item" :cell="cells[col]"
 | 
				
			||||||
 | 
					                    :attr="attr"/>
 | 
				
			||||||
 | 
					            <component :is="cellTag" :class="['cell', 'cell-' + attr]" :data-col="col"
 | 
				
			||||||
                    :draggable="orderable"
 | 
					                    :draggable="orderable"
 | 
				
			||||||
                    @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
 | 
					                    @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
 | 
				
			||||||
                <slot :name="attr" :item="item" :cell="cells[col]"
 | 
					                <slot :name="attr" :item="item" :cell="cells[col]"
 | 
				
			||||||
@ -10,9 +12,11 @@
 | 
				
			|||||||
                        :value="itemData && itemData[attr]">
 | 
					                        :value="itemData && itemData[attr]">
 | 
				
			||||||
                    {{ itemData && itemData[attr] }}
 | 
					                    {{ itemData && itemData[attr] }}
 | 
				
			||||||
                </slot>
 | 
					                </slot>
 | 
				
			||||||
            </td>
 | 
					            </component>
 | 
				
			||||||
 | 
					            <slot name="cell-after" :item="item" :col="col" :cell="cells[col]"
 | 
				
			||||||
 | 
					                    :attr="attr"/>
 | 
				
			||||||
        </template>
 | 
					        </template>
 | 
				
			||||||
        <slot name="tail" :item="item" :row="cell.row"/>
 | 
					        <slot name="tail" :item="item" :row="row"/>
 | 
				
			||||||
    </tr>
 | 
					    </tr>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
<script>
 | 
					<script>
 | 
				
			||||||
@ -24,20 +28,21 @@ export default {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    props: {
 | 
					    props: {
 | 
				
			||||||
        item: Object,
 | 
					        item: Object,
 | 
				
			||||||
        cell: Object,
 | 
					        columns: Array,
 | 
				
			||||||
 | 
					        cell: {type: Object, default() { return {row: 0}}},
 | 
				
			||||||
 | 
					        cellTag: {type: String, default: 'td'},
 | 
				
			||||||
        orderable: {type: Boolean, default: false},
 | 
					        orderable: {type: Boolean, default: false},
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    computed: {
 | 
					    computed: {
 | 
				
			||||||
        row() { return this.cell.row || 0 },
 | 
					        row() { return this.cell && this.cell.row },
 | 
				
			||||||
        columns() { return this.cell.columns },
 | 
					 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        itemData() {
 | 
					        itemData() {
 | 
				
			||||||
            return this.item instanceof Model ? this.item.data : this.item;
 | 
					            return this.item instanceof Model ? this.item.data : this.item;
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        cells() {
 | 
					        cells() {
 | 
				
			||||||
            const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell
 | 
					            const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell || {}
 | 
				
			||||||
            const cells = []
 | 
					            const cells = []
 | 
				
			||||||
            for(var col in this.columns)
 | 
					            for(var col in this.columns)
 | 
				
			||||||
                cells.push({...cell, col: Number(col)})
 | 
					                cells.push({...cell, col: Number(col)})
 | 
				
			||||||
@ -45,7 +50,7 @@ export default {
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        cellEls() {
 | 
					        cellEls() {
 | 
				
			||||||
            return [...this.$el.querySelectorAll('td')].filter(x => x.dataset.col)
 | 
					            return [...this.$el.querySelectorAll(self.cellTag)].filter(x => x.dataset.col)
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,27 +1,30 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
    <table class="table is-stripped is-fullwidth">
 | 
					    <table class="table is-stripped is-fullwidth">
 | 
				
			||||||
        <thead>
 | 
					        <thead>
 | 
				
			||||||
            <tr>
 | 
					            <a-row :item="labels" :columns="columns" :orderable="orderable"
 | 
				
			||||||
                <slot name="header-head"/>
 | 
					                    @move="$emit('colmove', $event)">
 | 
				
			||||||
                <th v-for="col in columns" :key="col"
 | 
					                <template v-if="$slots['header-head']" v-slot:head="data">
 | 
				
			||||||
                    style="vertical-align: middle">{{ labels[col] }}</th>
 | 
					                    <slot name="header-head" v-bind="data"/>
 | 
				
			||||||
                <slot name="header-tail"/>
 | 
					                </template>
 | 
				
			||||||
            </tr>
 | 
					                <template v-if="$slots['header-tail']" v-slot:tail="data">
 | 
				
			||||||
 | 
					                    <slot name="header-tail" v-bind="data"/>
 | 
				
			||||||
 | 
					                </template>
 | 
				
			||||||
 | 
					            </a-row>
 | 
				
			||||||
        </thead>
 | 
					        </thead>
 | 
				
			||||||
        <tbody>
 | 
					        <tbody>
 | 
				
			||||||
            <slot name="head"/>
 | 
					            <slot name="head"/>
 | 
				
			||||||
            <template v-for="(item,row) in items" :key="row">
 | 
					            <template v-for="(item,row) in items" :key="row">
 | 
				
			||||||
                <!-- data-index comes from AList component drag & drop -->
 | 
					                <!-- data-index comes from AList component drag & drop -->
 | 
				
			||||||
                <a-row :item="item" :cell="{row, columns}" :data-index="row"
 | 
					                <a-row :item="item" :cell="{row}" :columns="columns" :data-index="row"
 | 
				
			||||||
                        :draggable="orderable"
 | 
					                        :draggable="orderable"
 | 
				
			||||||
                        @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
 | 
					                        @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
 | 
				
			||||||
                        @cell="onCellEvent(index, $event)">
 | 
					                        @cell="onCellEvent(row, $event)">
 | 
				
			||||||
                    <template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
 | 
					                    <template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
 | 
				
			||||||
                        <template v-if="slot == 'head' || slot == 'tail'">
 | 
					                        <template v-if="slot == 'head' || slot == 'tail'">
 | 
				
			||||||
                            <slot :name="name" v-bind="data"/>
 | 
					                            <slot :name="name" v-bind="data"/>
 | 
				
			||||||
                        </template>
 | 
					                        </template>
 | 
				
			||||||
                        <template v-else>
 | 
					                        <template v-else>
 | 
				
			||||||
                            <div @keydown.capture.ctrl="onControlKey($event, data.cell)">
 | 
					                            <div @keydown.ctrl="onControlKey($event, data.cell)">
 | 
				
			||||||
                                <slot :name="name" v-bind="data"/>
 | 
					                                <slot :name="name" v-bind="data"/>
 | 
				
			||||||
                            </div>
 | 
					                            </div>
 | 
				
			||||||
                        </template>
 | 
					                        </template>
 | 
				
			||||||
@ -47,7 +50,7 @@ import ARow from './ARow.vue'
 | 
				
			|||||||
const Component = {
 | 
					const Component = {
 | 
				
			||||||
    extends: AList,
 | 
					    extends: AList,
 | 
				
			||||||
    components: { ARow },
 | 
					    components: { ARow },
 | 
				
			||||||
    emit: ['cell'],
 | 
					    emit: ['cell', 'colmove'],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    props: {
 | 
					    props: {
 | 
				
			||||||
        ...AList.props,
 | 
					        ...AList.props,
 | 
				
			||||||
@ -67,7 +70,7 @@ const Component = {
 | 
				
			|||||||
        rowCells() {
 | 
					        rowCells() {
 | 
				
			||||||
            const cells = []
 | 
					            const cells = []
 | 
				
			||||||
            for(var row in this.items)
 | 
					            for(var row in this.items)
 | 
				
			||||||
                cells.push({row, columns: this.columns,})
 | 
					                cells.push({row})
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        rows() {
 | 
					        rows() {
 | 
				
			||||||
 | 
				
			|||||||
@ -13,12 +13,15 @@ import AStreamer from './AStreamer.vue'
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * Core components
 | 
					 * Core components
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export default {
 | 
					export const base = {
 | 
				
			||||||
    AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist,
 | 
					    AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist,
 | 
				
			||||||
    AProgress, ASoundItem,
 | 
					    AProgress, ASoundItem,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default base
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const admin = {
 | 
					export const admin = {
 | 
				
			||||||
 | 
					    ...base,
 | 
				
			||||||
    AStatistics, AStreamer, APlaylistEditor
 | 
					    AStatistics, AStreamer, APlaylistEditor
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user