- various __all__

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,78 @@
<template>
<component :is="tag" @click="call" :class="buttonClass">
<span v-if="promise && runIcon">
<i :class="runIcon"></i>
</span>
<span v-else-if="icon" class="icon">
<i :class="icon"></i>
</span>
<span v-if="$slots.default"><slot name="default"/></span>
</component>
</template>
<script>
import Model from '../model'
/**
* Button that can be used to call API requests on provided url
*/
export default {
emit: ['start', 'done'],
props: {
//! Component tag, by default, `button`
tag: { type: String, default: 'a'},
//! Button icon
icon: String,
//! Data or model instance to send
data: Object,
//! Action method, by default, `POST`
method: { type: String, default: 'POST'},
//! Action url
url: String,
//! Extra request options
fetchOptions: {type: Object, default: () => {return {}}},
//! Component class while action is running
runClass: String,
//! Icon class while action is running
runIcon: String,
},
computed: {
//! Input data as model instance
item() {
return this.data instanceof Model ? this.data
: new Model(this.data)
},
//! Computed button class
buttonClass() {
return this.promise ? this.runClass : ''
}
},
data() {
return {
promise: false
}
},
methods: {
call() {
if(this.promise || !this.url)
return
const options = Model.getOptions({
...this.fetchOptions,
method: this.method,
body: JSON.stringify(this.item.data),
})
this.promise = fetch(this.url, options).then(data => {
const response = data.json();
this.promise = null;
this.$emit('done', response)
return response
}, data => { this.promise = null; return data })
return this.promise
},
},
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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