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

Reviewed-on: #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.forms import ModelForm
from django.utils.translation import gettext as _
@ -60,11 +58,21 @@ class EpisodeAdminForm(ModelForm):
class EpisodeAdmin(SortableAdminBase, PageAdmin):
form = EpisodeAdminForm
list_display = PageAdmin.list_display
list_filter = tuple(f for f in PageAdmin.list_filter if f != 'pub_date') + \
('diffusion__start', 'pub_date')
list_filter = tuple(f for f in PageAdmin.list_filter
if f != 'pub_date') + ('diffusion__start', 'pub_date')
search_fields = PageAdmin.search_fields + ('parent__title',)
# readonly_fields = ('parent',)
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
class TrackInline(SortableInlineAdminMixin, admin.TabularInline):
class TrackInline(admin.TabularInline):
template = 'admin/aircox/playlist_inline.html'
model = Track
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):
fields = TrackInline.fields + ('timestamp',)
@ -24,14 +25,15 @@ class SoundTrackInline(TrackInline):
class SoundInline(admin.TabularInline):
model = Sound
fields = ['type', 'name', 'audio', 'duration', 'is_good_quality', 'is_public',
'is_downloadable']
fields = ['type', 'name', 'audio', 'duration', 'is_good_quality',
'is_public', 'is_downloadable']
readonly_fields = ['type', 'audio', 'duration', 'is_good_quality']
extra = 0
max_num = 0
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')
def get_queryset(self, request):
@ -63,23 +65,40 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
related.short_description = _('Program / Episode')
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 ''
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)
class TrackAdmin(admin.ModelAdmin):
def tag_list(self, obj):
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_filter = ['artist', 'title', 'tags']
search_fields = ['artist', 'title']
fieldsets = [
(_('Playlist'), {'fields': ['episode', 'sound', 'position', 'timestamp']}),
(_('Playlist'), {'fields': ['episode', 'sound', 'position',
'timestamp']}),
(_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}),
]
@ -92,8 +111,6 @@ class TrackAdmin(admin.ModelAdmin):
h = math.floor(ts / 3600)
m = math.floor((ts - h) / 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')

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Aircox 0.1\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"
"Last-Translator: Aarys\n"
"Language-Team: Aircox's translators team\n"
@ -18,13 +18,13 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\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
msgid "start"
msgstr "début"
#: aircox/admin/episode.py:33 aircox/models/episode.py:184
#: aircox/models/program.py:473
#: aircox/admin/episode.py:31 aircox/models/episode.py:184
#: aircox/models/program.py:469
msgid "end"
msgstr "fin"
@ -52,7 +52,7 @@ msgstr "Tout"
msgid "Publication Settings"
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"
msgstr "Horaire"
@ -60,7 +60,7 @@ msgstr "Horaire"
msgid "Program Settings"
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"
msgstr "Émission"
@ -68,23 +68,25 @@ msgstr "Émission"
msgid "Day"
msgstr "Jour"
#: aircox/admin/sound.py:35 aircox/admin/sound.py:67
#: aircox/admin/sound.py:37 aircox/admin/sound.py:71
msgid "Audio"
msgstr "Audio"
#: aircox/admin/sound.py:63
#: aircox/admin/sound.py:65
msgid "Program / Episode"
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"
msgstr "Playlist"
#: aircox/admin/sound.py:82
#: aircox/admin/sound.py:102
msgid "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"
msgstr "temps"
@ -92,8 +94,8 @@ msgstr "temps"
msgid "Statistics"
msgstr "Statistiques"
#: aircox/filters.py:8 aircox/templates/admin/base.html:81
#: aircox/templates/admin/base.html:95 aircox/templates/admin/base.html:109
#: aircox/filters.py:8 aircox/templates/admin/base.html:84
#: aircox/templates/admin/base.html:98 aircox/templates/admin/base.html:112
#: aircox/templates/aircox/base.html:78
#: aircox/templates/aircox/page_list.html:15
msgid "Search"
@ -103,24 +105,24 @@ msgstr "Chercher"
msgid "Podcast"
msgstr "Podcast"
#: aircox/management/commands/sounds_monitor.py:204
#: aircox/management/commands/sounds_monitor.py:205
msgid "unknown"
msgstr "inconnu"
#: aircox/models/article.py:14
#: aircox/models/article.py:16
msgid "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
msgid "Articles"
msgstr "Articles"
#: aircox/models/episode.py:52
#: aircox/models/episode.py:52 aircox/templates/admin/aircox/statistics.html:23
msgid "Episode"
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"
msgstr "Épisodes"
@ -136,8 +138,8 @@ msgstr "non confirmé"
msgid "cancelled"
msgstr "annulé"
#: aircox/models/episode.py:174 aircox/models/sound.py:102
#: aircox/models/sound.py:233 aircox/templates/admin/aircox/statistics.html:23
#: aircox/models/episode.py:174 aircox/models/sound.py:99
#: aircox/models/sound.py:230
msgid "episode"
msgstr "épisode"
@ -146,7 +148,7 @@ msgid "schedule"
msgstr "horaire"
#: 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"
msgstr "type"
@ -171,12 +173,12 @@ msgstr "rediffusion"
msgid "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"
msgstr "autre"
#: 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"
msgstr "station"
@ -184,7 +186,7 @@ msgstr "station"
msgid "related station"
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"
msgstr "date"
@ -200,11 +202,12 @@ msgstr "identifiant de la source relative à ce log"
msgid "comment"
msgstr "commentaire"
#: aircox/models/log.py:107 aircox/models/sound.py:146
#: aircox/models/log.py:107 aircox/models/sound.py:143
msgid "Sound"
msgstr "Son"
#: aircox/models/log.py:112 aircox/models/sound.py:259
#: aircox/templates/admin/aircox/statistics.html:24
msgid "Track"
msgstr "Morceau"
@ -217,7 +220,7 @@ msgid "Logs"
msgstr "Logs"
#: aircox/models/page.py:30 aircox/models/page.py:251
#: aircox/models/sound.py:247
#: aircox/models/sound.py:244
msgid "title"
msgstr "titre"
@ -338,8 +341,8 @@ msgstr "Commentaires"
msgid "menu"
msgstr "menu"
#: aircox/models/page.py:250 aircox/models/sound.py:107
#: aircox/models/sound.py:240
#: aircox/models/page.py:250 aircox/models/sound.py:104
#: aircox/models/sound.py:237
msgid "order"
msgstr "ordre"
@ -359,234 +362,239 @@ msgstr "Élément du menu"
msgid "Menu items"
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
msgid "active"
msgstr "actif"
#: aircox/models/program.py:59
#: aircox/models/program.py:58
msgid "if not checked this program is no longer active"
msgstr "si selectionné, cette émission n'est plus active"
#: aircox/models/program.py:62
#: aircox/models/program.py:61
msgid "syncronise"
msgstr "synchroniser"
#: aircox/models/program.py:64
#: aircox/models/program.py:63
msgid "update later diffusions according to schedule changes"
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"
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"
msgstr "émission apparentée"
#: aircox/models/program.py:182
#: aircox/models/program.py:178
msgid "rerun of"
msgstr "rediffusion de"
#: aircox/models/program.py:226
#: aircox/models/program.py:222
msgid "rerun must happen after 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"
msgstr "date de la première diffusion"
#: aircox/models/program.py:257
#: aircox/templates/admin/aircox/statistics.html:22
#: aircox/models/program.py:253
msgid "time"
msgstr "heure"
#: aircox/models/program.py:257
#: aircox/models/program.py:253
msgid "start time"
msgstr "heure de début"
#: aircox/models/program.py:260
#: aircox/models/program.py:256
msgid "timezone"
msgstr "zone horaire"
#: aircox/models/program.py:263
#: aircox/models/program.py:259
msgid "timezone used for the 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"
msgstr "durée"
#: aircox/models/program.py:267
#: aircox/models/program.py:263
msgid "regular duration"
msgstr "durée normale"
#: aircox/models/program.py:270
#: aircox/models/program.py:266
msgid "frequency"
msgstr "fréquence"
#: aircox/models/program.py:272
#: aircox/models/program.py:268
msgid "ponctual"
msgstr "ponctuel"
#: aircox/models/program.py:273
#: aircox/models/program.py:269
#, python-brace-format
msgid "1st {day} of the month"
msgstr "1er {day} du mois"
#: aircox/models/program.py:274
#: aircox/models/program.py:270
#, python-brace-format
msgid "2nd {day} of the month"
msgstr "2ème {day} du mois"
#: aircox/models/program.py:275
#: aircox/models/program.py:271
#, python-brace-format
msgid "3rd {day} of the month"
msgstr "3ème {day} du mois"
#: aircox/models/program.py:276
#: aircox/models/program.py:272
#, python-brace-format
msgid "4th {day} of the month"
msgstr "4ème {day} du mois"
#: aircox/models/program.py:277
#: aircox/models/program.py:273
#, python-brace-format
msgid "last {day} of the month"
msgstr "dernier {day} du mois"
#: aircox/models/program.py:278
#: aircox/models/program.py:274
#, python-brace-format
msgid "1st and 3rd {day} of the month"
msgstr "1er et 3ème {day} du mois"
#: aircox/models/program.py:279
#: aircox/models/program.py:275
#, python-brace-format
msgid "2nd and 4th {day} of the month"
msgstr "2ème et 4ème {day} du mois"
#: aircox/models/program.py:280
#, fuzzy, python-brace-format
#| msgid "every {day}"
#: aircox/models/program.py:276
msgid "{day}"
msgstr "{day}"
#: aircox/models/program.py:281
#: aircox/models/program.py:277
#, python-brace-format
msgid "one {day} on two"
msgstr "un {day} sur deux"
#: aircox/models/program.py:287
#: aircox/models/program.py:283
msgid "Schedules"
msgstr "Horaires"
#: aircox/models/program.py:464
#: aircox/models/program.py:460
msgid "delay"
msgstr "délai"
#: aircox/models/program.py:465
#: aircox/models/program.py:461
msgid "minimal delay between two sound plays"
msgstr "délai minimum entre deux sons joués"
#: aircox/models/program.py:468
#: aircox/models/program.py:464
msgid "begin"
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"
msgstr ""
"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"
msgstr "archive"
#: aircox/models/sound.py:90
#: aircox/models/sound.py:87
msgid "excerpt"
msgstr "extrait"
#: aircox/models/sound.py:90
#: aircox/models/sound.py:87
msgid "removed"
msgstr "supprimé"
#: aircox/models/sound.py:93 aircox/models/station.py:37
#: aircox/models/sound.py:90 aircox/models/station.py:37
msgid "name"
msgstr "nom"
#: aircox/models/sound.py:96
#: aircox/models/sound.py:93
msgid "program"
msgstr "émission"
#: aircox/models/sound.py:97
#: aircox/models/sound.py:94
msgid "program related to it"
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"
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"
msgstr "fichier"
#: aircox/models/sound.py:122
#: aircox/models/sound.py:119
msgid "duration of the sound"
msgstr "durée du son"
#: aircox/models/sound.py:125
#: aircox/models/sound.py:122
msgid "modification time"
msgstr "dernière modification"
#: aircox/models/sound.py:127
#: aircox/models/sound.py:124
msgid "last modification date and time"
msgstr "date et heure de la dernière modification"
#: aircox/models/sound.py:130
#: aircox/models/sound.py:127
msgid "good quality"
msgstr "bonne qualité"
#: aircox/models/sound.py:130
#: aircox/models/sound.py:127
msgid "sound meets quality requirements"
msgstr "le son rencontre les exigences de qualité"
#: aircox/models/sound.py:134
#: aircox/models/sound.py:131
msgid "public"
msgstr "publique"
#: aircox/models/sound.py:134
#: aircox/models/sound.py:131
msgid "whether it is publicly available as podcast"
msgstr "coché pour rendre le podcast public"
#: aircox/models/sound.py:138
#: aircox/models/sound.py:135
msgid "downloadable"
msgstr "téléchargeable"
#: aircox/models/sound.py:139
#: aircox/models/sound.py:136
msgid ""
"whether it can be publicly downloaded by visitors (sound must be public)"
msgstr ""
"coché pour permettre le téléchargement public (le podcast doit être "
"disponible publiquement)"
#: aircox/models/sound.py:147
#: aircox/models/sound.py:144
msgid "Sounds"
msgstr "Sons"
#: aircox/models/sound.py:237
#: aircox/models/sound.py:234
msgid "sound"
msgstr "son"
#: aircox/models/sound.py:245
#: aircox/models/sound.py:242
msgid "position (in seconds)"
msgstr "position (en secondes)"
#: aircox/models/sound.py:248
#: aircox/models/sound.py:245
msgid "artist"
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"
msgstr "tags"
#: aircox/models/sound.py:248
msgid "year"
msgstr "année"
#: aircox/models/sound.py:251
msgid "information"
msgstr "information"
@ -677,6 +685,18 @@ msgstr ""
"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é"
#: 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
#, python-format
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_list.html:7
#: aircox/templates/admin/base.html:163
#: aircox/templates/admin/base.html:166
#: aircox/templates/admin/change_list.html:30
#: aircox/templates/aircox/base.html:54
msgid "Home"
@ -715,36 +735,46 @@ msgstr "Sauvegarder et continuer"
msgid "Publish"
msgstr "Publier"
#: aircox/templates/admin/aircox/statistics.html:24
msgid "track"
msgstr "piste"
#: aircox/templates/admin/aircox/playlist_inline.html:33
#: aircox/templates/admin/aircox/playlist_inline.html:34
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
msgid "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
msgid "Today"
msgstr "Aujourd'hui"
#: aircox/templates/admin/base.html:121
#: aircox/templates/admin/base.html:124
msgid "Tools"
msgstr "Outils"
#: aircox/templates/admin/base.html:137
#: aircox/templates/admin/base.html:140
msgid "View site"
msgstr "Voir le site"
#: aircox/templates/admin/base.html:142
#: aircox/templates/admin/base.html:145
msgid "Documentation"
msgstr "Documentation"
#: aircox/templates/admin/base.html:146
#: aircox/templates/admin/base.html:149
msgid "Change password"
msgstr "Changer le mot de passe"
#: aircox/templates/admin/base.html:149
#: aircox/templates/admin/base.html:152
msgid "Log out"
msgstr "Se déconnecter"
@ -958,67 +988,108 @@ msgstr "Épisode en ce moment sur les ondes"
msgid "Currently playing"
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/"
msgstr "articles/"
#: aircox/urls.py:43
#: aircox/urls.py:47
msgid "articles/<slug:slug>/"
msgstr "articles/<slug:slug>/"
#: aircox/urls.py:47
#: aircox/urls.py:51
msgid "episodes/"
msgstr "episodes/"
#: aircox/urls.py:49
#: aircox/urls.py:53
msgid "episodes/<slug:slug>/"
msgstr "episodes/<slug:slug>/"
#: aircox/urls.py:51
#: aircox/urls.py:55
msgid "week/"
msgstr "semaine/"
#: aircox/urls.py:53
#: aircox/urls.py:57
msgid "week/<date:date>/"
msgstr "semaine/<date:date>/"
#: aircox/urls.py:56
#: aircox/urls.py:60
msgid "logs/"
msgstr "logs/"
#: aircox/urls.py:57
#: aircox/urls.py:61
msgid "logs/<date:date>/"
msgstr "logs/<date:date>/"
#: aircox/urls.py:60
#: aircox/urls.py:64
msgid "publications/"
msgstr "publications/"
#: aircox/urls.py:63
#: aircox/urls.py:67
msgid "pages/"
msgstr "pages/"
#: aircox/urls.py:69
#: aircox/urls.py:73
msgid "pages/<slug:slug>/"
msgstr "pages/<slug:slug>/"
#: aircox/urls.py:76
#: aircox/urls.py:80
msgid "programs/"
msgstr "emissions/"
#: aircox/urls.py:78
#: aircox/urls.py:82
msgid "programs/<slug:slug>/"
msgstr "emissions/<slug:slug>/"
#: aircox/urls.py:80
#: aircox/urls.py:84
msgid "programs/<slug:parent_slug>/episodes/"
msgstr "emissions/<slug:parent_slug>/episodes/"
#: aircox/urls.py:82
#: aircox/urls.py:86
msgid "programs/<slug:parent_slug>/articles/"
msgstr "emissions/<slug:parent_slug>/articles/"
#: aircox/urls.py:84
#: aircox/urls.py:88
msgid "programs/<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"
msgstr "les commentaires ne sont pas autorisés"
#~ msgid "track"
#~ msgstr "morceau"
#~ msgid "if it can be podcasted from the server"
#~ msgstr "s'il peut être podcasté depuis le serveur"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,14 @@
from enum import IntEnum
import logging
import os
from django.conf import settings as conf
from django.db import models
from django.db.models import Q, Value as V
from django.db.models.functions import Concat
from django.db.models import Q
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager
from aircox import settings
from .program import Program
from .episode import Episode
@ -19,7 +16,7 @@ from .episode import Episode
logger = logging.getLogger('aircox')
__all__ = ['Sound', 'SoundQuerySet', 'Track']
__all__ = ('Sound', 'SoundQuerySet', 'Track')
class SoundQuerySet(models.QuerySet):
@ -246,7 +243,10 @@ class Track(models.Model):
)
title = models.CharField(_('title'), 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(
_('information'),
max_length=128,

View File

@ -2,13 +2,14 @@ import os
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property
from filer.fields.image import FilerImageField
from .. import settings
__all__ = ['Station', 'StationQuerySet', 'Port']
__all__ = ('Station', 'StationQuerySet', 'Port')
class StationQuerySet(models.QuerySet):
@ -74,6 +75,11 @@ class Station(models.Model):
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):
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 .models import Diffusion, Log, Sound
from ..models import Diffusion, Log
__all__ = ['LogInfo', 'LogInfoSerializer']
__all__ = ('LogInfo', 'LogInfoSerializer')
class LogInfo:
@ -51,21 +51,3 @@ class LogInfoSerializer(serializers.Serializer):
info = serializers.CharField(max_length=200, required=False)
url = serializers.URLField(required=False)
cover = serializers.URLField(required=False)
class SoundSerializer(serializers.ModelSerializer):
file = serializers.FileField(use_url=False)
class Meta:
model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type', 'file',
'duration', 'mtime', 'is_good_quality', 'is_public', 'url']
class PodcastSerializer(serializers.ModelSerializer):
# serializers.HyperlinkedIdentityField(view_name='sound', format='html')
class Meta:
model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type',
'duration', 'mtime', 'url', 'is_downloadable']

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 %}
{% load static i18n %}
{% load aircox aircox_admin static i18n %}
{% with inline_admin_formset.formset.instance as playlist %}
{% include "adminsortable2/edit_inline/tabular-django-4.1.html" %}
{% with inline_admin_formset as admin_formset %}
{% 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 %}

View File

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

View File

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

View File

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

View File

@ -9,8 +9,9 @@ The audio player
role="{% translate "player" %}"
aria-description="{% translate "Audio player used to listen to the radio and podcasts" %}">
<noscript>
<audio src="{{ audio_streams.0 }}" controls>
{% for stream in audio_streams %}
<audio src="{% if request.station.streams %}{{ request.station.streams.0 }}{% endif %}"
controls>
{% for stream in request.station.streams %}
<source src="{{ stream }}" />
{% endfor %}
</audio>
@ -18,7 +19,7 @@ The audio player
</noscript>
<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" %}">
<template v-slot:content="{ loaded, live, current }">
<h4 v-if="loaded" class="title is-4">

View File

@ -4,9 +4,8 @@ import json
from django import template
from django.contrib.admin.templatetags.admin_urls import admin_urlname
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()
register = template.Library()
@ -17,7 +16,8 @@ def do_admin_url(obj, arg, pass_id=True):
""" Reverse admin url for object """
name = admin_urlname(obj._meta, arg)
return reverse(name, args=(obj.id,)) if pass_id else reverse(name)
@register.filter(name='get_tracks')
def do_get_tracks(obj):
""" Get a list of track for the provided log, diffusion, or episode """
@ -28,6 +28,7 @@ def do_get_tracks(obj):
obj = obj.episode
return obj.track_set.all()
@register.simple_tag(name='has_perm', takes_context=True)
def do_has_perm(context, obj, perm, user=None):
""" 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(
obj._meta.app_label, perm, obj._meta.model_name))
@register.filter(name='is_diffusion')
def do_is_diffusion(obj):
""" Return True if object is a Diffusion. """
return isinstance(obj, Diffusion)
@register.filter(name='json')
def do_json(obj,fields=""):
def do_json(obj, fields=""):
""" Return object as json """
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)
@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)
def do_nav_items(context, menu, **kwargs):
""" 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))
for item in station.navitem_set.filter(menu=menu)]
@register.simple_tag(name='update_query')
def do_update_query(obj, **kwargs):
""" Replace provided querydict's values with **kwargs. """
@ -65,6 +81,7 @@ def do_update_query(obj, **kwargs):
obj.pop(k)
return obj
@register.filter(name='verbose_name')
def do_verbose_name(obj, plural=False):
"""
@ -72,6 +89,5 @@ def do_verbose_name(obj, plural=False):
string (can act for default values).
"""
return obj if isinstance(obj, str) else \
obj._meta.verbose_name_plural if plural else \
obj._meta.verbose_name
obj._meta.verbose_name_plural if plural else \
obj._meta.verbose_name

View File

@ -1,9 +1,63 @@
import json
from django import template
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.simple_tag(name='get_admin_tools')
def do_get_admin_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.register('sound', viewsets.SoundViewSet, basename='sound')
router.register('track', viewsets.TrackROViewSet, basename='track')
api = [
path('logs/', views.LogListAPIView.as_view(), name='live'),
path('user/settings/', viewsets.UserSettingsViewSet.as_view(
{'get': 'retrieve', 'post': 'update', 'put': 'update'}),
name='user-settings'),
] + router.urls

View File

@ -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.urls import reverse
from ..models import Page
from ..utils import Redirect
__all__ = ['BaseView']
__all__ = ('BaseView', 'BaseAPIView')
class BaseView(TemplateResponseMixin, ContextMixin):
@ -55,13 +51,14 @@ class BaseView(TemplateResponseMixin, ContextMixin):
kwargs['audio_streams'] = streams
if 'model' not in kwargs:
model = getattr(self, 'model', None) or hasattr(self, 'object') and \
type(self.object)
model = getattr(self, 'model', None) or \
hasattr(self, 'object') and type(self.object)
kwargs['model'] = model
return super().get_context_data(**kwargs)
# FIXME: rename to sth like [Base]?StationAPIView
class BaseAPIView:
@property
def station(self):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ $menu-item-active-background-color: #d2d2d2;
//-- helpers/modifiers
.is-fullwidth { width: 100%; }
.is-fullheight { height: 100%; }
.is-fixed-bottom {
position: fixed;
bottom: 0;
@ -40,6 +41,19 @@ $menu-item-active-background-color: #d2d2d2;
.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 {
from { opacity: 1; }
to { opacity: 0.4; }
@ -215,6 +229,10 @@ a.navbar-item.is-active {
.player-bar {
border-top: 1px $grey-light solid;
> div {
height: 3.75em !important;
}
> .media-left:not(:last-child) {
margin-right: 0em;
}
@ -235,7 +253,8 @@ a.navbar-item.is-active {
.button {
font-size: 1.5rem !important;
height: 2.5em;
height: 100%;
padding: auto 0.2em !important;
min-width: 2.5em;
border-radius: 0px;
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>
<div :class="dropdownClass">
<div class="dropdown-trigger is-fullwidth">
<input type="hidden" :name="name"
:value="selectedValue" />
<div v-show="!selected" class="control is-expanded">
<input type="text" :placeholder="placeholder"
ref="input" class="input is-fullwidth"
@keydown.capture="onKeyPress"
@keyup="onKeyUp" @focus="this.cursor < 0 && move(0)"/>
</div>
<button v-if="selected" class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
@click="select(-1, false, true)">
<span class="icon is-small ml-1">
<i class="fa fa-pen"></i>
</span>
<span class="is-inline-block" v-if="selected">
<slot name="button" :index="selectedIndex" :item="selected"
:value-field="valueField" :labelField="labelField">
{{ selected.data[labelField] }}
</slot>
</span>
</button>
</div>
<div class="dropdown-menu is-fullwidth">
<div class="dropdown-content" style="overflow: hidden">
<a v-for="(item, index) in items" :key="item.id"
:class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
@click.capture.prevent="select(index, false, false)" :title="item.data[labelField]">
<slot name="item" :index="index" :item="item" :value-field="valueField"
:labelField="labelField">
{{ item.data[labelField] }}
</slot>
</a>
<div class="control">
<input type="hidden" :name="name" :value="selectedValue"
@change="$emit('change', $event)"/>
<input type="text" ref="input" class="input is-fullwidth" :class="inputClass"
v-show="!button || !selected"
v-model="inputValue"
:placeholder="placeholder"
@keydown.capture="onKeyDown"
@keyup="onKeyUp($event); $emit('keyup', $event)"
@keydown="$emit('keydown', $event)"
@keypress="$emit('keypress', $event)"
@focus="onInputFocus" @blur="onBlur" />
<a v-if="selected && button"
class="button is-normal is-fullwidth has-text-left is-inline-block overflow-hidden"
@click="select(-1, false, true)">
<span class="icon is-small ml-1">
<i class="fa fa-pen"></i>
</span>
<span class="is-inline-block" v-if="selected">
<slot name="button" :index="selectedIndex" :item="selected"
:value-field="valueField" :labelField="labelField">
{{ labelField && selected.data[labelField] || selected }}
</slot>
</span>
</a>
<div :class="dropdownClass">
<div class="dropdown-menu is-fullwidth">
<div class="dropdown-content" style="overflow: hidden">
<a v-for="(item, index) in items" :key="item.id"
href="#" :data-autocomplete-index="index"
@click="select(index, false, false)"
:class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
:title="labelField && item.data[labelField] || item"
tabindex="-1">
<slot name="item" :index="index" :item="item" :value-field="valueField"
:labelField="labelField">
{{ labelField && item.data[labelField] || item }}
</slot>
</a>
</div>
</div>
</div>
</div>
@ -39,29 +46,63 @@
<script>
// import debounce from 'lodash/debounce'
import Model from '../model'
export default {
emit: ['change', 'keypress', 'keydown', 'keyup', 'select', 'unselect',
'update:modelValue'],
props: {
//! Search URL (where `${query}` is replaced by search term)
url: String,
//! Items' model
model: Function,
//! Input tag class
inputClass: Array,
//! input text placeholder
placeholder: String,
//! input form field name
name: String,
//! Field on items to use as label
labelField: String,
//! Field on selected item to get selectedValue from, if any
valueField: {type: String, default: null},
count: {type: Number, count: 10},
//! If true, show button when value has been selected
button: Boolean,
//! If true, value must come from a selection
mustExist: {type: Boolean, default: false},
//! Minimum input size before fetching
minFetchLength: {type: Number, default: 3},
modelValue: {default: ''},
},
data() {
return {
value: '',
inputValue: this.modelValue || '',
query: '',
items: [],
selectedIndex: -1,
cursor: -1,
isFetching: false,
promise: null,
}
},
watch: {
modelValue(value) {
this.inputValue = value
},
inputValue(value) {
if(value != this.inputValue && value != this.modelValue)
this.$emit('update:modelValue', value)
},
},
computed: {
isFetching() { return !!this.promise },
selected() {
let index = this.selectedIndex
if(index<0)
@ -71,23 +112,39 @@ export default {
},
selectedValue() {
const sel = this.selected
return sel && (this.valueField ?
sel.data[this.valueField] : sel.id)
let value = this.itemValue(this.selected)
if(!value && !this.mustExist)
value = this.inputValue
return value
},
selectedLabel() {
const sel = this.selected
return sel && sel.data[this.labelField]
return this.itemLabel(this.selected)
},
dropdownClass() {
const active = this.cursor > -1 && this.items.length;
return ['dropdown', active ? 'is-active':'']
var active = this.cursor > -1 && this.items.length;
if(active && this.items.length == 1 &&
this.itemValue(this.items[0]) == this.inputValue)
active = false
return ['dropdown is-fullwidth', active ? 'is-active':'']
},
},
methods: {
itemValue(item) {
return this.valueField ? item && item[this.valueField] : item;
},
itemLabel(item) {
return this.labelField ? item && item[this.labelField] : item;
},
hide() {
this.cursor = -1;
this.selectedIndex = -1;
},
move(index=-1, relative=false) {
if(relative)
index += this.cursor
@ -100,9 +157,9 @@ export default {
else if(index == this.selectedIndex)
return
this.selectedIndex = Math.max(-1, Math.min(index, this.items.length-1))
this.selectedIndex = Math.max(-1, Math.min(index, this.items.length-1))
if(index >= 0) {
this.$refs.input.value = this.selectedLabel
this.inputValue = this.selectedLabel
this.$refs.input.focus()
}
if(this.selectedIndex < 0)
@ -114,11 +171,24 @@ export default {
active && this.move(0) || this.move(-1)
},
onKeyPress: function(event) {
onInputFocus() {
this.cursor < 0 && this.move(0)
},
onBlur(event) {
var index = event.relatedTarget && event.relatedTarget.dataset.autocompleteIndex;
if(index !== undefined)
this.select(index, false, false)
this.cursor = -1;
},
onKeyDown(event) {
if(event.ctrlKey || event.altKey || event.metaKey)
return
switch(event.keyCode) {
case 13: this.select(this.cursor, false, false)
break
case 27: this.select()
case 27: this.hide(); this.select()
break
case 38: this.move(-1, true)
break
@ -130,35 +200,47 @@ export default {
event.stopPropagation()
},
onKeyUp: function(event) {
const value = event.target.value
if(value === this.value)
onKeyUp(event) {
if(event.ctrlKey || event.altKey || event.metaKey)
return
this.value = value;
const value = event.target.value
if(value === this.query)
return
this.inputValue = value;
if(!value)
return this.selected && this.select(-1)
this.fetch(value)
if(!this.minFetchLength || value.length >= this.minFetchLength)
this.fetch(value)
},
fetch: function(query) {
if(!query || this.isFetching)
fetch(query) {
if(!query || this.promise)
return
this.isFetching = true
return this.model.fetch(this.url.replace('${query}', query), {many:true})
.then(items => { this.items = items || []
this.isFetching = false
this.move(0)
return items },
data => {this.isFetching = false; Promise.reject(data)})
this.query = query
var url = this.url.replace('${query}', query)
var promise = this.model ? this.model.fetch(url, {many:true})
: fetch(url, Model.getOptions()).then(d => d.json())
promise = promise.then(items => {
this.items = items || []
this.promise = null;
this.move(0)
return items
}, data => {this.promise = null; Promise.reject(data)})
this.promise = promise
return promise
},
},
mounted() {
const form = this.$el.closest('form')
form.addEventListener('reset', () => { this.value=''; this.select(-1) })
form.addEventListener('reset', () => {
this.inputValue = this.value;
this.select(-1)
})
}
}

View File

@ -1,19 +1,22 @@
<template>
<div>
<!-- FIXME: header and footer should be inside list tags -->
<slot name="header"></slot>
<ul :class="listClass">
<component :is="listTag" :class="listClass">
<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>
</li>
</component>
</template>
</ul>
</component>
<slot name="footer"></slot>
</div>
</template>
<script>
export default {
emits: ['select', 'unselect'],
emits: ['select', 'unselect', 'move'],
data() {
return {
selectedIndex: this.defaultIndex,
@ -25,6 +28,9 @@ export default {
itemClass: String,
defaultIndex: { type: Number, default: -1},
set: Object,
orderable: { type: Boolean, default: false },
itemTag: { default: 'li' },
listTag: { default: 'ul' },
},
computed: {
@ -61,6 +67,34 @@ export default {
this.$emit('unselect', { item: this.selected, index: this.selectedIndex});
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>

View File

@ -1,7 +1,7 @@
<template>
<div class="player">
<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"
:actions="['page']"
:editable="true" :player="self" :set="sets.pin" @select="togglePlay('pin', $event.index)"
@ -13,7 +13,7 @@
</p>
</template>
</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']"
:editable="true" :player="self" :set="sets.queue" @select="togglePlay('queue', $event.index)"
listClass="menu-list" itemClass="menu-item">
@ -51,14 +51,14 @@
<span>Live</span>
</button>
<button ref="pinPlaylistButton" :class="playlistButtonClass('pin')"
@click="togglePanel('pin')">
<span class="mr-2 is-size-6" v-if="sets.pin.length">
@click="togglePanel('pin')" v-show="sets.pin.length">
<span class="is-size-6" v-if="sets.pin.length">
{{ sets.pin.length }}</span>
<span class="icon"><span class="fa fa-thumbtack"></span></span>
</button>
<button :class="playlistButtonClass('queue')"
@click="togglePanel('queue')">
<span class="mr-2 is-size-6" v-if="sets.queue.length">
@click="togglePanel('queue')" v-show="sets.queue.length">
<span class="is-size-6" v-if="sets.queue.length">
{{ sets.queue.length }}</span>
<span class="icon"><span class="fa fa-list"></span></span>
</button>
@ -186,13 +186,11 @@ export default {
if(!item)
throw `No sound at index ${index} for playlist ${playlist}`;
this.loaded = item
this.current = item
src = item.src;
}
// from live
else {
this.loaded = null;
this.current = this.live.current
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 APlayer from './APlayer.vue'
import APlaylist from './APlaylist.vue'
import APlaylistEditor from './APlaylistEditor.vue'
import AProgress from './AProgress.vue'
import ASoundItem from './ASoundItem.vue'
import AStatistics from './AStatistics.vue'
@ -12,12 +13,15 @@ import AStreamer from './AStreamer.vue'
/**
* Core components
*/
export default {
export const base = {
AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist,
AProgress, ASoundItem,
}
export default base
export const admin = {
AStatistics, AStreamer,
...base,
AStatistics, AStreamer, APlaylistEditor
}

View File

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

View File

@ -35,17 +35,21 @@ export default class Model {
* Instanciate model with provided data and options.
* By default `url` is taken from `data.url_`.
*/
constructor(data, {url=null, ...options}={}) {
constructor(data={}, {url=null, ...options}={}) {
this.url = url || data.url_;
this.options = options;
this.commit(data);
}
get errors() {
return this.data && this.data.__errors__
}
/**
* Get instance id from its 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
*/
commit(data) {
this.id = this.constructor.getId(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);
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
*/
@ -231,6 +259,25 @@ export class Set {
this.items.splice(index,1);
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 () {

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