Merge pull request '#39: Playlist Editor' (#81) from dev-1.0-playlist-editor into develop-1.0

Reviewed-on: rc/aircox#81
This commit is contained in:
Thomas Kairos 2023-01-25 12:17:05 +01:00
commit 46da13a0df
61 changed files with 14607 additions and 323 deletions

View File

@ -1,5 +1,3 @@
from copy import copy
from django.contrib import admin from django.contrib import admin
from django.forms import ModelForm from django.forms import ModelForm
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -60,11 +58,21 @@ class EpisodeAdminForm(ModelForm):
class EpisodeAdmin(SortableAdminBase, PageAdmin): class EpisodeAdmin(SortableAdminBase, PageAdmin):
form = EpisodeAdminForm form = EpisodeAdminForm
list_display = PageAdmin.list_display list_display = PageAdmin.list_display
list_filter = tuple(f for f in PageAdmin.list_filter if f != 'pub_date') + \ list_filter = tuple(f for f in PageAdmin.list_filter
('diffusion__start', 'pub_date') if f != 'pub_date') + ('diffusion__start', 'pub_date')
search_fields = PageAdmin.search_fields + ('parent__title',) search_fields = PageAdmin.search_fields + ('parent__title',)
# readonly_fields = ('parent',) # readonly_fields = ('parent',)
inlines = [TrackInline, SoundInline, DiffusionInline] inlines = [TrackInline, SoundInline, DiffusionInline]
def add_view(self, request, object_id, form_url='', context=None):
context = context or {}
context['init_app'] = True
context['init_el'] = '#inline-tracks'
return super().change_view(request, object_id, form_url, context)
def change_view(self, request, object_id, form_url='', context=None):
context = context or {}
context['init_app'] = True
context['init_el'] = '#inline-tracks'
return super().change_view(request, object_id, form_url, context)

View File

@ -9,14 +9,15 @@ from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ..models import Sound, Track from ..models import Sound, Track
class TrackInline(SortableInlineAdminMixin, admin.TabularInline): class TrackInline(admin.TabularInline):
template = 'admin/aircox/playlist_inline.html' template = 'admin/aircox/playlist_inline.html'
model = Track model = Track
extra = 0 extra = 0
fields = ('position', 'artist', 'title', 'info', 'tags') fields = ('position', 'artist', 'title', 'tags', 'album', 'year', 'info')
list_display = ['artist', 'album', 'title', 'tags', 'related']
list_filter = ['artist', 'album', 'title', 'tags']
list_display = ['artist', 'title', 'tags', 'related']
list_filter = ['artist', 'title', 'tags']
class SoundTrackInline(TrackInline): class SoundTrackInline(TrackInline):
fields = TrackInline.fields + ('timestamp',) fields = TrackInline.fields + ('timestamp',)
@ -24,14 +25,15 @@ class SoundTrackInline(TrackInline):
class SoundInline(admin.TabularInline): class SoundInline(admin.TabularInline):
model = Sound model = Sound
fields = ['type', 'name', 'audio', 'duration', 'is_good_quality', 'is_public', fields = ['type', 'name', 'audio', 'duration', 'is_good_quality',
'is_downloadable'] 'is_public', 'is_downloadable']
readonly_fields = ['type', 'audio', 'duration', 'is_good_quality'] readonly_fields = ['type', 'audio', 'duration', 'is_good_quality']
extra = 0 extra = 0
max_num = 0 max_num = 0
def audio(self, obj): def audio(self, obj):
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url)) return mark_safe('<audio src="{}" controls></audio>'
.format(obj.file.url))
audio.short_description = _('Audio') audio.short_description = _('Audio')
def get_queryset(self, request): def get_queryset(self, request):
@ -63,23 +65,40 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
related.short_description = _('Program / Episode') related.short_description = _('Program / Episode')
def audio(self, obj): def audio(self, obj):
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url)) \ return mark_safe('<audio src="{}" controls></audio>'
.format(obj.file.url)) \
if obj.type != Sound.TYPE_REMOVED else '' if obj.type != Sound.TYPE_REMOVED else ''
audio.short_description = _('Audio') audio.short_description = _('Audio')
def add_view(self, request, object_id, form_url='', context=None):
context = context or {}
context['init_app'] = True
context['init_el'] = '#inline-tracks'
context['track_timestamp'] = True
return super().change_view(request, object_id, form_url, context)
def change_view(self, request, object_id, form_url='', context=None):
context = context or {}
context['init_app'] = True
context['init_el'] = '#inline-tracks'
context['track_timestamp'] = True
return super().change_view(request, object_id, form_url, context)
@admin.register(Track) @admin.register(Track)
class TrackAdmin(admin.ModelAdmin): class TrackAdmin(admin.ModelAdmin):
def tag_list(self, obj): def tag_list(self, obj):
return u", ".join(o.name for o in obj.tags.all()) return u", ".join(o.name for o in obj.tags.all())
list_display = ['pk', 'artist', 'title', 'tag_list', 'episode', 'sound', 'ts'] list_display = ['pk', 'artist', 'title', 'tag_list', 'episode',
'sound', 'ts']
list_editable = ['artist', 'title'] list_editable = ['artist', 'title']
list_filter = ['artist', 'title', 'tags'] list_filter = ['artist', 'title', 'tags']
search_fields = ['artist', 'title'] search_fields = ['artist', 'title']
fieldsets = [ fieldsets = [
(_('Playlist'), {'fields': ['episode', 'sound', 'position', 'timestamp']}), (_('Playlist'), {'fields': ['episode', 'sound', 'position',
'timestamp']}),
(_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}), (_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}),
] ]
@ -92,8 +111,6 @@ class TrackAdmin(admin.ModelAdmin):
h = math.floor(ts / 3600) h = math.floor(ts / 3600)
m = math.floor((ts - h) / 60) m = math.floor((ts - h) / 60)
s = ts-h*3600-m*60 s = ts-h*3600-m*60
return '{:0>2}:{:0>2}:{:0>2}'.format(h,m,s) return '{:0>2}:{:0>2}:{:0>2}'.format(h, m, s)
ts.short_description = _('timestamp') ts.short_description = _('timestamp')

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Aircox 0.1\n" "Project-Id-Version: Aircox 0.1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-10-08 11:44+0000\n" "POT-Creation-Date: 2022-12-12 10:15+0000\n"
"PO-Revision-Date: 2016-10-10 16:00+02\n" "PO-Revision-Date: 2016-10-10 16:00+02\n"
"Last-Translator: Aarys\n" "Last-Translator: Aarys\n"
"Language-Team: Aircox's translators team\n" "Language-Team: Aircox's translators team\n"
@ -18,13 +18,13 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: aircox/admin/episode.py:29 aircox/models/episode.py:183 #: aircox/admin/episode.py:27 aircox/models/episode.py:183
#: aircox/models/log.py:82 #: aircox/models/log.py:82
msgid "start" msgid "start"
msgstr "début" msgstr "début"
#: aircox/admin/episode.py:33 aircox/models/episode.py:184 #: aircox/admin/episode.py:31 aircox/models/episode.py:184
#: aircox/models/program.py:473 #: aircox/models/program.py:469
msgid "end" msgid "end"
msgstr "fin" msgstr "fin"
@ -52,7 +52,7 @@ msgstr "Tout"
msgid "Publication Settings" msgid "Publication Settings"
msgstr "Paramètre de la publication" msgstr "Paramètre de la publication"
#: aircox/admin/program.py:40 aircox/models/program.py:286 #: aircox/admin/program.py:40 aircox/models/program.py:282
msgid "Schedule" msgid "Schedule"
msgstr "Horaire" msgstr "Horaire"
@ -60,7 +60,7 @@ msgstr "Horaire"
msgid "Program Settings" msgid "Program Settings"
msgstr "Paramètres de l'émission" msgstr "Paramètres de l'émission"
#: aircox/admin/program.py:64 aircox/models/program.py:122 #: aircox/admin/program.py:64 aircox/models/program.py:118
msgid "Program" msgid "Program"
msgstr "Émission" msgstr "Émission"
@ -68,23 +68,25 @@ msgstr "Émission"
msgid "Day" msgid "Day"
msgstr "Jour" msgstr "Jour"
#: aircox/admin/sound.py:35 aircox/admin/sound.py:67 #: aircox/admin/sound.py:37 aircox/admin/sound.py:71
msgid "Audio" msgid "Audio"
msgstr "Audio" msgstr "Audio"
#: aircox/admin/sound.py:63 #: aircox/admin/sound.py:65
msgid "Program / Episode" msgid "Program / Episode"
msgstr "Émission / Épisode" msgstr "Émission / Épisode"
#: aircox/admin/sound.py:81 aircox/templates/aircox/episode_detail.html:36 #: aircox/admin/sound.py:100
#: aircox/templates/admin/aircox/playlist_inline.html:20
#: aircox/templates/aircox/episode_detail.html:36
msgid "Playlist" msgid "Playlist"
msgstr "Playlist" msgstr "Playlist"
#: aircox/admin/sound.py:82 #: aircox/admin/sound.py:102
msgid "Info" msgid "Info"
msgstr "Info" msgstr "Info"
#: aircox/admin/sound.py:96 aircox/models/sound.py:243 #: aircox/admin/sound.py:116 aircox/models/sound.py:240
msgid "timestamp" msgid "timestamp"
msgstr "temps" msgstr "temps"
@ -92,8 +94,8 @@ msgstr "temps"
msgid "Statistics" msgid "Statistics"
msgstr "Statistiques" msgstr "Statistiques"
#: aircox/filters.py:8 aircox/templates/admin/base.html:81 #: aircox/filters.py:8 aircox/templates/admin/base.html:84
#: aircox/templates/admin/base.html:95 aircox/templates/admin/base.html:109 #: aircox/templates/admin/base.html:98 aircox/templates/admin/base.html:112
#: aircox/templates/aircox/base.html:78 #: aircox/templates/aircox/base.html:78
#: aircox/templates/aircox/page_list.html:15 #: aircox/templates/aircox/page_list.html:15
msgid "Search" msgid "Search"
@ -103,24 +105,24 @@ msgstr "Chercher"
msgid "Podcast" msgid "Podcast"
msgstr "Podcast" msgstr "Podcast"
#: aircox/management/commands/sounds_monitor.py:204 #: aircox/management/commands/sounds_monitor.py:205
msgid "unknown" msgid "unknown"
msgstr "inconnu" msgstr "inconnu"
#: aircox/models/article.py:14 #: aircox/models/article.py:16
msgid "Article" msgid "Article"
msgstr "Article" msgstr "Article"
#: aircox/models/article.py:15 aircox/templates/admin/base.html:92 #: aircox/models/article.py:17 aircox/templates/admin/base.html:95
#: aircox/templates/aircox/program_detail.html:19 #: aircox/templates/aircox/program_detail.html:19
msgid "Articles" msgid "Articles"
msgstr "Articles" msgstr "Articles"
#: aircox/models/episode.py:52 #: aircox/models/episode.py:52 aircox/templates/admin/aircox/statistics.html:23
msgid "Episode" msgid "Episode"
msgstr "Épisode" msgstr "Épisode"
#: aircox/models/episode.py:53 aircox/templates/admin/base.html:106 #: aircox/models/episode.py:53 aircox/templates/admin/base.html:109
msgid "Episodes" msgid "Episodes"
msgstr "Épisodes" msgstr "Épisodes"
@ -136,8 +138,8 @@ msgstr "non confirmé"
msgid "cancelled" msgid "cancelled"
msgstr "annulé" msgstr "annulé"
#: aircox/models/episode.py:174 aircox/models/sound.py:102 #: aircox/models/episode.py:174 aircox/models/sound.py:99
#: aircox/models/sound.py:233 aircox/templates/admin/aircox/statistics.html:23 #: aircox/models/sound.py:230
msgid "episode" msgid "episode"
msgstr "épisode" msgstr "épisode"
@ -146,7 +148,7 @@ msgid "schedule"
msgstr "horaire" msgstr "horaire"
#: aircox/models/episode.py:181 aircox/models/log.py:91 #: aircox/models/episode.py:181 aircox/models/log.py:91
#: aircox/models/sound.py:105 aircox/models/station.py:142 #: aircox/models/sound.py:102 aircox/models/station.py:142
msgid "type" msgid "type"
msgstr "type" msgstr "type"
@ -171,12 +173,12 @@ msgstr "rediffusion"
msgid "stop" msgid "stop"
msgstr "stop" msgstr "stop"
#: aircox/models/log.py:84 aircox/models/sound.py:89 #: aircox/models/log.py:84 aircox/models/sound.py:86
msgid "other" msgid "other"
msgstr "autre" msgstr "autre"
#: aircox/models/log.py:89 aircox/models/page.py:248 #: aircox/models/log.py:89 aircox/models/page.py:248
#: aircox/models/program.py:55 aircox/models/station.py:139 #: aircox/models/program.py:54 aircox/models/station.py:139
msgid "station" msgid "station"
msgstr "station" msgstr "station"
@ -184,7 +186,7 @@ msgstr "station"
msgid "related station" msgid "related station"
msgstr "station relative" msgstr "station relative"
#: aircox/models/log.py:92 aircox/models/program.py:254 #: aircox/models/log.py:92 aircox/models/program.py:250
msgid "date" msgid "date"
msgstr "date" msgstr "date"
@ -200,11 +202,12 @@ msgstr "identifiant de la source relative à ce log"
msgid "comment" msgid "comment"
msgstr "commentaire" msgstr "commentaire"
#: aircox/models/log.py:107 aircox/models/sound.py:146 #: aircox/models/log.py:107 aircox/models/sound.py:143
msgid "Sound" msgid "Sound"
msgstr "Son" msgstr "Son"
#: aircox/models/log.py:112 aircox/models/sound.py:259 #: aircox/models/log.py:112 aircox/models/sound.py:259
#: aircox/templates/admin/aircox/statistics.html:24
msgid "Track" msgid "Track"
msgstr "Morceau" msgstr "Morceau"
@ -217,7 +220,7 @@ msgid "Logs"
msgstr "Logs" msgstr "Logs"
#: aircox/models/page.py:30 aircox/models/page.py:251 #: aircox/models/page.py:30 aircox/models/page.py:251
#: aircox/models/sound.py:247 #: aircox/models/sound.py:244
msgid "title" msgid "title"
msgstr "titre" msgstr "titre"
@ -338,8 +341,8 @@ msgstr "Commentaires"
msgid "menu" msgid "menu"
msgstr "menu" msgstr "menu"
#: aircox/models/page.py:250 aircox/models/sound.py:107 #: aircox/models/page.py:250 aircox/models/sound.py:104
#: aircox/models/sound.py:240 #: aircox/models/sound.py:237
msgid "order" msgid "order"
msgstr "ordre" msgstr "ordre"
@ -359,234 +362,239 @@ msgstr "Élément du menu"
msgid "Menu items" msgid "Menu items"
msgstr "Éléments de menu" msgstr "Éléments de menu"
#: aircox/models/program.py:57 aircox/models/station.py:52 #: aircox/models/program.py:56 aircox/models/station.py:52
#: aircox/models/station.py:144 #: aircox/models/station.py:144
msgid "active" msgid "active"
msgstr "actif" msgstr "actif"
#: aircox/models/program.py:59 #: aircox/models/program.py:58
msgid "if not checked this program is no longer active" msgid "if not checked this program is no longer active"
msgstr "si selectionné, cette émission n'est plus active" msgstr "si selectionné, cette émission n'est plus active"
#: aircox/models/program.py:62 #: aircox/models/program.py:61
msgid "syncronise" msgid "syncronise"
msgstr "synchroniser" msgstr "synchroniser"
#: aircox/models/program.py:64 #: aircox/models/program.py:63
msgid "update later diffusions according to schedule changes" msgid "update later diffusions according to schedule changes"
msgstr "met à jour les dates de diffusion à venir lorsque l'horaire change" msgstr "met à jour les dates de diffusion à venir lorsque l'horaire change"
#: aircox/models/program.py:123 aircox/templates/admin/base.html:78 #: aircox/models/program.py:119 aircox/templates/admin/base.html:81
msgid "Programs" msgid "Programs"
msgstr "Émissions" msgstr "Émissions"
#: aircox/models/program.py:178 aircox/models/program.py:461 #: aircox/models/program.py:174 aircox/models/program.py:457
msgid "related program" msgid "related program"
msgstr "émission apparentée" msgstr "émission apparentée"
#: aircox/models/program.py:182 #: aircox/models/program.py:178
msgid "rerun of" msgid "rerun of"
msgstr "rediffusion de" msgstr "rediffusion de"
#: aircox/models/program.py:226 #: aircox/models/program.py:222
msgid "rerun must happen after original" msgid "rerun must happen after original"
msgstr "la rediffusion doit être après l'original" msgstr "la rediffusion doit être après l'original"
#: aircox/models/program.py:254 #: aircox/models/program.py:250
msgid "date of the first diffusion" msgid "date of the first diffusion"
msgstr "date de la première diffusion" msgstr "date de la première diffusion"
#: aircox/models/program.py:257 #: aircox/models/program.py:253
#: aircox/templates/admin/aircox/statistics.html:22
msgid "time" msgid "time"
msgstr "heure" msgstr "heure"
#: aircox/models/program.py:257 #: aircox/models/program.py:253
msgid "start time" msgid "start time"
msgstr "heure de début" msgstr "heure de début"
#: aircox/models/program.py:260 #: aircox/models/program.py:256
msgid "timezone" msgid "timezone"
msgstr "zone horaire" msgstr "zone horaire"
#: aircox/models/program.py:263 #: aircox/models/program.py:259
msgid "timezone used for the date" msgid "timezone used for the date"
msgstr "zone horaire utilisée pour la date" msgstr "zone horaire utilisée pour la date"
#: aircox/models/program.py:266 aircox/models/sound.py:120 #: aircox/models/program.py:262 aircox/models/sound.py:117
msgid "duration" msgid "duration"
msgstr "durée" msgstr "durée"
#: aircox/models/program.py:267 #: aircox/models/program.py:263
msgid "regular duration" msgid "regular duration"
msgstr "durée normale" msgstr "durée normale"
#: aircox/models/program.py:270 #: aircox/models/program.py:266
msgid "frequency" msgid "frequency"
msgstr "fréquence" msgstr "fréquence"
#: aircox/models/program.py:272 #: aircox/models/program.py:268
msgid "ponctual" msgid "ponctual"
msgstr "ponctuel" msgstr "ponctuel"
#: aircox/models/program.py:273 #: aircox/models/program.py:269
#, python-brace-format #, python-brace-format
msgid "1st {day} of the month" msgid "1st {day} of the month"
msgstr "1er {day} du mois" msgstr "1er {day} du mois"
#: aircox/models/program.py:274 #: aircox/models/program.py:270
#, python-brace-format #, python-brace-format
msgid "2nd {day} of the month" msgid "2nd {day} of the month"
msgstr "2ème {day} du mois" msgstr "2ème {day} du mois"
#: aircox/models/program.py:275 #: aircox/models/program.py:271
#, python-brace-format #, python-brace-format
msgid "3rd {day} of the month" msgid "3rd {day} of the month"
msgstr "3ème {day} du mois" msgstr "3ème {day} du mois"
#: aircox/models/program.py:276 #: aircox/models/program.py:272
#, python-brace-format #, python-brace-format
msgid "4th {day} of the month" msgid "4th {day} of the month"
msgstr "4ème {day} du mois" msgstr "4ème {day} du mois"
#: aircox/models/program.py:277 #: aircox/models/program.py:273
#, python-brace-format #, python-brace-format
msgid "last {day} of the month" msgid "last {day} of the month"
msgstr "dernier {day} du mois" msgstr "dernier {day} du mois"
#: aircox/models/program.py:278 #: aircox/models/program.py:274
#, python-brace-format #, python-brace-format
msgid "1st and 3rd {day} of the month" msgid "1st and 3rd {day} of the month"
msgstr "1er et 3ème {day} du mois" msgstr "1er et 3ème {day} du mois"
#: aircox/models/program.py:279 #: aircox/models/program.py:275
#, python-brace-format #, python-brace-format
msgid "2nd and 4th {day} of the month" msgid "2nd and 4th {day} of the month"
msgstr "2ème et 4ème {day} du mois" msgstr "2ème et 4ème {day} du mois"
#: aircox/models/program.py:280 #: aircox/models/program.py:276
#, fuzzy, python-brace-format
#| msgid "every {day}"
msgid "{day}" msgid "{day}"
msgstr "{day}" msgstr "{day}"
#: aircox/models/program.py:281 #: aircox/models/program.py:277
#, python-brace-format #, python-brace-format
msgid "one {day} on two" msgid "one {day} on two"
msgstr "un {day} sur deux" msgstr "un {day} sur deux"
#: aircox/models/program.py:287 #: aircox/models/program.py:283
msgid "Schedules" msgid "Schedules"
msgstr "Horaires" msgstr "Horaires"
#: aircox/models/program.py:464 #: aircox/models/program.py:460
msgid "delay" msgid "delay"
msgstr "délai" msgstr "délai"
#: aircox/models/program.py:465 #: aircox/models/program.py:461
msgid "minimal delay between two sound plays" msgid "minimal delay between two sound plays"
msgstr "délai minimum entre deux sons joués" msgstr "délai minimum entre deux sons joués"
#: aircox/models/program.py:468 #: aircox/models/program.py:464
msgid "begin" msgid "begin"
msgstr "début" msgstr "début"
#: aircox/models/program.py:469 aircox/models/program.py:475 #: aircox/models/program.py:465 aircox/models/program.py:471
msgid "used to define a time range this stream is played" msgid "used to define a time range this stream is played"
msgstr "" msgstr ""
"utilisé pour définir un intervalle de temps pendant lequel ce stream est joué" "utilisé pour définir un intervalle de temps pendant lequel ce stream est joué"
#: aircox/models/sound.py:89 #: aircox/models/sound.py:86
msgid "archive" msgid "archive"
msgstr "archive" msgstr "archive"
#: aircox/models/sound.py:90 #: aircox/models/sound.py:87
msgid "excerpt" msgid "excerpt"
msgstr "extrait" msgstr "extrait"
#: aircox/models/sound.py:90 #: aircox/models/sound.py:87
msgid "removed" msgid "removed"
msgstr "supprimé" msgstr "supprimé"
#: aircox/models/sound.py:93 aircox/models/station.py:37 #: aircox/models/sound.py:90 aircox/models/station.py:37
msgid "name" msgid "name"
msgstr "nom" msgstr "nom"
#: aircox/models/sound.py:96 #: aircox/models/sound.py:93
msgid "program" msgid "program"
msgstr "émission" msgstr "émission"
#: aircox/models/sound.py:97 #: aircox/models/sound.py:94
msgid "program related to it" msgid "program related to it"
msgstr "émission apparentée à celui-ci" msgstr "émission apparentée à celui-ci"
#: aircox/models/sound.py:107 aircox/models/sound.py:240 #: aircox/models/sound.py:104 aircox/models/sound.py:237
msgid "position in the playlist" msgid "position in the playlist"
msgstr "position dans la playlist" msgstr "position dans la playlist"
#: aircox/models/sound.py:116 aircox/models/station.py:135 #: aircox/models/sound.py:113 aircox/models/station.py:135
msgid "file" msgid "file"
msgstr "fichier" msgstr "fichier"
#: aircox/models/sound.py:122 #: aircox/models/sound.py:119
msgid "duration of the sound" msgid "duration of the sound"
msgstr "durée du son" msgstr "durée du son"
#: aircox/models/sound.py:125 #: aircox/models/sound.py:122
msgid "modification time" msgid "modification time"
msgstr "dernière modification" msgstr "dernière modification"
#: aircox/models/sound.py:127 #: aircox/models/sound.py:124
msgid "last modification date and time" msgid "last modification date and time"
msgstr "date et heure de la dernière modification" msgstr "date et heure de la dernière modification"
#: aircox/models/sound.py:130 #: aircox/models/sound.py:127
msgid "good quality" msgid "good quality"
msgstr "bonne qualité" msgstr "bonne qualité"
#: aircox/models/sound.py:130 #: aircox/models/sound.py:127
msgid "sound meets quality requirements" msgid "sound meets quality requirements"
msgstr "le son rencontre les exigences de qualité" msgstr "le son rencontre les exigences de qualité"
#: aircox/models/sound.py:134 #: aircox/models/sound.py:131
msgid "public" msgid "public"
msgstr "publique" msgstr "publique"
#: aircox/models/sound.py:134 #: aircox/models/sound.py:131
msgid "whether it is publicly available as podcast" msgid "whether it is publicly available as podcast"
msgstr "coché pour rendre le podcast public" msgstr "coché pour rendre le podcast public"
#: aircox/models/sound.py:138 #: aircox/models/sound.py:135
msgid "downloadable" msgid "downloadable"
msgstr "téléchargeable" msgstr "téléchargeable"
#: aircox/models/sound.py:139 #: aircox/models/sound.py:136
msgid "" msgid ""
"whether it can be publicly downloaded by visitors (sound must be public)" "whether it can be publicly downloaded by visitors (sound must be public)"
msgstr "" msgstr ""
"coché pour permettre le téléchargement public (le podcast doit être " "coché pour permettre le téléchargement public (le podcast doit être "
"disponible publiquement)" "disponible publiquement)"
#: aircox/models/sound.py:147 #: aircox/models/sound.py:144
msgid "Sounds" msgid "Sounds"
msgstr "Sons" msgstr "Sons"
#: aircox/models/sound.py:237 #: aircox/models/sound.py:234
msgid "sound" msgid "sound"
msgstr "son" msgstr "son"
#: aircox/models/sound.py:245 #: aircox/models/sound.py:242
msgid "position (in seconds)" msgid "position (in seconds)"
msgstr "position (en secondes)" msgstr "position (en secondes)"
#: aircox/models/sound.py:248 #: aircox/models/sound.py:245
msgid "artist" msgid "artist"
msgstr "artiste" msgstr "artiste"
#: aircox/models/sound.py:249 aircox/templates/admin/aircox/statistics.html:25 #: aircox/models/sound.py:246
msgid "album"
msgstr "album"
#: aircox/models/sound.py:247
msgid "tags" msgid "tags"
msgstr "tags" msgstr "tags"
#: aircox/models/sound.py:248
msgid "year"
msgstr "année"
#: aircox/models/sound.py:251 #: aircox/models/sound.py:251
msgid "information" msgid "information"
msgstr "information" msgstr "information"
@ -677,6 +685,18 @@ msgstr ""
"liste des paramètres disponibles séparés par des virgules; placé dans le " "liste des paramètres disponibles séparés par des virgules; placé dans le "
"fichier de configuration en tant que code brut; relatif au plugin utilisé" "fichier de configuration en tant que code brut; relatif au plugin utilisé"
#: aircox/models/user_settings.py:11
msgid "User"
msgstr "Utilisateur"
#: aircox/models/user_settings.py:14
msgid "Playlist Editor Columns"
msgstr "Colonnes de l'éditeur de playlist"
#: aircox/models/user_settings.py:16
msgid "Playlist Editor Separator"
msgstr "Séparateur de l'éditeur de playlist"
#: aircox/templates/admin/aircox/filters/filter.html:2 #: aircox/templates/admin/aircox/filters/filter.html:2
#, python-format #, python-format
msgid " By %(filter_title)s " msgid " By %(filter_title)s "
@ -684,7 +704,7 @@ msgstr "Par %(filter_title)s "
#: aircox/templates/admin/aircox/page_change_form.html:9 #: aircox/templates/admin/aircox/page_change_form.html:9
#: aircox/templates/admin/aircox/page_change_list.html:7 #: aircox/templates/admin/aircox/page_change_list.html:7
#: aircox/templates/admin/base.html:163 #: aircox/templates/admin/base.html:166
#: aircox/templates/admin/change_list.html:30 #: aircox/templates/admin/change_list.html:30
#: aircox/templates/aircox/base.html:54 #: aircox/templates/aircox/base.html:54
msgid "Home" msgid "Home"
@ -715,36 +735,46 @@ msgstr "Sauvegarder et continuer"
msgid "Publish" msgid "Publish"
msgstr "Publier" msgstr "Publier"
#: aircox/templates/admin/aircox/statistics.html:24 #: aircox/templates/admin/aircox/playlist_inline.html:33
msgid "track" #: aircox/templates/admin/aircox/playlist_inline.html:34
msgstr "piste" msgid "Track Position"
msgstr "Position dans la playlist"
#: aircox/templates/admin/aircox/statistics.html:22
msgid "Time"
msgstr "Heure"
#: aircox/templates/admin/aircox/statistics.html:25
#: aircox/templatetags/aircox_admin.py:52
msgid "Tags"
msgstr "Étiquettes"
#: aircox/templates/admin/aircox/statistics.html:67 #: aircox/templates/admin/aircox/statistics.html:67
msgid "Total" msgid "Total"
msgstr "Total" msgstr "Total"
#: aircox/templates/admin/base.html:65 aircox/templates/admin/index.html:11 #: aircox/templates/admin/base.html:68 aircox/templates/admin/index.html:11
#: aircox/templates/aircox/home.html:47 #: aircox/templates/aircox/home.html:47
msgid "Today" msgid "Today"
msgstr "Aujourd'hui" msgstr "Aujourd'hui"
#: aircox/templates/admin/base.html:121 #: aircox/templates/admin/base.html:124
msgid "Tools" msgid "Tools"
msgstr "Outils" msgstr "Outils"
#: aircox/templates/admin/base.html:137 #: aircox/templates/admin/base.html:140
msgid "View site" msgid "View site"
msgstr "Voir le site" msgstr "Voir le site"
#: aircox/templates/admin/base.html:142 #: aircox/templates/admin/base.html:145
msgid "Documentation" msgid "Documentation"
msgstr "Documentation" msgstr "Documentation"
#: aircox/templates/admin/base.html:146 #: aircox/templates/admin/base.html:149
msgid "Change password" msgid "Change password"
msgstr "Changer le mot de passe" msgstr "Changer le mot de passe"
#: aircox/templates/admin/base.html:149 #: aircox/templates/admin/base.html:152
msgid "Log out" msgid "Log out"
msgstr "Se déconnecter" msgstr "Se déconnecter"
@ -958,67 +988,108 @@ msgstr "Épisode en ce moment sur les ondes"
msgid "Currently playing" msgid "Currently playing"
msgstr "En ce moment" msgstr "En ce moment"
#: aircox/urls.py:40 #: aircox/templatetags/aircox_admin.py:51
msgid "Artist"
msgstr "Artiste"
#: aircox/templatetags/aircox_admin.py:51
msgid "Album"
msgstr "Album"
#: aircox/templatetags/aircox_admin.py:51
msgid "Title"
msgstr "Titre"
#: aircox/templatetags/aircox_admin.py:52
msgid "Year"
msgstr "Année"
#: aircox/templatetags/aircox_admin.py:53
msgid "Save Settings"
msgstr "Enregistrer la configuration"
#: aircox/templatetags/aircox_admin.py:54
msgid "Discard changes"
msgstr "Annuler les changements"
#: aircox/templatetags/aircox_admin.py:55
msgid "Columns"
msgstr "Colonnes"
#: aircox/templatetags/aircox_admin.py:56
msgid "Add a track"
msgstr "Ajouter un morceau"
#: aircox/templatetags/aircox_admin.py:57
msgid "Remove"
msgstr "Supprimer"
#: aircox/templatetags/aircox_admin.py:58
#| msgid "timestamp"
msgid "Timestamp"
msgstr "Temps"
#: aircox/urls.py:44
msgid "articles/" msgid "articles/"
msgstr "articles/" msgstr "articles/"
#: aircox/urls.py:43 #: aircox/urls.py:47
msgid "articles/<slug:slug>/" msgid "articles/<slug:slug>/"
msgstr "articles/<slug:slug>/" msgstr "articles/<slug:slug>/"
#: aircox/urls.py:47 #: aircox/urls.py:51
msgid "episodes/" msgid "episodes/"
msgstr "episodes/" msgstr "episodes/"
#: aircox/urls.py:49 #: aircox/urls.py:53
msgid "episodes/<slug:slug>/" msgid "episodes/<slug:slug>/"
msgstr "episodes/<slug:slug>/" msgstr "episodes/<slug:slug>/"
#: aircox/urls.py:51 #: aircox/urls.py:55
msgid "week/" msgid "week/"
msgstr "semaine/" msgstr "semaine/"
#: aircox/urls.py:53 #: aircox/urls.py:57
msgid "week/<date:date>/" msgid "week/<date:date>/"
msgstr "semaine/<date:date>/" msgstr "semaine/<date:date>/"
#: aircox/urls.py:56 #: aircox/urls.py:60
msgid "logs/" msgid "logs/"
msgstr "logs/" msgstr "logs/"
#: aircox/urls.py:57 #: aircox/urls.py:61
msgid "logs/<date:date>/" msgid "logs/<date:date>/"
msgstr "logs/<date:date>/" msgstr "logs/<date:date>/"
#: aircox/urls.py:60 #: aircox/urls.py:64
msgid "publications/" msgid "publications/"
msgstr "publications/" msgstr "publications/"
#: aircox/urls.py:63 #: aircox/urls.py:67
msgid "pages/" msgid "pages/"
msgstr "pages/" msgstr "pages/"
#: aircox/urls.py:69 #: aircox/urls.py:73
msgid "pages/<slug:slug>/" msgid "pages/<slug:slug>/"
msgstr "pages/<slug:slug>/" msgstr "pages/<slug:slug>/"
#: aircox/urls.py:76 #: aircox/urls.py:80
msgid "programs/" msgid "programs/"
msgstr "emissions/" msgstr "emissions/"
#: aircox/urls.py:78 #: aircox/urls.py:82
msgid "programs/<slug:slug>/" msgid "programs/<slug:slug>/"
msgstr "emissions/<slug:slug>/" msgstr "emissions/<slug:slug>/"
#: aircox/urls.py:80 #: aircox/urls.py:84
msgid "programs/<slug:parent_slug>/episodes/" msgid "programs/<slug:parent_slug>/episodes/"
msgstr "emissions/<slug:parent_slug>/episodes/" msgstr "emissions/<slug:parent_slug>/episodes/"
#: aircox/urls.py:82 #: aircox/urls.py:86
msgid "programs/<slug:parent_slug>/articles/" msgid "programs/<slug:parent_slug>/articles/"
msgstr "emissions/<slug:parent_slug>/articles/" msgstr "emissions/<slug:parent_slug>/articles/"
#: aircox/urls.py:84 #: aircox/urls.py:88
msgid "programs/<slug:parent_slug>/publications/" msgid "programs/<slug:parent_slug>/publications/"
msgstr "emissions/<slug:parent_slug>/publications/" msgstr "emissions/<slug:parent_slug>/publications/"
@ -1026,6 +1097,9 @@ msgstr "emissions/<slug:parent_slug>/publications/"
msgid "comments are not allowed" msgid "comments are not allowed"
msgstr "les commentaires ne sont pas autorisés" msgstr "les commentaires ne sont pas autorisés"
#~ msgid "track"
#~ msgstr "morceau"
#~ msgid "if it can be podcasted from the server" #~ msgid "if it can be podcasted from the server"
#~ msgstr "s'il peut être podcasté depuis le serveur" #~ msgstr "s'il peut être podcasté depuis le serveur"

View File

@ -219,7 +219,6 @@ class MonitorHandler(PatternMatchingEventHandler):
""" """
self.subdir = subdir self.subdir = subdir
self.pool = pool self.pool = pool
if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR: if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR:
self.sound_kwargs = {'type': Sound.TYPE_ARCHIVE} self.sound_kwargs = {'type': Sound.TYPE_ARCHIVE}
else: else:

View File

@ -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

View File

@ -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):

View File

@ -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),
} }

View File

@ -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)

View File

@ -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>)?'

View File

@ -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):

View File

@ -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

View File

@ -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):
@ -246,7 +243,10 @@ class Track(models.Model):
) )
title = models.CharField(_('title'), max_length=128) title = models.CharField(_('title'), max_length=128)
artist = models.CharField(_('artist'), max_length=128) artist = models.CharField(_('artist'), max_length=128)
tags = TaggableManager(verbose_name=_('tags'), blank=True,) album = models.CharField(_('album'), max_length=128, null=True, blank=True)
tags = TaggableManager(verbose_name=_('tags'), blank=True)
year = models.IntegerField(_('year'), blank=True, null=True)
# FIXME: remove?
info = models.CharField( info = models.CharField(
_('information'), _('information'),
max_length=128, max_length=128,

View File

@ -2,13 +2,14 @@ import os
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property
from filer.fields.image import FilerImageField 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):
@ -74,6 +75,11 @@ class Station(models.Model):
objects = StationQuerySet.as_manager() objects = StationQuerySet.as_manager()
@cached_property
def streams(self):
""" Audio streams as list of urls. """
return self.audio_streams.split('\n') if self.audio_streams else []
def __str__(self): def __str__(self):
return self.name return self.name

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', 'timestamp')
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,9 +1,9 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Diffusion, Log, Sound from ..models import Diffusion, Log
__all__ = ['LogInfo', 'LogInfoSerializer'] __all__ = ('LogInfo', 'LogInfoSerializer')
class LogInfo: class LogInfo:
@ -51,21 +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']

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

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vue App</title>
<script defer src="js/chunk-vendors.js"></script><script defer src="js/chunk-common.js"></script><script defer src="js/admin.js"></script><link href="css/chunk-vendors.css" rel="stylesheet"><link href="css/chunk-common.css" rel="stylesheet"><link href="css/admin.css" rel="stylesheet"></head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vue App</title>
<script defer src="js/chunk-vendors.js"></script><script defer src="js/chunk-common.js"></script><script defer src="js/core.js"></script><link href="css/chunk-vendors.css" rel="stylesheet"><link href="css/chunk-common.css" rel="stylesheet"></head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -1 +1,24 @@
.admin .navbar .navbar-brand{padding-right:1em}.admin .navbar .navbar-brand img{margin:0 .4em;margin-top:.3em;max-height:3em}.admin .breadcrumbs{margin-bottom:1em}.admin .results>#result_list{width:100%;margin:1em 0}.admin ul.menu-list li{list-style-type:none}.admin .submit-row a.deletelink{height:35px} /*!*************************************************************************************************************************************************************************************************************************************!*\
!*** css ./node_modules/css-loader/dist/cjs.js??clonedRuleSet-24.use[1]!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-24.use[2]!./node_modules/sass-loader/dist/cjs.js??clonedRuleSet-24.use[3]!./src/assets/admin.scss ***!
\*************************************************************************************************************************************************************************************************************************************/
.admin .navbar .navbar-brand {
padding-right: 1em;
}
.admin .navbar .navbar-brand img {
margin: 0em 0.4em;
margin-top: 0.3em;
max-height: 3em;
}
.admin .breadcrumbs {
margin-bottom: 1em;
}
.admin .results > #result_list {
width: 100%;
margin: 1em 0em;
}
.admin ul.menu-list li {
list-style-type: none;
}
.admin .submit-row a.deletelink {
height: 35px;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,225 @@
(function(){"use strict";var n={5159:function(n,t,e){e(9651),e(8880);var o=e(9643),r=e(1784);const i={...o.Z,components:{...o.Z.components,...r.S}};window.App=i},1784:function(n,t,e){e.d(t,{S:function(){return v}});var o=e(4156),r=e(1847),i=e(6294),u=e(5189),c=e(2530),f=e(6306),a=e(7079),s=e(7467),l=e(8833),p=e(5127);t["Z"]={AAutocomplete:o.Z,AEpisode:r.Z,AList:i.Z,APage:u.Z,APlayer:c.C,APlaylist:f.Z,AProgress:a.Z,ASoundItem:s.Z};const v={AStatistics:l.Z,AStreamer:p.Z}}},t={};function e(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return n[o](i,i.exports,e),i.exports}e.m=n,function(){var n=[];e.O=function(t,o,r,i){if(!o){var u=1/0;for(s=0;s<n.length;s++){o=n[s][0],r=n[s][1],i=n[s][2];for(var c=!0,f=0;f<o.length;f++)(!1&i||u>=i)&&Object.keys(e.O).every((function(n){return e.O[n](o[f])}))?o.splice(f--,1):(c=!1,i<u&&(u=i));if(c){n.splice(s--,1);var a=r();void 0!==a&&(t=a)}}return t}i=i||0;for(var s=n.length;s>0&&n[s-1][2]>i;s--)n[s]=n[s-1];n[s]=[o,r,i]}}(),function(){e.d=function(n,t){for(var o in t)e.o(t,o)&&!e.o(n,o)&&Object.defineProperty(n,o,{enumerable:!0,get:t[o]})}}(),function(){e.g=function(){if("object"===typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"===typeof window)return window}}()}(),function(){e.o=function(n,t){return Object.prototype.hasOwnProperty.call(n,t)}}(),function(){e.r=function(n){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})}}(),function(){var n={328:0};e.O.j=function(t){return 0===n[t]};var t=function(t,o){var r,i,u=o[0],c=o[1],f=o[2],a=0;if(u.some((function(t){return 0!==n[t]}))){for(r in c)e.o(c,r)&&(e.m[r]=c[r]);if(f)var s=f(e)}for(t&&t(o);a<u.length;a++)i=u[a],e.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return e.O(s)},o=self["webpackChunkaircox_assets"]=self["webpackChunkaircox_assets"]||[];o.forEach(t.bind(null,0)),o.push=t.bind(null,o.push.bind(o))}();var o=e.O(void 0,[998,64],(function(){return e(5159)}));o=e.O(o)})(); /*
//# sourceMappingURL=admin.js.map * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
/******/ (function() { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ "./src/admin.js":
/*!**********************!*\
!*** ./src/admin.js ***!
\**********************/
/***/ (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/* 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?");
/***/ }),
/***/ "./src/assets/admin.scss":
/*!*******************************!*\
!*** ./src/assets/admin.scss ***!
\*******************************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n// extracted by mini-css-extract-plugin\n\n\n//# sourceURL=webpack://aircox-assets/./src/assets/admin.scss?");
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ id: moduleId,
/******/ loaded: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.loaded = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = __webpack_modules__;
/******/
/************************************************************************/
/******/ /* webpack/runtime/chunk loaded */
/******/ !function() {
/******/ var deferred = [];
/******/ __webpack_require__.O = function(result, chunkIds, fn, priority) {
/******/ if(chunkIds) {
/******/ priority = priority || 0;
/******/ for(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1];
/******/ deferred[i] = [chunkIds, fn, priority];
/******/ return;
/******/ }
/******/ var notFulfilled = Infinity;
/******/ for (var i = 0; i < deferred.length; i++) {
/******/ var chunkIds = deferred[i][0];
/******/ var fn = deferred[i][1];
/******/ var priority = deferred[i][2];
/******/ var fulfilled = true;
/******/ for (var j = 0; j < chunkIds.length; j++) {
/******/ if ((priority & 1 === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every(function(key) { return __webpack_require__.O[key](chunkIds[j]); })) {
/******/ chunkIds.splice(j--, 1);
/******/ } else {
/******/ fulfilled = false;
/******/ if(priority < notFulfilled) notFulfilled = priority;
/******/ }
/******/ }
/******/ if(fulfilled) {
/******/ deferred.splice(i--, 1)
/******/ var r = fn();
/******/ if (r !== undefined) result = r;
/******/ }
/******/ }
/******/ return result;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/compat get default export */
/******/ !function() {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function() { return module['default']; } :
/******/ function() { return module; };
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ !function() {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = function(exports, definition) {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/global */
/******/ !function() {
/******/ __webpack_require__.g = (function() {
/******/ if (typeof globalThis === 'object') return globalThis;
/******/ try {
/******/ return this || new Function('return this')();
/******/ } catch (e) {
/******/ if (typeof window === 'object') return window;
/******/ }
/******/ })();
/******/ }();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ !function() {
/******/ __webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }
/******/ }();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ !function() {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = function(exports) {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/node module decorator */
/******/ !function() {
/******/ __webpack_require__.nmd = function(module) {
/******/ module.paths = [];
/******/ if (!module.children) module.children = [];
/******/ return module;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/jsonp chunk loading */
/******/ !function() {
/******/ // no baseURI
/******/
/******/ // object to store loaded and loading chunks
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
/******/ var installedChunks = {
/******/ "admin": 0
/******/ };
/******/
/******/ // no chunk on demand loading
/******/
/******/ // no prefetching
/******/
/******/ // no preloaded
/******/
/******/ // no HMR
/******/
/******/ // no HMR manifest
/******/
/******/ __webpack_require__.O.j = function(chunkId) { return installedChunks[chunkId] === 0; };
/******/
/******/ // install a JSONP callback for chunk loading
/******/ var webpackJsonpCallback = function(parentChunkLoadingFunction, data) {
/******/ var chunkIds = data[0];
/******/ var moreModules = data[1];
/******/ var runtime = data[2];
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId, chunkId, i = 0;
/******/ if(chunkIds.some(function(id) { return installedChunks[id] !== 0; })) {
/******/ for(moduleId in moreModules) {
/******/ if(__webpack_require__.o(moreModules, moduleId)) {
/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
/******/ }
/******/ }
/******/ if(runtime) var result = runtime(__webpack_require__);
/******/ }
/******/ if(parentChunkLoadingFunction) parentChunkLoadingFunction(data);
/******/ for(;i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
/******/ installedChunks[chunkId][0]();
/******/ }
/******/ installedChunks[chunkId] = 0;
/******/ }
/******/ return __webpack_require__.O(result);
/******/ }
/******/
/******/ var chunkLoadingGlobal = self["webpackChunkaircox_assets"] = self["webpackChunkaircox_assets"] || [];
/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
/******/ }();
/******/
/************************************************************************/
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module depends on other loaded chunks and execution need to be delayed
/******/ var __webpack_exports__ = __webpack_require__.O(undefined, ["chunk-vendors","chunk-common"], function() { return __webpack_require__("./src/admin.js"); })
/******/ __webpack_exports__ = __webpack_require__.O(__webpack_exports__);
/******/
/******/ })()
;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,215 @@
(function(){"use strict";var n={1784:function(n,t,e){var r=e(4156),o=e(1847),i=e(6294),u=e(5189),f=e(2530),c=e(6306),a=e(7079),s=e(7467),l=e(8833),p=e(5127);t["Z"]={AAutocomplete:r.Z,AEpisode:o.Z,AList:i.Z,APage:u.Z,APlayer:f.C,APlaylist:c.Z,AProgress:a.Z,ASoundItem:s.Z};l.Z,p.Z},5288:function(n,t,e){e(8880);var r=e(9643);window.App=r.Z}},t={};function e(r){var o=t[r];if(void 0!==o)return o.exports;var i=t[r]={exports:{}};return n[r](i,i.exports,e),i.exports}e.m=n,function(){var n=[];e.O=function(t,r,o,i){if(!r){var u=1/0;for(s=0;s<n.length;s++){r=n[s][0],o=n[s][1],i=n[s][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(e.O).every((function(n){return e.O[n](r[c])}))?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(s--,1);var a=o();void 0!==a&&(t=a)}}return t}i=i||0;for(var s=n.length;s>0&&n[s-1][2]>i;s--)n[s]=n[s-1];n[s]=[r,o,i]}}(),function(){e.d=function(n,t){for(var r in t)e.o(t,r)&&!e.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:t[r]})}}(),function(){e.g=function(){if("object"===typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"===typeof window)return window}}()}(),function(){e.o=function(n,t){return Object.prototype.hasOwnProperty.call(n,t)}}(),function(){e.r=function(n){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})}}(),function(){var n={321:0};e.O.j=function(t){return 0===n[t]};var t=function(t,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some((function(t){return 0!==n[t]}))){for(o in f)e.o(f,o)&&(e.m[o]=f[o]);if(c)var s=c(e)}for(t&&t(r);a<u.length;a++)i=u[a],e.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return e.O(s)},r=self["webpackChunkaircox_assets"]=self["webpackChunkaircox_assets"]||[];r.forEach(t.bind(null,0)),r.push=t.bind(null,r.push.bind(r))}();var r=e.O(void 0,[998,64],(function(){return e(5288)}));r=e.O(r)})(); /*
//# sourceMappingURL=core.js.map * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
/******/ (function() { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ "./src/core.js":
/*!*********************!*\
!*** ./src/core.js ***!
\*********************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _index_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./index.js */ \"./src/index.js\");\n/* harmony import */ var _app_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./app.js */ \"./src/app.js\");\n\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (_app_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"]);\nwindow.App = _app_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"];\n\n//# sourceURL=webpack://aircox-assets/./src/core.js?");
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ id: moduleId,
/******/ loaded: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.loaded = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = __webpack_modules__;
/******/
/************************************************************************/
/******/ /* webpack/runtime/chunk loaded */
/******/ !function() {
/******/ var deferred = [];
/******/ __webpack_require__.O = function(result, chunkIds, fn, priority) {
/******/ if(chunkIds) {
/******/ priority = priority || 0;
/******/ for(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1];
/******/ deferred[i] = [chunkIds, fn, priority];
/******/ return;
/******/ }
/******/ var notFulfilled = Infinity;
/******/ for (var i = 0; i < deferred.length; i++) {
/******/ var chunkIds = deferred[i][0];
/******/ var fn = deferred[i][1];
/******/ var priority = deferred[i][2];
/******/ var fulfilled = true;
/******/ for (var j = 0; j < chunkIds.length; j++) {
/******/ if ((priority & 1 === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every(function(key) { return __webpack_require__.O[key](chunkIds[j]); })) {
/******/ chunkIds.splice(j--, 1);
/******/ } else {
/******/ fulfilled = false;
/******/ if(priority < notFulfilled) notFulfilled = priority;
/******/ }
/******/ }
/******/ if(fulfilled) {
/******/ deferred.splice(i--, 1)
/******/ var r = fn();
/******/ if (r !== undefined) result = r;
/******/ }
/******/ }
/******/ return result;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/compat get default export */
/******/ !function() {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function() { return module['default']; } :
/******/ function() { return module; };
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ !function() {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = function(exports, definition) {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/global */
/******/ !function() {
/******/ __webpack_require__.g = (function() {
/******/ if (typeof globalThis === 'object') return globalThis;
/******/ try {
/******/ return this || new Function('return this')();
/******/ } catch (e) {
/******/ if (typeof window === 'object') return window;
/******/ }
/******/ })();
/******/ }();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ !function() {
/******/ __webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }
/******/ }();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ !function() {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = function(exports) {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/node module decorator */
/******/ !function() {
/******/ __webpack_require__.nmd = function(module) {
/******/ module.paths = [];
/******/ if (!module.children) module.children = [];
/******/ return module;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/jsonp chunk loading */
/******/ !function() {
/******/ // no baseURI
/******/
/******/ // object to store loaded and loading chunks
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
/******/ var installedChunks = {
/******/ "core": 0
/******/ };
/******/
/******/ // no chunk on demand loading
/******/
/******/ // no prefetching
/******/
/******/ // no preloaded
/******/
/******/ // no HMR
/******/
/******/ // no HMR manifest
/******/
/******/ __webpack_require__.O.j = function(chunkId) { return installedChunks[chunkId] === 0; };
/******/
/******/ // install a JSONP callback for chunk loading
/******/ var webpackJsonpCallback = function(parentChunkLoadingFunction, data) {
/******/ var chunkIds = data[0];
/******/ var moreModules = data[1];
/******/ var runtime = data[2];
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId, chunkId, i = 0;
/******/ if(chunkIds.some(function(id) { return installedChunks[id] !== 0; })) {
/******/ for(moduleId in moreModules) {
/******/ if(__webpack_require__.o(moreModules, moduleId)) {
/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
/******/ }
/******/ }
/******/ if(runtime) var result = runtime(__webpack_require__);
/******/ }
/******/ if(parentChunkLoadingFunction) parentChunkLoadingFunction(data);
/******/ for(;i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
/******/ installedChunks[chunkId][0]();
/******/ }
/******/ installedChunks[chunkId] = 0;
/******/ }
/******/ return __webpack_require__.O(result);
/******/ }
/******/
/******/ var chunkLoadingGlobal = self["webpackChunkaircox_assets"] = self["webpackChunkaircox_assets"] || [];
/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
/******/ }();
/******/
/************************************************************************/
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module depends on other loaded chunks and execution need to be delayed
/******/ var __webpack_exports__ = __webpack_require__.O(undefined, ["chunk-vendors","chunk-common"], function() { return __webpack_require__("./src/core.js"); })
/******/ __webpack_exports__ = __webpack_require__.O(__webpack_exports__);
/******/
/******/ })()
;

View File

@ -1,7 +1,85 @@
{% comment %}Inline block to edit playlists{% endcomment %} {% comment %}Inline block to edit playlists{% endcomment %}
{% load static i18n %} {% load aircox aircox_admin static i18n %}
{% with inline_admin_formset.formset.instance as playlist %} {% with inline_admin_formset as admin_formset %}
{% include "adminsortable2/edit_inline/tabular-django-4.1.html" %} {% with admin_formset.formset as formset %}
<div id="inline-tracks" class="box mb-5">
{{ admin_formset.non_form_errors }}
<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>
</template>
<template v-slot:top="{items}">
<input type="hidden" name="{{ formset.prefix }}-TOTAL_FORMS"
:value="items.length || 0"/>
<input type="hidden" name="{{ formset.prefix }}-INITIAL_FORMS"
value="{{ formset.initial_form_count }}"/>
<input type="hidden" name="{{ formset.prefix }}-MIN_NUM_FORMS"
value="{{ formset.min_num }}"/>
<input type="hidden" name="{{ formset.prefix }}-MAX_NUM_FORMS"
value="{{ formset.max_num }}"/>
</template>
<template #rows-header-head>
<th style="max-width:2em" title="{% trans "Track Position" %}"
aria-description="{% trans "Track Position" %}">
<span class="icon">
<i class="fa fa-arrow-down-1-9"></i>
</span>
</th>
</template>
<template v-slot:row-head="{item,row}">
<td>
[[ row+1 ]]
<input type="hidden"
:name="'{{ formset.prefix }}-' + row + '-position'"
:value="row"/>
<input t-if="item.data.id" type="hidden"
:name="'{{ formset.prefix }}-' + row + '-id'"
:value="item.data.id || item.id"/>
{% for field in admin_formset.fields %}
{% if field.name != 'position' and field.widget.is_hidden %}
<input type="hidden"
:name="'{{ formset.prefix }}-' + row + '-{{ field.name }}'"
v-model="item.data[attr]"/>
{% endif %}
{% endfor %}
</td>
</template>
{% for field in admin_formset.fields %}
{% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:row-{{ field.name }}="{item,cell,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 }}-' + cell.row + '-{{ field.name }}'"
v-model="item.data[attr]"
title="{{ field.help }}"
@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>
</div>
</template>
{% endif %}
{% endfor %}
</a-playlist-editor>
</div>
{% endwith %}
{% endwith %} {% endwith %}

View File

@ -18,10 +18,10 @@
<table class="table is-hoverable is-fullwidth"> <table class="table is-hoverable is-fullwidth">
<thead> <thead>
<tr> <tr>
<th>{% translate "time" %}</th> <th>{% translate "Time" %}</th>
<th>{% translate "episode" %}</th> <th>{% translate "Episode" %}</th>
<th>{% translate "track" %}</th> <th>{% translate "Track" %}</th>
<th>{% translate "tags" %}</th> <th>{% translate "Tags" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -44,6 +44,9 @@
{% if not init_app %} {% if not init_app %}
initBuilder: false, initBuilder: false,
{% endif %} {% endif %}
{% if init_el %}
el: "{{ init_el }}",
{% endif %}
}) })
{% endblock %} {% endblock %}
}) })

View File

@ -5,12 +5,13 @@ Base template used to display a Page
Context: Context:
- page: page - page: page
- parent: parent page
{% endcomment %} {% endcomment %}
{% block header_crumbs %} {% block header_crumbs %}
{{ block.super }} {{ block.super }}
{% if page.category %} {% if page.category %}
/ {{ page.category.title }} {% if parent %} / {% endif %} {{ page.category.title }}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -9,8 +9,9 @@ The audio player
role="{% translate "player" %}" role="{% translate "player" %}"
aria-description="{% translate "Audio player used to listen to the radio and podcasts" %}"> aria-description="{% translate "Audio player used to listen to the radio and podcasts" %}">
<noscript> <noscript>
<audio src="{{ audio_streams.0 }}" controls> <audio src="{% if request.station.streams %}{{ request.station.streams.0 }}{% endif %}"
{% for stream in audio_streams %} controls>
{% for stream in request.station.streams %}
<source src="{{ stream }}" /> <source src="{{ stream }}" />
{% endfor %} {% endfor %}
</audio> </audio>
@ -18,7 +19,7 @@ The audio player
</noscript> </noscript>
<a-player ref="player" <a-player ref="player"
:live-args="{url: '{% url "api:live" %}', timeout:10, src: {{ audio_streams|json }} || []}" :live-args="{% player_live_attr %}"
button-title="{% translate "Play or pause audio" %}"> button-title="{% translate "Play or pause audio" %}">
<template v-slot:content="{ loaded, live, current }"> <template v-slot:content="{ loaded, live, current }">
<h4 v-if="loaded" class="title is-4"> <h4 v-if="loaded" class="title is-4">

View File

@ -4,9 +4,8 @@ import json
from django import template from django import template
from django.contrib.admin.templatetags.admin_urls import admin_urlname from django.contrib.admin.templatetags.admin_urls import admin_urlname
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
from aircox.models import Page, Diffusion, Log from aircox.models import Diffusion, Log
random.seed() random.seed()
register = template.Library() register = template.Library()
@ -17,7 +16,8 @@ def do_admin_url(obj, arg, pass_id=True):
""" Reverse admin url for object """ """ Reverse admin url for object """
name = admin_urlname(obj._meta, arg) name = admin_urlname(obj._meta, arg)
return reverse(name, args=(obj.id,)) if pass_id else reverse(name) return reverse(name, args=(obj.id,)) if pass_id else reverse(name)
@register.filter(name='get_tracks') @register.filter(name='get_tracks')
def do_get_tracks(obj): def do_get_tracks(obj):
""" Get a list of track for the provided log, diffusion, or episode """ """ Get a list of track for the provided log, diffusion, or episode """
@ -28,6 +28,7 @@ def do_get_tracks(obj):
obj = obj.episode obj = obj.episode
return obj.track_set.all() return obj.track_set.all()
@register.simple_tag(name='has_perm', takes_context=True) @register.simple_tag(name='has_perm', takes_context=True)
def do_has_perm(context, obj, perm, user=None): def do_has_perm(context, obj, perm, user=None):
""" Return True if ``user.has_perm('[APP].[perm]_[MODEL]')`` """ """ Return True if ``user.has_perm('[APP].[perm]_[MODEL]')`` """
@ -36,18 +37,32 @@ def do_has_perm(context, obj, perm, user=None):
return user.has_perm('{}.{}_{}'.format( return user.has_perm('{}.{}_{}'.format(
obj._meta.app_label, perm, obj._meta.model_name)) obj._meta.app_label, perm, obj._meta.model_name))
@register.filter(name='is_diffusion') @register.filter(name='is_diffusion')
def do_is_diffusion(obj): def do_is_diffusion(obj):
""" Return True if object is a Diffusion. """ """ Return True if object is a Diffusion. """
return isinstance(obj, Diffusion) return isinstance(obj, Diffusion)
@register.filter(name='json') @register.filter(name='json')
def do_json(obj,fields=""): def do_json(obj, fields=""):
""" Return object as json """ """ Return object as json """
if fields: if fields:
obj = { k: getattr(obj,k,None) for k in ','.split(fields) } obj = {k: getattr(obj, k, None)
for k in ','.split(fields)}
return json.dumps(obj) return json.dumps(obj)
@register.simple_tag(name='player_live_attr', takes_context=True)
def do_player_live_attr(context):
""" Player 'live-args' attribute value """
station = getattr(context['request'], 'station', None)
return json.dumps({
'url': reverse('api:live'),
'src': station and station.audio_streams.split('\n')
})
@register.simple_tag(name='nav_items', takes_context=True) @register.simple_tag(name='nav_items', takes_context=True)
def do_nav_items(context, menu, **kwargs): def do_nav_items(context, menu, **kwargs):
""" Render navigation items for the provided menu name. """ """ Render navigation items for the provided menu name. """
@ -55,6 +70,7 @@ def do_nav_items(context, menu, **kwargs):
return [(item, item.render(request, **kwargs)) return [(item, item.render(request, **kwargs))
for item in station.navitem_set.filter(menu=menu)] for item in station.navitem_set.filter(menu=menu)]
@register.simple_tag(name='update_query') @register.simple_tag(name='update_query')
def do_update_query(obj, **kwargs): def do_update_query(obj, **kwargs):
""" Replace provided querydict's values with **kwargs. """ """ Replace provided querydict's values with **kwargs. """
@ -65,6 +81,7 @@ def do_update_query(obj, **kwargs):
obj.pop(k) obj.pop(k)
return obj return obj
@register.filter(name='verbose_name') @register.filter(name='verbose_name')
def do_verbose_name(obj, plural=False): def do_verbose_name(obj, plural=False):
""" """
@ -72,6 +89,5 @@ def do_verbose_name(obj, plural=False):
string (can act for default values). string (can act for default values).
""" """
return obj if isinstance(obj, str) else \ return obj if isinstance(obj, str) else \
obj._meta.verbose_name_plural if plural else \ obj._meta.verbose_name_plural if plural else \
obj._meta.verbose_name obj._meta.verbose_name

View File

@ -1,9 +1,63 @@
import json
from django import template from django import template
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from aircox.serializers.admin import UserSettingsSerializer
__all__ = ('register', 'do_get_admin_tools', 'do_track_inline_data')
register = template.Library() register = template.Library()
@register.simple_tag(name='get_admin_tools') @register.simple_tag(name='get_admin_tools')
def do_get_admin_tools(): def do_get_admin_tools():
return admin.site.get_tools() return admin.site.get_tools()
@register.simple_tag(name='track_inline_data', takes_context=True)
def do_track_inline_data(context, formset):
"""
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()
for name in form.fields.keys()}
item['__errors__'] = form.errors
# hack for playlist editor
tags = item.get('tags')
if tags and not isinstance(tags, str):
item['tags'] = ', '.join(tag.name for tag in tags)
items.append(item)
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 source
track_inline_labels_ = {
'artist': _('Artist'), 'album': _('Album'), 'title': _('Title'),
'tags': _('Tags'), 'year': _('Year'),
'save_settings': _('Save Settings'),
'discard_changes': _('Discard changes'),
'columns': _('Columns'),
'add_track': _('Add a track'),
'remove_track': _('Remove'),
'timestamp': _('Timestamp'),
}
@register.simple_tag(name='track_inline_labels')
def do_track_inline_labels():
""" Return labels for columns in playlist editor as dict """
return json.dumps({k: str(v) for k, v in track_inline_labels_.items()})

View File

@ -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

View File

@ -1,14 +1,10 @@
from django.http import Http404
from django.views.generic import DetailView, ListView
from django.views.generic.base import TemplateResponseMixin, ContextMixin from django.views.generic.base import TemplateResponseMixin, ContextMixin
from django.urls import reverse from django.urls import reverse
from ..models import Page from ..models import Page
from ..utils import Redirect
__all__ = ['BaseView'] __all__ = ('BaseView', 'BaseAPIView')
class BaseView(TemplateResponseMixin, ContextMixin): class BaseView(TemplateResponseMixin, ContextMixin):
@ -55,13 +51,14 @@ class BaseView(TemplateResponseMixin, ContextMixin):
kwargs['audio_streams'] = streams kwargs['audio_streams'] = streams
if 'model' not in kwargs: if 'model' not in kwargs:
model = getattr(self, 'model', None) or hasattr(self, 'object') and \ model = getattr(self, 'model', None) or \
type(self.object) hasattr(self, 'object') and type(self.object)
kwargs['model'] = model kwargs['model'] = model
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):

View File

@ -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)

View File

@ -24,8 +24,8 @@ from django.utils import timezone as tz
from aircox.models import Station, Episode, Diffusion, Track, Sound, Log from aircox.models import Station, Episode, Diffusion, Track, Sound, Log
from aircox.utils import date_range from aircox.utils import date_range
from aircox_streamer.controllers import Streamer
from aircox_streamer.controllers import Streamer
# force using UTC # force using UTC
tz.activate(pytz.UTC) tz.activate(pytz.UTC)

View File

@ -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 ]]

View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.0.0", "@fortawesome/fontawesome-free": "^6.0.0",
"core-js": "^3.8.3", "core-js": "^3.8.3",
"lodash": "^4.17.21",
"vue": "^3.2.13" "vue": "^3.2.13"
}, },
"devDependencies": { "devDependencies": {

View File

@ -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;

View File

@ -11,6 +11,7 @@ $menu-item-active-background-color: #d2d2d2;
//-- helpers/modifiers //-- helpers/modifiers
.is-fullwidth { width: 100%; } .is-fullwidth { width: 100%; }
.is-fullheight { height: 100%; }
.is-fixed-bottom { .is-fixed-bottom {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
@ -40,6 +41,19 @@ $menu-item-active-background-color: #d2d2d2;
.overflow-hidden.is-fullwidth { max-width: 100%; } .overflow-hidden.is-fullwidth { max-width: 100%; }
*[draggable="true"] {
cursor: move;
}
//-- forms
input.half-field:not(:active):not(:hover) {
border: none;
background-color: rgba(0,0,0,0);
cursor: pointer;
}
//-- animations
@keyframes blink { @keyframes blink {
from { opacity: 1; } from { opacity: 1; }
to { opacity: 0.4; } to { opacity: 0.4; }
@ -215,6 +229,10 @@ a.navbar-item.is-active {
.player-bar { .player-bar {
border-top: 1px $grey-light solid; border-top: 1px $grey-light solid;
> div {
height: 3.75em !important;
}
> .media-left:not(:last-child) { > .media-left:not(:last-child) {
margin-right: 0em; margin-right: 0em;
} }
@ -235,7 +253,8 @@ a.navbar-item.is-active {
.button { .button {
font-size: 1.5rem !important; font-size: 1.5rem !important;
height: 2.5em; height: 100%;
padding: auto 0.2em !important;
min-width: 2.5em; min-width: 2.5em;
border-radius: 0px; border-radius: 0px;
transition: background-color 1s; transition: background-color 1s;

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> <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,39 @@ 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 +157,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 +171,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 +200,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)
})
} }
} }

View File

@ -1,19 +1,22 @@
<template> <template>
<div> <div>
<!-- FIXME: header and footer should be inside list tags -->
<slot name="header"></slot> <slot name="header"></slot>
<ul :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">
<li :class="itemClass" @click="select(index)"> <component :is="itemTag" :class="itemClass" @click="select(index)"
:draggable="orderable" :data-index="index"
@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>
</li> </component>
</template> </template>
</ul> </component>
<slot name="footer"></slot> <slot name="footer"></slot>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
emits: ['select', 'unselect'], emits: ['select', 'unselect', 'move'],
data() { data() {
return { return {
selectedIndex: this.defaultIndex, selectedIndex: this.defaultIndex,
@ -25,6 +28,9 @@ export default {
itemClass: String, itemClass: String,
defaultIndex: { type: Number, default: -1}, defaultIndex: { type: Number, default: -1},
set: Object, set: Object,
orderable: { type: Boolean, default: false },
itemTag: { default: 'li' },
listTag: { default: 'ul' },
}, },
computed: { computed: {
@ -61,6 +67,34 @@ export default {
this.$emit('unselect', { item: this.selected, index: this.selectedIndex}); this.$emit('unselect', { item: this.selected, index: this.selectedIndex});
this.selectedIndex = -1; this.selectedIndex = -1;
}, },
onDragStart(ev) {
const dataset = ev.target.dataset;
const data = `row:${dataset.index}`
ev.dataTransfer.setData("text/cell", data)
ev.dataTransfer.dropEffect = 'move'
},
onDragOver(ev) {
ev.preventDefault()
ev.dataTransfer.dropEffect = 'move'
},
onDrop(ev) {
const data = ev.dataTransfer.getData("text/cell")
if(!data || !data.startsWith('row:'))
return
ev.preventDefault()
const from = Number(data.slice(4))
const target = ev.target.tagName == this.itemTag ? ev.target
: ev.target.closest(this.itemTag)
this.$emit('move', {
from, target,
to: Number(target.dataset.index),
set: this.set,
})
},
}, },
} }
</script> </script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="player"> <div class="player">
<div :class="['player-panels', panel ? 'is-open' : '']"> <div :class="['player-panels', panel ? 'is-open' : '']">
<APlaylist ref="pin" class="player-panel menu" v-show="panel == 'pin'" <APlaylist ref="pin" class="player-panel menu" v-show="panel == 'pin' && sets.pin.length"
name="Pinned" name="Pinned"
:actions="['page']" :actions="['page']"
:editable="true" :player="self" :set="sets.pin" @select="togglePlay('pin', $event.index)" :editable="true" :player="self" :set="sets.pin" @select="togglePlay('pin', $event.index)"
@ -13,7 +13,7 @@
</p> </p>
</template> </template>
</APlaylist> </APlaylist>
<APlaylist ref="queue" class="player-panel menu" v-show="panel == 'queue'" <APlaylist ref="queue" class="player-panel menu" v-show="panel == 'queue' && sets.queue.length"
:actions="['page']" :actions="['page']"
:editable="true" :player="self" :set="sets.queue" @select="togglePlay('queue', $event.index)" :editable="true" :player="self" :set="sets.queue" @select="togglePlay('queue', $event.index)"
listClass="menu-list" itemClass="menu-item"> listClass="menu-list" itemClass="menu-item">
@ -51,14 +51,14 @@
<span>Live</span> <span>Live</span>
</button> </button>
<button ref="pinPlaylistButton" :class="playlistButtonClass('pin')" <button ref="pinPlaylistButton" :class="playlistButtonClass('pin')"
@click="togglePanel('pin')"> @click="togglePanel('pin')" v-show="sets.pin.length">
<span class="mr-2 is-size-6" v-if="sets.pin.length"> <span class="is-size-6" v-if="sets.pin.length">
{{ sets.pin.length }}</span> {{ sets.pin.length }}</span>
<span class="icon"><span class="fa fa-thumbtack"></span></span> <span class="icon"><span class="fa fa-thumbtack"></span></span>
</button> </button>
<button :class="playlistButtonClass('queue')" <button :class="playlistButtonClass('queue')"
@click="togglePanel('queue')"> @click="togglePanel('queue')" v-show="sets.queue.length">
<span class="mr-2 is-size-6" v-if="sets.queue.length"> <span class="is-size-6" v-if="sets.queue.length">
{{ sets.queue.length }}</span> {{ sets.queue.length }}</span>
<span class="icon"><span class="fa fa-list"></span></span> <span class="icon"><span class="fa fa-list"></span></span>
</button> </button>
@ -186,13 +186,11 @@ export default {
if(!item) if(!item)
throw `No sound at index ${index} for playlist ${playlist}`; throw `No sound at index ${index} for playlist ${playlist}`;
this.loaded = item this.loaded = item
this.current = item
src = item.src; src = item.src;
} }
// from live // from live
else { else {
this.loaded = null; this.loaded = null;
this.current = this.live.current
src = this.live.src; src = this.live.src;
} }

View File

@ -0,0 +1,329 @@
<template>
<div class="playlist-editor">
<div class="columns">
<div class="column">
<slot name="title" />
</div>
<div class="column has-text-right">
<div class="float-right field has-addons">
<p class="control">
<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>
<span>Texte</span>
</a>
</p>
<p class="control">
<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>
<span>Liste</span>
</a>
</p>
</div>
</div>
</div>
<slot name="top" :set="set" :columns="columns" :items="items"/>
<section class="page" v-show="page == Page.Text">
<textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
@change="updateList"
/>
</section>
<section class="page" v-show="page == Page.List">
<a-rows :set="set" :columns="columns" :labels="labels"
:allow-create="true"
:orderable="true" @move="listItemMove" @colmove="columnMove"
@cell="onCellEvent">
<template v-for="[name,slot] of rowsSlots" :key="slot"
v-slot:[slot]="data">
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
</template>
<template v-slot:row-tail="data">
<slot v-if="$slots['row-tail']" :name="row-tail" v-bind="data"/>
<td>
<a class="button is-danger is-outlined p-3 is-size-6"
@click="items.splice(data.row,1)"
:title="labels.remove_track"
:aria-label="labels.remove_track">
<span class="icon"><i class="fa fa-trash" /></span>
</a>
</td>
</template>
</a-rows>
</section>
<div class="mt-2">
<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>
<a class="button is-primary p-2 ml-2" t-if="page == page.List"
@click="this.set.push(new this.set.model())">
<span class="icon"><i class="fa fa-plus"/></span>
<span>{{ labels.add_track }}</span>
</a>
</div>
<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-3">
<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>
<slot name="bottom" :set="set" :columns="columns" :items="items"/>
</div>
</template>
<script>
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'
/// Page display
export const Page = {
Text: 0, List: 1, Settings: 2,
}
export default {
components: { AActionButton, ARow, ARows },
props: {
initData: Object,
dataPrefix: String,
labels: Object,
settingsUrl: String,
defaultColumns: {
type: Array,
default: () => ['artist', 'title', 'tags', 'album', 'year', 'timestamp']},
},
data() {
const settings = {
playlist_editor_columns: this.defaultColumns,
playlist_editor_sep: ' -- ',
}
return {
Page: Page,
page: Page.Text,
set: new Set(Track),
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
},
rowsSlots() {
return Object.keys(this.$slots)
.filter(x => x.startsWith('row-') || x.startsWith('rows-'))
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
},
},
methods: {
onCellEvent(event) {
switch(event.name) {
case 'change': this.updateInput();
break;
}
},
formatMove({from, to}) {
const value = this.columns[from]
this.settings.playlist_editor_columns.splice(from, 1)
this.settings.playlist_editor_columns.splice(to, 0, value)
if(this.page == Page.Text)
this.updateList()
else
this.updateInput()
},
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}) {
set.move(from, to);
this.updateInput()
},
updateList() {
const items = this.toList(this.$refs.textarea.value)
this.set.reset(items)
},
updateInput() {
const input = this.toText(this.items)
this.$refs.textarea.value = input
},
/**
* From input and separator, return list of items.
*/
toList(input) {
var lines = input.split('\n')
var items = []
for(let line of lines) {
line = line.trimLeft()
if(!line)
continue
var lineBits = line.split(this.separator)
var item = {}
for(var col in this.columns) {
if(col >= lineBits.length)
break
const attr = this.columns[col]
item[attr] = lineBits[col].trim()
}
item && items.push(item)
}
return items
},
/**
* From items and separator return a string
*/
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 || !('' + x).trim())
line = line.join(sep).trimRight()
lines.push(line)
}
return lines.join('\n')
},
_data_key(key) {
key = key.slice(this.dataPrefix.length)
try {
var [index, attr] = key.split('-', 1)
return [Number(index), attr]
}
catch(err) {
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=[], settings=null}, reset=false) {
if(reset) {
this.set.items = []
}
for(var index in items)
this.set.push(cloneDeep(items[index]))
if(settings)
this.settingsSaved(settings)
this.updateInput()
},
},
watch: {
initData(val) {
this.loadData(val)
},
},
mounted() {
this.initData && this.loadData(this.initData)
this.page = this.items.length ? Page.List : Page.Text
},
}
</script>

View File

@ -0,0 +1,146 @@
<template>
<tr>
<slot name="head" :item="item" :row="row"/>
<template v-for="(attr,col) in columns" :key="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]"
:data="itemData" :attr="attr" :emit="cellEmit"
:value="itemData && itemData[attr]">
{{ itemData && itemData[attr] }}
</slot>
<slot name="cell" :item="item" :cell="cells[col]"
:data="itemData" :attr="attr" :emit="cellEmit"
:value="itemData && itemData[attr]"/>
</component>
<slot name="cell-after" :item="item" :col="col" :cell="cells[col]"
:attr="attr"/>
</template>
<slot name="tail" :item="item" :row="row"/>
</tr>
</template>
<script>
import {isReactive, toRefs} from 'vue'
import Model from '../model'
export default {
emit: ['move', 'cell'],
props: {
//! Item to display in row
item: Object,
//! Columns to display, as items' attributes
columns: Array,
//! Default cell's info
cell: {type: Object, default() { return {row: 0}}},
//! Cell component tag
cellTag: {type: String, default: 'td'},
//! If true, can reorder cell by drag & drop
orderable: {type: Boolean, default: false},
},
computed: {
/**
* Row index
*/
row() { return this.cell && this.cell.row || 0 },
/**
* Item's data if model instance, otherwise item
*/
itemData() {
return this.item instanceof Model ? this.item.data : this.item;
},
/**
* Computed cell infos
*/
cells() {
const cell = isReactive(this.cell) && toRefs(this.cell) || this.cell || {}
const cells = []
for(var col in this.columns)
cells.push({...cell, col: Number(col)})
return cells
},
},
methods: {
/**
* Emit a 'cell' event.
* Event data: `{name, cell, data, item}`
* @param {Number} col: cell column's index
* @param {String} name: cell's event name
* @param {} data: cell's event data
*/
cellEmit(name, cell, data) {
this.$emit('cell', {
name, cell, data,
item: this.item,
})
},
onDragStart(ev) {
const dataset = ev.target.dataset;
const data = `cell:${dataset.col}`
ev.dataTransfer.setData("text/cell", data)
ev.dataTransfer.dropEffect = 'move'
},
onDragOver(ev) {
ev.preventDefault()
ev.dataTransfer.dropEffect = 'move'
},
/**
* Handle drop event, emit `'move': { from, to }`.
*/
onDrop(ev) {
const data = ev.dataTransfer.getData("text/cell")
if(!data || !data.startsWith('cell:'))
return
ev.preventDefault()
this.$emit('move', {
from: Number(data.slice(5)),
to: Number(ev.target.dataset.col),
})
},
/**
* Return DOM node for cells at provided position `col`
*/
getCellEl(col) {
const els = this.$el.querySelectorAll(this.cellTag)
for(var el of els)
if(col == Number(el.dataset.col))
return el;
return null
},
/**
* Focus cell's form input. If from is provided, related focus
*/
focus(col, from) {
if(from)
col += from.col
const target = this.getCellEl(col)
if(!target)
return
const control = target.querySelector('input:not([type="hidden"])') ||
target.querySelector('button') ||
target.querySelector('select') ||
target.querySelector('a');
control && control.focus()
}
},
mounted() {
this.$el.__row = this
},
}
</script>

View File

@ -0,0 +1,118 @@
<template>
<table class="table is-stripped is-fullwidth">
<thead>
<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="columns" :data-index="row"
:data-row="row"
:draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
@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>
<slot :name="name" v-bind="data"/>
</div>
</template>
</template>
</a-row>
</template>
<slot name="tail"/>
</tbody>
</table>
</template>
<script>
import AList from './AList.vue'
import ARow from './ARow.vue'
const Component = {
extends: AList,
components: { ARow },
emit: ['cell', 'colmove'],
props: {
...AList.props,
columns: Array,
labels: Object,
allowCreate: Boolean,
},
data() {
return {
...super.data,
extraItem: new this.set.model(),
}
},
computed: {
rowCells() {
const cells = []
for(var row in this.items)
cells.push({row})
},
rowSlots() {
return Object.keys(this.$slots).filter(x => x.startsWith('row-'))
.map(x => [x, x.slice(4)])
},
},
methods: {
/**
* React on 'cell' event, re-emitting it with additional values:
* - `set`: data set
* - `row`: row index
*
* @param {Number} row: row index
* @param {} data: cell's event data
*/
onCellEvent(row, event) {
if(event.name == 'focus')
this.focus(event.data, event.cell)
this.$emit('cell', {
...event, row,
set: this.set
})
},
/**
* Return row component at provided index
*/
getRow(row) {
const els = this.$el.querySelectorAll('tr')
for(var el of els)
if(el.__row && row == Number(el.dataset.row))
return el.__row
},
/**
* Focus on a cell
*/
focus(row, col, from=null) {
if(from)
row += from.row
row = this.getRow(row)
row && row.focus(col, from)
},
},
}
Component.props.itemTag.default = 'tr'
Component.props.listTag.default = 'tbody'
export default Component
</script>

View File

@ -4,6 +4,7 @@ import AList from './AList.vue'
import APage from './APage.vue' import APage from './APage.vue'
import APlayer from './APlayer.vue' import APlayer from './APlayer.vue'
import APlaylist from './APlaylist.vue' import APlaylist from './APlaylist.vue'
import APlaylistEditor from './APlaylistEditor.vue'
import AProgress from './AProgress.vue' import AProgress from './AProgress.vue'
import ASoundItem from './ASoundItem.vue' import ASoundItem from './ASoundItem.vue'
import AStatistics from './AStatistics.vue' import AStatistics from './AStatistics.vue'
@ -12,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 = {
AStatistics, AStreamer, ...base,
AStatistics, AStreamer, APlaylistEditor
} }

View File

@ -32,7 +32,7 @@ window.aircox = {
* Initialize main application and player. * Initialize main application and player.
*/ */
init(props=null, {config=null, builder=null, initBuilder=true, init(props=null, {config=null, builder=null, initBuilder=true,
initPlayer=true, hotReload=false}={}) initPlayer=true, hotReload=false, el=null}={})
{ {
if(initPlayer) { if(initPlayer) {
let playerBuilder = this.playerBuilder let playerBuilder = this.playerBuilder
@ -44,6 +44,9 @@ window.aircox = {
this.builder = builder this.builder = builder
if(config || window.App) if(config || window.App)
builder.config = config || window.App builder.config = config || window.App
if(el)
builder.config.el = el
builder.title = document.title builder.title = document.title
builder.mount({props}) builder.mount({props})

View File

@ -35,17 +35,21 @@ export default class Model {
* Instanciate model with provided data and options. * Instanciate model with provided data and options.
* By default `url` is taken from `data.url_`. * By default `url` is taken from `data.url_`.
*/ */
constructor(data, {url=null, ...options}={}) { constructor(data={}, {url=null, ...options}={}) {
this.url = url || data.url_; this.url = url || data.url_;
this.options = options; this.options = options;
this.commit(data); this.commit(data);
} }
get errors() {
return this.data && this.data.__errors__
}
/** /**
* Get instance id from its data * Get instance id from its data
*/ */
static getId(data) { static getId(data) {
return data.id; return 'id' in data ? data.id : data.pk;
} }
/** /**
@ -112,8 +116,16 @@ export default class Model {
* Update instance's data with provided data. Return None * Update instance's data with provided data. Return None
*/ */
commit(data) { commit(data) {
this.id = this.constructor.getId(data);
this.data = data; this.data = data;
this.id = this.constructor.getId(this.data);
}
/**
* Update model data, without reset previous value
*/
update(data) {
this.data = {...this.data, ...data}
this.id = this.constructor.getId(this.data)
} }
/** /**
@ -130,9 +142,25 @@ export default class Model {
let item = window.localStorage.getItem(key); let item = window.localStorage.getItem(key);
return item === null ? item : new this(JSON.parse(item)); return item === null ? item : new this(JSON.parse(item));
} }
/**
* Return true if model instance has no data
*/
get isEmpty() {
return !this.data || Object.keys(this.data).findIndex(k => !!this.data[k] && this.data[k] !== 0) == -1
}
/**
* Return error for a specific attribute name if any
*/
error(attr=null) {
return attr === null ? this.errors : this.errors && this.errors[attr]
}
} }
/** /**
* List of models * List of models
*/ */
@ -231,6 +259,25 @@ export class Set {
this.items.splice(index,1); this.items.splice(index,1);
save && this.save(); save && this.save();
} }
/**
* Clear items, assign new ones
*/
reset(items=[]) {
// TODO: check reactivity
this.items = []
for(var item of items)
this.push(item)
}
move(from, to) {
if(from >= this.length || to > this.length)
throw "source or target index is not in range"
const value = this.items[from]
this.items.splice(from, 1)
this.items.splice(to, 0, value)
}
} }
Set[Symbol.iterator] = function () { Set[Symbol.iterator] = function () {

7
assets/src/track.js Normal file
View File

@ -0,0 +1,7 @@
import Model from './model'
export default class Track extends Model {
static getId(data) { return data.pk }
}