#39: Playlist Editor #81
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user