Merge branch 'feature/LIL-582' into 'master'

Feature/lil 582

See merge request lilcity/backend!105
remotes/origin/hotfix/LIL-661
cfwme 8 years ago
commit 08b25bfd7c
  1. 0
      __init__.py
  2. 5
      api/v1/serializers/content.py
  3. 68
      api/v1/serializers/contest.py
  4. 41
      api/v1/serializers/course.py
  5. 4
      api/v1/serializers/mixins.py
  6. 5
      api/v1/urls.py
  7. 47
      api/v1/views.py
  8. 48
      apps/content/migrations/0021_auto_20180813_1306.py
  9. 17
      apps/content/migrations/0022_auto_20180815_2129.py
  10. 65
      apps/content/models.py
  11. 64
      apps/content/templates/content/blocks/gallery.html
  12. 20
      apps/content/templates/content/blocks/image.html
  13. 0
      apps/content/templates/content/blocks/imagetext.html
  14. 18
      apps/content/templates/content/blocks/text.html
  15. 84
      apps/content/templates/content/blocks/video.html
  16. 41
      apps/content/templates/content/contest.html
  17. 18
      apps/content/templates/content/contest_edit.html
  18. 112
      apps/content/templates/content/contest_work.html
  19. 110
      apps/content/views.py
  20. 18
      apps/course/migrations/0040_course_age.py
  21. 36
      apps/course/migrations/0041_auto_20180813_1306.py
  22. 6
      apps/course/models.py
  23. 2
      apps/course/templates/course/course.html
  24. 2
      apps/course/templates/course/lesson.html
  25. 5
      project/templates/blocks/header.html
  26. 10
      project/templates/blocks/lil_store_js.html
  27. 2
      project/templates/blocks/popup_auth.html
  28. 2
      project/templates/blocks/share.html
  29. 1
      project/templates/lilcity/edit_index.html
  30. 1
      project/templates/lilcity/index.html
  31. 7
      project/urls.py
  32. 361
      web/src/components/ContestRedactor.vue
  33. 86
      web/src/components/ContestWorks.vue
  34. 193
      web/src/components/UploadContestWork.vue
  35. 62
      web/src/components/blocks/ContestWork.vue
  36. 2
      web/src/components/blocks/Image.vue
  37. 74
      web/src/components/blocks/Likes.vue
  38. 27
      web/src/js/app.js
  39. 19
      web/src/js/contest-redactor.js
  40. 5
      web/src/js/modules/popup.js
  41. 36
      web/src/sass/_common.sass
  42. 5
      web/webpack.config.js

@ -3,8 +3,7 @@ from rest_framework import serializers
from apps.content.models import (
Baner, Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject,
)
Gallery, GalleryImage, ImageObject,)
from . import Base64ImageField
@ -14,6 +13,7 @@ BASE_CONTENT_FIELDS = (
'uuid',
'course',
'lesson',
'contest',
'live_lesson',
'title',
'position',
@ -254,3 +254,4 @@ class ContentSerializer(serializers.ModelSerializer):
elif isinstance(obj, Gallery):
return GallerySerializer(obj, context=self.context).to_representation(obj)
return super(ContentSerializer, self).to_representation(obj)

@ -0,0 +1,68 @@
from rest_framework import serializers
from api.v1.serializers.content import ContentSerializer, ContentCreateSerializer, ImageObjectSerializer
from api.v1.serializers.mixins import DispatchContentMixin
from apps.content.models import (Contest, ContestWork)
class ContestSerializer(serializers.ModelSerializer):
cover = ImageObjectSerializer()
content = ContentSerializer(many=True)
class Meta:
model = Contest
fields = '__all__'
class ContestCreateSerializer(DispatchContentMixin, serializers.ModelSerializer):
content = serializers.ListSerializer(
child=ContentCreateSerializer(),
required=False,
)
class Meta:
model = Contest
fields = '__all__'
def create(self, validated_data):
content = validated_data.pop('content', [])
contest = super().create(validated_data)
self.dispatch_content(contest, content)
return contest
def update(self, instance, validated_data):
content = validated_data.pop('content', [])
contest = super().update(instance, validated_data)
self.dispatch_content(contest, content)
return contest
def to_representation(self, instance):
return ContestSerializer(instance=instance, context=self.context).to_representation(instance)
class ContestWorkSerializer(serializers.ModelSerializer):
image = ImageObjectSerializer()
likes = serializers.SerializerMethodField()
user_liked = serializers.SerializerMethodField()
class Meta:
model = ContestWork
fields = ['id', 'user', 'contest', 'image', 'child_full_name', 'age',
'created_at', 'likes', 'user_liked', 'img_width', 'img_height']
def get_likes(self, instance):
return instance.likes.count()
def get_user_liked(self, instance):
user = self.context['request'].user
return instance.likes.filter(user=user).exists() if user.is_authenticated else False
class ContestWorkCreateSerializer(serializers.ModelSerializer):
class Meta:
model = ContestWork
fields = '__all__'
def to_representation(self, instance):
return ContestWorkSerializer(instance=instance, context=self.context).to_representation(instance)

@ -1,6 +1,8 @@
from rest_framework import serializers
from rest_framework.validators import UniqueValidator
from django.contrib.auth import get_user_model
from apps.course.models import (
Category, Course,
Comment, CourseComment, LessonComment,
@ -15,12 +17,15 @@ from .content import (
from apps.content.models import (
Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject,
)
ContestWork)
from .user import UserSerializer
from .mixins import DispatchContentMixin, DispatchGalleryMixin, DispatchMaterialMixin
User = get_user_model()
class MaterialCreateSerializer(serializers.ModelSerializer):
class Meta:
@ -63,6 +68,40 @@ class LikeSerializer(serializers.ModelSerializer):
)
class LikeCreateSerializer(serializers.ModelSerializer):
OBJ_TYPE_CONTEST_WORK = 'contest_work'
obj_type = serializers.CharField(required=True)
obj_id = serializers.IntegerField(required=True)
class Meta:
model = Like
fields = ['user', 'obj_type', 'obj_id']
def create(self, validated_data):
# FIXME
if validated_data.get('user'):
user = validated_data.get('user')
else:
user = self.context['request'].user
if not user: # FIXME and user.is_authenticated):
return Like()
obj_type = validated_data.pop('obj_type')
obj_id = validated_data.pop('obj_id')
if obj_type == self.OBJ_TYPE_CONTEST_WORK:
contest_work = ContestWork.objects.get(pk=obj_id)
# FIXME in prod:
# if contest_work.user == user or contest_work.likes.filter(user=user).exists():
if contest_work.likes.filter(user=user).exists():
return Like()
like = Like.objects.create(user=user)
contest_work.likes.add(like)
return like
def to_representation(self, instance):
return LikeSerializer(instance, context=self.context).to_representation(instance)
class CategorySerializer(serializers.ModelSerializer):
class Meta:

@ -4,7 +4,7 @@ from apps.school.models import LiveLesson
from apps.content.models import (
Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject,
)
Contest)
from .content import (
TextCreateSerializer, ImageCreateSerializer,
ImageTextCreateSerializer, VideoCreateSerializer,
@ -25,6 +25,8 @@ class DispatchContentMixin(object):
obj_type = 'lesson'
elif isinstance(obj, LiveLesson):
obj_type = 'live_lesson'
elif isinstance(obj, Contest):
obj_type = 'contest'
cdata[obj_type] = obj.id
if ctype == 'text':
if 'id' in cdata and cdata['id']:

@ -18,7 +18,7 @@ from .views import (
UserViewSet, LessonViewSet, ImageObjectViewSet,
SchoolScheduleViewSet, LiveLessonViewSet,
PaymentViewSet,
)
ContestViewSet, ContestWorkViewSet)
router = DefaultRouter()
router.register(r'author-requests', AuthorRequestViewSet, base_name='author-requests')
@ -44,6 +44,9 @@ router.register(r'school-schedules', SchoolScheduleViewSet, base_name='school-sc
router.register(r'users', UserViewSet, base_name='users')
router.register(r'contests', ContestViewSet, base_name='contests')
router.register(r'contest-works', ContestWorkViewSet, base_name='contest_works')
# router.register(r'configs', ConfigViewSet, base_name='configs')

@ -14,7 +14,7 @@ from .serializers.course import (
CommentSerializer,
MaterialSerializer, MaterialCreateSerializer,
LessonSerializer, LessonCreateSerializer,
)
LikeCreateSerializer)
from .serializers.content import (
BanerSerializer,
ImageSerializer, ImageCreateSerializer,
@ -39,6 +39,9 @@ from .serializers.user import (
AuthorRequestSerializer,
UserSerializer, UserPhotoSerializer,
)
from .serializers.contest import (
ContestCreateSerializer, ContestSerializer, ContestWorkSerializer, ContestWorkCreateSerializer
)
from .permissions import (
IsAdmin, IsAdminOrIsSelf,
@ -56,7 +59,7 @@ from apps.config.models import Config
from apps.content.models import (
Baner, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject,
)
Contest, ContestWork)
from apps.payment.models import (
AuthorBalance, Payment,
CoursePayment, SchoolPayment,
@ -110,6 +113,8 @@ class BanerViewSet(ExtendedModelViewSet):
class ImageObjectViewSet(ExtendedModelViewSet):
queryset = ImageObject.objects.all()
serializer_class = ImageObjectSerializer
# FIXME
authentication_classes = []
# permission_classes = (IsAuthorOrAdmin,)
@ -127,10 +132,16 @@ class MaterialViewSet(ExtendedModelViewSet):
class LikeViewSet(ExtendedModelViewSet):
queryset = Like.objects.select_related('user').all()
serializer_class = LikeSerializer
serializer_class = LikeCreateSerializer
serializer_class_map = {
'list': LikeSerializer,
'retrieve': LikeSerializer,
}
search_fields = ('user__email', 'user__firstname', 'user__lastname',)
ordering_fields = ('created_at', 'update_at',)
# permission_classes = (IsAdmin,)
# FIXME
authentication_classes = []
class CategoryViewSet(ExtendedModelViewSet):
@ -440,3 +451,33 @@ class PaymentViewSet(ExtendedModelViewSet):
queryset = SchoolPayment.objects.filter(weekdays__overlap=weekdays)
return queryset.filter(status__isnull=False).order_by('-created_at')
class ContestViewSet(ExtendedModelViewSet):
queryset = Contest.objects.all()
serializer_class = ContestCreateSerializer
serializer_class_map = {
'list': ContestSerializer,
'retrieve': ContestSerializer,
}
filter_fields = ('active',)
search_fields = ('description', 'title', 'slug',)
ordering_fields = ('id', 'title', 'active', 'date_start', 'date_end',)
permission_classes = (IsAdmin,)
class ContestWorkViewSet(ExtendedModelViewSet):
queryset = ContestWork.objects.all()
serializer_class = ContestWorkCreateSerializer
serializer_class_map = {
'list': ContestWorkSerializer,
'retrieve': ContestWorkSerializer,
}
filter_fields = ('contest',)
# FIXME
authentication_classes = []
def create(self, request, *args, **kwargs):
# FIXME in prod:
# if ContestWork.objects.filter(user=request.user).exists():
# return Response(status=status.HTTP_400_BAD_REQUEST)
return super().create(request, *args, **kwargs)

@ -0,0 +1,48 @@
# Generated by Django 2.0.6 on 2018-08-13 13:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course', '0040_course_age'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('content', '0020_auto_20180424_1607'),
]
operations = [
migrations.CreateModel(
name='Contest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True, default='', max_length=1000)),
('slug', models.SlugField(allow_unicode=True, blank=True, max_length=100, null=True, unique=True)),
('date_start', models.DateField(blank=True, null=True, verbose_name='Дата начала')),
('date_end', models.DateField(blank=True, null=True, verbose_name='Дата окончания')),
('active', models.BooleanField(default=True)),
('cover', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contest_covers', to='content.ImageObject', verbose_name='Фоновая картинка')),
],
),
migrations.CreateModel(
name='ContestWork',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('child_full_name', models.CharField(max_length=255)),
('age', models.SmallIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content.Contest')),
('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_work_images', to='content.ImageObject', verbose_name='Работа участника')),
('likes', models.ManyToManyField(blank=True, to='course.Like')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='content',
name='contest',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content', to='content.Contest', verbose_name='Конкурс'),
),
]

@ -0,0 +1,17 @@
# Generated by Django 2.0.6 on 2018-08-15 21:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('content', '0021_auto_20180813_1306'),
]
operations = [
migrations.AlterModelOptions(
name='contestwork',
options={'ordering': ('-created_at',), 'verbose_name': 'Конкурсная работа', 'verbose_name_plural': 'Конкурсные работы'},
),
]

@ -1,9 +1,14 @@
from urllib.parse import urlparse
from django.db import models
from django.contrib.auth import get_user_model
from django.urls import reverse_lazy
from polymorphic.models import PolymorphicModel
User = get_user_model()
class ImageObject(models.Model):
image = models.ImageField('Изображение', upload_to='content/imageobject')
@ -36,6 +41,12 @@ class Content(PolymorphicModel):
verbose_name='Урок онлайн школы',
related_name='content',
)
contest = models.ForeignKey(
'Contest', on_delete=models.CASCADE,
null=True, blank=True,
verbose_name='Конкурс',
related_name='content',
)
title = models.CharField('Заголовок', max_length=100, default='')
position = models.PositiveSmallIntegerField(
'Положение на странице',
@ -131,3 +142,57 @@ class Baner(models.Model):
if self.use:
Baner.objects.filter(use=True).update(use=False)
return super().save(*args, **kwargs)
class Contest(models.Model):
title = models.CharField(max_length=255)
description = models.TextField(max_length=1000, blank=True, default='')
slug = models.SlugField(
allow_unicode=True, null=True, blank=True,
max_length=100, unique=True, db_index=True,
)
cover = models.ForeignKey(
ImageObject, related_name='contest_covers',
verbose_name='Фоновая картинка', on_delete=models.CASCADE,
null=True, blank=True,
)
date_start = models.DateField('Дата начала', null=True, blank=True)
date_end = models.DateField('Дата окончания', null=True, blank=True)
active = models.BooleanField(default=True)
# TODO? baner
def save(self, *args, **kwargs):
if self.active:
Contest.objects.filter(active=True).update(active=False)
return super().save(*args, **kwargs)
class ContestWork(models.Model):
user = models.ForeignKey(
User, on_delete=models.CASCADE
)
contest = models.ForeignKey(Contest, on_delete=models.CASCADE)
image = models.ForeignKey(
ImageObject, related_name='contest_work_images',
verbose_name='Работа участника', on_delete=models.CASCADE,
)
child_full_name = models.CharField(max_length=255)
age = models.SmallIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
likes = models.ManyToManyField('course.Like', blank=True)
class Meta:
verbose_name = 'Конкурсная работа'
verbose_name_plural = 'Конкурсные работы'
ordering = ('-created_at',)
@property
def img_width(self):
return self.image.image.width if self.image and self.image.image else None
@property
def img_height(self):
return self.image.image.height if self.image and self.image.image else None
def get_absolute_url(self):
return reverse_lazy('contest_work', args=[self.id])

@ -1,32 +1,32 @@
{% load thumbnail %}
{% if results %}
<div class="title">Галерея итогов обучения</div>
<div class="examples gallery">
{% for image in course.gallery.gallery_images.all %}
<div class="examples__item">
<a href="{{ image.img.image.url }}">
{% thumbnail image.img.image "165x165" crop="center" as im %}
<img class="examples__pic" src="{{ im.url }}">
{% endthumbnail %}
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="section section_gradient">
<div class="section__center center center_sm">
<div class="title">{{ content.title }}</div>
<div class="examples gallery">
{% for image in content.gallery_images.all %}
<div class="examples__item">
<a href="{{ image.img.image.url }}">
{% thumbnail image.img.image "165x165" crop="center" as im %}
<img class="examples__pic" src="{{ im.url }}">
{% endthumbnail %}
</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% load thumbnail %}
{% if results %}
<div class="title">Галерея итогов обучения</div>
<div class="examples gallery">
{% for image in course.gallery.gallery_images.all %}
<div class="examples__item">
<a href="{{ image.img.image.url }}">
{% thumbnail image.img.image "165x165" crop="center" as im %}
<img class="examples__pic" src="{{ im.url }}">
{% endthumbnail %}
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="section section_gradient">
<div class="section__center center center_sm">
<div class="title">{{ content.title }}</div>
<div class="examples gallery">
{% for image in content.gallery_images.all %}
<div class="examples__item">
<a href="{{ image.img.image.url }}">
{% thumbnail image.img.image "165x165" crop="center" as im %}
<img class="examples__pic" src="{{ im.url }}">
{% endthumbnail %}
</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}

@ -1,10 +1,10 @@
<div class="section section_border">
<div class="section__center center center_sm">
<div class="content-block title">
{{ content.title }}
</div>
<div>
<img class="content-block pic" src="{{ content.img.image.url }}" alt="">
</div>
</div>
</div>
<div class="section section_border">
<div class="section__center center center_sm">
<div class="content-block title">
{{ content.title }}
</div>
<div>
<img class="content-block pic" src="{{ content.img.image.url }}" alt="">
</div>
</div>
</div>

@ -1,10 +1,10 @@
<div class="section section_border">
<div class="section__center center center_sm">
<div class="content-block title">
{{ content.title }}
</div>
<div class="content-block text" style="margin-bottom:0">
{{ content.txt | safe }}
</div>
</div>
<div class="section section_border">
<div class="section__center center center_sm">
<div class="content-block title">
{{ content.title }}
</div>
<div class="content-block text" style="margin-bottom:0">
{{ content.txt | safe }}
</div>
</div>
</div>

@ -1,43 +1,43 @@
<div class="section section_border">
<div class="section__center center center_sm">
<div class="content-block title">
{{ content.title }}
</div>
<div class="iframe__container">
{% if 'youtube.com' in content.url or 'youtu.be' in content.url %}
<iframe src="https://www.youtube.com/embed/{{ content.video_index }}" frameborder="0" allow="autoplay; encrypted-media"
allowfullscreen></iframe>
{% elif 'vimeo.com' in content.url %}
<iframe src="https://player.vimeo.com/video/{{ content.video_index }}" frameborder="0" webkitallowfullscreen
mozallowfullscreen allowfullscreen>
</iframe>
{% endif %}
</div>
</div>
</div>
<style>
@media only screen and (max-width: 639px) {
iframe {
width: 100%;
height: 240px;
}
.iframe__container {
text-align: center;
width: 100%;
padding-bottom: 56.25%;
margin-bottom: -56.25%;
}
}
@media only screen and (min-width: 640px) {
.iframe__container {
text-align: center;
}
iframe {
width: 640px;
height: 360px;
}
}
<div class="section section_border">
<div class="section__center center center_sm">
<div class="content-block title">
{{ content.title }}
</div>
<div class="iframe__container">
{% if 'youtube.com' in content.url or 'youtu.be' in content.url %}
<iframe src="https://www.youtube.com/embed/{{ content.video_index }}" frameborder="0" allow="autoplay; encrypted-media"
allowfullscreen></iframe>
{% elif 'vimeo.com' in content.url %}
<iframe src="https://player.vimeo.com/video/{{ content.video_index }}" frameborder="0" webkitallowfullscreen
mozallowfullscreen allowfullscreen>
</iframe>
{% endif %}
</div>
</div>
</div>
<style>
@media only screen and (max-width: 639px) {
iframe {
width: 100%;
height: 240px;
}
.iframe__container {
text-align: center;
width: 100%;
padding-bottom: 56.25%;
margin-bottom: -56.25%;
}
}
@media only screen and (min-width: 640px) {
.iframe__container {
text-align: center;
}
iframe {
width: 640px;
height: 360px;
}
}
</style>

@ -0,0 +1,41 @@
{% extends "templates/lilcity/index.html" %}
{% block content %}
<upload-contest-work contest-id="{{ contest.id }}"></upload-contest-work>
<div class="main main_default" {% if contest.cover %}style="background-image: url({{ contest.cover.image.url }});"{% endif %}>
<div class="main__center center">
<div class="main__title">
{{ contest.title }}
</div>
<div class="main__subtitle">
{{ contest.description }}
</div>
<div class="main__actions">
<a class="main__btn btn" href=""
{# FIXME in prod: if request.user.is_authenticated and not contest_work_uploaded #}
{% if request.user.is_authenticated %}data-show-upload-contest-work{% else %}data-popup=".js-popup-auth"{% endif %}>Загрузить свою работу</a>
</div>
</div>
</div>
<div style="text-align: center;">
{% for content in contest.content.all %}
{% with template="content/blocks/"|add:content.ctype|add:".html" %}
{% include template %}
{% endwith %}
{% endfor %}
<div class="section">
<div class="section__center center">
<a id="gallery" name="gallery">
<div class="title title_center">Галерея</div>
</a>
<div class="text">
</div>
<contest-works contest-id="{{ contest.id }}" autoload="true"></contest-works>
</div>
</div>
</div>
{% endblock content %}

@ -0,0 +1,18 @@
{% extends "templates/lilcity/edit_index.html" %}
{% load static %}
{% block title %}
{% if object %}
Редактирование конкурса {{ object.title }}
{% else %}
Создание конкурса
{% endif %}
{% endblock title %}
{% block content %}
<contest-redactor {% if object and object.id %}:contest-id="{{ object.id }}"{% endif %}></contest-redactor>
{% endblock content %}
{% block foot %}
<script type="text/javascript" src="{% static 'contestRedactor.js' %}"></script>
<link rel="stylesheet" href="{% static 'contestRedactor.css' %}" />
{% endblock foot %}

@ -0,0 +1,112 @@
{% extends "templates/lilcity/index.html" %}
{% load static %}
{% block title %}{{ contest_work.child_full_name }}, {{ contest_work.age }} лет{% endblock title %}
{% block ogimage %}http://{{request.META.HTTP_HOST}}{{ contest_work.image.image.url }}{% endblock ogimage %}
{% block content %}
<div class="section" style="padding-bottom: 25px;">
<div class="section__center center center_sm">
<div class="go">
<a class="go__item" href="{% url 'contest' contest_work.contest.slug %}">
<div class="go__arrow">
<svg class="icon icon-arrow-left">
<use xlink:href="{% static 'img/sprite.svg' %}#icon-arrow-left"></use>
</svg>
</div>
<div class="go__title">Вернуться к&nbsp;галерее</div>
</a>
</div>
</div>
<div class="contest-work section__center center center_sm">
<div class="contest-work__img-wrap">
<img class="contest-work__img" src="{{ contest_work.image.image.url }}">
</div>
<div class="contest-work__info">
<div class="contest-work__bio">
<div>{{ contest_work.child_full_name }}</div>
<div class="contest-work__age">{{ contest_work.age }} {% if contest_work.age < 5 %}года{% else %}лет{% endif %}</div>
</div>
<div class="contest-work__likes">
<likes obj-type="contest_work" obj-id="{{ contest_work.id }}"
{% if user_liked %}:user-liked="true"{% endif %} likes="{{ contest_work.likes.count }}"></likes>
</div>
</div>
</div>
</div>
<div class="section" style="padding: 0;">
<div class="section__center center center_sm">
<div class="go">
{% if prev_contest_work %}
<a class="go__item" href="{% url 'contest_work' prev_contest_work.id %}">
<div class="go__arrow">
<svg class="icon icon-arrow-left">
<use xlink:href="{% static 'img/sprite.svg' %}#icon-arrow-left"></use>
</svg>
</div>
<div class="go__title">Предыдущая работа</div>
</a>
{% else %}
<div class="go__item"></div>
{% endif %}
{% if next_contest_work %}
<a class="go__item" href="{% url 'contest_work' next_contest_work.id %}">
<div class="go__title">Следующая работа</div>
<div class="go__arrow">
<svg class="icon icon-arrow-right">
<use xlink:href="{% static 'img/sprite.svg' %}#icon-arrow-right"></use>
</svg>
</div>
</a>
{% else %}
<div class="go__item"></div>
{% endif %}
</div>
</div>
</div>
<div class="section">
<div class="section__center center center_sm">
{% include 'templates/blocks/share.html' with share_object_name='работой' %}
</div>
</div>
<div class="section section_gray">
<div class="section__center center center_sm">
<div class="title">Задавайте вопросы:</div>
<div class="questions">
{% if request.user.is_authenticated %}
<form class="questions__form" method="post" action="{% url 'contest_work_comment' contest_work_id=contest_work.id %}">
<input type="hidden" name="reply_id">
<div class="questions__ava ava">
<img
class="ava__pic"
{% if request.user.photo %}
src="{{ request.user.photo.url }}"
{% else %}
src="{% static 'img/user_default.jpg' %}"
{% endif %}
>
</div>
<div class="questions__wrap">
<div class="questions__reply-info">В ответ на
<a href="" class="questions__reply-anchor">этот комментарий</a>.
<a href="#" class="questions__reply-cancel grey-link">Отменить</a>
</div>
<div class="questions__field">
<textarea class="questions__textarea" placeholder="Спросите автора курса интересующие вас вопросы"></textarea>
</div>
<button class="questions__btn btn btn_light">ОТПРАВИТЬ</button>
</div>
</form>
{% else %}
<div>Только зарегистрированные пользователи могут оставлять комментарии.</div>
{% endif %}
<div class="questions__list">
{% include "templates/blocks/comments.html" with object=contest_work %}
</div>
</div>
</div>
</div>
{% endblock content %}

@ -1,2 +1,110 @@
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.template import loader
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView, DetailView
from apps.content.models import Contest, ContestWork
from apps.course.models import ContestWorkComment
@method_decorator(login_required, name='dispatch')
class ContestEditView(TemplateView):
template_name = 'content/contest_edit.html'
def get(self, request, pk=None, lesson=None):
if pk:
self.object = get_object_or_404(Contest, pk=pk)
else:
self.object = Contest()
return super().get(request)
def get_context_data(self):
context = super().get_context_data()
context['object'] = self.object
return context
class ContestView(DetailView):
model = Contest
context_object_name = 'contest'
template_name = 'content/contest.html'
query_pk_and_slug = True
def get_context_data(self, *args, **kwargs):
context = super().get_context_data()
if self.request.user.is_authenticated:
context['contest_work_uploaded'] = ContestWork.objects.filter(user=self.request.user).exists()
return context
class ContestWorkView(DetailView):
model = ContestWork
context_object_name = 'contest_work'
template_name = 'content/contest_work.html'
def get_context_data(self, *args, **kwargs):
context = super().get_context_data()
prev_contest_work = ContestWork.objects.filter(created_at__gt=self.object.created_at)[:1]
if prev_contest_work:
context['prev_contest_work'] = prev_contest_work[0]
next_contest_work = ContestWork.objects.filter(created_at__lt=self.object.created_at)[:1]
if next_contest_work:
context['next_contest_work'] = next_contest_work[0]
context['user_liked'] = self.object.likes.filter(user=self.request.user).exists() \
if self.request.user.is_authenticated else False
return context
@login_required
@csrf_exempt
@require_http_methods(['POST'])
def contest_work_comment(request, contest_work_id):
try:
contest_work = ContestWork.objects.get(id=contest_work_id)
except ContestWork.DoesNotExist:
return JsonResponse({
'success': False,
'errors': ['Contest_work with id f{contest_work_id} not found']
}, status=400)
else:
reply_to = request.POST.get('reply_id', 0)
comment = request.POST.get('comment', '')
if not comment:
return JsonResponse({
'success': False,
'errors': ['Comment can not be empty']
}, status=400)
if not int(reply_to):
contest_work_comment = ContestWorkComment.objects.create(
author=request.user,
content=comment,
contest_work=contest_work,
)
else:
try:
_contest_work_comment = ContestWorkComment.objects.get(id=reply_to)
except ContestWorkComment.DoesNotExist:
return JsonResponse({
'success': False,
'errors': ['LessonComment with id f{reply_to} not found']
}, status=400)
else:
contest_work_comment = ContestWorkComment.objects.create(
author=request.user,
content=comment,
contest_work=contest_work,
parent=_contest_work_comment,
)
ctx = {'node': contest_work_comment, 'user': request.user}
html = loader.render_to_string('templates/blocks/comment.html', ctx)
return JsonResponse({
'success': True,
'comment': html,
})

@ -0,0 +1,18 @@
# Generated by Django 2.0.6 on 2018-08-08 01:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0039_lesson_position'),
]
operations = [
migrations.AddField(
model_name='course',
name='age',
field=models.SmallIntegerField(choices=[(0, ''), (1, 'до 5'), (2, '5-7'), (3, '7-9'), (4, '9-12'), (5, '12-15'), (6, '15-18'), (7, 'от 18')], default=0),
),
]

@ -0,0 +1,36 @@
# Generated by Django 2.0.6 on 2018-08-13 13:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('content', '0021_auto_20180813_1306'),
('course', '0040_course_age'),
]
operations = [
migrations.CreateModel(
name='ContestWorkComment',
fields=[
('comment_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='course.Comment')),
('contest_work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='content.ContestWork')),
],
options={
'ordering': ('tree_id', 'lft'),
'abstract': False,
'base_manager_name': 'objects',
},
bases=('course.comment',),
),
migrations.AlterModelOptions(
name='course',
options={'ordering': ['-is_featured'], 'verbose_name': 'Курс', 'verbose_name_plural': 'Курсы'},
),
migrations.RemoveField(
model_name='course',
name='age',
),
]

@ -15,7 +15,7 @@ from project.mixins import BaseModel, DeactivatedMixin
from .manager import CategoryQuerySet
from apps.content.models import ImageObject, Gallery, Video
from apps.content.models import ImageObject, Gallery, Video, ContestWork
User = get_user_model()
@ -284,3 +284,7 @@ class LessonComment(Comment):
class Meta(Comment.Meta):
verbose_name = 'Комментарий урока'
verbose_name_plural = 'Комментарии уроков'
class ContestWorkComment(Comment):
contest_work = models.ForeignKey(ContestWork, on_delete=models.CASCADE, related_name='comments')

@ -227,7 +227,7 @@
</div>
{% for content in course.content.all %}
{% with template="course/content/"|add:content.ctype|add:".html" %}
{% with template="content/blocks/"|add:content.ctype|add:".html" %}
{% include template %}
{% endwith %}

@ -63,7 +63,7 @@
</div>
{% for content in lesson.content.all %}
{% with template="course/content/"|add:content.ctype|add:".html" %}
{% with template="content/blocks/"|add:content.ctype|add:".html" %}
{% include template %}
{% endwith %}

@ -50,6 +50,11 @@
<div class="header__group">
<a class="header__section" href="https://blog.lil.school">БЛОГ</a>
</div>
{% comment %}
<div class="header__group">
<a class="header__section" href="{% url 'contest' 'august' %}">КОНКУРС</a>
</div>
{% endcomment %}
</nav>
</div>
{% include 'templates/blocks/user_menu.html' %}

@ -0,0 +1,10 @@
{% load static %}
<script>
window.LIL_STORE = {
staticUrl: '{% static "" %}',
accessToken: '{{ request.user.auth_token }}',
user: {
id: {{ request.user.id|default:'null' }},
}
};
</script>

@ -3,7 +3,7 @@
<div class="popup__wrap js-popup-wrap">
<button class="popup__close js-popup-close">
<svg class="icon icon-close">
<use xlink:href={% static "img/sprite.svg" %}#icon-close></use>
<use xlink:href="{% static 'img/sprite.svg' %}#icon-close"></use>
</svg>
</button>
<div class="popup__body">

@ -1,7 +1,7 @@
{% load static %}
<div class="share">
<div class="share__title">Поделиться {% if livelesson or lesson %}уроком{% else %}курсом{% endif %}</div>
<div class="share__title">Поделиться {% if share_object_name %}{{ share_object_name }}{% else %}{% if livelesson or lesson %}уроком{% else %}курсом{% endif %}{% endif %}</div>
<div class="share__list likely">
<a class="share__item facebook" href="#" data-url="http://{{request.META.HTTP_HOST}}{{object.get_absolute_url}}">
<svg class="icon icon-share-facebook">

@ -315,6 +315,7 @@
</div>
</div>
</div>
{% include 'templates/blocks/lil_store_js.html' %}
<script type="text/javascript" src={% static "app.js" %}></script>
<script>
var schoolDiscount = parseFloat({{ config.SERVICE_DISCOUNT }});

@ -130,6 +130,7 @@
{% include "templates/blocks/popup_course_lock.html" %}
{% include "templates/blocks/popup_subscribe.html" %}
</div>
{% include 'templates/blocks/lil_store_js.html' %}
<script type="text/javascript" src={% static "app.js" %}></script>
<script>
var schoolDiscount = parseFloat({{ config.SERVICE_DISCOUNT }});

@ -18,7 +18,7 @@ from django.contrib import admin
from django.views.generic import TemplateView
from django.urls import path, include
from apps.content.views import ContestEditView, ContestView, ContestWorkView, contest_work_comment
from apps.course.views import (
CoursesView, likes, coursecomment,
CourseView, LessonView, SearchView,
@ -83,6 +83,11 @@ urlpatterns = [
path('api/v1/', include(('api.v1.urls', 'api_v1'))),
path('school/', include(('apps.school.urls', 'school'))),
path('test', TemplateView.as_view(template_name='templates/lilcity/test.html'), name='test'),
path('contest/create', ContestEditView.as_view(), name='contest_create'),
path('contest/<int:pk>/edit', ContestEditView.as_view(), name='contest_edit'),
path('contest/<str:slug>/', ContestView.as_view(), name='contest'),
path('contest-work/<int:pk>/', ContestWorkView.as_view(), name='contest_work'),
path('contest-work/<int:contest_work_id>/comment', contest_work_comment, name='contest_work_comment'),
]

@ -0,0 +1,361 @@
<template>
<form @submit.prevent="save">
<div class="info">
<div class="info__section" :style="coverBackgroundStyle">
<div class="info__main">
<div class="info__head">
<div class="info__upload upload">
Загрузить фон
<input type="file" class="upload__file" @change="onCoverImageSelected">
</div>
</div>
<div class="info__title">
<div class="info__field field field_info"
v-bind:class="{ error: $v.contest.title.$dirty && $v.contest.title.$invalid }">
<div class="field__label">Название</div>
<div class="field__wrap">
<textarea class="field__textarea"
rows="1"
v-autosize="contest.title"
@change="onTitleInput"
v-model="contest.title"></textarea>
</div>
</div>
<div class="info__field field field_info field_short_description">
<div class="field__label">Описание</div>
<div class="field__wrap">
<textarea class="field__textarea"
v-autosize="contest.description"
v-model="contest.description"></textarea>
</div>
</div>
</div>
<div class="info__foot">
<div class="info__field field">
<div class="field__label field__label_gray">ССЫЛКА</div>
<div class="field__wrap">
<input type="text" class="field__input" v-model="contest.slug" @input="slugChanged = true">
</div>
<div class="field__wrap field__wrap--additional">{{ contestFullUrl }}</div>
</div>
<div class="info__field field">
<div class="field__label">ДАТА НАЧАЛА</div>
<div class="field__wrap">
<vue-datepicker input-class="field__input" v-model="contest.date_start" language="ru" format="dd/MM/yyyy"/>
</div>
</div>
<div class="info__field field">
<div class="field__label">ДАТА ОКОНЧАНИЯ</div>
<div class="field__wrap">
<vue-datepicker input-class="field__input" v-model="contest.date_end" language="ru" format="dd/MM/yyyy"/>
</div>
</div>
<button type="submit">Save</button>
</div>
</div>
</div>
<div class="info__sidebar">
<div class="info__wrap">
<div class="info__fieldset">
</div>
</div>
</div>
</div>
<div class="section">
<div class="section__center center">
<div class="kit">
<div class="kit__body">
<vue-draggable v-model="contest.content" @start="drag=true" @end="drag=false" :options="{ handle: '.sortable__handle' }">
<div v-for="(block, index) in contest.content" :key="block.data.id ? block.data.id : block.data.guid">
<block-text v-if="block.type === 'text'"
:index="index"
:title.sync="block.data.title"
:text.sync="block.data.text"
v-on:remove="onBlockRemoved"
:access-token="accessToken"/>
<block-image-text v-if="block.type === 'image-text'"
:index="index"
:title.sync="block.data.title"
:text.sync="block.data.text"
:image-id.sync="block.data.image_id"
:image-url.sync="block.data.image_url"
v-on:remove="onBlockRemoved"
:access-token="accessToken"/>
<block-image v-if="block.type === 'image'"
:index="index"
:title.sync="block.data.title"
:image-id.sync="block.data.image_id"
:image-url.sync="block.data.image_url"
v-on:remove="onBlockRemoved"
:access-token="accessToken"/>
<block-images v-if="block.type === 'images'"
:index="index"
:title.sync="block.data.title"
:text.sync="block.data.text"
:images.sync="block.data.images"
v-on:remove="onBlockRemoved"
:access-token="accessToken"/>
<block-video v-if="block.type === 'video'"
:index="index"
:title.sync="block.data.title"
v-on:remove="onBlockRemoved"
:video-url.sync="block.data.video_url"/>
</div>
</vue-draggable>
<block-add v-on:added="onBlockAdded"/>
</div>
</div>
</div>
</div>
</form>
</template>
<script>
import BlockText from './blocks/BlockText'
import BlockImage from './blocks/BlockImage'
import BlockImages from './blocks/BlockImages'
import BlockImageText from './blocks/BlockImageText'
import BlockVideo from './blocks/BlockVideo'
import BlockAdd from "./blocks/BlockAdd";
import {api} from "../js/modules/api";
import DatePicker from 'vuejs-datepicker';
import Draggable from 'vuedraggable';
import slugify from 'slugify';
import {required, minValue, numeric, url } from 'vuelidate/lib/validators'
import _ from 'lodash';
import moment from 'moment'
export default {
name: 'contest-redactor',
props: ["contestId"],
data() {
return {
loading: false,
slugChanged: false,
contest: {
coverImage: '',
coverImageId: null,
title: '',
description: '',
content: [],
date_start: '',
date_end: '',
slug: '',
active: true,
}
}
},
computed: {
accessToken() {
return window.LIL_STORE.accessToken;
},
coverBackgroundStyle() {
return this.contest.coverImage ? `background-image: url(${this.contest.coverImage});` : '';
},
contestFullUrl() {
return `https://lil.city/contest/${this.contest.slug}`;
},
},
validations() {
return {
contest: {
title: {
required
},
},
};
},
mounted() {
if (this.contestId) {
this.load();
}
},
methods: {
onBlockRemoved(blockIndex) {
const blockToRemove = this.contest.content[blockIndex];
// Удаляем блок из Vue
this.contest.content.splice(blockIndex, 1);
// Если блок уже был записан в БД, отправляем запрос на сервер на удаление блока из БД
if (blockToRemove.data.id) {
api.removeContentBlock(blockToRemove, this.accessToken);
}
},
onBlockAdded(blockData) {
this.contest.content.push(blockData);
},
onTitleInput() {
this.$v.contest.title.$touch();
if (!this.slugChanged) {
this.contest.slug = slugify(this.contest.title);
}
},
onCoverImageSelected(event) {
let file = event.target.files[0];
let reader = new FileReader();
reader.onload = () => {
this.$set(this.contest, 'coverImage', reader.result);
api.uploadImage(reader.result, this.accessToken)
.then((response) => {
this.contest.coverImageId = response.data.id;
})
.catch((error) => {
//console.log('error', error);
});
};
if (file) {
reader.readAsDataURL(file);
}
},
processContestJson(data) {
this.contest = {
coverImage: data.cover && data.cover.image || null,
coverImageId: data.cover && data.cover.id || null,
id: data.id,
title: data.title,
description: data.description,
content: api.convertContentResponse(data.content),
date_start: data.date_start,
date_end: data.date_end,
slug: data.slug,
active: data.active,
};
},
load() {
this.loading = true;
const request = api.get(`/api/v1/contests/${this.contestId}/`, {
headers: {
'Authorization': `Token ${window.LIL_STORE.accessToken}`,
}
});
request
.then((response) => {
this.processContestJson(response.data);
this.$nextTick(() => {
this.loading = false;
});
})
.catch((err) => {
this.loading = false;
});
return request;
},
save() {
let data = _.pick(this.contest, ['title', 'description', 'slug', 'active']);
data.date_start = data.date_start ? moment(data.date_start).format('MM-DD-YYYY') : null;
data.date_end = data.date_end ? moment(data.date_end).format('MM-DD-YYYY') : null;
data.cover = this.contest.coverImageId || '';
data.content = this.contest.content.map((block, index) => {
if (block.type === 'text') {
return {
'type': 'text',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'txt': block.data.text,
}
}
} else if (block.type === 'image') {
return {
'type': 'image',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'img': block.data.image_id,
}
}
} else if (block.type === 'image-text') {
return {
'type': 'image-text',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'img': block.data.image_id,
'txt': block.data.text,
}
}
} else if (block.type === 'images') {
return {
'type': 'images',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'images': block.data.images.map((galleryImage) => {
return {
'id': galleryImage.id ? galleryImage.id : null,
'img': galleryImage.img,
}
}),
}
}
} else if (block.type === 'video') {
return {
'type': 'video',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'url': block.data.video_url,
}
}
}
});
const request = this.contest.id
? api.put(`/api/v1/contests/${this.contest.id}/`, data, {
headers: {
'Authorization': `Token ${window.LIL_STORE.accessToken}`,
}
})
: api.post('/api/v1/contests/', data, {
headers: {
'Authorization': `Token ${window.LIL_STORE.accessToken}`,
}
});
request.then((response) => {
if(this.contest.id) {
this.contest = this.processContestJson(response.data);
}
else {
window.location.href = `/contest/${response.data.id}/edit`;
}
})
.catch((err) => {
//this.contestSaving = false;
});
}
},
components: {
BlockAdd,
'vue-datepicker': DatePicker,
'block-text': BlockText,
'block-image': BlockImage,
'block-image-text': BlockImageText,
'block-images': BlockImages,
'block-video': BlockVideo,
'vue-draggable': Draggable,
}
};
</script>
<style>
</style>

@ -0,0 +1,86 @@
<template>
<div class="contest-works">
<div class="contest-works__works">
<contest-work v-for="contestWork in contestWorks" :key="contestWork.id" :contest-work="contestWork"></contest-work>
</div>
<div v-show="loading" class="contest-works__loader"><div class="loading-loader"></div></div>
<div v-if="loaded && !contestWorks.length" class="contest-works__no-works">Здесь вы сможете увидеть работы участников после их добавления</div>
</div>
</template>
<script>
import {api} from "../js/modules/api";
import $ from 'jquery';
import ContestWork from "./blocks/ContestWork.vue";
export default {
name: "contest-works",
props: ['contestId', 'autoload'],
data(){
return {
page: 1,
lastPage: null,
loading: false,
loaded: false,
contestWorks: [],
};
},
mounted() {
this.load();
if(this.autoload) {
$(window).scroll(() => {
const pos = $(this.$el).offset().top + this.$el.clientHeight - 100;
if($(window).scrollTop() + $(window).height() >= pos
&& !this.loading && !this.lastPage) {
this.page += 1;
this.load();
}
});
}
},
methods: {
load() {
this.loading = true;
api.get(`/api/v1/contest-works/?contest=${this.contestId}&page=${this.page}`)
.then((response) => {
this.loading = false;
this.loaded = true;
if(this.page > 1){
this.contestWorks = this.contestWorks.concat(response.data.results);
}
else{
this.contestWorks = response.data.results;
}
})
.catch((response) => {
this.loading = false;
this.loaded = true;
if(response.response.status == 404){
this.lastPage = this.page;
}
});
}
},
components: {ContestWork},
}
</script>
<style>
.contest-works {
width: 100%;
}
.contest-works__works {
column-width: 300px;
column-gap: 20px;
text-align: left;
}
.contest-works__loader {
width: 100%;
height: 30px;
position: relative;
}
.contest-works__no-works {
text-align: center;
width: 100%;
}
</style>

@ -0,0 +1,193 @@
<template>
<div ref="popup" class="upload-contest-work popup" @click.prevent="hide">
<div class="popup__wrap popup__wrap_md" @click.stop>
<button class="popup__close" @click.prevent="hide">
<svg class="icon icon-close">
<use v-bind="{'xlink:href': $root.store.staticUrl + 'img/sprite.svg' + '#icon-close' }"></use>
</svg>
</button>
<div class="popup__body">
<form class="form">
<div class="title">
Чтобы принять участие<br>в конкурсе, заполните поля<br>и прикрепите изображение
<img src="/static/img/curve-1.svg" class="text__curve">
</div>
<div class="field"
v-bind:class="{ error: $v.contestWork.child_full_name.$dirty && $v.contestWork.child_full_name.$invalid }">
<div class="field__label">ИМЯ И ФАМИЛИЯ РЕБЕНКА</div>
<div class="field__wrap">
<input class="field__input" type="text" v-model="contestWork.child_full_name">
</div>
<div class="field__error"></div>
</div>
<div class="field"
v-bind:class="{ error: $v.contestWork.age.$dirty && $v.contestWork.age.$invalid }">
<div class="field__label">ВОЗРАСТ</div>
<div class="field__wrap">
<input class="field__input" type="number" v-model="contestWork.age" style="width: 100px;">
</div>
<div class="field__error"></div>
</div>
<div class="field">
<lil-image :image-id.sync="contestWork.imageId" :image-url.sync="contestWork.imageUrl"
v-on:update:imageId="onUpdateImageId" :access-token="$root.store.accessToken" />
</div>
<div class="field" style="text-align: center;">
<button class="btn" tabindex="3" @click.prevent="save">Отправить на конкурс</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
import {api} from "../js/modules/api";
import LilImage from './blocks/Image';
import {showNotification} from "../js/modules/notification";
import {required, minValue, numeric, url } from 'vuelidate/lib/validators';
import $ from 'jquery';
export default {
name: 'upload-contest-work',
props: ['contestId'],
data() {
return {
fields: {
image: "Изображение",
child_full_name: "Имя и фамилия ребенка",
age: 'Возраст',
},
contestWork: {
contest: this.contestId,
image: null,
imageId: null,
imageUrl: '',
child_full_name: '',
age: '',
}
}
},
validations() {
return {
contestWork: {
image: {
required
},
child_full_name: {
required
},
age: {
required, numeric
},
},
};
},
mounted() {
$('[data-show-upload-contest-work]').click((e) => {
e.preventDefault();
e.stopPropagation();
this.clear();
this.show();
});
},
methods: {
clear() {
Object.assign(this.$data, this.$options.data.apply(this))
},
onUpdateImageId(imageId) {
this.contestWork.image = imageId;
},
show() {
const $popup = $(this.$refs.popup)
$('body').addClass('no-scroll');
$popup.addClass('visible');
setTimeout(() => {
$popup.addClass('open');
}, 300);
},
hide() {
const $popup = $(this.$refs.popup)
$('body').removeClass('no-scroll');
$popup.removeClass('visible');
setTimeout(() => {
$popup.removeClass('open');
}, 300);
},
validate() {
if (this.$v.contestWork.$invalid) {
for(let i in this.$v.contestWork) {
if(this.$v.contestWork[i].$invalid) {
showNotification("error", "Ошибка валидации поля " + this.fields[i]);
}
}
return false;
}
return true;
},
save() {
if(! this.validate()) {
return false;
}
let data = this.contestWork;
data.contest = this.contestId;
data.user = this.$root.store.user.id;
const request = api.post(`/api/v1/contest-works/`, data, {
headers: {
'Authorization': `Token ${this.$root.store.accessToken}`,
}
});
request.then((response) => {
if(+response.data.id){
this.$emit('add:contest-work', response.data);
this.hide();
window.location.reload();
}
});
}
},
components: {
'lil-image': LilImage,
}
}
</script>
<style lang="scss">
.upload-contest-work {
.popup__wrap {
padding: 35px 35px 0;
}
.title {
text-align: center; font-size: 24px;
.text__curve {
right: 55px;
width: 170px;
bottom: -40px;
}
}
.kit__photo {
height: 400px;
}
.kit__photo.has-image {
border: none;
}
.kit__photo-image {
max-height: 400px;
height: auto;
width: auto;
}
.kit__file {
bottom: 0;
}
}
</style>

@ -0,0 +1,62 @@
<template>
<div class="contest-work-item">
<a :href="`/contest-work/${contestWork.id}/`">
<img class="contest-work-item__img" :src="contestWork.image.image" />
</a>
<div class="contest-work-item__info">
<div class="contest-work-item__bio">
<div>{{ contestWork.child_full_name }}</div>
<div class="contest-work-item__age">{{ contestWork.age }} {{ contestWork.age < 5 ? 'года' : 'лет' }}</div>
</div>
<div class="contest-work-item__likes">
<likes obj-type="contest_work" :obj-id="contestWork.id" :user-liked="contestWork.user_liked"
:likes="contestWork.likes"></likes>
</div>
</div>
</div>
</template>
<script>
import Likes from './Likes.vue';
export default {
name: "contest-work",
props: ['contestWork'],
components: {Likes},
}
</script>
<style lang="scss">
.contest-work-item {
break-inside: avoid;
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
transition: opacity .4s ease-in-out;
text-transform: uppercase;
font-weight: bold;
color: black;
border: 1px solid #ececec;
display: block;
}
.contest-work-item__img {
width: 100%;
height: auto;
}
.contest-work-item__info {
display: flex;
padding: 5px 10px;
}
.contest-work-item__age {
color: #919191;
}
.contest-work-item__bio {
flex: calc(100% - 70px);
}
@media only screen and (min-width: 1023px) {
.contest-works:hover .contest-work-item:not(:hover) {
opacity: 0.4;
}
}
</style>

@ -1,5 +1,5 @@
<template>
<div class="kit__photo" :class="{'loading': loading}">
<div class="kit__photo" :class="{ 'loading': loading, 'has-image': !! imageUrl }">
<svg class="icon icon-add-plus" v-if="!imageUrl">
<use xlink:href="/static/img/sprite.svg#icon-add-plus"></use>
</svg>

@ -0,0 +1,74 @@
<template>
<div class="likes" :class="{ 'likes_liked': userLikedProp }">
<span>{{ likesProp }}</span><span class="likes__like" @click="addLike"
v-bind="{ 'data-popup': $root.store.user.id ? '' : '.js-popup-auth' }">
<svg class="likes__icon icon icon-like">
<use v-bind="{'xlink:href': $root.store.staticUrl + 'img/sprite.svg' + '#icon-like' }"></use>
</svg></span>
</div>
</template>
<script>
import {api} from "../../js/modules/api";
export default {
name: 'likes',
props: ['likes', 'userLiked', 'objType', 'objId'],
data() {
return {
likesProp: +this.likes || 0,
userLikedProp: this.userLiked || false,
}
},
methods: {
addLike(event) {
if(this.userLikedProp){
return;
}
if(this.$root.store.user.id) {
event.stopPropagation();
api.post('/api/v1/likes/', {
user: this.$root.store.user.id, // FIXME
obj_type: this.objType,
obj_id: this.objId,
}, {
headers: {
'Authorization': `Token ${this.$root.store.accessToken}`,
}
})
.then((response) => {
if (response.data && response.data.id) {
this.userLikedProp = true;
this.likesProp += 1;
this.$emit('liked');
}
});
}
}
}
}
</script>
<style>
.likes {
font-weight: bold;
text-align: right;
}
.likes__like {
cursor: pointer;
margin-left: 5px;
}
.likes_liked .likes__like {
cursor: default;
}
.likes__icon {
margin-bottom: -3px;
}
.likes_liked .likes__icon {
fill: #d40700;
}
</style>

@ -17,3 +17,30 @@ import "./modules/notification";
import "./modules/mixpanel";
import "../sass/app.sass";
import Vue from 'vue';
import Vuelidate from 'vuelidate';
import UploadContestWork from '../components/UploadContestWork.vue';
import ContestWorks from '../components/ContestWorks.vue';
import Likes from '../components/blocks/Likes.vue';
Vue.use(Vuelidate);
if (process.env.NODE_ENV === 'development') {
// Enable vue-devtools
Vue.config.devtools = true;
}
const app = new Vue({
el: '#lilcity-vue-app',
data() {
return {
store: window.LIL_STORE,
}
},
components: {
UploadContestWork,
ContestWorks,
Likes,
}
});

@ -0,0 +1,19 @@
import Vue from 'vue'
import Vuelidate from 'vuelidate'
import VueAutosize from '../components/directives/autosize'
import ContestRedactor from '../components/ContestRedactor.vue'
if (process.env.NODE_ENV === 'development') {
// Enable vue-devtools
Vue.config.devtools = true;
}
Vue.use(VueAutosize);
Vue.use(Vuelidate);
let app = new Vue({
el: '#lilcity-vue-app',
components: {
'contest-redactor': ContestRedactor,
}
});

@ -6,10 +6,13 @@ $(document).ready(function () {
popup;
body.on('click', '[data-popup]', function(e){
let data = $(this).data('popup');
if(! data) {
return true;
}
e.preventDefault();
e.stopPropagation();
let data = $(this).data('popup');
popup = $(data);
showPopup();

@ -3906,7 +3906,7 @@ a.grey-link
position: relative
padding-left: 17px
&:before
content: ''
content: '-'
position: absolute
top: 0
left: 0
@ -4117,3 +4117,37 @@ a
.anchor
padding-top: 100px
margin-top: -100px
.contest-work
text-transform: uppercase;
font-weight: bold;
&__img-wrap
width: 100%;
text-align: center;
&__img
max-width: 100%;
&__info
display: flex;
padding: 5px 10px;
&__age
color: #919191;
&__bio
flex: calc(100% - 70px);
.loading-loader
content: ''
position: absolute
top: 50%
left: 50%
width: 24px
height: 24px
margin: -12px 0 0 -12px
border: 3px solid #B5B5B5
border-left: 3px solid transparent
border-radius: 50%
animation: loading .6s infinite linear

@ -9,6 +9,7 @@ module.exports = {
entry: {
app: "./src/js/app.js",
courseRedactor: "./src/js/course-redactor.js",
contestRedactor: "./src/js/contest-redactor.js",
mixpanel: "./src/js/third_party/mixpanel-2-latest.js",
sprite: glob('./src/icons/*.svg'),
images: glob('./src/img/*'),
@ -116,6 +117,10 @@ module.exports = {
},
watch: NODE_ENV === 'development',
watchOptions: {
ignored: 'node_modules',
poll: 2000,
},
devtool: NODE_ENV === 'development' ? 'source-map' : false
};

Loading…
Cancel
Save