work on page form; add image selector

This commit is contained in:
bkfox 2024-03-16 06:00:15 +01:00
parent c74ec6fb16
commit eb5bdcf167
29 changed files with 611 additions and 174 deletions

View File

@ -1,14 +1,23 @@
import django_filters as filters
from django.utils.translation import gettext_lazy as _
from .models import Episode, Page
from . import models
__all__ = (
"PageFilters",
"EpisodeFilters",
"ImageFilterSet",
"SoundFilterSet",
"TrackFilterSet",
)
class PageFilters(filters.FilterSet):
q = filters.CharFilter(method="search_filter", label=_("Search"))
class Meta:
model = Page
model = models.Page
fields = {
"category__id": ["in", "exact"],
"pub_date": ["exact", "gte", "lte"],
@ -22,10 +31,33 @@ class EpisodeFilters(PageFilters):
podcast = filters.BooleanFilter(method="podcast_filter", label=_("Podcast"))
class Meta:
model = Episode
model = models.Episode
fields = PageFilters.Meta.fields.copy()
def podcast_filter(self, queryset, name, value):
if value:
return queryset.filter(sound__is_public=True).distinct()
return queryset.filter(sound__isnull=True)
class ImageFilterSet(filters.FilterSet):
search = filters.CharFilter(field_name="search", method="search_filter")
def search_filter(self, queryset, name, value):
return queryset.filter(original_filename__icontains=value)
class SoundFilterSet(filters.FilterSet):
station = filters.NumberFilter(field_name="program__station__id")
program = filters.NumberFilter(field_name="program_id")
episode = filters.NumberFilter(field_name="episode_id")
search = filters.CharFilter(field_name="search", method="search_filter")
def search_filter(self, queryset, name, value):
return queryset.search(value)
class TrackFilterSet(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")

View File

@ -1,15 +1,15 @@
from django import forms
from django.forms import ModelForm, ImageField, FileField
from ckeditor.fields import RichTextField
from filer.models.imagemodels import Image
from filer.models.filemodels import File
from aircox.models import Comment, Episode, Program
from aircox import models
from aircox.controllers.sound_file import SoundFile
class CommentForm(ModelForm):
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm")
class CommentForm(forms.ModelForm):
nickname = forms.CharField()
email = forms.EmailField(required=False)
content = forms.CharField(widget=forms.Textarea())
@ -19,32 +19,31 @@ class CommentForm(ModelForm):
content.widget.attrs.update({"class": "textarea"})
class Meta:
model = Comment
model = models.Comment
fields = ["nickname", "email", "content"]
class ProgramForm(ModelForm):
content = RichTextField()
new_cover = ImageField(required=False)
class ImageForm(forms.Form):
file = forms.ImageField()
class PageForm(forms.ModelForm):
class Meta:
fields = ("title", "status", "cover", "content")
model = models.Page
class ProgramForm(PageForm):
class Meta:
fields = PageForm.Meta.fields
model = models.Program
class EpisodeForm(PageForm):
new_podcast = forms.FileField(required=False)
class Meta:
model = Program
fields = ["content"]
def save(self, commit=True):
file_obj = self.cleaned_data["new_cover"]
if file_obj:
obj, _ = Image.objects.get_or_create(original_filename=file_obj.name, file=file_obj)
self.instance.cover = obj
super().save(commit=commit)
class EpisodeForm(ModelForm):
content = RichTextField()
new_podcast = FileField(required=False)
class Meta:
model = Episode
model = models.Episode
fields = ["content"]
def save(self, commit=True):

View File

@ -127,9 +127,9 @@ class Program(Page):
self.editors = editors
super().save()
permission, _ = Permission.objects.get_or_create(
name=f"change program {self.title}",
codename=self.change_permission_codename,
content_type=ContentType.objects.get_for_model(self),
defaults={"name": f"change program {self.title}"},
)
if permission not in editors.permissions.all():
editors.permissions.add(permission)

View File

@ -1,9 +1,17 @@
from rest_framework import serializers
from filer.models.imagemodels import Image
from taggit.serializers import TaggitSerializer, TagListSerializerField
from ..models import Track, UserSettings
__all__ = ("TrackSerializer", "UserSettingsSerializer")
__all__ = ("ImageSerializer", "TrackSerializer", "UserSettingsSerializer")
class ImageSerializer(serializers.ModelSerializer):
class Meta:
model = Image
fields = "__all__"
class TrackSerializer(TaggitSerializer, serializers.ModelSerializer):

View File

@ -121,6 +121,10 @@
color: var(--heading-hg-fg);
}
.panels .panel:not(.active) {
display: none;
}
.preview {
position: relative;
background-size: cover;
@ -525,6 +529,34 @@
overflow: hidden;
}
.a-select-file > *:not(:last-child) {
margin-bottom: 0.6rem;
}
.a-select-file .upload-preview {
max-width: 100%;
}
.a-select-file .a-select-file-list {
max-height: 30rem;
overflow-y: auto;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0.6rem;
}
.a-select-file .file-preview {
width: 100%;
overflow: hidden;
}
.a-select-file .file-preview:hover {
box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.2);
}
.a-select-file .file-preview.active {
box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.4);
}
.a-select-file .file-preview img {
width: 100%;
max-height: 10rem;
}
/* Bulma Utilities */
.button {
-moz-appearance: none;

View File

@ -6643,6 +6643,11 @@ a.tag:hover {
text-decoration: underline;
}
.text-light {
weight: 400;
color: var(--text-color-light);
}
.align-left {
text-align: left;
justify-content: left;
@ -6665,6 +6670,10 @@ a.tag:hover {
clear: both !important;
}
.clear-unset {
clear: unset !important;
}
.d-inline {
display: inline !important;
}
@ -6866,11 +6875,10 @@ input.half-field:not(:active):not(:hover) {
}
:root {
font-size: 16px;
font-size: 14px;
}
body {
font-size: 1rem;
background-color: var(--body-bg);
}
@ -6878,22 +6886,24 @@ body.mobile .grid {
grid-template-columns: 1fr;
}
@media screen and (max-width: 1280px) {
html {
font-size: 18px !important;
@media screen and (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
}
}
@media screen and (max-width: 1024px) {
html {
font-size: 20px !important;
}
:root {
--header-height: 20rem;
font-size: 18px !important;
}
}
@media screen and (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
@media screen and (max-width: 1280px) {
html {
font-size: 20px !important;
}
}
@media screen and (min-width: 1280px) {
html {
font-size: 24px !important;
}
}
h1, h2, h3, h4, h5, h6, .heading, .title, .subtitle {
@ -6903,3 +6913,8 @@ h1, h2, h3, h4, h5, h6, .heading, .title, .subtitle {
.container:empty {
display: none;
}
.header-cover {
display: flex;
flex-direction: column;
}

View File

@ -1211,7 +1211,7 @@
background-color: var(--vc-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-hg-color: transparent;
-webkit-tap-highlight-color: transparent;
}
.vc-container,

View File

@ -121,6 +121,10 @@
color: var(--heading-hg-fg);
}
.panels .panel:not(.active) {
display: none;
}
.preview {
position: relative;
background-size: cover;
@ -525,6 +529,34 @@
overflow: hidden;
}
.a-select-file > *:not(:last-child) {
margin-bottom: 0.6rem;
}
.a-select-file .upload-preview {
max-width: 100%;
}
.a-select-file .a-select-file-list {
max-height: 30rem;
overflow-y: auto;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0.6rem;
}
.a-select-file .file-preview {
width: 100%;
overflow: hidden;
}
.a-select-file .file-preview:hover {
box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.2);
}
.a-select-file .file-preview.active {
box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.4);
}
.a-select-file .file-preview img {
width: 100%;
max-height: 10rem;
}
/* Bulma Utilities */
.file-cta,
.file-name, .select select, .textarea, .input {

File diff suppressed because one or more lines are too long

View File

@ -1,29 +0,0 @@
/* global CKEDITOR, django */
/* Modified in order to be manually loaded after vue.js */
function initialiseCKEditor() {
var textareas = Array.prototype.slice.call(
document.querySelectorAll("textarea[data-type=ckeditortype]"),
)
for (var i = 0; i < textareas.length; ++i) {
var t = textareas[i]
if (
t.getAttribute("data-processed") == "0" &&
t.id.indexOf("__prefix__") == -1
) {
t.setAttribute("data-processed", "1")
var ext = JSON.parse(t.getAttribute("data-external-plugin-resources"))
for (var j = 0; j < ext.length; ++j) {
CKEDITOR.plugins.addExternal(ext[j][0], ext[j][1], ext[j][2])
}
CKEDITOR.replace(t.id, JSON.parse(t.getAttribute("data-config")))
}
}
}
function initialiseCKEditorInInlinedForms() {
if (typeof django === "object" && django.jQuery) {
django.jQuery(document).on("formset:added", initialiseCKEditor)
}
}
//})()

View File

@ -7,9 +7,6 @@ Usefull context:
- cover: image cover
- site: current website
- model: view model or displayed `object`'s
- sidebar_object_list: item to display in sidebar
- sidebar_url_name: url name sidebar item complete list
- sidebar_url_parent: parent page for sidebar items complete list
{% endcomment %}
<html>
<head>
@ -100,16 +97,19 @@ Usefull context:
{% if page or cover or title %}
<header class="container header preview preview-header {% if cover %}has-cover{% endif %}">
{% block header %}
{% if cover %}
<figure class="header-cover">
<img src="{{ cover }}" class="cover">
{% block header-cover %}
{% if cover %}
<img src="{{ cover }}" ref="cover" class="cover">
{% endif %}
{% endblock %}
</figure>
{% endif %}
<div class="headings preview-card-headings">
{% block headings %}
<div>
{% block title-container %}
<h1 class="title is-1 {% block title-class %}{% endblock %}">{% block title %}{{ title|default:"" }}{% endblock %}</h1>
{% include "aircox/edit-link.html" %}
{% endblock %}
</div>
<div>
{% spaceless %}
@ -146,17 +146,26 @@ Usefull context:
{% block footer-container %}
<footer class="page-footer">
{% block footer %}
{% if request.station and request.station.legal_label %}
{{ request.station.legal_label }} &mdash;
{% endif %}
{% comment %}
{% nav_items "footer" css_class="nav-item" active_class="active" as items %}
{% for item, render in items %}
{{ render }}
{% endfor %}
{% endcomment %}
{% if not request.user.is_authenticated %}
<a class="nav-item" href="{% url "profile" %}" target="new"
title="{% translate "Profile" %}">
<span class="small icon">
<i class="fa fa-account">
<i class="fa fa-user"></i>
</span>
</a>
{% endif %}
{% endblock %}
{% if request.station and request.station.legal_label %}
{{ request.station.legal_label }} &mdash;
{% endif %}
</footer>
{% endblock %}

View File

@ -1,24 +0,0 @@
{% load aircox i18n %}
{% block user-actions-container %}
{% has_perm page page.program.change_permission_codename simple=True as can_edit %}
{% if user.is_authenticated and can_edit %}
{% with request.resolver_match.view_name as view_name %}
&nbsp;
{% if view_name in 'page-edit,program-edit,episode-edit' %}
<a href="{% url view_name|detail_view page.slug %}" target="_self">
<small>
<span title="{% translate 'View' %} {{ page }}">{% translate 'View' %} </span>
<i class="fa-regular fa-eye"></i>
</small>
</a>
{% else %}
<a href="{% url view_name|edit_view page.pk %}" target="_self">
<small>
<span title="{% translate 'Edit' %} {{ page }}">{% translate 'Edit' %} </span>
<i class="fa-solid fa-pencil"></i>
</small>
</a>
{% endif %}
{% endwith %}
{% endif %}
{% endblock %}

View File

@ -23,6 +23,12 @@ Context:
{% endif %}
{% endblock %}
{% block title-container %}
{{ block.super }}
{% block page-actions %}
{% include "aircox/widgets/page_actions.html" %}
{% endblock %}
{% endblock %}
{% block main %}
{{ block.super }}

View File

@ -0,0 +1,71 @@
{% extends "./page_detail.html" %}
{% load static i18n %}
{% block header-cover %}
<img src="{{ cover }}" ref="cover" class="cover">
{% endblock %}
{% block content-container %}
<section class="container active">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for field in form %}
<div class="field">
{% if field.name == "cover" %}
<input type="hidden" name="{{ field.name }}" value="{{ field.pk }}" ref="coverField"/>
{% else %}
<label class="label">{{ field.label }}</label>
<div class="control clear-unset">
{% if field.name == "pub_date" %}
<input type="datetime-local" name="{{ field.name }}"
value="{{ field.value|date:"Y-m-d" }}T{{ field.value|date:"H:i" }}"/>
{% elif field.name == "content" %}
<textarea name="{{ field.name }}" class="is-fullwidth">{{ field.value|striptags|safe }}</textarea>
{% else %}
{{ field }}
{% endif %}
</div>
{% endif %}
</div>
{% if field.errors %}
<p class="help is-danger">{{ field.errors }}</p>
{% endif %}
{% if field.help_text %}
<p class="help">{{ field.help_text|safe }}</p>
{% endif %}
{% endfor %}
<div class="has-text-right">
<button type="submit" class="button">{% translate "Update" %}</button>
</div>
</form>
</section>
<section id="cover-modal" class="container page-edit-panel">
<h3 class="title">{% translate "Change cover" %}</h3>
<a-select-file list-url="{% url "api:image-list" %}" upload-url="{% url "api:image-list" %}"
prev-label="{% translate "Show previous" %}"
next-label="{% translate "Show next" %}"
>
<template #upload-preview="{item}">
<template v-if="item">
<img :src="item.src" class="upload-preview"/>
</template>
</template>
<template #default="{item}">
<div class="flex-column">
<div class="flex-grow-1">
<img :src="item.file"/>
</div>
<label class="label">[[ item.name || item.original_filename ]]</label>
</div>
</template>
<template #footer="{item}">
<button type="button" class="button float-right"
@click="(event) => {$refs.cover.src = item.file; $refs.coverField.value = item.id}">
{% translate "Select" %}
</button>
</template>
</a-select-file>
</section>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% load aircox i18n %}
{% block user-actions-container %}
{% has_perm page page.program.change_permission_codename simple=True as can_edit %}
{% if user.is_authenticated %}
{{ object.get_status_display }}
({{ object.pub_date|date:"d/m/Y H:i" }})
{% endif %}
{% if user.is_authenticated and can_edit %}
{% with request.resolver_match.view_name as view_name %}
&nbsp;
{% if "-edit" in view_name %}
<a href="{% url view_name|detail_view page.slug %}" target="_self" title="{% translate 'View' %} {{ page }}">
<span class="icon">
<i class="fa-regular fa-eye"></i>
</span>
<span>{% translate 'View' %} </span>
</a>
{% else %}
<a href="{% url view_name|edit_view page.pk %}" target="_self" title="{% translate 'Edit' %} {{ page }}">
<span class="icon">
<i class="fa-solid fa-pencil"></i>
</span>
<span>{% translate 'Edit' %} </span>
</a>
{% endif %}
{% endwith %}
{% endif %}
{% endblock %}

View File

@ -2,7 +2,7 @@ from django.urls import include, path, register_converter
from django.utils.translation import gettext_lazy as _
from rest_framework.routers import DefaultRouter
from . import models, views, viewsets
from . import forms, models, views, viewsets
from .converters import DateConverter, PagePathConverter, WeekConverter
__all__ = ["api", "urls"]
@ -21,6 +21,7 @@ register_converter(WeekConverter, "week")
router = DefaultRouter()
router.register("images", viewsets.ImageViewSet, basename="image")
router.register("sound", viewsets.SoundViewSet, basename="sound")
router.register("track", viewsets.TrackROViewSet, basename="track")
@ -132,6 +133,12 @@ urls = [
views.errors.NoStationErrorView.as_view(),
name="errors-no-station",
),
path(_("manage/"), views.ProfileView.as_view(), name="profile"),
# ---- backoffice
path(_("edit/"), views.ProfileView.as_view(), name="profile"),
path(
_("edit/programs/<slug:slug>"),
views.PageUpdateView.as_view(model=models.Program, form_class=forms.ProgramForm),
name="program-update",
),
path("accounts/profile/", views.ProfileView.as_view(), name="profile"),
]

View File

@ -1,6 +1,5 @@
from . import admin, errors
from .article import ArticleDetailView, ArticleListView
from .base import BaseAPIView, BaseView
from .diffusion import DiffusionListView, TimeTableView
from .episode import EpisodeDetailView, EpisodeListView, PodcastListView, EpisodeUpdateView
from .home import HomeView
@ -10,6 +9,7 @@ from .page import (
BasePageListView,
PageDetailView,
PageListView,
PageUpdateView,
)
from .profile import ProfileView
from .program import (
@ -20,13 +20,12 @@ from .program import (
ProgramUpdateView,
)
__all__ = (
"admin",
"errors",
"ArticleDetailView",
"ArticleListView",
"BaseAPIView",
"BaseView",
"DiffusionListView",
"TimeTableView",
"EpisodeDetailView",
@ -39,6 +38,7 @@ __all__ = (
"BasePageDetailView",
"BasePageListView",
"PageDetailView",
"PageUpdateView",
"PageListView",
"ProfileView",
"ProgramDetailView",

View File

@ -24,7 +24,10 @@ class BasePageMixin:
category = None
def get_queryset(self):
return super().get_queryset().select_subclasses().published().select_related("cover")
qs = super().get_queryset().select_subclasses().select_related("cover")
if self.request.user.is_authenticated:
return qs
return qs.published()
def get_category(self, page, **kwargs):
if page:
@ -153,8 +156,8 @@ class PageDetailView(BasePageDetailView):
return super().get_queryset().select_related("category")
def get_context_data(self, **kwargs):
if self.object.allow_comments and "comment_form" not in kwargs:
kwargs["comment_form"] = CommentForm()
if "comment_form" not in kwargs:
kwargs["comment_form"] = self.get_comment_form()
kwargs["comments"] = Comment.objects.filter(page=self.object).order_by("-date")
if self.object.parent_subclass:
@ -167,6 +170,11 @@ class PageDetailView(BasePageDetailView):
kwargs["related_objects"] = related
return super().get_context_data(**kwargs)
def get_comment_form(self):
if self.object.allow_comments:
return CommentForm()
return None
@classmethod
def as_view(cls, *args, **kwargs):
view = super(PageDetailView, cls).as_view(*args, **kwargs)
@ -186,6 +194,14 @@ class PageDetailView(BasePageDetailView):
class PageUpdateView(BaseView, UpdateView):
context_object_name = "page"
template_name = "aircox/page_form.html"
# FIXME: remove?
def get_page(self):
return self.object
def get_success_url(self):
return self.request.path
def get_comment_form(self):
return None

View File

@ -56,9 +56,6 @@ class ProgramUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
model = Program
form_class = ProgramForm
def get_sidebar_queryset(self):
return super().get_sidebar_queryset().filter(parent=self.program)
def test_func(self):
program = self.get_object()
return self.request.user.has_perm("aircox.%s" % program.change_permission_codename)
@ -94,7 +91,3 @@ class ProgramPageListView(BaseProgramMixin, PageListView):
def get_program(self):
return self.parent
def get_context_data(self, **kwargs):
kwargs.setdefault("sidebar_url_parent", None)
return super().get_context_data(**kwargs)

View File

@ -1,44 +1,46 @@
from django_filters import rest_framework as filters
from django_filters import rest_framework as drf_filters
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 rest_framework.parsers import MultiPartParser
from .models import Sound, Track
from filer.models.imagemodels import Image
from . import models, forms, filters
from .serializers import SoundSerializer, admin
from .views import BaseAPIView
__all__ = (
"SoundFilter",
"ImageViewSet",
"SoundViewSet",
"TrackFilter",
"TrackROViewSet",
"UserSettingsViewSet",
)
class SoundFilter(filters.FilterSet):
station = filters.NumberFilter(field_name="program__station__id")
program = filters.NumberFilter(field_name="program_id")
episode = filters.NumberFilter(field_name="episode_id")
search = filters.CharFilter(field_name="search", method="search_filter")
class ImageViewSet(viewsets.ModelViewSet):
parsers = (MultiPartParser,)
serializer_class = admin.ImageSerializer
queryset = Image.objects.all().order_by("-uploaded_at")
filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.ImageFilterSet
def search_filter(self, queryset, name, value):
return queryset.search(value)
def create(self, request, **kwargs):
# FIXME: to be replaced by regular DRF
form = forms.ImageForm(request.POST, request.FILES)
if form.is_valid():
file = form.cleaned_data["file"]
Image.objects.create(original_filename=file.name, file=file)
return Response({"status": "ok"})
return Response({"status": "error", "errors": form.errors})
class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
serializer_class = SoundSerializer
queryset = Sound.objects.available().order_by("-pk")
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")
queryset = models.Sound.objects.available().order_by("-pk")
filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.SoundFilterSet
class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
@ -46,9 +48,9 @@ class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = admin.TrackSerializer
permission_classes = [IsAuthenticated]
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = TrackFilter
queryset = Track.objects.all()
filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.TrackFilterSet
queryset = models.Track.objects.all()
@action(name="autocomplete", detail=False)
def autocomplete(self, request):
@ -60,6 +62,7 @@ class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
return self.list(request)
# --- admin
class UserSettingsViewSet(viewsets.ViewSet):
"""User's settings specific to aircox.

View File

@ -86,3 +86,8 @@ h1, h2, h3, h4, h5, h6, .heading, .title, .subtitle {
.container:empty {
display: none;
}
.header-cover {
display: flex;
flex-direction: column;
}

View File

@ -138,6 +138,11 @@
}
// ---- panels
.panels {
.panel:not(.active) { display: none; }
}
// ---- button
@mixin button {
.button, a.button, button.button {
@ -701,3 +706,40 @@
overflow: hidden;
}
}
/// ----------------
.a-select-file {
> *:not(:last-child) {
margin-bottom: v.$mp-3;
}
.upload-preview {
max-width: 100%;
}
.a-select-file-list {
max-height: 30rem;
overflow-y: auto;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: v.$mp-3;
}
.file-preview {
width: 100%;
overflow: hidden;
&:hover {
box-shadow: 0em 0em 1em rgba(0,0,0,0.2);
}
&.active {
box-shadow: 0em 0em 1em rgba(0,0,0,0.4);
}
img {
width: 100%;
max-height: 10rem;
}
}
}

View File

@ -1,5 +1,7 @@
@use "./vars" as v;
.text-light { weight: 400; color: var(--text-color-light); }
// ---- layout
.align-left { text-align: left; justify-content: left; }
.align-right { text-align: right; justify-content: right; }
@ -7,6 +9,7 @@
.clear-left { clear: left !important }
.clear-right { clear: right !important }
.clear-both { clear: both !important }
.clear-unset { clear: unset !important }
.d-inline { display: inline !important; }
.d-block { display: block !important; }
@ -20,6 +23,7 @@
.ws-nowrap { white-space: nowrap; }
// ---- grid
@mixin grid {
display: grid;

View File

@ -5,6 +5,7 @@
// ---- main theme & layout
.page {
padding-bottom: 5rem;

View File

@ -0,0 +1,124 @@
<template>
<div class="a-select-file">
<div class="a-select-file-list" ref="list">
<div class="flex-column file-preview">
<div class="field flex-grow-1" v-if="!uploadFile">
<label class="label">{{ uploadLabel }}</label>
<input type="file" @change="previewFile"/>
</div>
<slot name="upload-preview" :item="uploadFile"></slot>
<div v-if="uploadFile">
<button class="button secondary" @click="removeUpload">
<span class="icon">
<i class="fa fa-trash"></i>
</span>
</button>
<button class="button float-right" @click="doUpload">
<span class="icon">
<i class="fa fa-upload"></i>
</span>
Upload
</button>
</div>
</div>
<div v-if="prevUrl">
<a href="#" @click="load(prevUrl)">
{{ prevLabel }}
</a>
</div>
<template v-for="item in items" v-bind:key="item.id">
<div :class="['file-preview', this.item && item.id == this.item.id && 'active']" @click="select(item)">
<slot :item="item"></slot>
</div>
</template>
<div v-if="nextUrl">
<a href="#" @click="load(nextUrl)">
{{ nextLabel }}
</a>
</div>
</div>
<div class="a-select-footer">
<slot name="footer" :item="item" :items="items"></slot>
</div>
</div>
</template>
<script>
import {getCsrf} from "../model"
export default {
props: {
name: { type: String },
prevLabel: { type: String, default: "Prev" },
nextLabel: { type: String, default: "Next" },
listUrl: { type: String },
uploadLabel: { type: String, default: "Upload a file" },
},
data() {
return {
item: null,
items: [],
uploadFile: null,
uploadUrl: null,
uploadFieldName: null,
uploadCSRF: null,
nextUrl: "",
prevUrl: "",
}
},
methods: {
previewFile(event) {
const [file] = event.target.files
this.uploadFile = file && {
file: file,
src: URL.createObjectURL(file)
}
},
removeUpload() {
this.uploadFile = null;
},
doUpload() {
const formData = new FormData();
formData.append('file', this.uploadFile.file)
formData.append('original_filename', this.uploadFile.file.name)
formData.append('csrfmiddlewaretoken', getCsrf())
fetch(this.listUrl, {
method: "POST",
body: formData
}).then(
() => {
this.uploadFile = null;
this.load()
}
)
},
load(url) {
fetch(url || this.listUrl).then(
response => response.ok ? response.json() : Promise.reject(response)
).then(data => {
this.nextUrl = data.next
this.prevUrl = data.previous
this.items = data.results
this.$forceUpdate()
this.$refs.list.scroll(0, 0)
})
},
select(item) {
this.item = item;
},
},
mounted() {
this.load()
},
}
</script>

View File

@ -1,5 +1,6 @@
<template>
<button :title="ariaLabel"
type="button"
:aria-label="ariaLabel || label" :aria-description="ariaDescription"
@click="toggle" :class="buttonClass">
<slot name="default" :active="active">
@ -49,17 +50,21 @@ export default {
},
set(active) {
const el = document.querySelector(this.el)
if(active)
el.classList.add(this.activeClass)
else
el.classList.remove(this.activeClass)
if(this.el) {
const el = document.querySelector(this.el)
if(active)
el.classList.add(this.activeClass)
else
el.classList.remove(this.activeClass)
}
this.active = active
if(active)
this.resetGroup()
},
resetGroup() {
if(!this.groupClass)
return
const els = document.querySelectorAll("." + this.groupClass)
for(var el of els)
if(el != this.$el)

View File

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

View File

@ -30,6 +30,7 @@ export default class Live {
response.ok ? response.json()
: Promise.reject(response)
).then(data => {
data = data.results
data.forEach(item => {
if(item.start) item.start = new Date(item.start)
if(item.end) item.end = new Date(item.end)

View File

@ -252,3 +252,6 @@ TEMPLATES = [
WSGI_APPLICATION = "instance.wsgi.application"
LOGOUT_REDIRECT_URL = "/"
REST_FRAMEWORK = {"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", "PAGE_SIZE": 50}