diff --git a/boofilsic/urls.py b/boofilsic/urls.py index 43778b6f..1c9cec13 100644 --- a/boofilsic/urls.py +++ b/boofilsic/urls.py @@ -25,7 +25,13 @@ urlpatterns = [ path('users/', include('users.urls')), path('books/', include('books.urls')), path('movies/', include('movies.urls')), + path('music/', include('music.urls')), path('announcement/', include('management.urls')), path('', include('common.urls')), ] + +if settings.DEBUG: + from django.conf.urls.static import static + urlpatterns += static(settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT) diff --git a/books/forms.py b/books/forms.py index 88c58c17..da7ecee6 100644 --- a/books/forms.py +++ b/books/forms.py @@ -77,42 +77,16 @@ class BookForm(forms.ModelForm): return isbn -class BookMarkForm(forms.ModelForm): - IS_PRIVATE_CHOICES = [ - (True, _("仅关注者")), - (False, _("公开")), - ] - STATUS_CHOICES = [(v, BookMarkStatusTranslator(v)) for v in MarkStatusEnum.values] +class BookMarkForm(MarkForm): + + STATUS_CHOICES = [(v, BookMarkStatusTranslator(v)) + for v in MarkStatusEnum.values] - id = forms.IntegerField(required=False, widget=forms.HiddenInput()) - share_to_mastodon = forms.BooleanField(label=_("分享到长毛象"), initial=True, required=False) - rating = forms.IntegerField(validators=[RatingValidator()], widget=forms.HiddenInput(), required=False) status = forms.ChoiceField( label=_(""), widget=forms.RadioSelect(), choices=STATUS_CHOICES ) - is_private = RadioBooleanField( - label=_("可见性"), - initial=True, - choices=IS_PRIVATE_CHOICES - ) - tags = TagField( - required=False, - widget=TagInput(attrs={'placeholder': _("回车增加标签")}), - label = _("标签") - ) - text = forms.CharField( - required=False, - widget=forms.Textarea( - attrs={ - "placeholder": _("最多只能写360字哦~"), - "maxlength": 360 - } - ), - - label = _("短评"), - ) class Meta: model = BookMark @@ -132,18 +106,8 @@ class BookMarkForm(forms.ModelForm): } -class BookReviewForm(forms.ModelForm): - IS_PRIVATE_CHOICES = [ - (True, _("仅关注者")), - (False, _("公开")), - ] - share_to_mastodon = forms.BooleanField(label=_("分享到长毛象"), initial=True, required=False) - id = forms.IntegerField(required=False, widget=forms.HiddenInput()) - is_private = RadioBooleanField( - label=_("可见性"), - initial=True, - choices=IS_PRIVATE_CHOICES - ) +class BookReviewForm(ReviewForm): + class Meta: model = BookReview fields = [ diff --git a/books/models.py b/books/models.py index 9eecb479..fcd387ad 100644 --- a/books/models.py +++ b/books/models.py @@ -47,7 +47,7 @@ class Book(Entity): # since data origin is not formatted and might be CNY USD or other currency, use char instead price = models.CharField(_("pricing"), blank=True, default='', max_length=50) pages = models.PositiveIntegerField(_("pages"), null=True, blank=True) - isbn = models.CharField(_("ISBN"), blank=True, null=True, max_length=20, db_index=True) + isbn = models.CharField(_("ISBN"), blank=True, null=False, max_length=20, db_index=True, default='') # to store previously scrapped data cover = models.ImageField(_("cover picture"), upload_to=book_cover_path, default=DEFAULT_BOOK_IMAGE, blank=True) contents = models.TextField(blank=True, default="") diff --git a/books/templates/books/delete.html b/books/templates/books/delete.html index 37d9520d..bde89c96 100644 --- a/books/templates/books/delete.html +++ b/books/templates/books/delete.html @@ -52,9 +52,12 @@ {% endif %} {% if book.last_editor %} - -
{% trans '最近编辑者:' %}{{ book.last_editor | default:"" }}
-
+
+ {% trans '最近编辑者:' %} + + {{ book.last_editor | default:"" }} + +
{% endif %}
{% trans '上次编辑时间:' %}{{ book.edited_time }}
diff --git a/books/templates/books/detail.html b/books/templates/books/detail.html index 0585a5cc..0a21c58f 100644 --- a/books/templates/books/detail.html +++ b/books/templates/books/detail.html @@ -99,7 +99,7 @@
{% trans '编辑这本书' %} {% if user.is_staff %} - / {% trans '删除' %} + / {% trans '删除' %} {% endif %}
@@ -120,20 +120,18 @@
+ {% if book.brief %}
{% trans '简介' %}
- {% if book.brief %}

{{ book.brief | linebreaksbr }}

展开全部
- {% else %} -
{% trans '暂无简介' %}
- {% endif %} - +
+ {% endif %} {% if book.contents %}
@@ -160,7 +158,7 @@ {% endif %} {% if others_mark.is_private %} - + {% endif %} {{ others_mark.edited_time }} {% if others_mark.text %} @@ -184,7 +182,7 @@
  • {{ others_review.owner.username }} {% if others_review.is_private %} - + {% endif %} {{ others_review.edited_time }} {{ others_review.title }} @@ -212,7 +210,7 @@ {% endif %} {% endif %} {% if mark.is_private %} - + {% endif %} {% trans '修改' %} @@ -255,7 +253,7 @@ {% trans '我的评论' %} {% if review.is_private %} - + {% endif %} diff --git a/books/templates/books/mark_list.html b/books/templates/books/mark_list.html index 8ca02ad8..99c390ea 100644 --- a/books/templates/books/mark_list.html +++ b/books/templates/books/mark_list.html @@ -45,8 +45,8 @@ data-rating-score="{{ mark.rating | floatformat:"0" }}"> {% endif %} {% if mark.is_private %} - + + diff --git a/books/templates/books/review_detail.html b/books/templates/books/review_detail.html index 9f294ca1..bfde5434 100644 --- a/books/templates/books/review_detail.html +++ b/books/templates/books/review_detail.html @@ -37,8 +37,7 @@ {{ review.title }} {% if review.is_private %} - + diff --git a/books/templates/books/review_list.html b/books/templates/books/review_list.html index d0ccc31f..646a67fb 100644 --- a/books/templates/books/review_list.html +++ b/books/templates/books/review_list.html @@ -40,7 +40,7 @@ {{ review.owner.username }} {% if review.is_private %} - + {% endif %} {{ review.edited_time }} diff --git a/books/views.py b/books/views.py index 721918f6..a06c31cf 100644 --- a/books/views.py +++ b/books/views.py @@ -8,7 +8,6 @@ from django.db import IntegrityError, transaction from django.db.models import Count from django.utils import timezone from django.core.paginator import Paginator -from django.core.files.uploadedfile import SimpleUploadedFile from mastodon import mastodon_request_included from mastodon.api import check_visibility, post_toot, TootVisibilityEnum from mastodon.utils import rating_to_emoji diff --git a/common/forms.py b/common/forms.py index 1ae0e2a6..57a3d7ff 100644 --- a/common/forms.py +++ b/common/forms.py @@ -7,30 +7,42 @@ import json class KeyValueInput(forms.Widget): - template_name = 'widgets/key_value.html' - + """ + Input widget for Json field + """ + template_name = 'widgets/hstore.html' + def get_context(self, name, value, attrs): - """ called when rendering """ - context = {} - context['widget'] = { - 'name': name, - 'is_hidden': self.is_hidden, - 'required': self.is_required, - 'value': self.format_value(value), - 'attrs': self.build_attrs(self.attrs, attrs), - 'template_name': self.template_name, - 'keyvalue_pairs': {}, - } - if context['widget']['value']: - key_value_pairs = json.loads(context['widget']['value']) - # for kv in key_value_pairs: - context['widget']['keyvalue_pairs'] = key_value_pairs + context = super().get_context(name, value, attrs) + data = json.loads(context['widget']['value']) + context['widget']['value'] = [ {p[0]: p[1]} for p in data.items()] return context class Media: js = ('js/key_value_input.js',) +class HstoreInput(forms.Widget): + """ + Input widget for Hstore field + """ + template_name = 'widgets/hstore.html' + + def format_value(self, value): + """ + Return a value as it should appear when rendered in a template. + """ + if value == '' or value is None: + return None + if self.is_localized: + return formats.localize_input(value) + # do not return str + return value + + class Media: + js = ('js/key_value_input.js',) + + class JSONField(postgres.JSONField): widget = KeyValueInput def to_python(self, value): @@ -145,23 +157,6 @@ class MultiSelect(forms.SelectMultiple): js = ('lib/js/multiple-select.min.js',) -class HstoreInput(forms.Widget): - template_name = 'widgets/hstore.html' - - def format_value(self, value): - """ - Return a value as it should appear when rendered in a template. - """ - if value == '' or value is None: - return None - if self.is_localized: - return formats.localize_input(value) - return value - - class Media: - js = ('js/key_value_input.js',) - - class HstoreField(forms.CharField): widget = HstoreInput def to_python(self, value): @@ -174,3 +169,104 @@ class HstoreField(forms.CharField): if len(pairs) == 1: pairs = (pairs,) return pairs + + +class DurationInput(forms.TextInput): + """ + HH:mm:ss input widget + """ + input_type = "time" + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + # context['widget']['type'] = self.input_type + context['widget']['attrs']['step'] = "1" + return context + + def format_value(self, value): + """ + Given `value` is an integer in ms + """ + ms = value + if not ms: + return super().format_value(None) + x = ms // 1000 + seconds = x % 60 + x //= 60 + if x == 0: + return super().format_value(f"00:00:{seconds:0>2}") + minutes = x % 60 + x //= 60 + if x == 0: + return super().format_value(f"00:{minutes:0>2}:{seconds:0>2}") + hours = x % 24 + return super().format_value(f"{hours:0>2}:{minutes:0>2}:{seconds:0>2}") + + +class DurationField(forms.TimeField): + widget = DurationInput + def to_python(self, value): + + # empty value + if value is None or value == '': + return + + # if value is integer in ms + if isinstance(value, int): + return value + + # if value is string in time format + h, m, s = value.split(':') + return (int(h) * 3600 + int(m) * 60 + int(s)) * 1000 + + +############################# +# Form +############################# + +class MarkForm(forms.ModelForm): + IS_PRIVATE_CHOICES = [ + (True, _("仅关注者")), + (False, _("公开")), + ] + + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + share_to_mastodon = forms.BooleanField( + label=_("分享到长毛象"), initial=True, required=False) + rating = forms.IntegerField( + validators=[RatingValidator()], widget=forms.HiddenInput(), required=False) + is_private = RadioBooleanField( + label=_("可见性"), + initial=True, + choices=IS_PRIVATE_CHOICES + ) + tags = TagField( + required=False, + widget=TagInput(attrs={'placeholder': _("回车增加标签")}), + label=_("标签") + ) + text = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={ + "placeholder": _("最多只能写360字哦~"), + "maxlength": 360 + } + ), + + label=_("短评"), + ) + +class ReviewForm(forms.ModelForm): + IS_PRIVATE_CHOICES = [ + (True, _("仅关注者")), + (False, _("公开")), + ] + share_to_mastodon = forms.BooleanField( + label=_("分享到长毛象"), initial=True, required=False) + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + is_private = RadioBooleanField( + label=_("可见性"), + initial=True, + choices=IS_PRIVATE_CHOICES + ) diff --git a/common/models.py b/common/models.py index 3f0b742f..4ac502e2 100644 --- a/common/models.py +++ b/common/models.py @@ -20,6 +20,7 @@ RE_HTML_TAG = re.compile(r"<[^>]*>") class SourceSiteEnum(models.TextChoices): IN_SITE = "in-site", CLIENT_NAME DOUBAN = "douban", _("豆瓣") + SPOTIFY = "spotify", _("Spotify") class Entity(models.Model): @@ -32,8 +33,8 @@ class Entity(models.Model): edited_time = models.DateTimeField(auto_now_add=True) last_editor = models.ForeignKey( User, on_delete=models.SET_NULL, related_name='%(class)s_last_editor', null=True, blank=False) - brief = models.TextField(blank=True, default="") - other_info = postgres.JSONField( + brief = models.TextField(_("简介"), blank=True, default="") + other_info = postgres.JSONField(_("其他信息"), blank=True, null=True, encoder=DjangoJSONEncoder, default=dict) # source_url should include shceme, which is normally https:// source_url = models.URLField(_("URL"), max_length=500, unique=True) @@ -114,7 +115,7 @@ class Entity(models.Model): """ raise NotImplementedError("Subclass should implement this method.") - def get_revies_manager(self): + def get_reviews_manager(self): """ Normally this won't be used. There is no ocassion where visitor can simply view all the reviews. diff --git a/common/scraper.py b/common/scraper.py index 02770ab2..033cffcb 100644 --- a/common/scraper.py +++ b/common/scraper.py @@ -2,15 +2,26 @@ import requests import functools import random import logging -from lxml import html import re +import dateparser +import datetime +import time +from lxml import html +from mimetypes import guess_extension +from threading import Thread from boofilsic.settings import LUMINATI_USERNAME, LUMINATI_PASSWORD, DEBUG +from boofilsic.settings import SPOTIFY_CREDENTIAL +from django.utils import timezone from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.files.uploadedfile import SimpleUploadedFile from common.models import SourceSiteEnum from movies.models import Movie, MovieGenreEnum from movies.forms import MovieForm from books.models import Book from books.forms import BookForm +from music.models import Album, Song +from music.forms import AlbumForm, SongForm RE_NUMBERS = re.compile(r"\d+\d*") @@ -18,7 +29,7 @@ RE_WHITESPACES = re.compile(r"\s+") DEFAULT_REQUEST_HEADERS = { - 'Host': 'book.douban.com', + 'Host': '', 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; rv:70.0) Gecko/20100101 Firefox/70.0', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2', @@ -53,19 +64,23 @@ def log_url(func): try: return func(*args, **kwargs) except Exception as e: - # log the url + # log the url and trace stack logger.error(f"Scrape Failed URL: {args[1]}") - logger.error(str(e)) + logger.error("Expections during scraping:", exc_info=e) raise e return wrapper class AbstractScraper: + """ + Scrape entities. The entities means those defined in the models.py file, + like Book, Movie...... + """ # subclasses must specify those two variables # site means general sites, like amazon/douban etc - site = None + site_name = None # host means technically hostname host = None # corresponding data class @@ -74,34 +89,49 @@ class AbstractScraper: form_class = None # used to extract effective url regex = None + # scraped raw image + raw_img = None + # scraped raw data + raw_data = {} def __init_subclass__(cls, **kwargs): # this statement initialize the subclasses super().__init_subclass__(**kwargs) - assert cls.site is not None, "class variable `site` must be specified" - assert cls.host is not None, "class variable `host` must be specified" + assert cls.site_name is not None, "class variable `site_name` must be specified" + assert bool(cls.host), "class variable `host` must be specified" assert cls.data_class is not None, "class variable `data_class` must be specified" assert cls.form_class is not None, "class variable `form_class` must be specified" assert cls.regex is not None, "class variable `regex` must be specified" - assert isinstance(cls.host, str), "`host` must be type str" - assert cls.site in SourceSiteEnum, "`site` must be one of `SourceSiteEnum` value" - assert hasattr(cls, 'scrape') and callable(cls.scrape), "scaper must have method `.scrape()`" + assert isinstance(cls.host, str) or (isinstance(cls.host, list) and isinstance( + cls.host[0], str)), "`host` must be type str or list" + assert cls.site_name in SourceSiteEnum, "`site_name` must be one of `SourceSiteEnum` value" + assert hasattr(cls, 'scrape') and callable( + cls.scrape), "scaper must have method `.scrape()`" + # decorate the scrape method cls.scrape = classmethod(log_url(cls.scrape)) - scraper_registry[cls.host] = cls - + + # register scraper + if isinstance(cls.host, list): + for host in cls.host: + scraper_registry[host] = cls + else: + scraper_registry[cls.host] = cls def scrape(self, url): """ Scrape/request model schema specified data from given url and return it. Implementations of subclasses to this method would be decorated as class method. return (data_dict, image) + Should set the `raw_data` and the `raw_img` """ raise NotImplementedError("Subclass should implement this method") - @classmethod def get_effective_url(cls, raw_url): + """ + The return value should be identical with that saved in DB as `source_url` + """ url = cls.regex.findall(raw_url) if not url: raise ValueError("not valid url") @@ -113,14 +143,15 @@ class AbstractScraper: session_id = random.random() proxy_url = ('http://%s-country-cn-session-%s:%s@zproxy.lum-superproxy.io:%d' % - (LUMINATI_USERNAME, session_id, LUMINATI_PASSWORD, PORT)) + (LUMINATI_USERNAME, session_id, LUMINATI_PASSWORD, PORT)) proxies = { 'http': proxy_url, 'https': proxy_url, } # if DEBUG: # proxies = None - r = requests.get(url, proxies=proxies, headers=headers, timeout=TIMEOUT) + r = requests.get(url, proxies=proxies, + headers=headers, timeout=TIMEOUT) # r = requests.get(url, headers=DEFAULT_REQUEST_HEADERS, timeout=TIMEOUT) return html.fromstring(r.content.decode('utf-8')) @@ -132,7 +163,7 @@ class AbstractScraper: raw_img = None session_id = random.random() proxy_url = ('http://%s-country-cn-session-%s:%s@zproxy.lum-superproxy.io:%d' % - (LUMINATI_USERNAME, session_id, LUMINATI_PASSWORD, PORT)) + (LUMINATI_USERNAME, session_id, LUMINATI_PASSWORD, PORT)) proxies = { 'http': proxy_url, 'https': proxy_url, @@ -155,16 +186,34 @@ class AbstractScraper: ) if img_response.status_code == 200: raw_img = img_response.content - return raw_img + content_type = img_response.headers.get('Content-Type') + ext = guess_extension(content_type.partition(';')[0].strip()) + return raw_img, ext + + + @classmethod + def save(cls, request_user): + entity_cover = { + 'cover': SimpleUploadedFile('temp' + cls.img_ext, cls.raw_img) + } + form = cls.form_class(cls.raw_data, entity_cover) + if form.is_valid(): + form.instance.last_editor = request_user + form.save() + cls.instance = form.instance + else: + logger.error(str(form.errors)) + raise ValidationError("Form invalid.") + return form class DoubanBookScraper(AbstractScraper): - site = SourceSiteEnum.DOUBAN.value + site_name = SourceSiteEnum.DOUBAN.value host = "book.douban.com" data_class = Book form_class = BookForm - regex = re.compile(r"https://book.douban.com/subject/\d+/{0,1}") + regex = re.compile(r"https://book\.douban\.com/subject/\d+/{0,1}") def scrape(self, url): headers = DEFAULT_REQUEST_HEADERS.copy() @@ -234,7 +283,8 @@ class DoubanBookScraper(AbstractScraper): brief_elem = content.xpath( "//h2/span[text()='内容简介']/../following-sibling::div[1]//div[@class='intro'][not(ancestor::span[@class='short'])]/p/text()") - brief = '\n'.join(p.strip() for p in brief_elem) if brief_elem else None + brief = '\n'.join(p.strip() + for p in brief_elem) if brief_elem else None contents = None try: @@ -253,7 +303,7 @@ class DoubanBookScraper(AbstractScraper): img_url_elem = content.xpath("//*[@id='mainpic']/a/img/@src") img_url = img_url_elem[0].strip() if img_url_elem else None - raw_img = self.download_image(img_url) + raw_img, ext = self.download_image(img_url) # there are two html formats for authors and translators authors_elem = content.xpath("""//div[@id='info']//span[text()='作者:']/following-sibling::br[1]/ @@ -311,23 +361,24 @@ class DoubanBookScraper(AbstractScraper): 'brief': brief, 'contents': contents, 'other_info': other, - 'source_site': self.site, + 'source_site': self.site_name, 'source_url': self.get_effective_url(url), } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext return data, raw_img class DoubanMovieScraper(AbstractScraper): - site = SourceSiteEnum.DOUBAN.value + site_name = SourceSiteEnum.DOUBAN.value host = 'movie.douban.com' data_class = Movie form_class = MovieForm - regex = re.compile(r"https://movie.douban.com/subject/\d+/{0,1}") + regex = re.compile(r"https://movie\.douban\.com/subject/\d+/{0,1}") def scrape(self, url): headers = DEFAULT_REQUEST_HEADERS.copy() - headers['Host'] = 'movie.douban.com' + headers['Host'] = self.host content = self.download_page(url, headers) # parsing starts here @@ -461,7 +512,7 @@ class DoubanMovieScraper(AbstractScraper): img_url_elem = content.xpath("//img[@rel='v:image']/@src") img_url = img_url_elem[0].strip() if img_url_elem else None - raw_img = self.download_image(img_url) + raw_img, ext = self.download_image(img_url) data = { 'title': title, @@ -483,8 +534,387 @@ class DoubanMovieScraper(AbstractScraper): 'single_episode_length': single_episode_length, 'brief': brief, 'is_series': is_series, - 'source_site': self.site, + 'source_site': self.site_name, 'source_url': self.get_effective_url(url), } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext return data, raw_img - + + +class DoubanAlbumScraper(AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'music.douban.com' + data_class = Album + form_class = AlbumForm + + regex = re.compile(r"https://music\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + + # parsing starts here + try: + title = content.xpath("//h1/span/text()")[0].strip() + except IndexError: + raise ValueError("given url contains no album info") + if not title: + raise ValueError("given url contains no album info") + + + artists_elem = content.xpath("""//div[@id='info']/span/span[@class='pl']/a/text()""") + artist = None if not artists_elem else artists_elem + + genre_elem = content.xpath( + "//div[@id='info']//span[text()='流派:']/following::text()[1]") + genre = genre_elem[0].strip() if genre_elem else None + + date_elem = content.xpath( + "//div[@id='info']//span[text()='发行时间:']/following::text()[1]") + release_date = dateparser.parse(date_elem[0].strip(), settings={ + "RELATIVE_BASE": datetime.datetime(1900, 1, 1)}) if date_elem else None + + company_elem = content.xpath( + "//div[@id='info']//span[text()='出版者:']/following::text()[1]") + company = company_elem[0].strip() if company_elem else None + + track_list_elem = content.xpath( + "//div[@class='track-list']/div[@class='indent']/div/text()" + ) + if track_list_elem: + track_list = '\n'.join([track.strip() for track in track_list_elem]) + else: + track_list = None + + brief_elem = content.xpath("//span[@class='all hidden']") + if not brief_elem: + brief_elem = content.xpath("//span[@property='v:summary']") + brief = '\n'.join([e.strip() for e in brief_elem[0].xpath( + './text()')]) if brief_elem else None + + other_info = {} + other_elem = content.xpath( + "//div[@id='info']//span[text()='又名:']/following-sibling::text()[1]") + if other_elem: + other_info['又名'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='专辑类型:']/following-sibling::text()[1]") + if other_elem: + other_info['专辑类型'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='介质:']/following-sibling::text()[1]") + if other_elem: + other_info['介质'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='ISRC:']/following-sibling::text()[1]") + if other_elem: + other_info['ISRC'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='条形码:']/following-sibling::text()[1]") + if other_elem: + other_info['条形码'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='碟片数:']/following-sibling::text()[1]") + if other_elem: + other_info['碟片数'] = other_elem[0].strip() + + img_url_elem = content.xpath("//div[@id='mainpic']//img/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url) + + data = { + 'title': title, + 'artist': artist, + 'genre': genre, + 'release_date': release_date, + 'duration': None, + 'company': company, + 'track_list': track_list, + 'brief': brief, + 'other_info': other_info, + 'source_site': self.site_name, + 'source_url': self.get_effective_url(url), + } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + +spotify_token = None +spotify_token_expire_time = time.time() + +class SpotifyTrackScraper(AbstractScraper): + site_name = SourceSiteEnum.SPOTIFY.value + # API URL + host = 'https://open.spotify.com/track/' + data_class = Song + form_class = SongForm + + regex = re.compile(r"(?<=https://open\.spotify\.com/track/)[a-zA-Z0-9]+") + + def scrape(self, url): + """ + Request from API, not really scraping + """ + global spotify_token, spotify_token_expire_time + + if spotify_token is None or is_spotify_token_expired(): + invoke_spotify_token() + effective_url = self.get_effective_url(url) + if effective_url is None: + raise ValueError("not valid url") + + api_url = self.get_api_url(effective_url) + headers = { + 'Authorization': f"Bearer {spotify_token}" + } + r = requests.get(api_url, headers=headers) + res_data = r.json() + + artist = [] + for artist_dict in res_data['artists']: + artist.append(artist_dict['name']) + if not artist: + artist = None + + title = res_data['name'] + + release_date = dateparser.parse( + res_data['album']['release_date'], + settings={ + "RELATIVE_BASE": datetime.datetime(1900, 1, 1) + } + ) + + duration = res_data['duration_ms'] + + if res_data['external_ids'].get('isrc'): + isrc = res_data['external_ids']['isrc'] + else: + isrc = None + + raw_img, ext = self.download_image(res_data['album']['images'][0]['url']) + + data = { + 'title': title, + 'artist': artist, + 'genre': None, + 'release_date': release_date, + 'duration': duration, + 'isrc': isrc, + 'album': None, + 'brief': None, + 'other_info': None, + 'source_site': self.site_name, + 'source_url': effective_url, + } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + @classmethod + def get_effective_url(cls, raw_url): + code = cls.regex.findall(raw_url) + if code: + return f"https://open.spotify.com/track/{code[0]}" + else: + return None + + @classmethod + def get_api_url(cls, url): + return "https://api.spotify.com/v1/tracks/" + cls.regex.findall(url)[0] + + +class SpotifyAlbumScraper(AbstractScraper): + site_name = SourceSiteEnum.SPOTIFY.value + # API URL + host = 'https://open.spotify.com/album/' + data_class = Album + form_class = AlbumForm + + regex = re.compile(r"(?<=https://open\.spotify\.com/album/)[a-zA-Z0-9]+") + + def scrape(self, url): + """ + Request from API, not really scraping + """ + global spotify_token, spotify_token_expire_time + + if spotify_token is None or is_spotify_token_expired(): + invoke_spotify_token() + effective_url = self.get_effective_url(url) + if effective_url is None: + raise ValueError("not valid url") + + api_url = self.get_api_url(effective_url) + headers = { + 'Authorization': f"Bearer {spotify_token}" + } + r = requests.get(api_url, headers=headers) + res_data = r.json() + + artist = [] + for artist_dict in res_data['artists']: + artist.append(artist_dict['name']) + + title = res_data['name'] + + genre = ', '.join(res_data['genres']) + + company = [] + for com in res_data['copyrights']: + company.append(com['text']) + + duration = 0 + track_list = [] + track_urls = [] + for track in res_data['tracks']['items']: + track_urls.append(track['external_urls']['spotify']) + duration += track['duration_ms'] + if res_data['tracks']['items'][-1]['disc_number'] > 1: + # more than one disc + track_list.append(str( + track['disc_number']) + '-' + str(track['track_number']) + '. ' + track['name']) + else: + track_list.append(str(track['track_number']) + '. ' + track['name']) + track_list = '\n'.join(track_list) + + + release_date = dateparser.parse( + res_data['release_date'], + settings={ + "RELATIVE_BASE": datetime.datetime(1900, 1, 1) + } + ) + + other_info = {} + if res_data['external_ids'].get('upc'): + # bar code + other_info['UPC'] = res_data['external_ids']['upc'] + + raw_img, ext = self.download_image(res_data['images'][0]['url']) + + data = { + 'title': title, + 'artist': artist, + 'genre': genre, + 'track_list': track_list, + 'release_date': release_date, + 'duration': duration, + 'company': company, + 'brief': None, + 'other_info': other_info, + 'source_site': self.site_name, + 'source_url': effective_url, + } + + # set tracks_data, used for adding tracks + self.track_urls = track_urls + + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + @classmethod + def get_effective_url(cls, raw_url): + code = cls.regex.findall(raw_url) + if code: + return f"https://open.spotify.com/album/{code[0]}" + else: + return None + + @classmethod + def save(cls, request_user): + form = super().save(request_user) + task = Thread( + target=cls.add_tracks, + args=(form.instance, request_user), + daemon=True + ) + task.start() + return form + + @classmethod + def get_api_url(cls, url): + return "https://api.spotify.com/v1/albums/" + cls.regex.findall(url)[0] + + @classmethod + def add_tracks(cls, album: Album, request_user): + to_be_updated_tracks = [] + for track_url in cls.track_urls: + track = cls.get_track_or_none(track_url) + # seems lik if fire too many requests at the same time + # spotify would limit access + if track is None: + task = Thread( + target=cls.scrape_and_save_track, + args=(track_url, album, request_user), + daemon=True + ) + task.start() + task.join() + else: + to_be_updated_tracks.append(track) + cls.bulk_update_track_album(to_be_updated_tracks, album, request_user) + + @classmethod + def get_track_or_none(cls, track_url: str): + try: + instance = Song.objects.get(source_url=track_url) + return instance + except ObjectDoesNotExist: + return None + + @classmethod + def scrape_and_save_track(cls, url: str, album: Album, request_user): + data, img = SpotifyTrackScraper.scrape(url) + SpotifyTrackScraper.raw_data['album'] = album + SpotifyTrackScraper.save(request_user) + + @classmethod + def bulk_update_track_album(cls, tracks, album, request_user): + for track in tracks: + track.last_editor = request_user + track.edited_time = timezone.now() + track.album = album + Song.objects.bulk_update(tracks, [ + 'last_editor', + 'edited_time', + 'album' + ]) + + +def is_spotify_token_expired(): + global spotify_token_expire_time + return True if spotify_token_expire_time <= time.time() else False + + +def invoke_spotify_token(): + global spotify_token, spotify_token_expire_time + r = requests.post( + "https://accounts.spotify.com/api/token", + data={ + "grant_type": "client_credentials" + }, + headers={ + "Authorization": f"Basic {SPOTIFY_CREDENTIAL}" + } + ) + data = r.json() + if r.status_code == 401: + # token expired, try one more time + # this maybe caused by external operations, + # for example debugging using a http client + r = requests.post( + "https://accounts.spotify.com/api/token", + data={ + "grant_type": "client_credentials" + }, + headers={ + "Authorization": f"Basic {SPOTIFY_CREDENTIAL}" + } + ) + data = r.json() + elif r.status_code != 200: + raise Exception(f"Request to spotify API fails. Reason: {r.reason}") + # minus 2 for execution time error + spotify_token_expire_time = int(data['expires_in']) + time.time() - 2 + spotify_token = data['access_token'] diff --git a/common/static/css/boofilsic.css b/common/static/css/boofilsic.css index c2c81b23..828e087b 100644 --- a/common/static/css/boofilsic.css +++ b/common/static/css/boofilsic.css @@ -374,6 +374,9 @@ input[type='search'], input[type='tel'], input[type='text'], input[type='url'], +input[type='date'], +input[type='time'], +input[type='color'], textarea, select { -webkit-appearance: none; @@ -396,6 +399,9 @@ input[type='search']:focus, input[type='tel']:focus, input[type='text']:focus, input[type='url']:focus, +input[type='date']:focus, +input[type='time']:focus, +input[type='color']:focus, textarea:focus, select:focus { border-color: #00a1cc; @@ -409,6 +415,9 @@ input[type='search']::-webkit-input-placeholder, input[type='tel']::-webkit-input-placeholder, input[type='text']::-webkit-input-placeholder, input[type='url']::-webkit-input-placeholder, +input[type='date']::-webkit-input-placeholder, +input[type='time']::-webkit-input-placeholder, +input[type='color']::-webkit-input-placeholder, textarea::-webkit-input-placeholder, select::-webkit-input-placeholder { color: #ccc; @@ -421,6 +430,9 @@ input[type='search']:-ms-input-placeholder, input[type='tel']:-ms-input-placeholder, input[type='text']:-ms-input-placeholder, input[type='url']:-ms-input-placeholder, +input[type='date']:-ms-input-placeholder, +input[type='time']:-ms-input-placeholder, +input[type='color']:-ms-input-placeholder, textarea:-ms-input-placeholder, select:-ms-input-placeholder { color: #ccc; @@ -433,6 +445,9 @@ input[type='search']::-ms-input-placeholder, input[type='tel']::-ms-input-placeholder, input[type='text']::-ms-input-placeholder, input[type='url']::-ms-input-placeholder, +input[type='date']::-ms-input-placeholder, +input[type='time']::-ms-input-placeholder, +input[type='color']::-ms-input-placeholder, textarea::-ms-input-placeholder, select::-ms-input-placeholder { color: #ccc; @@ -445,6 +460,9 @@ input[type='search']::placeholder, input[type='tel']::placeholder, input[type='text']::placeholder, input[type='url']::placeholder, +input[type='date']::placeholder, +input[type='time']::placeholder, +input[type='color']::placeholder, textarea::placeholder, select::placeholder { color: #ccc; @@ -1184,6 +1202,8 @@ select::placeholder { font-weight: lighter; letter-spacing: 0.1rem; word-break: keep-all; + opacity: 0.8; + position: relative; top: -1px; } @@ -1194,8 +1214,16 @@ select::placeholder { } .source-label.source-label__douban { - border-color: #319840; - color: #319840; + border: none; + color: white; + background-color: #319840; +} + +.source-label.source-label__spotify { + background-color: #1ed760; + color: black; + border: none; + font-weight: bold; } .main-section-wrapper { @@ -1308,6 +1336,8 @@ select::placeholder { object-fit: contain; float: left; max-width: 150px; + -o-object-position: top; + object-position: top; } .entity-detail .entity-detail__info { @@ -1377,7 +1407,7 @@ select::placeholder { } .entity-desc .entity-desc__content--folded { - max-height: 200px; + max-height: 202px; } .entity-desc .entity-desc__unfold-button { @@ -1647,6 +1677,77 @@ select::placeholder { color: #00a1cc; } +.track-carousel { + position: relative; + overflow: auto; + scroll-behavior: smooth; + scrollbar-width: none; + margin-top: 5px; +} + +.track-carousel::-webkit-scrollbar { + height: 0px; +} + +.track-carousel__content { + display: -ms-grid; + display: grid; + grid-gap: 16px; + margin: auto; + -webkit-box-sizing: border-box; + box-sizing: border-box; + grid-auto-flow: column; +} + +.track-carousel__track { + width: 10vw; + height: 13vw; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; +} + +.track-carousel__track img { + -o-object-fit: contain; + object-fit: contain; +} + +.track-carousel__track-title { + white-space: nowrap; +} + +.track-carousel__button { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + -ms-flex-line-pack: center; + align-content: center; + background: white; + border: none; + padding: 8px; + border-radius: 50%; + outline: 0; + cursor: pointer; + position: absolute; +} + +.track-carousel__button--prev { + top: 50%; + left: 0; + -webkit-transform: translate(50%, -50%); + transform: translate(50%, -50%); +} + +.track-carousel__button--next { + top: 50%; + right: 0; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + @media (max-width: 575.98px) { .entity-list .entity-list__entity { -webkit-box-orient: vertical; @@ -1720,6 +1821,10 @@ select::placeholder { .review-head .review-head__actions { float: unset; } + .track-carousel__track { + width: 32vw; + height: 40vw; + } } @media (max-width: 991.98px) { @@ -1772,7 +1877,7 @@ select::placeholder { line-height: unset; height: unset; padding: 4px 15px; - margin: 0 5px; + margin: 5px; } .action-panel { diff --git a/common/static/css/boofilsic.min.css b/common/static/css/boofilsic.min.css index e8304266..5434d4c9 100644 --- a/common/static/css/boofilsic.min.css +++ b/common/static/css/boofilsic.min.css @@ -1 +1 @@ -@import url(https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css);.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#00a1cc;border:0.1rem solid #00a1cc;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.4rem;letter-spacing:.1rem;line-height:3.4rem;padding:0 2.8rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#00a1cc;border-color:#00a1cc}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#00a1cc}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#00a1cc}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#00a1cc}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#00a1cc}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}textarea{min-height:6.5rem;width:100%}select{width:100%}label,legend{display:block;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:1rem}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%;-o-object-fit:contain;object-fit:contain}img.emoji{height:14px;-webkit-box-sizing:border-box;box-sizing:border-box;-o-object-fit:contain;object-fit:contain;position:relative;top:3px}img.emoji--large{height:20px;position:relative;top:2px}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right}.highlight{font-weight:bold}:root{font-size:10px}*,*:after,*:before{-webkit-box-sizing:inherit;box-sizing:inherit}html{-webkit-box-sizing:border-box;box-sizing:border-box;height:100%}body{color:#606c76;font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif;font-size:1.3rem;font-weight:300;letter-spacing:.05rem;line-height:1.6;margin:0;height:100%}textarea{font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif}a{color:#00a1cc;text-decoration:none}a:active,a:hover,a:hover:visited{color:#606c76}li{list-style:none}input[type=text]::-ms-clear,input[type=text]::-ms-reveal{display:none;width:0;height:0}input[type="search"]::-webkit-search-decoration,input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-results-button,input[type="search"]::-webkit-search-results-decoration{display:none}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #ccc;border-radius:.4rem;-webkit-box-shadow:none;box-shadow:none;-webkit-box-sizing:inherit;box-sizing:inherit;padding:.6rem 1.0rem}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border-color:#00a1cc;outline:0}input[type='email']::-webkit-input-placeholder,input[type='number']::-webkit-input-placeholder,input[type='password']::-webkit-input-placeholder,input[type='search']::-webkit-input-placeholder,input[type='tel']::-webkit-input-placeholder,input[type='text']::-webkit-input-placeholder,input[type='url']::-webkit-input-placeholder,textarea::-webkit-input-placeholder,select::-webkit-input-placeholder{color:#ccc}input[type='email']:-ms-input-placeholder,input[type='number']:-ms-input-placeholder,input[type='password']:-ms-input-placeholder,input[type='search']:-ms-input-placeholder,input[type='tel']:-ms-input-placeholder,input[type='text']:-ms-input-placeholder,input[type='url']:-ms-input-placeholder,textarea:-ms-input-placeholder,select:-ms-input-placeholder{color:#ccc}input[type='email']::-ms-input-placeholder,input[type='number']::-ms-input-placeholder,input[type='password']::-ms-input-placeholder,input[type='search']::-ms-input-placeholder,input[type='tel']::-ms-input-placeholder,input[type='text']::-ms-input-placeholder,input[type='url']::-ms-input-placeholder,textarea::-ms-input-placeholder,select::-ms-input-placeholder{color:#ccc}input[type='email']::placeholder,input[type='number']::placeholder,input[type='password']::placeholder,input[type='search']::placeholder,input[type='tel']::placeholder,input[type='text']::placeholder,input[type='url']::placeholder,textarea::placeholder,select::placeholder{color:#ccc}::-moz-selection{color:white;background-color:#00a1cc}::selection{color:white;background-color:#00a1cc}.navbar{background-color:#f7f7f7;-webkit-box-sizing:border-box;box-sizing:border-box;padding:10px 0;margin-bottom:50px;border-bottom:#ccc 0.5px solid}.navbar .navbar__wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:center;-ms-flex-align:center;align-items:center;position:relative}.navbar .navbar__logo{-ms-flex-preferred-size:100px;flex-basis:100px}.navbar .navbar__logo-link{display:inline-block}.navbar .navbar__link-list{margin:0;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-pack:distribute;justify-content:space-around}.navbar .navbar__link{margin:9px;color:#606c76}.navbar .navbar__link:active,.navbar .navbar__link:hover,.navbar .navbar__link:hover:visited{color:#00a1cc}.navbar .navbar__link:visited{color:#606c76}.navbar .navbar__search-box{margin:0 12% 0 15px;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-flex:1;-ms-flex:1;flex:1}.navbar .navbar__search-box>input[type="search"]{border-top-right-radius:0;border-bottom-right-radius:0;margin:0;height:32px;background-color:white !important;width:100%}.navbar .navbar__search-box .navbar__search-dropdown{margin:0;margin-left:-1px;padding:0;padding-left:10px;color:#606c76;-webkit-appearance:auto;-moz-appearance:auto;appearance:auto;background-color:white;height:32px;width:80px;border-top-left-radius:0;border-bottom-left-radius:0}.navbar .navbar__dropdown-btn{display:none;padding:0;margin:0;border:none;background-color:transparent;color:#00a1cc}.navbar .navbar__dropdown-btn:focus,.navbar .navbar__dropdown-btn:hover{background-color:transparent;color:#606c76}@media (max-width: 575.98px){.navbar{padding:2px 0}.navbar .navbar__wrapper{display:block}.navbar .navbar__logo-img{width:72px;margin-right:10px;position:relative;top:7px}.navbar .navbar__link-list{margin-top:7px;max-height:0;-webkit-transition:max-height 0.6s ease-out;transition:max-height 0.6s ease-out;overflow:hidden}.navbar .navbar__dropdown-btn{display:block;position:absolute;right:5px;top:3px;-webkit-transform:scale(0.7);transform:scale(0.7)}.navbar .navbar__dropdown-btn:hover+.navbar__link-list{max-height:500px;-webkit-transition:max-height 0.6s ease-in;transition:max-height 0.6s ease-in}.navbar .navbar__search-box{margin:0;width:46vw}.navbar .navbar__search-box>input[type="search"]{height:26px;padding:4px 6px;width:32vw}.navbar .navbar__search-box .navbar__search-dropdown{cursor:pointer;height:26px;width:80px;padding-left:5px}}@media (max-width: 991.98px){.navbar{margin-bottom:20px}}.grid{margin:0 auto;position:relative;max-width:110rem;padding:0 2.0rem;width:100%}.grid .grid__main{width:70%;float:left;position:relative}.grid .grid__aside{width:26%;float:right;position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:distribute;justify-content:space-around}.grid::after{content:' ';clear:both;display:table}@media (max-width: 575.98px){.grid .grid__aside{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}}@media (max-width: 991.98px){.grid .grid__main{width:100%;float:none}.grid .grid__aside{width:100%;float:none;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.grid .grid__aside--tablet-column{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.grid--reverse-order{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.grid .grid__main--reverse-order{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.grid .grid__aside--reverse-order{-webkit-transform:scaleY(-1);transform:scaleY(-1)}}.pagination{text-align:center;width:100%}.pagination .pagination__page-link{font-weight:normal;margin:0 5px}.pagination .pagination__page-link--current{font-weight:bold;font-size:1.2em;color:#606c76}.pagination .pagination__nav-link{font-size:1.4em;margin:0 2px}.pagination .pagination__nav-link--right-margin{margin-right:18px}.pagination .pagination__nav-link--left-margin{margin-left:18px}.pagination .pagination__nav-link--hidden{display:none}@media (max-width: 575.98px){.pagination .pagination__page-link{margin:0 3px}.pagination .pagination__nav-link{font-size:1.4em;margin:0 2px}.pagination .pagination__nav-link--right-margin{margin-right:10px}.pagination .pagination__nav-link--left-margin{margin-left:10px}}#page-wrapper{position:relative;min-height:100vh;z-index:0}#content-wrapper{padding-bottom:160px}.footer{padding-top:0.4em !important;text-align:center;margin-bottom:4px !important;position:absolute !important;left:50%;-webkit-transform:translateX(-50%);transform:translateX(-50%);bottom:0;width:100%}.footer__border{padding-top:4px;border-top:#f7f7f7 solid 2px}.footer__link{margin:0 12px;white-space:nowrap}@media (max-width: 575.98px){#content-wrapper{padding-bottom:120px}}.icon-lock svg{fill:#ccc;height:12px;position:relative;top:1px;margin-left:3px}.icon-cross svg{fill:#ccc;height:10px;position:relative}.icon-arrow svg{fill:#606c76;height:15px;position:relative;top:3px}.spinner{display:inline-block;position:relative;left:50%;-webkit-transform:translateX(-50%) scale(0.4);transform:translateX(-50%) scale(0.4);width:80px;height:80px}.spinner div{-webkit-transform-origin:40px 40px;transform-origin:40px 40px;-webkit-animation:spinner 1.2s linear infinite;animation:spinner 1.2s linear infinite}.spinner div::after{content:" ";display:block;position:absolute;top:3px;left:37px;width:6px;height:18px;border-radius:20%;background:#606c76}.spinner div:nth-child(1){-webkit-transform:rotate(0deg);transform:rotate(0deg);-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.spinner div:nth-child(2){-webkit-transform:rotate(30deg);transform:rotate(30deg);-webkit-animation-delay:-1s;animation-delay:-1s}.spinner div:nth-child(3){-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-animation-delay:-.9s;animation-delay:-.9s}.spinner div:nth-child(4){-webkit-transform:rotate(90deg);transform:rotate(90deg);-webkit-animation-delay:-.8s;animation-delay:-.8s}.spinner div:nth-child(5){-webkit-transform:rotate(120deg);transform:rotate(120deg);-webkit-animation-delay:-.7s;animation-delay:-.7s}.spinner div:nth-child(6){-webkit-transform:rotate(150deg);transform:rotate(150deg);-webkit-animation-delay:-.6s;animation-delay:-.6s}.spinner div:nth-child(7){-webkit-transform:rotate(180deg);transform:rotate(180deg);-webkit-animation-delay:-.5s;animation-delay:-.5s}.spinner div:nth-child(8){-webkit-transform:rotate(210deg);transform:rotate(210deg);-webkit-animation-delay:-.4s;animation-delay:-.4s}.spinner div:nth-child(9){-webkit-transform:rotate(240deg);transform:rotate(240deg);-webkit-animation-delay:-.3s;animation-delay:-.3s}.spinner div:nth-child(10){-webkit-transform:rotate(270deg);transform:rotate(270deg);-webkit-animation-delay:-.2s;animation-delay:-.2s}.spinner div:nth-child(11){-webkit-transform:rotate(300deg);transform:rotate(300deg);-webkit-animation-delay:-.1s;animation-delay:-.1s}.spinner div:nth-child(12){-webkit-transform:rotate(330deg);transform:rotate(330deg);-webkit-animation-delay:0s;animation-delay:0s}@-webkit-keyframes spinner{0%{opacity:1}100%{opacity:0}}@keyframes spinner{0%{opacity:1}100%{opacity:0}}.bg-mask{background-color:black;z-index:1;-webkit-filter:opacity(20%);filter:opacity(20%);position:fixed;width:100%;height:100%;left:0;top:0;display:none}.mark-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.mark-modal .mark-modal__head{margin-bottom:20px}.mark-modal .mark-modal__head::after{content:' ';clear:both;display:table}.mark-modal .mark-modal__title{font-weight:bold;font-size:1.2em;float:left}.mark-modal .mark-modal__close-button{float:right;cursor:pointer}.mark-modal .mark-modal__confirm-button{float:right}.mark-modal input[type="radio"]{margin-right:0}.mark-modal .mark-modal__rating-star{display:inline;float:left;position:relative;left:-3px}.mark-modal .mark-modal__status-radio{float:right}.mark-modal .mark-modal__status-radio ul{margin-bottom:0}.mark-modal .mark-modal__status-radio li,.mark-modal .mark-modal__status-radio label{display:inline}.mark-modal .mark-modal__status-radio input[type="radio"]{position:relative;top:1px}.mark-modal .mark-modal__clear{content:' ';clear:both;display:table}.mark-modal .mark-modal__content-input,.mark-modal form textarea{height:200px;width:100%;margin-top:5px;margin-bottom:5px;resize:vertical}.mark-modal .mark-modal__tag{margin-bottom:20px}.mark-modal .mark-modal__option{margin-bottom:24px}.mark-modal .mark-modal__option::after{content:' ';clear:both;display:table}.mark-modal .mark-modal__visibility-radio{float:left}.mark-modal .mark-modal__visibility-radio ul,.mark-modal .mark-modal__visibility-radio li,.mark-modal .mark-modal__visibility-radio label{display:inline}.mark-modal .mark-modal__visibility-radio label{font-size:normal}.mark-modal .mark-modal__visibility-radio input[type="radio"]{position:relative;top:2px}.mark-modal .mark-modal__share-checkbox{float:right}.mark-modal .mark-modal__share-checkbox input[type="checkbox"]{position:relative;top:2px}.confirm-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.confirm-modal .confirm-modal__head{margin-bottom:20px}.confirm-modal .confirm-modal__head::after{content:' ';clear:both;display:table}.confirm-modal .confirm-modal__title{font-weight:bold;font-size:1.2em;float:left}.confirm-modal .confirm-modal__close-button{float:right;cursor:pointer}.confirm-modal .confirm-modal__confirm-button{float:right}.announcement-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.announcement-modal .announcement-modal__head{margin-bottom:20px}.announcement-modal .announcement-modal__head::after{content:' ';clear:both;display:table}.announcement-modal .announcement-modal__title{font-weight:bold;font-size:1.2em;float:left}.announcement-modal .announcement-modal__close-button{float:right;cursor:pointer}.announcement-modal .announcement-modal__confirm-button{float:right}.announcement-modal .announcement-modal__body{overflow-y:auto;max-height:64vh}.announcement-modal .announcement-modal__body .announcement__title{display:inline-block}.announcement-modal .announcement-modal__body .announcement__datetime{color:#ccc;margin-left:10px}.announcement-modal .announcement-modal__body .announcement__content{word-break:break-all}@media (max-width: 575.98px){.mark-modal,.confirm-modal,.announcement-modal{width:100%}}.source-label{display:inline;background:transparent;border-radius:.3rem;border-style:solid;border-width:.1rem;line-height:1.2rem;font-size:1.1rem;margin:3px;padding:1px 3px;padding-top:2px;font-weight:lighter;letter-spacing:0.1rem;word-break:keep-all;position:relative;top:-1px}.source-label.source-label__in-site{border-color:#00a1cc;color:#00a1cc}.source-label.source-label__douban{border-color:#319840;color:#319840}.main-section-wrapper{padding:32px 48px 32px 36px;background-color:#f7f7f7;overflow:auto}.main-section-wrapper input,.main-section-wrapper select{width:100%}.entity-list .entity-list__title{margin-bottom:20px}.entity-list .entity-list__entity{display:-webkit-box;display:-ms-flexbox;display:flex;margin-bottom:36px}.entity-list .entity-list__entity::after{content:' ';clear:both;display:table}.entity-list .entity-list__entity-img{-o-object-fit:contain;object-fit:contain;min-width:130px;max-width:130px}.entity-list .entity-list__entity-text{margin-left:20px;overflow:hidden;width:100%}.entity-list .entity-list__entity-text .tag-collection{margin-left:-3px}.entity-list .entity-list__entity-link{font-size:1.2em}.entity-list .entity-list__entity-title{display:block}.entity-list .entity-list__entity-category{color:#bbb;margin-left:5px;position:relative;top:-1px}.entity-list .entity-list__entity-info{max-width:73%;white-space:nowrap;overflow:hidden;display:inline-block;text-overflow:ellipsis;position:relative;top:0.52em}.entity-list .entity-list__entity-info--full-length{max-width:100%}.entity-list .entity-list__entity-brief{margin-top:8px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:4;overflow:hidden;margin-bottom:0}.entity-list .entity-list__rating{display:inline-block;margin:0}.entity-list .entity-list__rating--empty{margin-right:5px}.entity-list .entity-list__rating-score{margin-right:5px;position:relative;top:1px}.entity-list .entity-list__rating-star{display:inline;position:relative;top:0.3em;left:-0.3em}.entity-detail .entity-detail__img{height:210px;-o-object-fit:contain;object-fit:contain;float:left;max-width:150px}.entity-detail .entity-detail__info{float:left;margin-left:20px;overflow:hidden;text-overflow:ellipsis;width:70%}.entity-detail .entity-detail__title{font-weight:bold}.entity-detail .entity-detail__title--secondary{color:#bbb}.entity-detail .entity-detail__fields{display:inline-block;vertical-align:top;width:46%;margin-left:2%}.entity-detail .entity-detail__fields div,.entity-detail .entity-detail__fields span{margin:1px 0}.entity-detail .entity-detail__fields+.tag-collection{margin-top:5px;margin-left:6px}.entity-detail .entity-detail__rating{position:relative;top:-5px}.entity-detail .entity-detail__rating-star{position:relative;left:-4px;top:3px}.entity-detail .entity-detail__rating-score{font-weight:bold}.entity-detail::after{content:' ';clear:both;display:table}.entity-desc{margin-bottom:28px}.entity-desc .entity-desc__title{display:inline-block;margin-bottom:8px}.entity-desc .entity-desc__content{overflow:hidden}.entity-desc .entity-desc__content--folded{max-height:200px}.entity-desc .entity-desc__unfold-button{display:-webkit-box;display:-ms-flexbox;display:flex;color:#00a1cc;background-color:transparent;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;text-align:center}.entity-desc .entity-desc__unfold-button--hidden{display:none}.entity-marks{margin-bottom:28px}.entity-marks .entity-marks__title{margin-bottom:8px;display:inline-block}.entity-marks .entity-marks__title>a{margin-right:5px}.entity-marks .entity-marks__title--stand-alone{margin-bottom:20px}.entity-marks .entity-marks__more-link{margin-left:5px}.entity-marks .entity-marks__mark{margin:0;padding:3px 0;border-bottom:1px dashed #e5e5e5}.entity-marks .entity-marks__mark:last-child{border:none}.entity-marks .entity-marks__mark--wider{padding:6px 0}.entity-marks .entity-marks__mark-content{margin-bottom:0}.entity-marks .entity-marks__mark-time{color:#ccc;margin-left:2px}.entity-marks .entity-marks__rating-star{position:relative;top:4px}.entity-reviews:first-child{margin-bottom:28px}.entity-reviews .entity-reviews__title{display:inline-block;margin-bottom:8px}.entity-reviews .entity-reviews__title>a{margin-right:5px}.entity-reviews .entity-reviews__title--stand-alone{margin-bottom:20px}.entity-reviews .entity-reviews__more-link{margin-left:5px}.entity-reviews .entity-reviews__review{margin:0;padding:3px 0;border-bottom:1px dashed #e5e5e5}.entity-reviews .entity-reviews__review:last-child{border:none}.entity-reviews .entity-reviews__review--wider{padding:6px 0}.entity-reviews .entity-reviews__review-time{color:#ccc;margin-left:2px}.dividing-line{height:0;width:100%;margin:40px 0 24px 0;border-top:solid 1px #ccc}.dividing-line.dividing-line--dashed{margin:0;margin-top:10px;margin-bottom:2px;border-top:1px dashed #e5e5e5}.entity-sort{margin-bottom:30px}.entity-sort .entity-sort__label{font-size:large;display:inline-block;margin-bottom:20px}.entity-sort .entity-sort__more-link{margin-left:5px}.entity-sort .entity-sort__entity-list{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;-ms-flex-wrap:wrap;flex-wrap:wrap}.entity-sort .entity-sort__entity{padding:0 10px;-ms-flex-preferred-size:20%;flex-basis:20%;text-align:center;display:inline-block;color:#606c76}.entity-sort .entity-sort__entity:hover{color:#00a1cc}.entity-sort .entity-sort__entity>a{color:inherit}.entity-sort .entity-sort__entity-img{height:110px}.entity-sort .entity-sort__entity-name{text-overflow:ellipsis;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.related-user-list .related-user-list__title{margin-bottom:20px}.related-user-list .related-user-list__user{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;margin-bottom:20px}.related-user-list .related-user-list__user-info{margin-left:15px;overflow:auto}.related-user-list .related-user-list__user-avatar{max-height:72px;min-width:72px}.review-head .review-head__title{display:inline-block;font-weight:bold}.review-head .review-head__body{margin-bottom:10px}.review-head .review-head__body::after{content:' ';clear:both;display:table}.review-head .review-head__info{float:left}.review-head .review-head__owner-link{color:#ccc}.review-head .review-head__owner-link:hover{color:#00a1cc}.review-head .review-head__time{color:#ccc}.review-head .review-head__rating-star{position:relative;top:3px;left:-1px}.review-head .review-head__actions{float:right}.review-head .review-head__action-link:not(:first-child){margin-left:5px}.tag-collection{margin-left:-9px}.tag-collection .tag-collection__tag{position:relative;display:block;float:left;color:white;background:#ccc;padding:5px;border-radius:.3rem;line-height:1.2em;font-size:80%;margin:3px}.tag-collection .tag-collection__tag a{color:white}.tag-collection .tag-collection__tag a:hover{color:#00a1cc}@media (max-width: 575.98px){.entity-list .entity-list__entity{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;margin-bottom:30px}.entity-list .entity-list__entity-text{margin-left:0}.entity-list .entity-list__entity-img-wrapper{margin-bottom:8px}.entity-list .entity-list__entity-info{max-width:unset}.entity-list .entity-list__rating--empty+.entity-list__entity-info{max-width:70%}.entity-list .entity-list__entity-brief{-webkit-line-clamp:5}.entity-detail{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.entity-detail .entity-detail__title{margin-bottom:5px}.entity-detail .entity-detail__info{margin-left:0;float:none;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;width:100%}.entity-detail .entity-detail__img{margin-bottom:24px;float:none;height:unset;max-width:170px}.entity-detail .entity-detail__fields{width:unset;margin-left:unset}.entity-detail .entity-detail__fields+.tag-collection{margin-left:-3px}.dividing-line{margin-top:24px}.entity-sort .entity-sort__entity{-ms-flex-preferred-size:50%;flex-basis:50%}.entity-sort .entity-sort__entity-img{height:130px}.review-head .review-head__info{float:unset}.review-head .review-head__actions{float:unset}}@media (max-width: 991.98px){.main-section-wrapper{padding:32px 28px 28px 28px}.entity-detail{display:-webkit-box;display:-ms-flexbox;display:flex}}.aside-section-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1;flex:1;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;width:100%;padding:28px 25px 12px 25px;background-color:#f7f7f7;margin-bottom:30px;overflow:auto}.aside-section-wrapper--transparent{background-color:unset}.aside-section-wrapper--collapse{padding:unset}.add-entity-entries .add-entity-entries__entry{margin-bottom:10px}.add-entity-entries .add-entity-entries__label{font-size:1.2em;margin-bottom:8px}.add-entity-entries .add-entity-entries__button{line-height:unset;height:unset;padding:4px 15px;margin:0 5px}.action-panel{margin-bottom:20px}.action-panel .action-panel__label{font-weight:bold;margin-bottom:12px}.action-panel .action-panel__button-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.action-panel .action-panel__button-group--center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.action-panel .action-panel__button{line-height:unset;height:unset;padding:4px 15px;margin:0 5px}.mark-panel{margin-bottom:20px}.mark-panel .mark-panel__status{font-weight:bold}.mark-panel .mark-panel__rating-star{position:relative;top:2px}.mark-panel .mark-panel__actions{float:right}.mark-panel .mark-panel__actions form{display:inline}.mark-panel .mark-panel__time{color:#ccc;margin-bottom:10px}.mark-panel .mark-panel__clear{content:' ';clear:both;display:table}.review-panel .review-panel__label{font-weight:bold}.review-panel .review-panel__actions{float:right}.review-panel .review-panel__time{color:#ccc;margin-bottom:10px}.review-panel .review-panel__review-title{display:block;margin-bottom:15px;font-weight:bold}.review-panel .review-panel__clear{content:' ';clear:both;display:table}.user-profile .user-profile__header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;margin-bottom:15px}.user-profile .user-profile__avatar{width:72px}.user-profile .user-profile__username{font-size:large;margin-left:10px;margin-bottom:0}.user-profile .user-profile__report-link{color:#ccc}.user-relation .user-relation__label{display:inline-block;font-size:large;margin-bottom:10px}.user-relation .user-relation__more-link{margin-left:5px}.user-relation .user-relation__related-user-list{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.user-relation .user-relation__related-user{-ms-flex-preferred-size:25%;flex-basis:25%;padding:0px 3px;text-align:center;display:inline-block;overflow:hidden}.user-relation .user-relation__related-user>a:hover{color:#606c76}.user-relation .user-relation__related-user-avatar{width:48px}.user-relation .user-relation__related-user-name{color:inherit;overflow:hidden;text-overflow:ellipsis;-webkit-box-orient:vertical;-webkit-line-clamp:2}.report-panel .report-panel__label{display:inline-block;margin-bottom:10px}.report-panel .report-panel__report{margin:2px 0}.report-panel .report-panel__user-link{margin:0 2px}.report-panel .report-panel__all-link{margin-left:5px}.relation-dropdown .relation-dropdown__button{display:none}.entity-card{display:-webkit-box;display:-ms-flexbox;display:flex;margin-bottom:10px;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.entity-card--horizontal{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.entity-card .entity-card__img{height:150px}.entity-card .entity-card__rating-star{position:relative;top:4px;left:-3px}.entity-card .entity-card__rating-score{position:relative;top:1px;margin-left:2px}.entity-card .entity-card__title{margin-bottom:10px;margin-top:5px}.entity-card .entity-card__info-wrapper--horizontal{margin-left:20px}.entity-card .entity-card__img-wrapper{-ms-flex-preferred-size:100px;flex-basis:100px}@media (max-width: 575.98px){.add-entity-entries{display:block !important}.add-entity-entries .add-entity-entries__button{width:100%;margin:5px 0 5px 0}.aside-section-wrapper:first-child{margin-right:0 !important;margin-bottom:0 !important}.aside-section-wrapper--singular:first-child{margin-bottom:20px !important}.action-panel{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.entity-card--horizontal{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.entity-card .entity-card__info-wrapper{margin-left:10px !important}.entity-card .entity-card__info-wrapper--horizontal{margin-left:0 !important}}@media (max-width: 991.98px){.add-entity-entries{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-pack:distribute;justify-content:space-around}.aside-section-wrapper{padding:24px 25px 10px 25px;margin-top:20px}.aside-section-wrapper:not(:last-child){margin-right:20px}.aside-section-wrapper--collapse{padding:24px 25px 10px 25px !important;margin-top:0;margin-bottom:0}.aside-section-wrapper--collapse:first-child{margin-right:0}.aside-section-wrapper--no-margin{margin:0}.action-panel{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.action-panel .action-panel__button-group{-webkit-box-pack:space-evenly;-ms-flex-pack:space-evenly;justify-content:space-evenly}.relation-dropdown{margin-bottom:20px}.relation-dropdown .relation-dropdown__button{padding-bottom:10px;background-color:#f7f7f7;width:100%;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.relation-dropdown .relation-dropdown__button>.icon-arrow{-webkit-transition:-webkit-transform 0.3s;transition:-webkit-transform 0.3s;transition:transform 0.3s;transition:transform 0.3s, -webkit-transform 0.3s}.relation-dropdown .relation-dropdown__button:hover>.icon-arrow{-webkit-transform:rotate(-180deg);transform:rotate(-180deg)}.relation-dropdown .relation-dropdown__button:hover+.relation-dropdown__body{max-height:500px;-webkit-transition:max-height 0.6s ease-in;transition:max-height 0.6s ease-in}.relation-dropdown .relation-dropdown__body{background-color:#f7f7f7;max-height:0;-webkit-transition:max-height 0.6s ease-out;transition:max-height 0.6s ease-out;overflow:hidden}.entity-card{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.entity-card .entity-card__info-wrapper{margin-left:30px}}.single-section-wrapper{padding:32px 36px;background-color:#f7f7f7;overflow:auto}.single-section-wrapper .single-section-wrapper__link--secondary{display:inline-block;color:#ccc;margin-bottom:20px}.single-section-wrapper .single-section-wrapper__link--secondary:hover{color:#00a1cc}.entity-form,.review-form{overflow:auto}.entity-form>input[type='email'],.entity-form>input[type='number'],.entity-form>input[type='password'],.entity-form>input[type='search'],.entity-form>input[type='tel'],.entity-form>input[type='text'],.entity-form>input[type='url'],.entity-form textarea,.review-form>input[type='email'],.review-form>input[type='number'],.review-form>input[type='password'],.review-form>input[type='search'],.review-form>input[type='tel'],.review-form>input[type='text'],.review-form>input[type='url'],.review-form textarea{width:100%}.entity-form img,.review-form img{display:block}.review-form .review-form__preview-button{color:#00a1cc;font-weight:bold;cursor:pointer}.review-form .review-form__fyi{color:#ccc}.review-form .review-form__main-content,.review-form textarea{margin-bottom:5px;resize:vertical;height:400px}.review-form .review-form__option{margin-top:24px;margin-bottom:10px}.review-form .review-form__option::after{content:' ';clear:both;display:table}.review-form .review-form__visibility-radio{float:left}.review-form .review-form__visibility-radio ul,.review-form .review-form__visibility-radio li,.review-form .review-form__visibility-radio label{display:inline}.review-form .review-form__visibility-radio label{font-size:normal}.review-form .review-form__visibility-radio input[type="radio"]{position:relative;top:2px}.review-form .review-form__share-checkbox{float:right}.review-form .review-form__share-checkbox input[type="checkbox"]{position:relative;top:2px}.report-form input,.report-form select{width:100%}@media (max-width: 575.98px){.review-form .review-form__visibility-radio{float:unset}.review-form .review-form__share-checkbox{float:unset;position:relative;left:-3px}}.markdownx-preview{min-height:100px}.markdownx-preview ul li{list-style:circle inside}.rating-star .jq-star{cursor:unset !important}.ms-parent>.ms-choice{margin-bottom:1.5rem;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #ccc;border-radius:.4rem;-webkit-box-shadow:none;box-shadow:none;-webkit-box-sizing:inherit;box-sizing:inherit;padding:.6rem 1.0rem;width:100%;height:30.126px}.ms-parent>.ms-choice:focus{border-color:#00a1cc}.ms-parent>.ms-choice>.icon-caret{top:15.5px}.ms-parent>.ms-choice>span{color:black;font-weight:initial;font-size:13.3333px;top:2.5px;left:2px}.ms-parent>.ms-choice>span:hover,.ms-parent>.ms-choice>span:focus{color:black}.ms-parent>.ms-drop>ul>li>label>span{margin-left:10px}.ms-parent>.ms-drop>ul>li>label>input{width:unset} +@import url(https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css);.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#00a1cc;border:0.1rem solid #00a1cc;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.4rem;letter-spacing:.1rem;line-height:3.4rem;padding:0 2.8rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#00a1cc;border-color:#00a1cc}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#00a1cc}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#00a1cc}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#00a1cc}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#00a1cc}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}textarea{min-height:6.5rem;width:100%}select{width:100%}label,legend{display:block;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:1rem}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%;-o-object-fit:contain;object-fit:contain}img.emoji{height:14px;-webkit-box-sizing:border-box;box-sizing:border-box;-o-object-fit:contain;object-fit:contain;position:relative;top:3px}img.emoji--large{height:20px;position:relative;top:2px}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right}.highlight{font-weight:bold}:root{font-size:10px}*,*:after,*:before{-webkit-box-sizing:inherit;box-sizing:inherit}html{-webkit-box-sizing:border-box;box-sizing:border-box;height:100%}body{color:#606c76;font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif;font-size:1.3rem;font-weight:300;letter-spacing:.05rem;line-height:1.6;margin:0;height:100%}textarea{font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif}a{color:#00a1cc;text-decoration:none}a:active,a:hover,a:hover:visited{color:#606c76}li{list-style:none}input[type=text]::-ms-clear,input[type=text]::-ms-reveal{display:none;width:0;height:0}input[type="search"]::-webkit-search-decoration,input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-results-button,input[type="search"]::-webkit-search-results-decoration{display:none}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='date'],input[type='time'],input[type='color'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #ccc;border-radius:.4rem;-webkit-box-shadow:none;box-shadow:none;-webkit-box-sizing:inherit;box-sizing:inherit;padding:.6rem 1.0rem}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,input[type='date']:focus,input[type='time']:focus,input[type='color']:focus,textarea:focus,select:focus{border-color:#00a1cc;outline:0}input[type='email']::-webkit-input-placeholder,input[type='number']::-webkit-input-placeholder,input[type='password']::-webkit-input-placeholder,input[type='search']::-webkit-input-placeholder,input[type='tel']::-webkit-input-placeholder,input[type='text']::-webkit-input-placeholder,input[type='url']::-webkit-input-placeholder,input[type='date']::-webkit-input-placeholder,input[type='time']::-webkit-input-placeholder,input[type='color']::-webkit-input-placeholder,textarea::-webkit-input-placeholder,select::-webkit-input-placeholder{color:#ccc}input[type='email']:-ms-input-placeholder,input[type='number']:-ms-input-placeholder,input[type='password']:-ms-input-placeholder,input[type='search']:-ms-input-placeholder,input[type='tel']:-ms-input-placeholder,input[type='text']:-ms-input-placeholder,input[type='url']:-ms-input-placeholder,input[type='date']:-ms-input-placeholder,input[type='time']:-ms-input-placeholder,input[type='color']:-ms-input-placeholder,textarea:-ms-input-placeholder,select:-ms-input-placeholder{color:#ccc}input[type='email']::-ms-input-placeholder,input[type='number']::-ms-input-placeholder,input[type='password']::-ms-input-placeholder,input[type='search']::-ms-input-placeholder,input[type='tel']::-ms-input-placeholder,input[type='text']::-ms-input-placeholder,input[type='url']::-ms-input-placeholder,input[type='date']::-ms-input-placeholder,input[type='time']::-ms-input-placeholder,input[type='color']::-ms-input-placeholder,textarea::-ms-input-placeholder,select::-ms-input-placeholder{color:#ccc}input[type='email']::placeholder,input[type='number']::placeholder,input[type='password']::placeholder,input[type='search']::placeholder,input[type='tel']::placeholder,input[type='text']::placeholder,input[type='url']::placeholder,input[type='date']::placeholder,input[type='time']::placeholder,input[type='color']::placeholder,textarea::placeholder,select::placeholder{color:#ccc}::-moz-selection{color:white;background-color:#00a1cc}::selection{color:white;background-color:#00a1cc}.navbar{background-color:#f7f7f7;-webkit-box-sizing:border-box;box-sizing:border-box;padding:10px 0;margin-bottom:50px;border-bottom:#ccc 0.5px solid}.navbar .navbar__wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:center;-ms-flex-align:center;align-items:center;position:relative}.navbar .navbar__logo{-ms-flex-preferred-size:100px;flex-basis:100px}.navbar .navbar__logo-link{display:inline-block}.navbar .navbar__link-list{margin:0;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-pack:distribute;justify-content:space-around}.navbar .navbar__link{margin:9px;color:#606c76}.navbar .navbar__link:active,.navbar .navbar__link:hover,.navbar .navbar__link:hover:visited{color:#00a1cc}.navbar .navbar__link:visited{color:#606c76}.navbar .navbar__search-box{margin:0 12% 0 15px;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-flex:1;-ms-flex:1;flex:1}.navbar .navbar__search-box>input[type="search"]{border-top-right-radius:0;border-bottom-right-radius:0;margin:0;height:32px;background-color:white !important;width:100%}.navbar .navbar__search-box .navbar__search-dropdown{margin:0;margin-left:-1px;padding:0;padding-left:10px;color:#606c76;-webkit-appearance:auto;-moz-appearance:auto;appearance:auto;background-color:white;height:32px;width:80px;border-top-left-radius:0;border-bottom-left-radius:0}.navbar .navbar__dropdown-btn{display:none;padding:0;margin:0;border:none;background-color:transparent;color:#00a1cc}.navbar .navbar__dropdown-btn:focus,.navbar .navbar__dropdown-btn:hover{background-color:transparent;color:#606c76}@media (max-width: 575.98px){.navbar{padding:2px 0}.navbar .navbar__wrapper{display:block}.navbar .navbar__logo-img{width:72px;margin-right:10px;position:relative;top:7px}.navbar .navbar__link-list{margin-top:7px;max-height:0;-webkit-transition:max-height 0.6s ease-out;transition:max-height 0.6s ease-out;overflow:hidden}.navbar .navbar__dropdown-btn{display:block;position:absolute;right:5px;top:3px;-webkit-transform:scale(0.7);transform:scale(0.7)}.navbar .navbar__dropdown-btn:hover+.navbar__link-list{max-height:500px;-webkit-transition:max-height 0.6s ease-in;transition:max-height 0.6s ease-in}.navbar .navbar__search-box{margin:0;width:46vw}.navbar .navbar__search-box>input[type="search"]{height:26px;padding:4px 6px;width:32vw}.navbar .navbar__search-box .navbar__search-dropdown{cursor:pointer;height:26px;width:80px;padding-left:5px}}@media (max-width: 991.98px){.navbar{margin-bottom:20px}}.grid{margin:0 auto;position:relative;max-width:110rem;padding:0 2.0rem;width:100%}.grid .grid__main{width:70%;float:left;position:relative}.grid .grid__aside{width:26%;float:right;position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:distribute;justify-content:space-around}.grid::after{content:' ';clear:both;display:table}@media (max-width: 575.98px){.grid .grid__aside{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}}@media (max-width: 991.98px){.grid .grid__main{width:100%;float:none}.grid .grid__aside{width:100%;float:none;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.grid .grid__aside--tablet-column{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.grid--reverse-order{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.grid .grid__main--reverse-order{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.grid .grid__aside--reverse-order{-webkit-transform:scaleY(-1);transform:scaleY(-1)}}.pagination{text-align:center;width:100%}.pagination .pagination__page-link{font-weight:normal;margin:0 5px}.pagination .pagination__page-link--current{font-weight:bold;font-size:1.2em;color:#606c76}.pagination .pagination__nav-link{font-size:1.4em;margin:0 2px}.pagination .pagination__nav-link--right-margin{margin-right:18px}.pagination .pagination__nav-link--left-margin{margin-left:18px}.pagination .pagination__nav-link--hidden{display:none}@media (max-width: 575.98px){.pagination .pagination__page-link{margin:0 3px}.pagination .pagination__nav-link{font-size:1.4em;margin:0 2px}.pagination .pagination__nav-link--right-margin{margin-right:10px}.pagination .pagination__nav-link--left-margin{margin-left:10px}}#page-wrapper{position:relative;min-height:100vh;z-index:0}#content-wrapper{padding-bottom:160px}.footer{padding-top:0.4em !important;text-align:center;margin-bottom:4px !important;position:absolute !important;left:50%;-webkit-transform:translateX(-50%);transform:translateX(-50%);bottom:0;width:100%}.footer__border{padding-top:4px;border-top:#f7f7f7 solid 2px}.footer__link{margin:0 12px;white-space:nowrap}@media (max-width: 575.98px){#content-wrapper{padding-bottom:120px}}.icon-lock svg{fill:#ccc;height:12px;position:relative;top:1px;margin-left:3px}.icon-cross svg{fill:#ccc;height:10px;position:relative}.icon-arrow svg{fill:#606c76;height:15px;position:relative;top:3px}.spinner{display:inline-block;position:relative;left:50%;-webkit-transform:translateX(-50%) scale(0.4);transform:translateX(-50%) scale(0.4);width:80px;height:80px}.spinner div{-webkit-transform-origin:40px 40px;transform-origin:40px 40px;-webkit-animation:spinner 1.2s linear infinite;animation:spinner 1.2s linear infinite}.spinner div::after{content:" ";display:block;position:absolute;top:3px;left:37px;width:6px;height:18px;border-radius:20%;background:#606c76}.spinner div:nth-child(1){-webkit-transform:rotate(0deg);transform:rotate(0deg);-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.spinner div:nth-child(2){-webkit-transform:rotate(30deg);transform:rotate(30deg);-webkit-animation-delay:-1s;animation-delay:-1s}.spinner div:nth-child(3){-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-animation-delay:-.9s;animation-delay:-.9s}.spinner div:nth-child(4){-webkit-transform:rotate(90deg);transform:rotate(90deg);-webkit-animation-delay:-.8s;animation-delay:-.8s}.spinner div:nth-child(5){-webkit-transform:rotate(120deg);transform:rotate(120deg);-webkit-animation-delay:-.7s;animation-delay:-.7s}.spinner div:nth-child(6){-webkit-transform:rotate(150deg);transform:rotate(150deg);-webkit-animation-delay:-.6s;animation-delay:-.6s}.spinner div:nth-child(7){-webkit-transform:rotate(180deg);transform:rotate(180deg);-webkit-animation-delay:-.5s;animation-delay:-.5s}.spinner div:nth-child(8){-webkit-transform:rotate(210deg);transform:rotate(210deg);-webkit-animation-delay:-.4s;animation-delay:-.4s}.spinner div:nth-child(9){-webkit-transform:rotate(240deg);transform:rotate(240deg);-webkit-animation-delay:-.3s;animation-delay:-.3s}.spinner div:nth-child(10){-webkit-transform:rotate(270deg);transform:rotate(270deg);-webkit-animation-delay:-.2s;animation-delay:-.2s}.spinner div:nth-child(11){-webkit-transform:rotate(300deg);transform:rotate(300deg);-webkit-animation-delay:-.1s;animation-delay:-.1s}.spinner div:nth-child(12){-webkit-transform:rotate(330deg);transform:rotate(330deg);-webkit-animation-delay:0s;animation-delay:0s}@-webkit-keyframes spinner{0%{opacity:1}100%{opacity:0}}@keyframes spinner{0%{opacity:1}100%{opacity:0}}.bg-mask{background-color:black;z-index:1;-webkit-filter:opacity(20%);filter:opacity(20%);position:fixed;width:100%;height:100%;left:0;top:0;display:none}.mark-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.mark-modal .mark-modal__head{margin-bottom:20px}.mark-modal .mark-modal__head::after{content:' ';clear:both;display:table}.mark-modal .mark-modal__title{font-weight:bold;font-size:1.2em;float:left}.mark-modal .mark-modal__close-button{float:right;cursor:pointer}.mark-modal .mark-modal__confirm-button{float:right}.mark-modal input[type="radio"]{margin-right:0}.mark-modal .mark-modal__rating-star{display:inline;float:left;position:relative;left:-3px}.mark-modal .mark-modal__status-radio{float:right}.mark-modal .mark-modal__status-radio ul{margin-bottom:0}.mark-modal .mark-modal__status-radio li,.mark-modal .mark-modal__status-radio label{display:inline}.mark-modal .mark-modal__status-radio input[type="radio"]{position:relative;top:1px}.mark-modal .mark-modal__clear{content:' ';clear:both;display:table}.mark-modal .mark-modal__content-input,.mark-modal form textarea{height:200px;width:100%;margin-top:5px;margin-bottom:5px;resize:vertical}.mark-modal .mark-modal__tag{margin-bottom:20px}.mark-modal .mark-modal__option{margin-bottom:24px}.mark-modal .mark-modal__option::after{content:' ';clear:both;display:table}.mark-modal .mark-modal__visibility-radio{float:left}.mark-modal .mark-modal__visibility-radio ul,.mark-modal .mark-modal__visibility-radio li,.mark-modal .mark-modal__visibility-radio label{display:inline}.mark-modal .mark-modal__visibility-radio label{font-size:normal}.mark-modal .mark-modal__visibility-radio input[type="radio"]{position:relative;top:2px}.mark-modal .mark-modal__share-checkbox{float:right}.mark-modal .mark-modal__share-checkbox input[type="checkbox"]{position:relative;top:2px}.confirm-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.confirm-modal .confirm-modal__head{margin-bottom:20px}.confirm-modal .confirm-modal__head::after{content:' ';clear:both;display:table}.confirm-modal .confirm-modal__title{font-weight:bold;font-size:1.2em;float:left}.confirm-modal .confirm-modal__close-button{float:right;cursor:pointer}.confirm-modal .confirm-modal__confirm-button{float:right}.announcement-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.announcement-modal .announcement-modal__head{margin-bottom:20px}.announcement-modal .announcement-modal__head::after{content:' ';clear:both;display:table}.announcement-modal .announcement-modal__title{font-weight:bold;font-size:1.2em;float:left}.announcement-modal .announcement-modal__close-button{float:right;cursor:pointer}.announcement-modal .announcement-modal__confirm-button{float:right}.announcement-modal .announcement-modal__body{overflow-y:auto;max-height:64vh}.announcement-modal .announcement-modal__body .announcement__title{display:inline-block}.announcement-modal .announcement-modal__body .announcement__datetime{color:#ccc;margin-left:10px}.announcement-modal .announcement-modal__body .announcement__content{word-break:break-all}@media (max-width: 575.98px){.mark-modal,.confirm-modal,.announcement-modal{width:100%}}.source-label{display:inline;background:transparent;border-radius:.3rem;border-style:solid;border-width:.1rem;line-height:1.2rem;font-size:1.1rem;margin:3px;padding:1px 3px;padding-top:2px;font-weight:lighter;letter-spacing:0.1rem;word-break:keep-all;opacity:0.8;position:relative;top:-1px}.source-label.source-label__in-site{border-color:#00a1cc;color:#00a1cc}.source-label.source-label__douban{border:none;color:#fff;background-color:#319840}.source-label.source-label__spotify{background-color:#1ed760;color:#000;border:none;font-weight:bold}.main-section-wrapper{padding:32px 48px 32px 36px;background-color:#f7f7f7;overflow:auto}.main-section-wrapper input,.main-section-wrapper select{width:100%}.entity-list .entity-list__title{margin-bottom:20px}.entity-list .entity-list__entity{display:-webkit-box;display:-ms-flexbox;display:flex;margin-bottom:36px}.entity-list .entity-list__entity::after{content:' ';clear:both;display:table}.entity-list .entity-list__entity-img{-o-object-fit:contain;object-fit:contain;min-width:130px;max-width:130px}.entity-list .entity-list__entity-text{margin-left:20px;overflow:hidden;width:100%}.entity-list .entity-list__entity-text .tag-collection{margin-left:-3px}.entity-list .entity-list__entity-link{font-size:1.2em}.entity-list .entity-list__entity-title{display:block}.entity-list .entity-list__entity-category{color:#bbb;margin-left:5px;position:relative;top:-1px}.entity-list .entity-list__entity-info{max-width:73%;white-space:nowrap;overflow:hidden;display:inline-block;text-overflow:ellipsis;position:relative;top:0.52em}.entity-list .entity-list__entity-info--full-length{max-width:100%}.entity-list .entity-list__entity-brief{margin-top:8px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:4;overflow:hidden;margin-bottom:0}.entity-list .entity-list__rating{display:inline-block;margin:0}.entity-list .entity-list__rating--empty{margin-right:5px}.entity-list .entity-list__rating-score{margin-right:5px;position:relative;top:1px}.entity-list .entity-list__rating-star{display:inline;position:relative;top:0.3em;left:-0.3em}.entity-detail .entity-detail__img{height:210px;-o-object-fit:contain;object-fit:contain;float:left;max-width:150px;-o-object-position:top;object-position:top}.entity-detail .entity-detail__info{float:left;margin-left:20px;overflow:hidden;text-overflow:ellipsis;width:70%}.entity-detail .entity-detail__title{font-weight:bold}.entity-detail .entity-detail__title--secondary{color:#bbb}.entity-detail .entity-detail__fields{display:inline-block;vertical-align:top;width:46%;margin-left:2%}.entity-detail .entity-detail__fields div,.entity-detail .entity-detail__fields span{margin:1px 0}.entity-detail .entity-detail__fields+.tag-collection{margin-top:5px;margin-left:6px}.entity-detail .entity-detail__rating{position:relative;top:-5px}.entity-detail .entity-detail__rating-star{position:relative;left:-4px;top:3px}.entity-detail .entity-detail__rating-score{font-weight:bold}.entity-detail::after{content:' ';clear:both;display:table}.entity-desc{margin-bottom:28px}.entity-desc .entity-desc__title{display:inline-block;margin-bottom:8px}.entity-desc .entity-desc__content{overflow:hidden}.entity-desc .entity-desc__content--folded{max-height:202px}.entity-desc .entity-desc__unfold-button{display:-webkit-box;display:-ms-flexbox;display:flex;color:#00a1cc;background-color:transparent;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;text-align:center}.entity-desc .entity-desc__unfold-button--hidden{display:none}.entity-marks{margin-bottom:28px}.entity-marks .entity-marks__title{margin-bottom:8px;display:inline-block}.entity-marks .entity-marks__title>a{margin-right:5px}.entity-marks .entity-marks__title--stand-alone{margin-bottom:20px}.entity-marks .entity-marks__more-link{margin-left:5px}.entity-marks .entity-marks__mark{margin:0;padding:3px 0;border-bottom:1px dashed #e5e5e5}.entity-marks .entity-marks__mark:last-child{border:none}.entity-marks .entity-marks__mark--wider{padding:6px 0}.entity-marks .entity-marks__mark-content{margin-bottom:0}.entity-marks .entity-marks__mark-time{color:#ccc;margin-left:2px}.entity-marks .entity-marks__rating-star{position:relative;top:4px}.entity-reviews:first-child{margin-bottom:28px}.entity-reviews .entity-reviews__title{display:inline-block;margin-bottom:8px}.entity-reviews .entity-reviews__title>a{margin-right:5px}.entity-reviews .entity-reviews__title--stand-alone{margin-bottom:20px}.entity-reviews .entity-reviews__more-link{margin-left:5px}.entity-reviews .entity-reviews__review{margin:0;padding:3px 0;border-bottom:1px dashed #e5e5e5}.entity-reviews .entity-reviews__review:last-child{border:none}.entity-reviews .entity-reviews__review--wider{padding:6px 0}.entity-reviews .entity-reviews__review-time{color:#ccc;margin-left:2px}.dividing-line{height:0;width:100%;margin:40px 0 24px 0;border-top:solid 1px #ccc}.dividing-line.dividing-line--dashed{margin:0;margin-top:10px;margin-bottom:2px;border-top:1px dashed #e5e5e5}.entity-sort{margin-bottom:30px}.entity-sort .entity-sort__label{font-size:large;display:inline-block;margin-bottom:20px}.entity-sort .entity-sort__more-link{margin-left:5px}.entity-sort .entity-sort__entity-list{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;-ms-flex-wrap:wrap;flex-wrap:wrap}.entity-sort .entity-sort__entity{padding:0 10px;-ms-flex-preferred-size:20%;flex-basis:20%;text-align:center;display:inline-block;color:#606c76}.entity-sort .entity-sort__entity:hover{color:#00a1cc}.entity-sort .entity-sort__entity>a{color:inherit}.entity-sort .entity-sort__entity-img{height:110px}.entity-sort .entity-sort__entity-name{text-overflow:ellipsis;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.related-user-list .related-user-list__title{margin-bottom:20px}.related-user-list .related-user-list__user{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;margin-bottom:20px}.related-user-list .related-user-list__user-info{margin-left:15px;overflow:auto}.related-user-list .related-user-list__user-avatar{max-height:72px;min-width:72px}.review-head .review-head__title{display:inline-block;font-weight:bold}.review-head .review-head__body{margin-bottom:10px}.review-head .review-head__body::after{content:' ';clear:both;display:table}.review-head .review-head__info{float:left}.review-head .review-head__owner-link{color:#ccc}.review-head .review-head__owner-link:hover{color:#00a1cc}.review-head .review-head__time{color:#ccc}.review-head .review-head__rating-star{position:relative;top:3px;left:-1px}.review-head .review-head__actions{float:right}.review-head .review-head__action-link:not(:first-child){margin-left:5px}.tag-collection{margin-left:-9px}.tag-collection .tag-collection__tag{position:relative;display:block;float:left;color:white;background:#ccc;padding:5px;border-radius:.3rem;line-height:1.2em;font-size:80%;margin:3px}.tag-collection .tag-collection__tag a{color:white}.tag-collection .tag-collection__tag a:hover{color:#00a1cc}.track-carousel{position:relative;overflow:auto;scroll-behavior:smooth;scrollbar-width:none;margin-top:5px}.track-carousel::-webkit-scrollbar{height:0px}.track-carousel__content{display:-ms-grid;display:grid;grid-gap:16px;margin:auto;-webkit-box-sizing:border-box;box-sizing:border-box;grid-auto-flow:column}.track-carousel__track{width:10vw;height:13vw;text-align:center;overflow:hidden;text-overflow:ellipsis}.track-carousel__track img{-o-object-fit:contain;object-fit:contain}.track-carousel__track-title{white-space:nowrap}.track-carousel__button{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-ms-flex-line-pack:center;align-content:center;background:white;border:none;padding:8px;border-radius:50%;outline:0;cursor:pointer;position:absolute}.track-carousel__button--prev{top:50%;left:0;-webkit-transform:translate(50%, -50%);transform:translate(50%, -50%)}.track-carousel__button--next{top:50%;right:0;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%)}@media (max-width: 575.98px){.entity-list .entity-list__entity{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;margin-bottom:30px}.entity-list .entity-list__entity-text{margin-left:0}.entity-list .entity-list__entity-img-wrapper{margin-bottom:8px}.entity-list .entity-list__entity-info{max-width:unset}.entity-list .entity-list__rating--empty+.entity-list__entity-info{max-width:70%}.entity-list .entity-list__entity-brief{-webkit-line-clamp:5}.entity-detail{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.entity-detail .entity-detail__title{margin-bottom:5px}.entity-detail .entity-detail__info{margin-left:0;float:none;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;width:100%}.entity-detail .entity-detail__img{margin-bottom:24px;float:none;height:unset;max-width:170px}.entity-detail .entity-detail__fields{width:unset;margin-left:unset}.entity-detail .entity-detail__fields+.tag-collection{margin-left:-3px}.dividing-line{margin-top:24px}.entity-sort .entity-sort__entity{-ms-flex-preferred-size:50%;flex-basis:50%}.entity-sort .entity-sort__entity-img{height:130px}.review-head .review-head__info{float:unset}.review-head .review-head__actions{float:unset}.track-carousel__track{width:32vw;height:40vw}}@media (max-width: 991.98px){.main-section-wrapper{padding:32px 28px 28px 28px}.entity-detail{display:-webkit-box;display:-ms-flexbox;display:flex}}.aside-section-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1;flex:1;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;width:100%;padding:28px 25px 12px 25px;background-color:#f7f7f7;margin-bottom:30px;overflow:auto}.aside-section-wrapper--transparent{background-color:unset}.aside-section-wrapper--collapse{padding:unset}.add-entity-entries .add-entity-entries__entry{margin-bottom:10px}.add-entity-entries .add-entity-entries__label{font-size:1.2em;margin-bottom:8px}.add-entity-entries .add-entity-entries__button{line-height:unset;height:unset;padding:4px 15px;margin:5px}.action-panel{margin-bottom:20px}.action-panel .action-panel__label{font-weight:bold;margin-bottom:12px}.action-panel .action-panel__button-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.action-panel .action-panel__button-group--center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.action-panel .action-panel__button{line-height:unset;height:unset;padding:4px 15px;margin:0 5px}.mark-panel{margin-bottom:20px}.mark-panel .mark-panel__status{font-weight:bold}.mark-panel .mark-panel__rating-star{position:relative;top:2px}.mark-panel .mark-panel__actions{float:right}.mark-panel .mark-panel__actions form{display:inline}.mark-panel .mark-panel__time{color:#ccc;margin-bottom:10px}.mark-panel .mark-panel__clear{content:' ';clear:both;display:table}.review-panel .review-panel__label{font-weight:bold}.review-panel .review-panel__actions{float:right}.review-panel .review-panel__time{color:#ccc;margin-bottom:10px}.review-panel .review-panel__review-title{display:block;margin-bottom:15px;font-weight:bold}.review-panel .review-panel__clear{content:' ';clear:both;display:table}.user-profile .user-profile__header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;margin-bottom:15px}.user-profile .user-profile__avatar{width:72px}.user-profile .user-profile__username{font-size:large;margin-left:10px;margin-bottom:0}.user-profile .user-profile__report-link{color:#ccc}.user-relation .user-relation__label{display:inline-block;font-size:large;margin-bottom:10px}.user-relation .user-relation__more-link{margin-left:5px}.user-relation .user-relation__related-user-list{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.user-relation .user-relation__related-user{-ms-flex-preferred-size:25%;flex-basis:25%;padding:0px 3px;text-align:center;display:inline-block;overflow:hidden}.user-relation .user-relation__related-user>a:hover{color:#606c76}.user-relation .user-relation__related-user-avatar{width:48px}.user-relation .user-relation__related-user-name{color:inherit;overflow:hidden;text-overflow:ellipsis;-webkit-box-orient:vertical;-webkit-line-clamp:2}.report-panel .report-panel__label{display:inline-block;margin-bottom:10px}.report-panel .report-panel__report{margin:2px 0}.report-panel .report-panel__user-link{margin:0 2px}.report-panel .report-panel__all-link{margin-left:5px}.relation-dropdown .relation-dropdown__button{display:none}.entity-card{display:-webkit-box;display:-ms-flexbox;display:flex;margin-bottom:10px;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.entity-card--horizontal{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.entity-card .entity-card__img{height:150px}.entity-card .entity-card__rating-star{position:relative;top:4px;left:-3px}.entity-card .entity-card__rating-score{position:relative;top:1px;margin-left:2px}.entity-card .entity-card__title{margin-bottom:10px;margin-top:5px}.entity-card .entity-card__info-wrapper--horizontal{margin-left:20px}.entity-card .entity-card__img-wrapper{-ms-flex-preferred-size:100px;flex-basis:100px}@media (max-width: 575.98px){.add-entity-entries{display:block !important}.add-entity-entries .add-entity-entries__button{width:100%;margin:5px 0 5px 0}.aside-section-wrapper:first-child{margin-right:0 !important;margin-bottom:0 !important}.aside-section-wrapper--singular:first-child{margin-bottom:20px !important}.action-panel{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.entity-card--horizontal{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.entity-card .entity-card__info-wrapper{margin-left:10px !important}.entity-card .entity-card__info-wrapper--horizontal{margin-left:0 !important}}@media (max-width: 991.98px){.add-entity-entries{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-pack:distribute;justify-content:space-around}.aside-section-wrapper{padding:24px 25px 10px 25px;margin-top:20px}.aside-section-wrapper:not(:last-child){margin-right:20px}.aside-section-wrapper--collapse{padding:24px 25px 10px 25px !important;margin-top:0;margin-bottom:0}.aside-section-wrapper--collapse:first-child{margin-right:0}.aside-section-wrapper--no-margin{margin:0}.action-panel{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.action-panel .action-panel__button-group{-webkit-box-pack:space-evenly;-ms-flex-pack:space-evenly;justify-content:space-evenly}.relation-dropdown{margin-bottom:20px}.relation-dropdown .relation-dropdown__button{padding-bottom:10px;background-color:#f7f7f7;width:100%;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.relation-dropdown .relation-dropdown__button>.icon-arrow{-webkit-transition:-webkit-transform 0.3s;transition:-webkit-transform 0.3s;transition:transform 0.3s;transition:transform 0.3s, -webkit-transform 0.3s}.relation-dropdown .relation-dropdown__button:hover>.icon-arrow{-webkit-transform:rotate(-180deg);transform:rotate(-180deg)}.relation-dropdown .relation-dropdown__button:hover+.relation-dropdown__body{max-height:500px;-webkit-transition:max-height 0.6s ease-in;transition:max-height 0.6s ease-in}.relation-dropdown .relation-dropdown__body{background-color:#f7f7f7;max-height:0;-webkit-transition:max-height 0.6s ease-out;transition:max-height 0.6s ease-out;overflow:hidden}.entity-card{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.entity-card .entity-card__info-wrapper{margin-left:30px}}.single-section-wrapper{padding:32px 36px;background-color:#f7f7f7;overflow:auto}.single-section-wrapper .single-section-wrapper__link--secondary{display:inline-block;color:#ccc;margin-bottom:20px}.single-section-wrapper .single-section-wrapper__link--secondary:hover{color:#00a1cc}.entity-form,.review-form{overflow:auto}.entity-form>input[type='email'],.entity-form>input[type='number'],.entity-form>input[type='password'],.entity-form>input[type='search'],.entity-form>input[type='tel'],.entity-form>input[type='text'],.entity-form>input[type='url'],.entity-form textarea,.review-form>input[type='email'],.review-form>input[type='number'],.review-form>input[type='password'],.review-form>input[type='search'],.review-form>input[type='tel'],.review-form>input[type='text'],.review-form>input[type='url'],.review-form textarea{width:100%}.entity-form img,.review-form img{display:block}.review-form .review-form__preview-button{color:#00a1cc;font-weight:bold;cursor:pointer}.review-form .review-form__fyi{color:#ccc}.review-form .review-form__main-content,.review-form textarea{margin-bottom:5px;resize:vertical;height:400px}.review-form .review-form__option{margin-top:24px;margin-bottom:10px}.review-form .review-form__option::after{content:' ';clear:both;display:table}.review-form .review-form__visibility-radio{float:left}.review-form .review-form__visibility-radio ul,.review-form .review-form__visibility-radio li,.review-form .review-form__visibility-radio label{display:inline}.review-form .review-form__visibility-radio label{font-size:normal}.review-form .review-form__visibility-radio input[type="radio"]{position:relative;top:2px}.review-form .review-form__share-checkbox{float:right}.review-form .review-form__share-checkbox input[type="checkbox"]{position:relative;top:2px}.report-form input,.report-form select{width:100%}@media (max-width: 575.98px){.review-form .review-form__visibility-radio{float:unset}.review-form .review-form__share-checkbox{float:unset;position:relative;left:-3px}}.markdownx-preview{min-height:100px}.markdownx-preview ul li{list-style:circle inside}.rating-star .jq-star{cursor:unset !important}.ms-parent>.ms-choice{margin-bottom:1.5rem;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #ccc;border-radius:.4rem;-webkit-box-shadow:none;box-shadow:none;-webkit-box-sizing:inherit;box-sizing:inherit;padding:.6rem 1.0rem;width:100%;height:30.126px}.ms-parent>.ms-choice:focus{border-color:#00a1cc}.ms-parent>.ms-choice>.icon-caret{top:15.5px}.ms-parent>.ms-choice>span{color:black;font-weight:initial;font-size:13.3333px;top:2.5px;left:2px}.ms-parent>.ms-choice>span:hover,.ms-parent>.ms-choice>span:focus{color:black}.ms-parent>.ms-drop>ul>li>label>span{margin-left:10px}.ms-parent>.ms-drop>ul>li>label>input{width:unset} \ No newline at end of file diff --git a/common/static/js/key_value_input.js b/common/static/js/key_value_input.js index 29fc9dda..8b15b1b0 100644 --- a/common/static/js/key_value_input.js +++ b/common/static/js/key_value_input.js @@ -8,6 +8,8 @@ function keyValueInput(valueKeyWidget, hiddenInput) { if (placeholderValue == null) { placeholderValue = ''; } + // assign existing pairs to hidden input + setHiddenInput(valueKeyWidget); let newInputPair = $(''); valueKeyWidget.append(newInputPair.clone()); @@ -27,7 +29,7 @@ function keyValueInput(valueKeyWidget, hiddenInput) { $(this).next().remove(); $(this).remove(); } - }); + }); valueKeyWidget.on('input', ':nth-last-child(3)', function () { if (!$(this).val() && !$(this).prev().val() && valueKeyWidget.children("input").length > 2) { @@ -37,12 +39,16 @@ function keyValueInput(valueKeyWidget, hiddenInput) { }); valueKeyWidget.on('input', function () { - let keys = $(this).children(":nth-child(odd)").map(function () { + setHiddenInput(this); + }); + + function setHiddenInput(elem) { + let keys = $(elem).children(":nth-child(odd)").map(function () { if ($(this).val()) { return $(this).val(); } }).get(); - let values = $(this).children(":nth-child(even)").map(function () { + let values = $(elem).children(":nth-child(even)").map(function () { if ($(this).val()) { return $(this).val(); } @@ -55,7 +61,7 @@ function keyValueInput(valueKeyWidget, hiddenInput) { finalValue.push(JSON.stringify(json)) }); hiddenInput.val(finalValue.toString()); - } else if(keys.length - values.length == 1) { + } else if (keys.length - values.length == 1) { let finalValue = []; keys.forEach(function (key, i) { let json = new Object; @@ -66,8 +72,10 @@ function keyValueInput(valueKeyWidget, hiddenInput) { } finalValue.push(JSON.stringify(json)) }); - hiddenInput.val(finalValue.toString()); + hiddenInput.val(finalValue.toString()); } - }); + } + + } diff --git a/common/static/sass/_AsideSection.sass b/common/static/sass/_AsideSection.sass index a32728f0..7da9ff0f 100644 --- a/common/static/sass/_AsideSection.sass +++ b/common/static/sass/_AsideSection.sass @@ -30,7 +30,7 @@ $aside-section-padding-mobile: 24px 25px 10px 25px line-height: unset; height: unset; padding: 4px 15px; - margin: 0 5px; + margin: 5px; .action-panel margin-bottom: 20px diff --git a/common/static/sass/_Global.sass b/common/static/sass/_Global.sass index be8a7597..4476f845 100644 --- a/common/static/sass/_Global.sass +++ b/common/static/sass/_Global.sass @@ -68,6 +68,9 @@ input[type='search'], input[type='tel'], input[type='text'], input[type='url'], +input[type='date'], +input[type='time'], +input[type='color'], textarea, select appearance: none // Removes awkward default styles on some inputs for iOS diff --git a/common/static/sass/_Label.sass b/common/static/sass/_Label.sass index 262629b7..7a634877 100644 --- a/common/static/sass/_Label.sass +++ b/common/static/sass/_Label.sass @@ -1,7 +1,10 @@ // source label name should match the enum value in `common.models.SourceSiteEnum` -$douban-color: #319840 +$douban-color-primary: #319840 +$douban-color-secondary: white $in-site-color: $color-primary +$spotify-color-primary: #1ed760 +$spotify-color-secondary: black .source-label display: inline @@ -17,6 +20,8 @@ $in-site-color: $color-primary font-weight: lighter letter-spacing: 0.1rem word-break: keep-all + opacity: 0.8 + position: relative top: -1px @@ -25,6 +30,12 @@ $in-site-color: $color-primary border-color: $in-site-color color: $in-site-color &.source-label__douban - border-color: $douban-color - color: $douban-color - &.source-label__amazon \ No newline at end of file + border: none + color: $douban-color-secondary + background-color: $douban-color-primary + &.source-label__amazon + &.source-label__spotify + background-color: $spotify-color-primary + color: $spotify-color-secondary + border: none + font-weight: bold \ No newline at end of file diff --git a/common/static/sass/_MainSection.sass b/common/static/sass/_MainSection.sass index 4675f738..52f22e29 100644 --- a/common/static/sass/_MainSection.sass +++ b/common/static/sass/_MainSection.sass @@ -66,7 +66,9 @@ $sub-section-title-margin: 8px position: relative top: 0.52em &--full-length + // display: block max-width: 100% + // margin-bottom: 12px & &__entity-brief margin-top: 8px @@ -100,10 +102,11 @@ $sub-section-title-margin: 8px .entity-detail & &__img - height: 210px; - object-fit: contain; - float: left; - max-width: 150px; + height: 210px + object-fit: contain + float: left + max-width: 150px + object-position: top & &__info float: left @@ -158,7 +161,7 @@ $mark-review-padding-wider: 6px 0 & &__content overflow: hidden &--folded - max-height: 200px + max-height: 202px & &__unfold-button display: flex @@ -373,6 +376,59 @@ $mark-review-padding-wider: 6px 0 &:hover color: $color-primary + +.track-carousel + position: relative + overflow: auto + scroll-behavior: smooth + scrollbar-width: none + margin-top: 5px + // padding: 0 + + &::-webkit-scrollbar + height: 0px + &__content + display: grid + grid-gap: 16px + margin: auto + box-sizing: border-box + grid-auto-flow: column + // grid-template-columns: max-content + &__track + width: 10vw + height: 13vw + // grid-column: 1 + text-align: center + overflow: hidden + text-overflow: ellipsis + & img + object-fit: contain + &__track-title + // word-break: keep-all + // overflow-wrap: anywhere + white-space: nowrap + + &__button + display: flex + justify-content: center + align-content: center + background: white + border: none + padding: 8px + border-radius: 50% + outline: 0 + cursor: pointer + position: absolute + &--prev + top: 50% + left: 0 + transform: translate(50%, -50%) + &--next + top: 50% + right: 0 + transform: translate(-50%, -50%) + + // Small devices (landscape phones, 576px and up) @media (max-width: $small-devices) .entity-list @@ -437,6 +493,11 @@ $mark-review-padding-wider: 6px 0 float: unset & &__actions float: unset + + .track-carousel + &__track + width: 32vw + height: 40vw // Medium devices (tablets, 768px and up) diff --git a/common/templates/common/home.html b/common/templates/common/home.html index 3da7c29e..490a8de1 100644 --- a/common/templates/common/home.html +++ b/common/templates/common/home.html @@ -61,7 +61,7 @@
  • -
    +
    {% trans '在读的书' %}
    @@ -117,7 +117,7 @@
    {% trans '想看的电影/剧集' %}
    - {% if wish_movies_more %} + {% if wish_music_more %} {% trans '更多' %} {% endif %} @@ -139,7 +139,7 @@
    -
    +
    {% trans '在看的电影/剧集' %}
    @@ -191,6 +191,114 @@
    + +
    +
    + {% trans '想听的音乐' %} +
    + {% if wish_music_more %} + {% trans '更多' %} + {% endif %} + + +
    + +
    +
    + {% trans '在听的音乐' %} +
    + {% if do_music_more %} + {% trans '更多' %} + {% endif %} + + +
    + +
    +
    + {% trans '听过的音乐' %} +
    + {% if collect_music_more %} + {% trans '更多' %} + {% endif %} + + +
    + +
    diff --git a/common/templates/common/search_result.html b/common/templates/common/search_result.html index dd090c33..e448d35b 100644 --- a/common/templates/common/search_result.html +++ b/common/templates/common/search_result.html @@ -175,7 +175,7 @@
    {% trans '暂无评分' %}
    {% endif %} - + {% if movie.director %}{% trans '导演' %} @@ -187,7 +187,7 @@ {% if movie.genre %}{% trans '类型' %} {% for genre in movie.get_genre_display %} {{ genre }}{% if not forloop.last %} {% endif %} - {% endfor %} + {% endfor %}/ {% endif %} @@ -196,13 +196,13 @@ {% for actor in movie.actor %} 5 %}style="display: none;" {% endif %}>{{ actor }} {% if forloop.counter <= 5 %} - {% if not forloop.counter == 5 %} / {% endif %} + {% if not forloop.counter == 5 and not forloop.last %} {% endif %} {% endif %} {% endfor %} {% endif %}

    - {{ movie.brief | truncate:170 }} + {{ movie.brief }}

    {% for tag_dict in movie.tag_list %} @@ -219,6 +219,111 @@ {% endwith %} + + {% elif item.category_name|lower == 'album' or item.category_name|lower == 'song' %} + + {% with music=item %} +
  • +
    + + {% if item.category_name|lower == 'album' %} + + + + {% elif item.category_name|lower == 'song' %} + + + + {% endif %} + +
    +
    +
    + + {% if item.category_name|lower == 'album' %} + + {% if request.GET.q %} + {{ music.title | highlight:request.GET.q }} + {% else %} + {{ music.title }} + {% endif %} + + {% elif item.category_name|lower == 'song' %} + + {% if request.GET.q %} + {{ music.title | highlight:request.GET.q }} + {% else %} + {{ music.title }} + {% endif %} + + {% endif %} + + + {% if not request.GET.c or request.GET.c != 'music' and request.GET.c != 'book' and request.GET.c != 'music' %} + [{{item.verbose_category_name}}] + {% endif %} + + {{ music.get_source_site_display }} + +
    + + {% if music.rating %} +
    + {{ music.rating }} + {% else %} +
    {% trans '暂无评分' %}
    + {% endif %} + + + {% if music.artist %}{% trans '艺术家' %} + {% for artist in music.artist %} + {{ artist }} + {% if not forloop.last %} {% endif %} + {% endfor %} + {% endif %} + + {% if music.genre %}/ {% trans '流派' %} + {{ music.genre }} + {% endif %} + + {% if music.release_date %}/ {% trans '发行日期' %} + {{ music.release_date }} + {% endif %} + + + + + + {% if music.brief %} +

    + {{ music.brief }} +

    + {% elif music.category_name|lower == 'album' %} +

    + {% trans '曲目:' %}{{ music.track_list }} +

    + {% else %} + +

    + {% trans '所属专辑:' %}{{ music.album }} +

    + {% endif %} + +
    + {% for tag_dict in music.tag_list %} + {% for k, v in tag_dict.items %} + {% if k == 'content' %} + + {{ v }} + + {% endif %} + {% endfor %} + {% endfor %} +
    +
    + +
  • + {% endwith %} {% endif %} @@ -278,6 +383,12 @@ + + {% elif request.GET.c|lower == 'music' %} + + + + {% else %} @@ -286,6 +397,12 @@ + + + + + + {% endif %} @@ -296,6 +413,12 @@ + + + + + + {% endif %}
    @@ -319,6 +442,15 @@ + {% elif request.GET.c|lower == 'music' %} + +
    + {% trans '或者(≖ ◡ ≖)✧' %} +
    + + + + {% else %}
    @@ -330,6 +462,9 @@ + + + {% endif %} @@ -344,7 +479,9 @@ - + + + {% endif %}
    diff --git a/common/templates/partial/_footer.html b/common/templates/partial/_footer.html index ff15fcb6..375fb522 100644 --- a/common/templates/partial/_footer.html +++ b/common/templates/partial/_footer.html @@ -6,7 +6,7 @@ Github 支持的网站 - Version 0.2.0 + Version 0.3.0
    \ No newline at end of file diff --git a/common/templates/partial/_navbar.html b/common/templates/partial/_navbar.html index a129a7ab..2ec9eb7d 100644 --- a/common/templates/partial/_navbar.html +++ b/common/templates/partial/_navbar.html @@ -17,6 +17,7 @@ + diff --git a/common/templates/widgets/key_value.html b/common/templates/widgets/key_value.html deleted file mode 100644 index 80082bf8..00000000 --- a/common/templates/widgets/key_value.html +++ /dev/null @@ -1,26 +0,0 @@ - -
    - {% if widget.value != None %} - - {% for k, v in widget.keyvalue_pairs.items %} - - {% endfor %} - - {% endif %} -
    - - \ No newline at end of file diff --git a/common/templatetags/highlight.py b/common/templatetags/highlight.py index 32af3aa2..a2276800 100644 --- a/common/templatetags/highlight.py +++ b/common/templatetags/highlight.py @@ -3,11 +3,15 @@ from django.utils.safestring import mark_safe from django.template.defaultfilters import stringfilter from django.utils.html import format_html +import re register = template.Library() @register.filter @stringfilter def highlight(text, search): - highlighted = text.replace(search, '{}'.format(search)) - return mark_safe(highlighted) \ No newline at end of file + to_be_replaced_words = set(re.findall(search, text, flags=re.IGNORECASE)) + + for word in to_be_replaced_words: + text = text.replace(word, f'{word}') + return mark_safe(text) diff --git a/common/views.py b/common/views.py index 08b2245a..7cbbc0c1 100644 --- a/common/views.py +++ b/common/views.py @@ -8,11 +8,11 @@ from django.utils.translation import gettext_lazy as _ from django.core.paginator import Paginator from django.core.validators import URLValidator from django.core.exceptions import ValidationError, ObjectDoesNotExist -from django.core.files.uploadedfile import SimpleUploadedFile from django.db.models import Q, Count from django.http import HttpResponseBadRequest from books.models import Book from movies.models import Movie +from music.models import Album, Song, AlbumMark, SongMark from users.models import Report, User from mastodon.decorators import mastodon_request_included from common.models import MarkStatusEnum @@ -27,6 +27,8 @@ BOOKS_PER_SET = 5 # how many movies have in each set at the home page MOVIES_PER_SET = 5 +MUSIC_PER_SET = 5 + # how many items are showed in one search result page ITEMS_PER_PAGE = 20 @@ -42,6 +44,8 @@ logger = logging.getLogger(__name__) def home(request): if request.method == 'GET': + # really shitty code here + unread_announcements = Announcement.objects.filter( pk__gt=request.user.read_announcement_index).order_by('-pk') try: @@ -76,6 +80,28 @@ def home(request): status=MarkStatusEnum.COLLECT).order_by("-edited_time") collect_movies_more = True if collect_movie_marks.count() > MOVIES_PER_SET else False + do_music_marks = list(request.user.user_songmarks.filter(status=MarkStatusEnum.DO)[:MUSIC_PER_SET]) \ + + list(request.user.user_albummarks.filter(status=MarkStatusEnum.DO)[:MUSIC_PER_SET]) + do_music_more = True if len(do_music_marks) > MUSIC_PER_SET else False + do_music_marks = sorted(do_music_marks, key=lambda e: e.edited_time, reverse=True)[:MUSIC_PER_SET] + + wish_music_marks = list(request.user.user_songmarks.filter(status=MarkStatusEnum.WISH)[:MUSIC_PER_SET]) \ + + list(request.user.user_albummarks.filter(status=MarkStatusEnum.WISH)[:MUSIC_PER_SET]) + wish_music_more = True if len(wish_music_marks) > MUSIC_PER_SET else False + wish_music_marks = sorted(wish_music_marks, key=lambda e: e.edited_time, reverse=True)[:MUSIC_PER_SET] + + collect_music_marks = list(request.user.user_songmarks.filter(status=MarkStatusEnum.COLLECT)[:MUSIC_PER_SET]) \ + + list(request.user.user_albummarks.filter(status=MarkStatusEnum.COLLECT)[:MUSIC_PER_SET]) + collect_music_more = True if len(collect_music_marks) > MUSIC_PER_SET else False + collect_music_marks = sorted(collect_music_marks, key=lambda e: e.edited_time, reverse=True)[:MUSIC_PER_SET] + + for mark in do_music_marks + wish_music_marks + collect_music_marks: + # for template convenience + if mark.__class__ == AlbumMark: + mark.type = "album" + else: + mark.type = "song" + reports = Report.objects.order_by('-submitted_time').filter(is_read=False) # reports = Report.objects.latest('submitted_time').filter(is_read=False) @@ -95,6 +121,12 @@ def home(request): 'do_movies_more': do_movies_more, 'wish_movies_more': wish_movies_more, 'collect_movies_more': collect_movies_more, + 'do_music_marks': do_music_marks, + 'wish_music_marks': wish_music_marks, + 'collect_music_marks': collect_music_marks, + 'do_music_more': do_music_more, + 'wish_music_more': wish_music_more, + 'collect_music_more': collect_music_more, 'reports': reports, 'unread_announcements': unread_announcements, } @@ -122,22 +154,30 @@ def search(request): except ValidationError as e: pass - # category, book/movie/record etc + # category, book/movie/music etc category = request.GET.get("c", default='').strip().lower() + # keywords, seperated by blank space + keywords = request.GET.get("q", default='').strip().split() + # tag, when tag is provided there should be no keywords , for now + tag = request.GET.get("tag", default='') - def book_param_handler(): - q = Q() - query_args = [] + # white space string, empty query + if not (keywords or tag): + return [] + + def book_param_handler(**kwargs): # keywords - keywords = request.GET.get("q", default='').strip() + keywords = kwargs.get('keywords') + # tag + tag = kwargs.get('tag') - for keyword in [keywords]: + query_args = [] + q = Q() + + for keyword in keywords: q = q | Q(title__icontains=keyword) q = q | Q(subtitle__icontains=keyword) q = q | Q(orig_title__icontains=keyword) - - # tag - tag = request.GET.get("tag", default='') if tag: q = q & Q(book_tags__content__iexact=tag) @@ -158,6 +198,8 @@ def search(request): elif tag: # search by single tag book.similarity = 0 if book.rating_number is None else book.rating_number + else: + book.similarity = 0 return book.similarity if len(queryset) > 0: ordered_queryset = sorted(queryset, key=calculate_similarity, reverse=True) @@ -165,19 +207,19 @@ def search(request): ordered_queryset = list(queryset) return ordered_queryset - def movie_param_handler(): - q = Q() - query_args = [] + def movie_param_handler(**kwargs): # keywords - keywords = request.GET.get("q", default='').strip() + keywords = kwargs.get('keywords') + # tag + tag = kwargs.get('tag') - for keyword in [keywords]: + query_args = [] + q = Q() + + for keyword in keywords: q = q | Q(title__icontains=keyword) q = q | Q(other_title__icontains=keyword) q = q | Q(orig_title__icontains=keyword) - - # tag - tag = request.GET.get("tag", default='') if tag: q = q & Q(movie_tags__content__iexact=tag) @@ -197,6 +239,8 @@ def search(request): elif tag: # search by single tag movie.similarity = 0 if movie.rating_number is None else movie.rating_number + else: + movie.similarity = 0 return movie.similarity if len(queryset) > 0: ordered_queryset = sorted(queryset, key=calculate_similarity, reverse=True) @@ -204,11 +248,71 @@ def search(request): ordered_queryset = list(queryset) return ordered_queryset - def all_param_handler(): - book_queryset = book_param_handler() - movie_queryset = movie_param_handler() + def music_param_handler(**kwargs): + # keywords + keywords = kwargs.get('keywords') + # tag + tag = kwargs.get('tag') + + query_args = [] + q = Q() + + # search albums + for keyword in keywords: + q = q | Q(title__icontains=keyword) + q = q | Q(artist__icontains=keyword) + if tag: + q = q & Q(album_tags__content__iexact=tag) + + query_args.append(q) + album_queryset = Album.objects.filter(*query_args).distinct() + + # extra query args for songs + q = Q() + for keyword in keywords: + q = q | Q(album__title__icontains=keyword) + q = q | Q(title__icontains=keyword) + q = q | Q(artist__icontains=keyword) + if tag: + q = q & Q(song_tags__content__iexact=tag) + query_args.clear() + query_args.append(q) + song_queryset = Song.objects.filter(*query_args).distinct() + queryset = list(album_queryset) + list(song_queryset) + + def calculate_similarity(music): + if keywords: + # search by name + similarity, n = 0, 0 + artist_dump = ' '.join(music.artist) + for keyword in keywords: + if music.__class__ == Album: + similarity += 1/2 * SequenceMatcher(None, keyword, music.title).quick_ratio() \ + + 1/2 * SequenceMatcher(None, keyword, artist_dump).quick_ratio() + elif music.__class__ == Song: + similarity += 1/2 * SequenceMatcher(None, keyword, music.title).quick_ratio() \ + + 1/6 * SequenceMatcher(None, keyword, artist_dump).quick_ratio() \ + + 1/6 * SequenceMatcher(None, keyword, music.album.title).quick_ratio() + n += 1 + music.similarity = similarity / n + elif tag: + # search by single tag + music.similarity = 0 if music.rating_number is None else music.rating_number + else: + music.similarity = 0 + return music.similarity + if len(queryset) > 0: + ordered_queryset = sorted(queryset, key=calculate_similarity, reverse=True) + else: + ordered_queryset = list(queryset) + return ordered_queryset + + def all_param_handler(**kwargs): + book_queryset = book_param_handler(**kwargs) + movie_queryset = movie_param_handler(**kwargs) + music_queryset = music_param_handler(**kwargs) ordered_queryset = sorted( - book_queryset + movie_queryset, + book_queryset + movie_queryset + music_queryset, key=operator.attrgetter('similarity'), reverse=True ) @@ -217,14 +321,21 @@ def search(request): param_handler = { 'book': book_param_handler, 'movie': movie_param_handler, + 'music': music_param_handler, 'all': all_param_handler, '': all_param_handler } try: - queryset = param_handler[category]() + queryset = param_handler[category]( + keywords=keywords, + tag=tag + ) except KeyError as e: - queryset = param_handler['all']() + queryset = param_handler['all']( + keywords=keywords, + tag=tag + ) paginator = Paginator(queryset, ITEMS_PER_PAGE) page_number = request.GET.get('page', default=1) items = paginator.get_page(page_number) @@ -279,17 +390,11 @@ def jump_or_scrape(request, url): except ObjectDoesNotExist: # scrape if not exists try: - scraped_entity, raw_cover = scraper.scrape(url) - except: + scraper.scrape(url) + form = scraper.save(request_user=request.user) + except Exception as e: + logger.error(f"Scrape Failed URL: {url}") + logger.error("Expections during saving scraped data:", exc_info=e) return render(request, 'common/error.html', {'msg': _("爬取数据失败😫")}) - scraped_cover = { - 'cover': SimpleUploadedFile('temp.jpg', raw_cover)} - form = scraper.form_class(scraped_entity, scraped_cover) - if form.is_valid(): - form.instance.last_editor = request.user - form.save() - return redirect(form.instance) - else: - msg = _("爬取数据失败😫") - logger.error(str(form.errors)) - return render(request, 'common/error.html', {'msg': msg}) + return redirect(form.instance) + diff --git a/movies/forms.py b/movies/forms.py index caf59955..aa320bd5 100644 --- a/movies/forms.py +++ b/movies/forms.py @@ -16,11 +16,6 @@ def MovieMarkStatusTranslator(status): class MovieForm(forms.ModelForm): - # pub_year = forms.IntegerField( - # required=False, max_value=9999, min_value=0, label=_("出版年份")) - # pub_month = forms.IntegerField( - # required=False, max_value=12, min_value=1, label=_("出版月份")) - id = forms.IntegerField(required=False, widget=forms.HiddenInput()) genre = forms.MultipleChoiceField( required=False, @@ -103,52 +98,18 @@ class MovieForm(forms.ModelForm): 'is_series': forms.CheckboxInput(attrs={'style': 'width: auto; position: relative; top: 2px'}) } - # def clean_isbn(self): - # isbn = self.cleaned_data.get('isbn') - # if isbn: - # isbn = isbn.strip() - # return isbn +class MovieMarkForm(MarkForm): -class MovieMarkForm(forms.ModelForm): - IS_PRIVATE_CHOICES = [ - (True, _("仅关注者")), - (False, _("公开")), - ] STATUS_CHOICES = [(v, MovieMarkStatusTranslator(v)) for v in MarkStatusEnum.values] - id = forms.IntegerField(required=False, widget=forms.HiddenInput()) - share_to_mastodon = forms.BooleanField( - label=_("分享到长毛象"), initial=True, required=False) - rating = forms.IntegerField( - validators=[RatingValidator()], widget=forms.HiddenInput(), required=False) status = forms.ChoiceField( label=_(""), widget=forms.RadioSelect(), choices=STATUS_CHOICES ) - is_private = RadioBooleanField( - label=_("可见性"), - initial=True, - choices=IS_PRIVATE_CHOICES - ) - tags = TagField( - required=False, - widget=TagInput(attrs={'placeholder': _("回车增加标签")}), - label=_("标签") - ) - text = forms.CharField( - required=False, - widget=forms.Textarea( - attrs={ - "placeholder": _("最多只能写360字哦~"), - "maxlength": 360 - } - ), - label=_("短评"), - ) class Meta: model = MovieMark @@ -168,19 +129,7 @@ class MovieMarkForm(forms.ModelForm): } -class MovieReviewForm(forms.ModelForm): - IS_PRIVATE_CHOICES = [ - (True, _("仅关注者")), - (False, _("公开")), - ] - share_to_mastodon = forms.BooleanField( - label=_("分享到长毛象"), initial=True, required=False) - id = forms.IntegerField(required=False, widget=forms.HiddenInput()) - is_private = RadioBooleanField( - label=_("可见性"), - initial=True, - choices=IS_PRIVATE_CHOICES - ) +class MovieReviewForm(ReviewForm): class Meta: model = MovieReview diff --git a/movies/models.py b/movies/models.py index 563eea0d..abb97aac 100644 --- a/movies/models.py +++ b/movies/models.py @@ -54,6 +54,7 @@ class MovieGenreEnum(models.TextChoices): REALITY_TV = 'Reality-TV', _('真人秀') FAMILY = 'Family', _('家庭') TALK_SHOW = 'Talk-Show', _('脱口秀') + OTHER = 'Other', _('其他') MovieGenreTranslator = ChoicesDictGenerator(MovieGenreEnum) @@ -76,7 +77,7 @@ class Movie(Entity): default=list, ) imdb_code = models.CharField( - blank=True, max_length=10, null=True, db_index=True) + blank=True, max_length=10, null=False, db_index=True, default='') director = postgres.ArrayField( models.CharField(_("director"), blank=True, default='', max_length=100), diff --git a/movies/templates/movies/delete.html b/movies/templates/movies/delete.html index 1b8a54ad..9fec90df 100644 --- a/movies/templates/movies/delete.html +++ b/movies/templates/movies/delete.html @@ -59,9 +59,12 @@ {% endif %} {% if movie.last_editor %} - -
    {% trans '最近编辑者:' %}{{ movie.last_editor | default:"" }}
    -
    +
    + {% trans '最近编辑者:' %} + + {{ movie.last_editor | default:"" }} + +
    {% endif %}
    {% trans '上次编辑时间:' %}{{ movie.edited_time }}
    diff --git a/movies/templates/movies/delete_review.html b/movies/templates/movies/delete_review.html index c81e0e0c..a16e6c49 100644 --- a/movies/templates/movies/delete_review.html +++ b/movies/templates/movies/delete_review.html @@ -37,8 +37,7 @@ {% if review.is_private %} + viewBox="0 0 20 20"> diff --git a/movies/templates/movies/detail.html b/movies/templates/movies/detail.html index cce63619..31392376 100644 --- a/movies/templates/movies/detail.html +++ b/movies/templates/movies/detail.html @@ -204,7 +204,7 @@ {% trans '编辑这部电影' %} {% endif %} {% if user.is_staff %} - / {% trans '删除' %} + / {% trans '删除' %} {% endif %} @@ -225,30 +225,18 @@
    + {% if movie.brief %}
    {% trans '简介' %}
    - {% if movie.brief %}

    {{ movie.brief | linebreaksbr }}

    展开全部
    - {% else %} -
    {% trans '暂无简介' %}
    - {% endif %} - -
    - - {% if movie.contents %} -
    -
    {% trans '目录' %}
    -

    {{ movie.contents | linebreaksbr }}

    -
    - 展开全部 -
    {% endif %} +
    @@ -270,7 +258,7 @@ {% endif %} {% if others_mark.is_private %} - + {% endif %} {{ others_mark.edited_time }} {% if others_mark.text %} @@ -299,7 +287,7 @@
  • {{ others_review.owner.username }} {% if others_review.is_private %} - + {% endif %} {{ others_review.edited_time }} {{ others_review.title }} @@ -327,7 +315,7 @@ {% endif %} {% endif %} {% if mark.is_private %} - + {% endif %} {% trans '修改' %} @@ -375,7 +363,7 @@ {% trans '我的评论' %} {% if review.is_private %} - + {% endif %} diff --git a/movies/templates/movies/mark_list.html b/movies/templates/movies/mark_list.html index e661c479..8b0d68a6 100644 --- a/movies/templates/movies/mark_list.html +++ b/movies/templates/movies/mark_list.html @@ -47,8 +47,7 @@ data-rating-score="{{ mark.rating | floatformat:"0" }}"> {% endif %} {% if mark.is_private %} - + diff --git a/movies/templates/movies/review_detail.html b/movies/templates/movies/review_detail.html index b93401fd..03986460 100644 --- a/movies/templates/movies/review_detail.html +++ b/movies/templates/movies/review_detail.html @@ -39,8 +39,7 @@ {{ review.title }} {% if review.is_private %} - + diff --git a/movies/templates/movies/review_list.html b/movies/templates/movies/review_list.html index 25d34afb..88aa3046 100644 --- a/movies/templates/movies/review_list.html +++ b/movies/templates/movies/review_list.html @@ -42,7 +42,7 @@ {{ review.owner.username }} {% if review.is_private %} - + {% endif %} {{ review.edited_time }} diff --git a/movies/views.py b/movies/views.py index a54bfe3e..f63cdeef 100644 --- a/movies/views.py +++ b/movies/views.py @@ -8,7 +8,6 @@ from django.db import IntegrityError, transaction from django.db.models import Count from django.utils import timezone from django.core.paginator import Paginator -from django.core.files.uploadedfile import SimpleUploadedFile from mastodon import mastodon_request_included from mastodon.api import check_visibility, post_toot, TootVisibilityEnum from mastodon.utils import rating_to_emoji @@ -17,7 +16,6 @@ from common.views import PAGE_LINK_NUMBER, jump_or_scrape from common.models import SourceSiteEnum from .models import * from .forms import * -from .forms import MovieMarkStatusTranslator from boofilsic.settings import MASTODON_TAGS @@ -225,7 +223,7 @@ def retrieve(request, id): } ) else: - logger.warning('non-GET method at /movie/') + logger.warning('non-GET method at /movies/') return HttpResponseBadRequest() diff --git a/music/__init__.py b/music/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/music/admin.py b/music/admin.py new file mode 100644 index 00000000..eb770458 --- /dev/null +++ b/music/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from .models import * + +admin.site.register(Song) +admin.site.register(SongMark) +admin.site.register(SongReview) +admin.site.register(SongTag) +admin.site.register(Album) +admin.site.register(AlbumMark) +admin.site.register(AlbumReview) +admin.site.register(AlbumTag) diff --git a/music/apps.py b/music/apps.py new file mode 100644 index 00000000..d909c7fb --- /dev/null +++ b/music/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MusicConfig(AppConfig): + name = 'music' diff --git a/music/forms.py b/music/forms.py new file mode 100644 index 00000000..3eec5646 --- /dev/null +++ b/music/forms.py @@ -0,0 +1,180 @@ +from django import forms +from django.contrib.postgres.forms import SimpleArrayField +from django.utils.translation import gettext_lazy as _ +from .models import * +from common.models import MarkStatusEnum +from common.forms import * + + +def MusicMarkStatusTranslator(status): + trans_dict = { + MarkStatusEnum.DO.value: _("在听"), + MarkStatusEnum.WISH.value: _("想听"), + MarkStatusEnum.COLLECT.value: _("听过") + } + return trans_dict[status] + + +class SongForm(forms.ModelForm): + + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + other_info = JSONField(required=False, label=_("其他信息")) + duration = DurationField(required=False) + + class Meta: + model = Song + # fields = '__all__' + fields = [ + 'id', + 'title', + 'source_site', + 'source_url', + 'artist', + 'release_date', + 'duration', + 'isrc', + 'genre', + 'cover', + 'album', + 'brief', + 'other_info', + ] + widgets = { + 'artist': forms.TextInput(attrs={'placeholder': _("多个艺术家使用英文逗号分隔")}), + 'duration': forms.TextInput(attrs={'placeholder': _("毫秒")}), + 'cover': PreviewImageInput(), + } + + +class SongMarkForm(MarkForm): + + STATUS_CHOICES = [(v, MusicMarkStatusTranslator(v)) + for v in MarkStatusEnum.values] + + status = forms.ChoiceField( + label=_(""), + widget=forms.RadioSelect(), + choices=STATUS_CHOICES + ) + + class Meta: + model = SongMark + fields = [ + 'id', + 'song', + 'status', + 'rating', + 'text', + 'is_private', + ] + labels = { + 'rating': _("评分"), + } + widgets = { + 'song': forms.TextInput(attrs={"hidden": ""}), + } + + +class SongReviewForm(ReviewForm): + + class Meta: + model = SongReview + fields = [ + 'id', + 'song', + 'title', + 'content', + 'is_private' + ] + labels = { + 'song': "", + 'title': _("标题"), + 'content': _("正文"), + 'share_to_mastodon': _("分享到长毛象") + } + widgets = { + 'song': forms.TextInput(attrs={"hidden": ""}), + } + + +class AlbumForm(forms.ModelForm): + + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + other_info = JSONField(required=False, label=_("其他信息")) + duration = DurationField(required=False) + + class Meta: + model = Album + # fields = '__all__' + fields = [ + 'id', + 'title', + 'source_site', + 'source_url', + 'artist', + 'company', + 'release_date', + 'duration', + 'genre', + 'cover', + 'brief', + 'track_list', + 'other_info', + ] + widgets = { + 'artist': forms.TextInput(attrs={'placeholder': _("多个艺术家使用英文逗号分隔")}), + 'company': forms.TextInput(attrs={'placeholder': _("多个发行方使用英文逗号分隔")}), + 'duration': forms.TextInput(attrs={'placeholder': _("毫秒")}), + 'cover': PreviewImageInput(), + } + + +class AlbumMarkForm(MarkForm): + + STATUS_CHOICES = [(v, MusicMarkStatusTranslator(v)) + for v in MarkStatusEnum.values] + + status = forms.ChoiceField( + label=_(""), + widget=forms.RadioSelect(), + choices=STATUS_CHOICES + ) + + class Meta: + model = AlbumMark + fields = [ + 'id', + 'album', + 'status', + 'rating', + 'text', + 'is_private', + ] + labels = { + 'rating': _("评分"), + } + widgets = { + 'album': forms.TextInput(attrs={"hidden": ""}), + } + + +class AlbumReviewForm(ReviewForm): + + class Meta: + model = AlbumReview + fields = [ + 'id', + 'album', + 'title', + 'content', + 'is_private' + ] + labels = { + 'album': "", + 'title': _("标题"), + 'content': _("正文"), + 'share_to_mastodon': _("分享到长毛象") + } + widgets = { + 'album': forms.TextInput(attrs={"hidden": ""}), + } diff --git a/music/models.py b/music/models.py new file mode 100644 index 00000000..6becf007 --- /dev/null +++ b/music/models.py @@ -0,0 +1,181 @@ +import uuid +import django.contrib.postgres.fields as postgres +from django.utils.translation import ugettext_lazy as _ +from django.db import models +from django.core.serializers.json import DjangoJSONEncoder +from django.shortcuts import reverse +from common.models import Entity, Mark, Review, Tag +from common.utils import ChoicesDictGenerator +from boofilsic.settings import SONG_MEDIA_PATH_ROOT, DEFAULT_SONG_IMAGE, ALBUM_MEDIA_PATH_ROOT, DEFAULT_ALBUM_IMAGE +from django.utils import timezone + + +def song_cover_path(instance, filename): + ext = filename.split('.')[-1] + filename = "%s.%s" % (uuid.uuid4(), ext) + root = '' + if SONG_MEDIA_PATH_ROOT.endswith('/'): + root = SONG_MEDIA_PATH_ROOT + else: + root = SONG_MEDIA_PATH_ROOT + '/' + return root + timezone.now().strftime('%Y/%m/%d') + f'{filename}' + + +def album_cover_path(instance, filename): + ext = filename.split('.')[-1] + filename = "%s.%s" % (uuid.uuid4(), ext) + root = '' + if ALBUM_MEDIA_PATH_ROOT.endswith('/'): + root = ALBUM_MEDIA_PATH_ROOT + else: + root = ALBUM_MEDIA_PATH_ROOT + '/' + return root + timezone.now().strftime('%Y/%m/%d') + f'{filename}' + + +class Album(Entity): + title = models.CharField(_("标题"), max_length=500) + release_date = models.DateField( + _('发行日期'), auto_now=False, auto_now_add=False, null=True, blank=True) + cover = models.ImageField( + _("封面"), upload_to=album_cover_path, default=DEFAULT_ALBUM_IMAGE, blank=True) + duration = models.PositiveIntegerField(_("时长"), null=True, blank=True) + artist = postgres.ArrayField( + models.CharField(_("artist"), blank=True, + default='', max_length=100), + null=True, + blank=True, + default=list, + verbose_name=_("艺术家") + ) + genre = models.CharField(_("流派"), blank=True, + default='', max_length=100) + company = postgres.ArrayField( + models.CharField(blank=True, + default='', max_length=500), + null=True, + blank=True, + default=list, + verbose_name=_("发行方") + ) + track_list = models.TextField(_("曲目"), blank=True, default="") + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse("music:retrieve_album", args=[self.id]) + + def get_tags_manager(self): + return self.album_tags + + @property + def verbose_category_name(self): + return _("专辑") + + +class Song(Entity): + ''' + Song(track) entity, can point to entity Album + ''' + title = models.CharField(_("标题"), max_length=500) + release_date = models.DateField(_('发行日期'), auto_now=False, auto_now_add=False, null=True, blank=True) + isrc = models.CharField(_("ISRC"), + blank=True, max_length=15, db_index=True, default='') + # duration in ms + duration = models.PositiveIntegerField(_("时长"), null=True, blank=True) + cover = models.ImageField( + _("封面"), upload_to=song_cover_path, default=DEFAULT_SONG_IMAGE, blank=True) + artist = postgres.ArrayField( + models.CharField(blank=True, + default='', max_length=100), + null=True, + blank=True, + default=list, + verbose_name=_("艺术家") + ) + genre = models.CharField(_("流派"), blank=True, default='', max_length=100) + + album = models.ForeignKey( + Album, models.SET_NULL, "album_songs", null=True, blank=True, verbose_name=_("所属专辑")) + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse("music:retrieve_song", args=[self.id]) + + def get_tags_manager(self): + return self.song_tags + + @property + def verbose_category_name(self): + return _("单曲") + +class SongMark(Mark): + song = models.ForeignKey( + Song, on_delete=models.CASCADE, related_name='song_marks', null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['owner', 'song'], name='unique_song_mark') + ] + + +class SongReview(Review): + song = models.ForeignKey( + Song, on_delete=models.CASCADE, related_name='song_reviews', null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['owner', 'song'], name='unique_song_review') + ] + + +class SongTag(Tag): + song = models.ForeignKey( + Song, on_delete=models.CASCADE, related_name='song_tags', null=True) + mark = models.ForeignKey( + SongMark, on_delete=models.CASCADE, related_name='songmark_tags', null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['content', 'mark'], name="unique_songmark_tag") + ] + + +class AlbumMark(Mark): + album = models.ForeignKey( + Album, on_delete=models.CASCADE, related_name='album_marks', null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['owner', 'album'], name='unique_album_mark') + ] + + +class AlbumReview(Review): + album = models.ForeignKey( + Album, on_delete=models.CASCADE, related_name='album_reviews', null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['owner', 'album'], name='unique_album_review') + ] + + +class AlbumTag(Tag): + album = models.ForeignKey( + Album, on_delete=models.CASCADE, related_name='album_tags', null=True) + mark = models.ForeignKey( + AlbumMark, on_delete=models.CASCADE, related_name='albummark_tags', null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['content', 'mark'], name="unique_albummark_tag") + ] diff --git a/music/templates/music/album_detail.html b/music/templates/music/album_detail.html new file mode 100644 index 00000000..29e66c95 --- /dev/null +++ b/music/templates/music/album_detail.html @@ -0,0 +1,453 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load strip_scheme %} + + + + + + + + + + + + + + {% trans 'NiceDB - 音乐详情' %} | {{ album.title }} + + + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    + + {{ album.title }} + +
    +
    + + {{ album.title }} + {{ album.get_source_site_display }} +
    + +
    +
    + {% if album.rating %} + + {{ album.rating }} + {% else %} + {% trans '评分:暂无评分' %} + {% endif %} +
    +
    {% if album.artist %}{% trans '艺术家:' %} + {% for artist in album.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if album.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %}
    +
    {% if album.company %}{% trans '发行方:' %} + {% for company in album.company %} + 5 %}style="display: none;" {% endif %}> + {{ company }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if album.company|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %}
    +
    {% if album.release_date %} + {% trans '发行日期:' %}{{ album.release_date }} + {% endif %} +
    +
    {% if album.duration %} + {% trans '时长:' %}{{ album.get_duration_display }} + {% endif %} +
    +
    {% if album.genre %} + {% trans '流派:' %}{{ album.genre }} + {% endif %} +
    + + +
    +
    + {% if album.other_info %} + {% for k, v in album.other_info.items %} +
    + {{k}}:{{v}} +
    + {% endfor %} + {% endif %} + + + {% if album.last_editor %} +
    {% trans '最近编辑者:' %}{{ album.last_editor | default:"" }}
    + {% endif %} + +
    + {% trans '编辑这个作品' %} + {% if user.is_staff %} + / {% trans '删除' %} + {% endif %} +
    +
    + +
    + + {% for tag_dict in album_tag_list %} + {% for k, v in tag_dict.items %} + {% if k == 'content' %} + + {{ v }} + + {% endif %} + {% endfor %} + {% endfor %} + +
    +
    +
    +
    + {% if album.brief %} +
    +
    {% trans '简介' %}
    + +

    {{ album.brief | linebreaksbr }}

    + +
    + {% endif %} + + {% if album.track_list %} +
    +
    {% trans '曲目' %}
    +

    {{ album.track_list | linebreaksbr }}

    + +
    + {% endif %} + + {% if album.album_songs.count %} +
    +
    {% trans '关联单曲' %}
    + + + +
    + {% endif %} + + +
    + +
    {% trans '这部作品的标记' %}
    + {% if mark_list_more %} + {% trans '更多' %} + {% endif %} + {% if mark_list %} +
      + {% for others_mark in mark_list %} +
    • + {{ others_mark.owner.username }} + {{ others_mark.get_status_display }} + {% if others_mark.rating %} + + {% endif %} + {% if others_mark.is_private %} + + {% endif %} + {{ others_mark.edited_time }} + {% if others_mark.text %} +

      {{ others_mark.text }}

      + {% endif %} +
    • + {% endfor %} +
    + {% else %} +
    {% trans '暂无标记' %}
    + {% endif %} +
    +
    +
    {% trans '这部作品的评论' %}
    + + {% if review_list_more %} + {% trans '更多' %} + {% endif %} + {% if review_list %} + + {% else %} +
    {% trans '暂无评论' %}
    + {% endif %} +
    +
    +
    + +
    +
    + + {% if mark %} +
    + + {% trans '我' %}{{ mark.get_status_display }} + {% if mark.status == status_enum.DO.value or mark.status == status_enum.COLLECT.value%} + {% if mark.rating %} + + {% endif %} + {% endif %} + {% if mark.is_private %} + + {% endif %} + + {% trans '修改' %} +
    + {% csrf_token %} + {% trans '删除' %} +
    +
    +
    + +
    {{ mark.edited_time }}
    + + {% if mark.text %} +

    {{ mark.text }}

    + {% endif %} +
    + + {% for tag in mark_tags %} + {{ tag }} + {% endfor %} + +
    +
    + {% else %} +
    +
    {% trans '标记这部作品' %}
    +
    + + + +
    +
    + {% endif %} + +
    + +
    + {% if review %} +
    + + {% trans '我的评论' %} + {% if review.is_private %} + + {% endif %} + + + {% trans '编辑' %} + {% trans '删除' %} + + +
    {{ review.edited_time }}
    + + + {{ review.title }} + +
    + {% else %} + +
    +
    {% trans '我的评论' %}
    + +
    + + {% endif %} +
    + +
    +
    +
    + +
    + {% include "partial/_footer.html" %} +
    + +
    + + + +
    +
    + + + + + + diff --git a/music/templates/music/album_mark_list.html b/music/templates/music/album_mark_list.html new file mode 100644 index 00000000..a971e472 --- /dev/null +++ b/music/templates/music/album_mark_list.html @@ -0,0 +1,169 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} + + + + + + + {% trans 'NiceDB - ' %}{{ album.title }}{% trans '的标记' %} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    +
    + {{ album.title }}{% trans '的标记' %} +
    +
      + + {% for mark in marks %} + +
    • + {{ mark.owner.username }} + {{ mark.get_status_display }} + {% if mark.rating %} + + {% endif %} + {% if mark.is_private %} + + + + {% endif %} + {{ mark.edited_time }} + {% if mark.text %} +

      {{ mark.text }}

      + {% endif %} +
    • + + {% empty %} +
      + {% trans '无结果' %} +
      + {% endfor %} + +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + {{ album.title }} + + + {{ album.get_source_site_display }} +
    + +
    {% if album.artist %}{% trans '艺术家:' %} + {% for artist in album.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if album.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %} +
    +
    {% if album.genre %}{% trans '流派:' %}{{ album.genre }}{% endif %}
    + +
    {% if album.release_date %}{% trans '发行日期:' %}{{ album.release_date}}{% endif %}
    + {% if album.rating %} + {% trans '评分: ' %} + {{ album.rating }} + {% endif %} +
    + +
    +
    +
    +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + \ No newline at end of file diff --git a/music/templates/music/album_review_detail.html b/music/templates/music/album_review_detail.html new file mode 100644 index 00000000..c8fd1666 --- /dev/null +++ b/music/templates/music/album_review_detail.html @@ -0,0 +1,156 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + + + + + + {% trans 'NiceDB - 评论详情' %} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    +
    + {{ review.title }} +
    + {% if review.is_private %} + + + + {% endif %} +
    +
    + + {{ review.owner.username }} + + {% if mark %} + + {% if mark.rating %} + + {% endif %} + + {% endif %} + + {{ review.edited_time }} + +
    +
    + {% if request.user == review.owner %} + {% trans '编辑' %} + {% trans '删除' %} + {% endif %} +
    +
    + +
    + {{ form.content }} +
    + {{ form.media }} +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + {{ album.title }} + + + + {{ album.get_source_site_display }} + + +
    + +
    {% if album.artist %}{% trans '艺术家:' %} + {% for artist in album.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if album.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %} +
    +
    {% if album.genre %}{% trans '流派:' %}{{ album.genre }}{% endif %}
    + +
    {% if album.release_date %}{% trans '发行日期:' %}{{ album.release_date}}{% endif %}
    + {% if album.rating %} + {% trans '评分: ' %} + {{ album.rating }} + {% endif %} +
    + +
    +
    +
    +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + \ No newline at end of file diff --git a/music/templates/music/album_review_list.html b/music/templates/music/album_review_list.html new file mode 100644 index 00000000..d815dd20 --- /dev/null +++ b/music/templates/music/album_review_list.html @@ -0,0 +1,166 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} + + + + + + + {% trans 'NiceDB - ' %}{{ album.title }}{% trans '的评论' %} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    +
    + {{ album.title }}{% trans '的评论' %} +
    +
      + + {% for review in reviews %} + +
    • + + {{ review.owner.username }} + {% if review.is_private %} + + + + {% endif %} + {{ review.edited_time }} + + + {{ review.title + }} + +
    • + {% empty %} +
      {% trans '无结果' %}
      + {% endfor %} + +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + {{ album.title }} + + + {{ album.get_source_site_display }} +
    + +
    {% if album.artist %}{% trans '艺术家:' %} + {% for artist in album.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if album.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %} +
    +
    {% if album.genre %}{% trans '流派:' %}{{ album.genre }}{% endif %}
    + +
    {% if album.release_date %}{% trans '发行日期:' %}{{ album.release_date}}{% endif %}
    + {% if album.rating %} + {% trans '评分: ' %} + {{ album.rating }} + {% endif %} +
    + +
    +
    +
    +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + \ No newline at end of file diff --git a/music/templates/music/create_update_album.html b/music/templates/music/create_update_album.html new file mode 100644 index 00000000..841eab79 --- /dev/null +++ b/music/templates/music/create_update_album.html @@ -0,0 +1,94 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - ' %}{{ title }} + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    + {% trans '>>> 试试一键剽取~ <<<' %} +
    + {% csrf_token %} + {{ form.media }} + {% for field in form %} + {% if field.name == 'release_date' %} + {{ field.label_tag }} + + + + {% else %} + {% if field.name != 'id' %} + {{ field.label_tag }} + {% endif %} + {{ field }} + {% endif %} + + {% endfor %} + + +
    +
    + +
    +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + \ No newline at end of file diff --git a/music/templates/music/create_update_album_review.html b/music/templates/music/create_update_album_review.html new file mode 100644 index 00000000..c12d6276 --- /dev/null +++ b/music/templates/music/create_update_album_review.html @@ -0,0 +1,131 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - ' %}{{ title }} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    + + + +
    +
    + +
    + {{ album.title }} + + {{ album.get_source_site_display }} +
    +
    {% if album.artist %}{% trans '艺术家:' %} + {% for artist in album.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if album.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %} +
    +
    {% if album.genre %}{% trans '流派:' %}{{ album.genre }}{% endif %}
    + +
    {% if album.release_date %}{% trans '发行日期:' %}{{ album.release_date}}{% endif %}
    + + {% if album.rating %} + {% trans '评分:' %} + {{ album.rating }} + {% endif %} +
    +
    +
    + +
    + {% csrf_token %} + {{ form.album }} +
    + {{ form.title.label }} +
    + {{ form.title }} +
    + + {{ form.content.label }} + + + {% trans '预览' %} + +
    +
    + {{ form.content }} +
    +
    {% trans '不知道什么是Markdown?可以参考' %}{% trans '这里' %}
    +
    +
    + + {{ form.is_private.label }}{{ form.is_private }} +
    + +
    +
    + +
    + {{ form.media }} +
    + +
    + +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/music/templates/music/create_update_song.html b/music/templates/music/create_update_song.html new file mode 100644 index 00000000..a694c891 --- /dev/null +++ b/music/templates/music/create_update_song.html @@ -0,0 +1,97 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - ' %}{{ title }} + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    + + {% comment %} + {% trans '>>> 试试一键剽取~ <<<' %} + + {% endcomment %} + +
    + {% csrf_token %} + {{ form.media }} + {% for field in form %} + {% if field.name == 'release_date' %} + {{ field.label_tag }} + + {% else %} + {% if field.name != 'id' %} + {{ field.label_tag }} + {% endif %} + {{ field }} + {% endif %} + + {% endfor %} + + +
    +
    + +
    +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + \ No newline at end of file diff --git a/music/templates/music/create_update_song_review.html b/music/templates/music/create_update_song_review.html new file mode 100644 index 00000000..52ee274d --- /dev/null +++ b/music/templates/music/create_update_song_review.html @@ -0,0 +1,135 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - ' %}{{ title }} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    + + + +
    +
    + +
    + {{ song.title }} + + {{ song.get_source_site_display }} +
    +
    {% if song.artist %}{% trans '艺术家:' %} + {% for artist in song.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if song.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %} +
    +
    {% if song.genre %}{% trans '流派:' %}{{ song.genre }}{% endif %}
    +
    {% if song.album %}{% trans '所属专辑:' %} + {{ song.album }} + {% endif %} +
    + +
    {% if song.release_date %}{% trans '发行日期:' %}{{ song.release_date }}{% endif %}
    + + {% if song.rating %} + {% trans '评分:' %} + {{ song.rating }} + {% endif %} +
    +
    +
    + +
    + {% csrf_token %} + {{ form.song }} +
    + {{ form.title.label }} +
    + {{ form.title }} +
    + + {{ form.content.label }} + + + {% trans '预览' %} + +
    +
    + {{ form.content }} +
    +
    {% trans '不知道什么是Markdown?可以参考' %}{% trans '这里' %}
    +
    +
    + + {{ form.is_private.label }}{{ form.is_private }} +
    + +
    +
    + +
    + {{ form.media }} +
    + +
    + +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/music/templates/music/delete_album.html b/music/templates/music/delete_album.html new file mode 100644 index 00000000..36593eba --- /dev/null +++ b/music/templates/music/delete_album.html @@ -0,0 +1,104 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - 删除音乐' %} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    {% trans '确认删除这个作品吗?相关评论和标记将一并删除。' %}
    + +
    +
    + + + +
    +
    + +
    + {{ album.title }} + {{ album.get_source_site_display }} +
    + + {% if album.rating %} + {% trans '评分:' %} + + {{ album.rating }} + {% else %} + {% trans '评分:暂无评分' %} + {% endif %} + + {% if album.last_editor %} +
    + {% trans '最近编辑者:' %} + + {{ album.last_editor | default:"" }} + +
    + {% endif %} + +
    {% trans '上次编辑时间:' %}{{ album.edited_time }}
    + + {% if album.album_marks.all %} +
    {% trans '这个条目有' %} {{ album.album_marks.count }} 个标记
    + {% endif %} + {% if album.album_reviews.all %} +
    {% trans '这个条目有' %} {{ album.album_reviews.count }} 个评论
    + {% endif %} + +
    +
    +
    +
    +
    + {% csrf_token %} + +
    + +
    +
    +
    +
    + +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/music/templates/music/delete_album_review.html b/music/templates/music/delete_album_review.html new file mode 100644 index 00000000..7deaaa49 --- /dev/null +++ b/music/templates/music/delete_album_review.html @@ -0,0 +1,107 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - 删除评论' %} + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    {% trans '确认删除这篇评论吗?' %}
    + +
    + + +
    + +
    + {{ review.title }} +
    + {% if review.is_private %} + + + + {% endif %} + +
    +
    + + {{ review.owner.username }} + + {% if mark %} + + {% if mark.rating %} + + {% endif %} + + {% endif %} + + {{ review.edited_time }} + +
    +
    + + +
    +
    + {{ form.content }} +
    + {{ form.media }} + +
    + +
    +
    + {% csrf_token %} + +
    + +
    +
    +
    +
    + +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + \ No newline at end of file diff --git a/music/templates/music/delete_song.html b/music/templates/music/delete_song.html new file mode 100644 index 00000000..ace5bc50 --- /dev/null +++ b/music/templates/music/delete_song.html @@ -0,0 +1,104 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - 删除音乐' %} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    {% trans '确认删除这个作品吗?相关评论和标记将一并删除。' %}
    + +
    +
    + + + +
    +
    + +
    + {{ song.title }} + {{ song.get_source_site_display }} +
    + + {% if song.rating %} + {% trans '评分:' %} + + {{ song.rating }} + {% else %} + {% trans '评分:暂无评分' %} + {% endif %} + + {% if song.last_editor %} +
    + {% trans '最近编辑者:' %} + + {{ song.last_editor | default:"" }} + +
    + {% endif %} + +
    {% trans '上次编辑时间:' %}{{ song.edited_time }}
    + + {% if song.song_marks.all %} +
    {% trans '这个条目有' %} {{ song.song_marks.count }} 个标记
    + {% endif %} + {% if song.song_reviews.all %} +
    {% trans '这个条目有' %} {{ song.song_reviews.count }} 个评论
    + {% endif %} + +
    +
    +
    +
    +
    + {% csrf_token %} + +
    + +
    +
    +
    +
    + +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/music/templates/music/delete_song_review.html b/music/templates/music/delete_song_review.html new file mode 100644 index 00000000..9e515b02 --- /dev/null +++ b/music/templates/music/delete_song_review.html @@ -0,0 +1,107 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - 删除评论' %} + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    {% trans '确认删除这篇评论吗?' %}
    + +
    + + +
    + +
    + {{ review.title }} +
    + {% if review.is_private %} + + + + {% endif %} + +
    +
    + + {{ review.owner.username }} + + {% if mark %} + + {% if mark.rating %} + + {% endif %} + + {% endif %} + + {{ review.edited_time }} + +
    +
    + + +
    +
    + {{ form.content }} +
    + {{ form.media }} + +
    + +
    +
    + {% csrf_token %} + +
    + +
    +
    +
    +
    + +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + \ No newline at end of file diff --git a/music/templates/music/scrape_album.html b/music/templates/music/scrape_album.html new file mode 100644 index 00000000..01d48925 --- /dev/null +++ b/music/templates/music/scrape_album.html @@ -0,0 +1,109 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - 从豆瓣获取数据' %} + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    +
    + {% trans '根据豆瓣内容填写下方表单' %} +
    + +
    +
    +
    + {% csrf_token %} + + {{ form.media }} + + {% for field in form %} + + {% if field.id_for_label == 'id_is_series' %} + + {{ field }} + {% else %} + {% if field.id_for_label != 'id_id' %} + + {% endif %} + {{ field }} + {% endif %} + + {% endfor %} + +
    + {% trans '剽取!' %} +
    +
    +
    +
    + +
    +
    +
    + {% trans '复制详情页链接' %} +
    +
    + {% csrf_token %} + + +
    +
    + +
    +
    +
    + +
    + {% include "partial/_footer.html" %} + +
    + + + + + diff --git a/music/templates/music/scrape_song.html b/music/templates/music/scrape_song.html new file mode 100644 index 00000000..e2cd4730 --- /dev/null +++ b/music/templates/music/scrape_song.html @@ -0,0 +1,109 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'NiceDB - 从豆瓣获取数据' %} + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    +
    + {% trans '根据豆瓣内容填写下方表单' %} +
    + +
    +
    +
    + {% csrf_token %} + + {{ form.media }} + + {% for field in form %} + + {% if field.id_for_label == 'id_is_series' %} + + {{ field }} + {% else %} + {% if field.id_for_label != 'id_id' %} + + {% endif %} + {{ field }} + {% endif %} + + {% endfor %} + +
    + {% trans '剽取!' %} +
    +
    +
    +
    + +
    +
    +
    + {% trans '复制详情页链接' %} +
    +
    + {% csrf_token %} + + +
    +
    + +
    +
    +
    + +
    + {% include "partial/_footer.html" %} + +
    + + + + + diff --git a/music/templates/music/song_detail.html b/music/templates/music/song_detail.html new file mode 100644 index 00000000..841514d7 --- /dev/null +++ b/music/templates/music/song_detail.html @@ -0,0 +1,400 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load strip_scheme %} + + + + + + + + + + + + + + {% trans 'NiceDB - 音乐详情' %} | {{ song.title }} + + + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    + + {{ song.title }} + +
    +
    + + {{ song.title }} + {{ song.get_source_site_display }} +
    + +
    +
    + {% if song.rating %} + + {{ song.rating }} + {% else %} + {% trans '评分:暂无评分' %} + {% endif %} +
    +
    {% if song.artist %}{% trans '艺术家:' %} + {% for artist in song.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if song.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %}
    +
    {% if song.release_date %} + {% trans '发行日期:' %}{{ song.release_date }} + {% endif %} +
    +
    {% if song.duration %} + {% trans '时长:' %}{{ song.get_duration_display }} + {% endif %} +
    +
    {% if song.genre %} + {% trans '流派:' %}{{ song.genre }} + {% endif %} +
    + + +
    +
    +
    {% if song.isrc %} + {% trans 'ISRC:' %}{{ song.isrc }} + {% endif %} +
    +
    {% if song.album %} + {% trans '所属专辑:' %}{{ song.album }} + {% endif %} +
    + {% if song.other_info %} + {% for k, v in song.other_info.items %} +
    + {{k}}:{{v}} +
    + {% endfor %} + {% endif %} + + + {% if song.last_editor %} +
    {% trans '最近编辑者:' %}{{ song.last_editor | default:"" }}
    + {% endif %} + +
    + {% trans '编辑这个作品' %} + {% if user.is_staff %} + / {% trans '删除' %} + {% endif %} +
    +
    + +
    + + {% for tag_dict in song_tag_list %} + {% for k, v in tag_dict.items %} + {% if k == 'content' %} + + {{ v }} + + {% endif %} + {% endfor %} + {% endfor %} + +
    +
    +
    +
    + {% if song.brief %} +
    +
    {% trans '简介' %}
    + +

    {{ song.brief | linebreaksbr }}

    + + + +
    + {% endif %} + + +
    + +
    {% trans '这部作品的标记' %}
    + {% if mark_list_more %} + {% trans '更多' %} + {% endif %} + {% if mark_list %} +
      + {% for others_mark in mark_list %} +
    • + {{ others_mark.owner.username }} + {{ others_mark.get_status_display }} + {% if others_mark.rating %} + + {% endif %} + {% if others_mark.is_private %} + + {% endif %} + {{ others_mark.edited_time }} + {% if others_mark.text %} +

      {{ others_mark.text }}

      + {% endif %} +
    • + {% endfor %} +
    + {% else %} +
    {% trans '暂无标记' %}
    + {% endif %} +
    +
    +
    {% trans '这部作品的评论' %}
    + + {% if review_list_more %} + {% trans '更多' %} + {% endif %} + {% if review_list %} + + {% else %} +
    {% trans '暂无评论' %}
    + {% endif %} +
    +
    +
    + +
    +
    + + {% if mark %} +
    + + {% trans '我' %}{{ mark.get_status_display }} + {% if mark.status == status_enum.DO.value or mark.status == status_enum.COLLECT.value%} + {% if mark.rating %} + + {% endif %} + {% endif %} + {% if mark.is_private %} + + {% endif %} + + {% trans '修改' %} +
    + {% csrf_token %} + {% trans '删除' %} +
    +
    +
    + +
    {{ mark.edited_time }}
    + + {% if mark.text %} +

    {{ mark.text }}

    + {% endif %} +
    + + {% for tag in mark_tags %} + {{ tag }} + {% endfor %} + +
    +
    + {% else %} +
    +
    {% trans '标记这部作品' %}
    +
    + + + +
    +
    + {% endif %} + +
    + +
    + {% if review %} +
    + + {% trans '我的评论' %} + {% if review.is_private %} + + {% endif %} + + + {% trans '编辑' %} + {% trans '删除' %} + + +
    {{ review.edited_time }}
    + + + {{ review.title }} + +
    + {% else %} + +
    +
    {% trans '我的评论' %}
    + +
    + + {% endif %} +
    + +
    +
    +
    + +
    + {% include "partial/_footer.html" %} +
    + +
    + + + +
    +
    + + + + + + diff --git a/music/templates/music/song_mark_list.html b/music/templates/music/song_mark_list.html new file mode 100644 index 00000000..07dbae3b --- /dev/null +++ b/music/templates/music/song_mark_list.html @@ -0,0 +1,175 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} + + + + + + + {% trans 'NiceDB - ' %}{{ song.title }}{% trans '的标记' %} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    +
    + {{ song.title }}{% trans ' + 的标记' %} +
    +
      + + {% for mark in marks %} + +
    • + {{ mark.owner.username }} + {{ mark.get_status_display }} + {% if mark.rating %} + + {% endif %} + {% if mark.is_private %} + + + + {% endif %} + {{ mark.edited_time }} + {% if mark.text %} +

      {{ mark.text }}

      + {% endif %} +
    • + + {% empty %} +
      + {% trans '无结果' %} +
      + {% endfor %} + +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + {{ song.title }} + + {{ + song.get_source_site_display }} +
    + +
    {% if song.artist %}{% trans '艺术家:' %} + {% for artist in song.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if song.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %} +
    +
    {% if song.genre %}{% trans '流派:' %}{{ song.genre }}{% endif %}
    +
    {% if song.album %}{% trans '所属专辑:' %} + {{ song.album }} + {% endif %} +
    + +
    {% if song.release_date %}{% trans '发行日期:' %}{{ song.release_date }}{% endif %} +
    + {% if song.rating %} + {% trans '评分: ' %} + {{ song.rating }} + {% endif %} +
    + +
    +
    +
    +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + \ No newline at end of file diff --git a/music/templates/music/song_review_detail.html b/music/templates/music/song_review_detail.html new file mode 100644 index 00000000..14c43fc3 --- /dev/null +++ b/music/templates/music/song_review_detail.html @@ -0,0 +1,152 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + + + + + + {% trans 'NiceDB - 评论详情' %} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    +
    + {{ review.title }} +
    + {% if review.is_private %} + + + + {% endif %} +
    +
    + + {{ review.owner.username }} + + {% if mark %} + + {% if mark.rating %} + + {% endif %} + + {% endif %} + + {{ review.edited_time }} + +
    +
    + {% if request.user == review.owner %} + {% trans '编辑' %} + {% trans '删除' %} + {% endif %} +
    +
    + +
    + {{ form.content }} +
    + {{ form.media }} +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + {{ song.title }} + + {{ song.get_source_site_display }} +
    + +
    {% if song.artist %}{% trans '艺术家:' %} + {% for artist in song.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if song.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %} +
    +
    {% if song.genre %}{% trans '流派:' %}{{ song.genre }}{% endif %}
    +
    {% if song.album %}{% trans '所属专辑:' %} + {{ song.album }} + {% endif %} +
    + +
    {% if song.release_date %}{% trans '发行日期:' %}{{ song.release_date }}{% endif %}
    + {% if song.rating %} + {% trans '评分: ' %} + {{ song.rating }} + {% endif %} +
    + +
    +
    +
    +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/music/templates/music/song_review_list.html b/music/templates/music/song_review_list.html new file mode 100644 index 00000000..b204767e --- /dev/null +++ b/music/templates/music/song_review_list.html @@ -0,0 +1,158 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} + + + + + + + {% trans 'NiceDB - ' %}{{ song.title }}{% trans '的评论' %} + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    +
    + {{ song.title }}{% trans ' 的评论' %} +
    +
      + + {% for review in reviews %} + +
    • + + {{ review.owner.username }} + {% if review.is_private %} + + {% endif %} + {{ review.edited_time }} + + + {{ review.title }} + +
    • + {% empty %} +
      {% trans '无结果' %}
      + {% endfor %} + +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + {{ song.title }} + + {{ song.get_source_site_display }} +
    + +
    {% if song.artist %}{% trans '艺术家:' %} + {% for artist in song.artist %} + 5 %}style="display: none;" {% endif %}> + {{ artist }} + {% if not forloop.last %} / {% endif %} + + {% endfor %} + {% if song.artist|length > 5 %} + {% trans '更多' %} + + {% endif %} + {% endif %} +
    +
    {% if song.genre %}{% trans '流派:' %}{{ song.genre }}{% endif %}
    +
    {% if song.album %}{% trans '所属专辑:' %} + {{ song.album }} + {% endif %} +
    + +
    {% if song.release_date %}{% trans '发行日期:' %}{{ song.release_date }}{% endif %}
    + {% if song.rating %} + {% trans '评分: ' %} + {{ song.rating }} + {% endif %} +
    + +
    +
    +
    +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/music/tests.py b/music/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/music/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/music/urls.py b/music/urls.py new file mode 100644 index 00000000..4acab46d --- /dev/null +++ b/music/urls.py @@ -0,0 +1,40 @@ +from django.urls import path +from .views import * + + +app_name = 'music' +urlpatterns = [ + path('song/create/', create_song, name='create_song'), + path('song//', retrieve_song, name='retrieve_song'), + path('song/update//', update_song, name='update_song'), + path('song/delete//', delete_song, name='delete_song'), + path('song/mark/', create_update_song_mark, name='create_update_song_mark'), + path('song//mark/list/', + retrieve_song_mark_list, name='retrieve_song_mark_list'), + path('song/mark/delete//', delete_song_mark, name='delete_song_mark'), + path('song//review/create/', create_song_review, name='create_song_review'), + path('song/review/update//', update_song_review, name='update_song_review'), + path('song/review/delete//', delete_song_review, name='delete_song_review'), + path('song/review//', retrieve_song_review, name='retrieve_song_review'), + path('song//review/list/', + retrieve_song_review_list, name='retrieve_song_review_list'), +# path('song/scrape/', scrape_song, name='scrape_song'), + path('song/click_to_scrape/', click_to_scrape_song, name='click_to_scrape_song'), + + path('album/create/', create_album, name='create_album'), + path('album//', retrieve_album, name='retrieve_album'), + path('album/update//', update_album, name='update_album'), + path('album/delete//', delete_album, name='delete_album'), + path('album/mark/', create_update_album_mark, name='create_update_album_mark'), + path('album//mark/list/', + retrieve_album_mark_list, name='retrieve_album_mark_list'), + path('album/mark/delete//', delete_album_mark, name='delete_album_mark'), + path('album//review/create/', create_album_review, name='create_album_review'), + path('album/review/update//', update_album_review, name='update_album_review'), + path('album/review/delete//', delete_album_review, name='delete_album_review'), + path('album/review//', retrieve_album_review, name='retrieve_album_review'), + path('album//review/list/', + retrieve_album_review_list, name='retrieve_album_review_list'), + path('album/scrape/', scrape_album, name='scrape_album'), + path('album/click_to_scrape/', click_to_scrape_album, name='click_to_scrape_album'), +] diff --git a/music/views.py b/music/views.py new file mode 100644 index 00000000..993dae35 --- /dev/null +++ b/music/views.py @@ -0,0 +1,1182 @@ +# from boofilsic.settings import MASTODON_TAGS +from .forms import * +from .models import * +from common.models import SourceSiteEnum +from common.views import PAGE_LINK_NUMBER, jump_or_scrape +from common.utils import PageLinksGenerator +from mastodon.utils import rating_to_emoji +from mastodon.api import check_visibility, post_toot, TootVisibilityEnum +from mastodon import mastodon_request_included +from django.core.paginator import Paginator +from django.utils import timezone +from django.db.models import Count +from django.db import IntegrityError, transaction +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.http import HttpResponseBadRequest, HttpResponseServerError +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.decorators import login_required, permission_required +from django.shortcuts import render, get_object_or_404, redirect, reverse +import logging +from django.shortcuts import render + + + +logger = logging.getLogger(__name__) +mastodon_logger = logging.getLogger("django.mastodon") + + +# how many marks showed on the detail page +MARK_NUMBER = 5 +# how many marks at the mark page +MARK_PER_PAGE = 20 +# how many reviews showed on the detail page +REVIEW_NUMBER = 5 +# how many reviews at the mark page +REVIEW_PER_PAGE = 20 +# max tags on detail page +TAG_NUMBER = 10 + + +# public data +########################### +@login_required +def create_song(request): + if request.method == 'GET': + form = SongForm() + return render( + request, + 'music/create_update_song.html', + { + 'form': form, + 'title': _('添加音乐'), + 'submit_url': reverse("music:create_song"), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + elif request.method == 'POST': + if request.user.is_authenticated: + # only local user can alter public data + form = SongForm(request.POST, request.FILES) + if form.is_valid(): + form.instance.last_editor = request.user + try: + with transaction.atomic(): + form.save() + if form.instance.source_site == SourceSiteEnum.IN_SITE.value: + real_url = form.instance.get_absolute_url() + form.instance.source_url = real_url + form.instance.save() + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + return redirect(reverse("music:retrieve_song", args=[form.instance.id])) + else: + return render( + request, + 'music/create_update_song.html', + { + 'form': form, + 'title': _('添加音乐'), + 'submit_url': reverse("music:create_song"), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + else: + return redirect(reverse("users:login")) + else: + return HttpResponseBadRequest() + + +@login_required +def update_song(request, id): + if request.method == 'GET': + song = get_object_or_404(Song, pk=id) + form = SongForm(instance=song) + page_title = _('修改音乐') + return render( + request, + 'music/create_update_song.html', + { + 'form': form, + 'title': page_title, + 'submit_url': reverse("music:update_song", args=[song.id]), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + elif request.method == 'POST': + song = get_object_or_404(Song, pk=id) + form = SongForm(request.POST, request.FILES, instance=song) + page_title = _('修改音乐') + if form.is_valid(): + form.instance.last_editor = request.user + form.instance.edited_time = timezone.now() + try: + with transaction.atomic(): + form.save() + if form.instance.source_site == SourceSiteEnum.IN_SITE.value: + real_url = form.instance.get_absolute_url() + form.instance.source_url = real_url + form.instance.save() + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + else: + return render( + request, + 'music/create_update_song.html', + { + 'form': form, + 'title': page_title, + 'submit_url': reverse("music:update_song", args=[song.id]), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + return redirect(reverse("music:retrieve_song", args=[form.instance.id])) + + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +# @login_required +def retrieve_song(request, id): + if request.method == 'GET': + song = get_object_or_404(Song, pk=id) + mark = None + mark_tags = None + review = None + + def ms_to_readable(ms): + if not ms: + return + x = ms // 1000 + seconds = x % 60 + x //= 60 + if x == 0: + return f"{seconds}秒" + minutes = x % 60 + x //= 60 + if x == 0: + return f"{minutes}分{seconds}秒" + hours = x % 24 + return f"{hours}时{minutes}分{seconds}秒" + + song.get_duration_display = ms_to_readable(song.duration) + + + # retrieve tags + song_tag_list = song.song_tags.values('content').annotate( + tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER] + + # retrieve user mark and initialize mark form + try: + if request.user.is_authenticated: + mark = SongMark.objects.get(owner=request.user, song=song) + except ObjectDoesNotExist: + mark = None + if mark: + mark_tags = mark.songmark_tags.all() + mark.get_status_display = MusicMarkStatusTranslator(mark.status) + mark_form = SongMarkForm(instance=mark, initial={ + 'tags': mark_tags + }) + else: + mark_form = SongMarkForm(initial={ + 'song': song, + 'tags': mark_tags + }) + + # retrieve user review + try: + if request.user.is_authenticated: + review = SongReview.objects.get( + owner=request.user, song=song) + except ObjectDoesNotExist: + review = None + + # retrieve other related reviews and marks + if request.user.is_anonymous: + # hide all marks and reviews for anonymous user + mark_list = None + review_list = None + mark_list_more = None + review_list_more = None + else: + mark_list = SongMark.get_available( + song, request.user, request.session['oauth_token']) + review_list = SongReview.get_available( + song, request.user, request.session['oauth_token']) + mark_list_more = True if len(mark_list) > MARK_NUMBER else False + mark_list = mark_list[:MARK_NUMBER] + for m in mark_list: + m.get_status_display = MusicMarkStatusTranslator(m.status) + review_list_more = True if len( + review_list) > REVIEW_NUMBER else False + review_list = review_list[:REVIEW_NUMBER] + + # def strip_html_tags(text): + # import re + # regex = re.compile('<.*?>') + # return re.sub(regex, '', text) + + # for r in review_list: + # r.content = strip_html_tags(r.content) + + return render( + request, + 'music/song_detail.html', + { + 'song': song, + 'mark': mark, + 'review': review, + 'status_enum': MarkStatusEnum, + 'mark_form': mark_form, + 'mark_list': mark_list, + 'mark_list_more': mark_list_more, + 'review_list': review_list, + 'review_list_more': review_list_more, + 'song_tag_list': song_tag_list, + 'mark_tags': mark_tags, + } + ) + else: + logger.warning('non-GET method at /song/') + return HttpResponseBadRequest() + + +@permission_required("music.delete_song") +@login_required +def delete_song(request, id): + if request.method == 'GET': + song = get_object_or_404(Song, pk=id) + return render( + request, + 'music/delete_song.html', + { + 'song': song, + } + ) + elif request.method == 'POST': + if request.user.is_staff: + # only staff has right to delete + song = get_object_or_404(Song, pk=id) + song.delete() + return redirect(reverse("common:home")) + else: + raise PermissionDenied() + else: + return HttpResponseBadRequest() + + +# user owned entites +########################### +@mastodon_request_included +@login_required +def create_update_song_mark(request): + # check list: + # clean rating if is wish + # transaction on updating song rating + # owner check(guarantee) + if request.method == 'POST': + pk = request.POST.get('id') + old_rating = None + old_tags = None + if pk: + mark = get_object_or_404(SongMark, pk=pk) + if request.user != mark.owner: + return HttpResponseBadRequest() + old_rating = mark.rating + old_tags = mark.songmark_tags.all() + # update + form = SongMarkForm(request.POST, instance=mark) + else: + # create + form = SongMarkForm(request.POST) + + if form.is_valid(): + if form.instance.status == MarkStatusEnum.WISH.value: + form.instance.rating = None + form.cleaned_data['rating'] = None + form.instance.owner = request.user + form.instance.edited_time = timezone.now() + song = form.instance.song + + try: + with transaction.atomic(): + # update song rating + song.update_rating(old_rating, form.instance.rating) + form.save() + # update tags + if old_tags: + for tag in old_tags: + tag.delete() + if form.cleaned_data['tags']: + for tag in form.cleaned_data['tags']: + SongTag.objects.create( + content=tag, + song=song, + mark=form.instance + ) + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + + if form.cleaned_data['share_to_mastodon']: + if form.cleaned_data['is_private']: + visibility = TootVisibilityEnum.PRIVATE + else: + visibility = TootVisibilityEnum.UNLISTED + url = "https://" + request.get_host() + reverse("music:retrieve_song", + args=[song.id]) + words = MusicMarkStatusTranslator(form.cleaned_data['status']) +\ + f"《{song.title}》" + \ + rating_to_emoji(form.cleaned_data['rating']) + + # tags = MASTODON_TAGS % {'category': '书', 'type': '标记'} + tags = '' + content = words + '\n' + url + '\n' + \ + form.cleaned_data['text'] + '\n' + tags + response = post_toot(request.user.mastodon_site, content, visibility, + request.session['oauth_token']) + if response.status_code != 200: + mastodon_logger.error( + f"CODE:{response.status_code} {response.text}") + return HttpResponseServerError("publishing mastodon status failed") + else: + return HttpResponseBadRequest("invalid form data") + + return redirect(reverse("music:retrieve_song", args=[form.instance.song.id])) + else: + return HttpResponseBadRequest("invalid method") + + +@mastodon_request_included +@login_required +def retrieve_song_mark_list(request, song_id): + if request.method == 'GET': + song = get_object_or_404(Song, pk=song_id) + queryset = SongMark.get_available( + song, request.user, request.session['oauth_token']) + paginator = Paginator(queryset, MARK_PER_PAGE) + page_number = request.GET.get('page', default=1) + marks = paginator.get_page(page_number) + marks.pagination = PageLinksGenerator( + PAGE_LINK_NUMBER, page_number, paginator.num_pages) + for m in marks: + m.get_status_display = MusicMarkStatusTranslator(m.status) + return render( + request, + 'music/song_mark_list.html', + { + 'marks': marks, + 'song': song, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def delete_song_mark(request, id): + if request.method == 'POST': + mark = get_object_or_404(SongMark, pk=id) + if request.user != mark.owner: + return HttpResponseBadRequest() + song_id = mark.song.id + try: + with transaction.atomic(): + # update song rating + mark.song.update_rating(mark.rating, None) + mark.delete() + except IntegrityError as e: + return HttpResponseServerError() + return redirect(reverse("music:retrieve_song", args=[song_id])) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def create_song_review(request, song_id): + if request.method == 'GET': + form = SongReviewForm(initial={'song': song_id}) + song = get_object_or_404(Song, pk=song_id) + return render( + request, + 'music/create_update_song_review.html', + { + 'form': form, + 'title': _("添加评论"), + 'song': song, + 'submit_url': reverse("music:create_song_review", args=[song_id]), + } + ) + elif request.method == 'POST': + form = SongReviewForm(request.POST) + if form.is_valid(): + form.instance.owner = request.user + form.save() + if form.cleaned_data['share_to_mastodon']: + if form.cleaned_data['is_private']: + visibility = TootVisibilityEnum.PRIVATE + else: + visibility = TootVisibilityEnum.UNLISTED + url = "https://" + request.get_host() + reverse("music:retrieve_song_review", + args=[form.instance.id]) + words = "发布了关于" + f"《{form.instance.song.title}》" + "的评论" + # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} + tags = '' + content = words + '\n' + url + \ + '\n' + form.cleaned_data['title'] + '\n' + tags + response = post_toot(request.user.mastodon_site, content, visibility, + request.session['oauth_token']) + if response.status_code != 200: + mastodon_logger.error( + f"CODE:{response.status_code} {response.text}") + return HttpResponseServerError("publishing mastodon status failed") + return redirect(reverse("music:retrieve_song_review", args=[form.instance.id])) + else: + return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def update_song_review(request, id): + if request.method == 'GET': + review = get_object_or_404(SongReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + form = SongReviewForm(instance=review) + song = review.song + return render( + request, + 'music/create_update_song_review.html', + { + 'form': form, + 'title': _("编辑评论"), + 'song': song, + 'submit_url': reverse("music:update_song_review", args=[review.id]), + } + ) + elif request.method == 'POST': + review = get_object_or_404(SongReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + form = SongReviewForm(request.POST, instance=review) + if form.is_valid(): + form.instance.edited_time = timezone.now() + form.save() + if form.cleaned_data['share_to_mastodon']: + if form.cleaned_data['is_private']: + visibility = TootVisibilityEnum.PRIVATE + else: + visibility = TootVisibilityEnum.UNLISTED + url = "https://" + request.get_host() + reverse("music:retrieve_song_review", + args=[form.instance.id]) + words = "发布了关于" + f"《{form.instance.song.title}》" + "的评论" + # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} + tags = '' + content = words + '\n' + url + \ + '\n' + form.cleaned_data['title'] + '\n' + tags + response = post_toot(request.user.mastodon_site, content, visibility, + request.session['oauth_token']) + if response.status_code != 200: + mastodon_logger.error( + f"CODE:{response.status_code} {response.text}") + return HttpResponseServerError("publishing mastodon status failed") + return redirect(reverse("music:retrieve_song_review", args=[form.instance.id])) + else: + return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() + + +@login_required +def delete_song_review(request, id): + if request.method == 'GET': + review = get_object_or_404(SongReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + review_form = SongReviewForm(instance=review) + return render( + request, + 'music/delete_song_review.html', + { + 'form': review_form, + 'review': review, + } + ) + elif request.method == 'POST': + review = get_object_or_404(SongReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + song_id = review.song.id + review.delete() + return redirect(reverse("music:retrieve_song", args=[song_id])) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def retrieve_song_review(request, id): + if request.method == 'GET': + review = get_object_or_404(SongReview, pk=id) + if not check_visibility(review, request.session['oauth_token'], request.user): + msg = _("你没有访问这个页面的权限😥") + return render( + request, + 'common/error.html', + { + 'msg': msg, + } + ) + review_form = SongReviewForm(instance=review) + song = review.song + try: + mark = SongMark.objects.get(owner=review.owner, song=song) + mark.get_status_display = MusicMarkStatusTranslator(mark.status) + except ObjectDoesNotExist: + mark = None + return render( + request, + 'music/song_review_detail.html', + { + 'form': review_form, + 'review': review, + 'song': song, + 'mark': mark, + } + ) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def retrieve_song_review_list(request, song_id): + if request.method == 'GET': + song = get_object_or_404(Song, pk=song_id) + queryset = SongReview.get_available( + song, request.user, request.session['oauth_token']) + paginator = Paginator(queryset, REVIEW_PER_PAGE) + page_number = request.GET.get('page', default=1) + reviews = paginator.get_page(page_number) + reviews.pagination = PageLinksGenerator( + PAGE_LINK_NUMBER, page_number, paginator.num_pages) + return render( + request, + 'music/song_review_list.html', + { + 'reviews': reviews, + 'song': song, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def scrape_song(request): + if request.method == 'GET': + keywords = request.GET.get('q') + form = SongForm() + return render( + request, + 'music/scrape_song.html', + { + 'q': keywords, + 'form': form, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def click_to_scrape_song(request): + if request.method == "POST": + url = request.POST.get("url") + if url: + return jump_or_scrape(request, url) + else: + return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() + + +@login_required +def create_album(request): + if request.method == 'GET': + form = AlbumForm() + return render( + request, + 'music/create_update_album.html', + { + 'form': form, + 'title': _('添加音乐'), + 'submit_url': reverse("music:create_album"), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + elif request.method == 'POST': + if request.user.is_authenticated: + # only local user can alter public data + form = AlbumForm(request.POST, request.FILES) + if form.is_valid(): + form.instance.last_editor = request.user + try: + with transaction.atomic(): + form.save() + if form.instance.source_site == SourceSiteEnum.IN_SITE.value: + real_url = form.instance.get_absolute_url() + form.instance.source_url = real_url + form.instance.save() + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + return redirect(reverse("music:retrieve_album", args=[form.instance.id])) + else: + return render( + request, + 'music/create_update_album.html', + { + 'form': form, + 'title': _('添加音乐'), + 'submit_url': reverse("music:create_album"), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + else: + return redirect(reverse("users:login")) + else: + return HttpResponseBadRequest() + + +@login_required +def update_album(request, id): + if request.method == 'GET': + album = get_object_or_404(Album, pk=id) + form = AlbumForm(instance=album) + page_title = _('修改音乐') + return render( + request, + 'music/create_update_album.html', + { + 'form': form, + 'title': page_title, + 'submit_url': reverse("music:update_album", args=[album.id]), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + elif request.method == 'POST': + album = get_object_or_404(Album, pk=id) + form = AlbumForm(request.POST, request.FILES, instance=album) + page_title = _('修改音乐') + if form.is_valid(): + form.instance.last_editor = request.user + form.instance.edited_time = timezone.now() + try: + with transaction.atomic(): + form.save() + if form.instance.source_site == SourceSiteEnum.IN_SITE.value: + real_url = form.instance.get_absolute_url() + form.instance.source_url = real_url + form.instance.save() + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + else: + return render( + request, + 'music/create_update_album.html', + { + 'form': form, + 'title': page_title, + 'submit_url': reverse("music:update_album", args=[album.id]), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + return redirect(reverse("music:retrieve_album", args=[form.instance.id])) + + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +# @login_required +def retrieve_album(request, id): + if request.method == 'GET': + album = get_object_or_404(Album, pk=id) + mark = None + mark_tags = None + review = None + + def ms_to_readable(ms): + if not ms: + return + x = ms // 1000 + seconds = x % 60 + x //= 60 + if x == 0: + return f"{seconds}秒" + minutes = x % 60 + x //= 60 + if x == 0: + return f"{minutes}分{seconds}秒" + hours = x % 24 + return f"{hours}时{minutes}分{seconds}秒" + + album.get_duration_display = ms_to_readable(album.duration) + + # retrieve tags + album_tag_list = album.album_tags.values('content').annotate( + tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER] + + # retrieve user mark and initialize mark form + try: + if request.user.is_authenticated: + mark = AlbumMark.objects.get(owner=request.user, album=album) + except ObjectDoesNotExist: + mark = None + if mark: + mark_tags = mark.albummark_tags.all() + mark.get_status_display = MusicMarkStatusTranslator(mark.status) + mark_form = AlbumMarkForm(instance=mark, initial={ + 'tags': mark_tags + }) + else: + mark_form = AlbumMarkForm(initial={ + 'album': album, + 'tags': mark_tags + }) + + # retrieve user review + try: + if request.user.is_authenticated: + review = AlbumReview.objects.get( + owner=request.user, album=album) + except ObjectDoesNotExist: + review = None + + # retrieve other related reviews and marks + if request.user.is_anonymous: + # hide all marks and reviews for anonymous user + mark_list = None + review_list = None + mark_list_more = None + review_list_more = None + else: + mark_list = AlbumMark.get_available( + album, request.user, request.session['oauth_token']) + review_list = AlbumReview.get_available( + album, request.user, request.session['oauth_token']) + mark_list_more = True if len(mark_list) > MARK_NUMBER else False + mark_list = mark_list[:MARK_NUMBER] + for m in mark_list: + m.get_status_display = MusicMarkStatusTranslator(m.status) + review_list_more = True if len( + review_list) > REVIEW_NUMBER else False + review_list = review_list[:REVIEW_NUMBER] + + # def strip_html_tags(text): + # import re + # regex = re.compile('<.*?>') + # return re.sub(regex, '', text) + + # for r in review_list: + # r.content = strip_html_tags(r.content) + + return render( + request, + 'music/album_detail.html', + { + 'album': album, + 'mark': mark, + 'review': review, + 'status_enum': MarkStatusEnum, + 'mark_form': mark_form, + 'mark_list': mark_list, + 'mark_list_more': mark_list_more, + 'review_list': review_list, + 'review_list_more': review_list_more, + 'album_tag_list': album_tag_list, + 'mark_tags': mark_tags, + } + ) + else: + logger.warning('non-GET method at /album/') + return HttpResponseBadRequest() + + +@permission_required("music.delete_album") +@login_required +def delete_album(request, id): + if request.method == 'GET': + album = get_object_or_404(Album, pk=id) + return render( + request, + 'music/delete_album.html', + { + 'album': album, + } + ) + elif request.method == 'POST': + if request.user.is_staff: + # only staff has right to delete + album = get_object_or_404(Album, pk=id) + album.delete() + return redirect(reverse("common:home")) + else: + raise PermissionDenied() + else: + return HttpResponseBadRequest() + + +# user owned entites +########################### +@mastodon_request_included +@login_required +def create_update_album_mark(request): + # check list: + # clean rating if is wish + # transaction on updating album rating + # owner check(guarantee) + if request.method == 'POST': + pk = request.POST.get('id') + old_rating = None + old_tags = None + if pk: + mark = get_object_or_404(AlbumMark, pk=pk) + if request.user != mark.owner: + return HttpResponseBadRequest() + old_rating = mark.rating + old_tags = mark.albummark_tags.all() + # update + form = AlbumMarkForm(request.POST, instance=mark) + else: + # create + form = AlbumMarkForm(request.POST) + + if form.is_valid(): + if form.instance.status == MarkStatusEnum.WISH.value: + form.instance.rating = None + form.cleaned_data['rating'] = None + form.instance.owner = request.user + form.instance.edited_time = timezone.now() + album = form.instance.album + + try: + with transaction.atomic(): + # update album rating + album.update_rating(old_rating, form.instance.rating) + form.save() + # update tags + if old_tags: + for tag in old_tags: + tag.delete() + if form.cleaned_data['tags']: + for tag in form.cleaned_data['tags']: + AlbumTag.objects.create( + content=tag, + album=album, + mark=form.instance + ) + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + + if form.cleaned_data['share_to_mastodon']: + if form.cleaned_data['is_private']: + visibility = TootVisibilityEnum.PRIVATE + else: + visibility = TootVisibilityEnum.UNLISTED + url = "https://" + request.get_host() + reverse("music:retrieve_album", + args=[album.id]) + words = MusicMarkStatusTranslator(form.cleaned_data['status']) +\ + f"《{album.title}》" + \ + rating_to_emoji(form.cleaned_data['rating']) + + # tags = MASTODON_TAGS % {'category': '书', 'type': '标记'} + tags = '' + content = words + '\n' + url + '\n' + \ + form.cleaned_data['text'] + '\n' + tags + response = post_toot(request.user.mastodon_site, content, visibility, + request.session['oauth_token']) + if response.status_code != 200: + mastodon_logger.error( + f"CODE:{response.status_code} {response.text}") + return HttpResponseServerError("publishing mastodon status failed") + else: + return HttpResponseBadRequest("invalid form data") + + return redirect(reverse("music:retrieve_album", args=[form.instance.album.id])) + else: + return HttpResponseBadRequest("invalid method") + + +@mastodon_request_included +@login_required +def retrieve_album_mark_list(request, album_id): + if request.method == 'GET': + album = get_object_or_404(Album, pk=album_id) + queryset = AlbumMark.get_available( + album, request.user, request.session['oauth_token']) + paginator = Paginator(queryset, MARK_PER_PAGE) + page_number = request.GET.get('page', default=1) + marks = paginator.get_page(page_number) + marks.pagination = PageLinksGenerator( + PAGE_LINK_NUMBER, page_number, paginator.num_pages) + for m in marks: + m.get_status_display = MusicMarkStatusTranslator(m.status) + return render( + request, + 'music/album_mark_list.html', + { + 'marks': marks, + 'album': album, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def delete_album_mark(request, id): + if request.method == 'POST': + mark = get_object_or_404(AlbumMark, pk=id) + if request.user != mark.owner: + return HttpResponseBadRequest() + album_id = mark.album.id + try: + with transaction.atomic(): + # update album rating + mark.album.update_rating(mark.rating, None) + mark.delete() + except IntegrityError as e: + return HttpResponseServerError() + return redirect(reverse("music:retrieve_album", args=[album_id])) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def create_album_review(request, album_id): + if request.method == 'GET': + form = AlbumReviewForm(initial={'album': album_id}) + album = get_object_or_404(Album, pk=album_id) + return render( + request, + 'music/create_update_album_review.html', + { + 'form': form, + 'title': _("添加评论"), + 'album': album, + 'submit_url': reverse("music:create_album_review", args=[album_id]), + } + ) + elif request.method == 'POST': + form = AlbumReviewForm(request.POST) + if form.is_valid(): + form.instance.owner = request.user + form.save() + if form.cleaned_data['share_to_mastodon']: + if form.cleaned_data['is_private']: + visibility = TootVisibilityEnum.PRIVATE + else: + visibility = TootVisibilityEnum.UNLISTED + url = "https://" + request.get_host() + reverse("music:retrieve_album_review", + args=[form.instance.id]) + words = "发布了关于" + f"《{form.instance.album.title}》" + "的评论" + # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} + tags = '' + content = words + '\n' + url + \ + '\n' + form.cleaned_data['title'] + '\n' + tags + response = post_toot(request.user.mastodon_site, content, visibility, + request.session['oauth_token']) + if response.status_code != 200: + mastodon_logger.error( + f"CODE:{response.status_code} {response.text}") + return HttpResponseServerError("publishing mastodon status failed") + return redirect(reverse("music:retrieve_album_review", args=[form.instance.id])) + else: + return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def update_album_review(request, id): + if request.method == 'GET': + review = get_object_or_404(AlbumReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + form = AlbumReviewForm(instance=review) + album = review.album + return render( + request, + 'music/create_update_album_review.html', + { + 'form': form, + 'title': _("编辑评论"), + 'album': album, + 'submit_url': reverse("music:update_album_review", args=[review.id]), + } + ) + elif request.method == 'POST': + review = get_object_or_404(AlbumReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + form = AlbumReviewForm(request.POST, instance=review) + if form.is_valid(): + form.instance.edited_time = timezone.now() + form.save() + if form.cleaned_data['share_to_mastodon']: + if form.cleaned_data['is_private']: + visibility = TootVisibilityEnum.PRIVATE + else: + visibility = TootVisibilityEnum.UNLISTED + url = "https://" + request.get_host() + reverse("music:retrieve_album_review", + args=[form.instance.id]) + words = "发布了关于" + f"《{form.instance.album.title}》" + "的评论" + # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} + tags = '' + content = words + '\n' + url + \ + '\n' + form.cleaned_data['title'] + '\n' + tags + response = post_toot(request.user.mastodon_site, content, visibility, + request.session['oauth_token']) + if response.status_code != 200: + mastodon_logger.error( + f"CODE:{response.status_code} {response.text}") + return HttpResponseServerError("publishing mastodon status failed") + return redirect(reverse("music:retrieve_album_review", args=[form.instance.id])) + else: + return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() + + +@login_required +def delete_album_review(request, id): + if request.method == 'GET': + review = get_object_or_404(AlbumReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + review_form = AlbumReviewForm(instance=review) + return render( + request, + 'music/delete_album_review.html', + { + 'form': review_form, + 'review': review, + } + ) + elif request.method == 'POST': + review = get_object_or_404(AlbumReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + album_id = review.album.id + review.delete() + return redirect(reverse("music:retrieve_album", args=[album_id])) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def retrieve_album_review(request, id): + if request.method == 'GET': + review = get_object_or_404(AlbumReview, pk=id) + if not check_visibility(review, request.session['oauth_token'], request.user): + msg = _("你没有访问这个页面的权限😥") + return render( + request, + 'common/error.html', + { + 'msg': msg, + } + ) + review_form = AlbumReviewForm(instance=review) + album = review.album + try: + mark = AlbumMark.objects.get(owner=review.owner, album=album) + mark.get_status_display = MusicMarkStatusTranslator(mark.status) + except ObjectDoesNotExist: + mark = None + return render( + request, + 'music/album_review_detail.html', + { + 'form': review_form, + 'review': review, + 'album': album, + 'mark': mark, + } + ) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def retrieve_album_review_list(request, album_id): + if request.method == 'GET': + album = get_object_or_404(Album, pk=album_id) + queryset = AlbumReview.get_available( + album, request.user, request.session['oauth_token']) + paginator = Paginator(queryset, REVIEW_PER_PAGE) + page_number = request.GET.get('page', default=1) + reviews = paginator.get_page(page_number) + reviews.pagination = PageLinksGenerator( + PAGE_LINK_NUMBER, page_number, paginator.num_pages) + return render( + request, + 'music/album_review_list.html', + { + 'reviews': reviews, + 'album': album, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def scrape_album(request): + if request.method == 'GET': + keywords = request.GET.get('q') + form = AlbumForm() + return render( + request, + 'music/scrape_album.html', + { + 'q': keywords, + 'form': form, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def click_to_scrape_album(request): + if request.method == "POST": + url = request.POST.get("url") + if url: + return jump_or_scrape(request, url) + else: + return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() diff --git a/users/templates/users/book_list.html b/users/templates/users/book_list.html index 3790556d..5694ed22 100644 --- a/users/templates/users/book_list.html +++ b/users/templates/users/book_list.html @@ -118,8 +118,7 @@ data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;">
    {% endif %} {% if mark.is_private %} - + diff --git a/users/templates/users/movie_list.html b/users/templates/users/movie_list.html index 09834a6b..8e0ff739 100644 --- a/users/templates/users/movie_list.html +++ b/users/templates/users/movie_list.html @@ -122,8 +122,7 @@ data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"> {% endif %} {% if mark.is_private %} - + diff --git a/users/templates/users/music_list.html b/users/templates/users/music_list.html new file mode 100644 index 00000000..8ed992ff --- /dev/null +++ b/users/templates/users/music_list.html @@ -0,0 +1,289 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} + + + + + + + {% trans 'NiceDB - ' %}{{ user.username }}{{ list_title }} + + + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    + +
    +
    + {{ user.username }}{{ list_title }} +
    +
    +
      + + {% for mark in marks %} + + {% with mark.music as music %} + +
    • +
      + {% if music.category_name|lower == 'album' %} + + + + {% elif music.category_name|lower == 'song' %} + + + + {% endif %} +
      +
      +
      + {% if music.category_name|lower == 'album' %} + + {{ music.title }} + + {% elif music.category_name|lower == 'song' %} + + {{ music.title }} + + {% endif %} + + {{ music.get_source_site_display }} + +
      + + {% if music.artist %}{% trans '艺术家' %} + {% for artist in music.artist %} + {{ artist }} + {% if not forloop.last %} {% endif %} + {% endfor %} + {% endif %} + + {% if music.genre %}/ {% trans '流派' %} + {{ music.genre }} + {% endif %} + + {% if music.release_date %}/ {% trans '发行日期' %} + {{ music.release_date }} + {% endif %} + + {% if music.brief %} +

      + {{ music.brief }} +

      + {% elif music.category_name|lower == 'album' %} +

      + {% trans '曲目:' %}{{ music.track_list }} +

      + {% else %} + +

      + {% trans '所属专辑:' %}{{ music.album }} +

      + {% endif %} +
      + {% for tag_dict in music.tag_list %} + {% for k, v in tag_dict.items %} + {% if k == 'content' %} + + {{ v }} + + {% endif %} + {% endfor %} + {% endfor %} +
      +
      +
      +
      +
        +
      • + + {% if mark.rating %} + + {% endif %} + {% if mark.is_private %} + + + + {% endif %} + {% trans '于' %} {{ mark.edited_time }} {% trans '标记' %} + {% if mark.text %} +

        {{ mark.text }}

        + {% endif %} +
      • +
      +
      +
      + +
    • + + {% endwith %} + + {% empty %} +
      {% trans '无结果' %}
      + {% endfor %} + + + +
    +
    + +
    +
    + +
    +
    + +
    + +
    +
    + + + + + +
    +
    +
    + +
    +
    + {% trans '关注的人' %} +
    + {% trans '更多' %} + +
    + +
    +
    + {% trans '被他们关注' %} +
    + {% trans '更多' %} + +
    + +
    +
    +
    + +
    +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + + + + + {% if user == request.user %} + + {% else %} + + {% endif %} + + + + + + + diff --git a/users/templates/users/register.html b/users/templates/users/register.html index d0e6056b..9b750a66 100644 --- a/users/templates/users/register.html +++ b/users/templates/users/register.html @@ -19,7 +19,7 @@
    -

    欢迎来到NiceDB书影音(其实现在只有书汗电影)!

    +

    欢迎来到NiceDB书影音!

    NiceDB书影音继承了长毛象的用户关系,比如您在里瓣屏蔽了某人,那您将不会在书影音的公共区域看到TA的痕迹。 这里仍是一片处女地,丰富的内容需要大家共同创造。 diff --git a/users/urls.py b/users/urls.py index cf79b2b3..4b8adb7d 100644 --- a/users/urls.py +++ b/users/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('/following/', following, name='following'), path('/book//', book_list, name='book_list'), path('/movie//', movie_list, name='movie_list'), + path('/music//', music_list, name='music_list'), path('/', home, name='home'), path('/followers/', followers, name='followers'), path('/following/', following, name='following'), diff --git a/users/views.py b/users/views.py index 96e183ab..b9918405 100644 --- a/users/views.py +++ b/users/views.py @@ -17,8 +17,10 @@ from common.models import MarkStatusEnum from common.utils import PageLinksGenerator from books.models import * from movies.models import * +from music.models import * from books.forms import BookMarkStatusTranslator from movies.forms import MovieMarkStatusTranslator +from music.forms import MusicMarkStatusTranslator from mastodon.models import MastodonApplication @@ -108,6 +110,14 @@ def register(request): elif request.method == 'POST': token = request.session['new_user_token'] user_data = get_user_data(request.COOKIES['mastodon_domain'], token) + if user_data is None: + return render( + request, + 'common/error.html', + { + 'msg': _("长毛象访问失败😫") + } + ) new_user = User( username=user_data['username'], mastodon_id=user_data['id'], @@ -372,7 +382,7 @@ def book_list(request, id, status): mark.book.tag_list = mark.book.get_tags_manager().values('content').annotate( tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST] marks.pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages) - list_title = str(BookMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的书")) + list_title = str(BookMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("标记的书")) return render( request, 'users/book_list.html', @@ -441,7 +451,7 @@ def movie_list(request, id, status): mark.movie.tag_list = mark.movie.get_tags_manager().values('content').annotate( tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST] marks.pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages) - list_title = str(MovieMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的电影和剧集")) + list_title = str(MovieMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("标记的电影和剧集")) return render( request, 'users/movie_list.html', @@ -455,6 +465,86 @@ def movie_list(request, id, status): return HttpResponseBadRequest() +@mastodon_request_included +@login_required +def music_list(request, id, status): + if request.method == 'GET': + if not status.upper() in MarkStatusEnum.names: + return HttpResponseBadRequest() + + if isinstance(id, str): + try: + username = id.split('@')[0] + site = id.split('@')[1] + except IndexError as e: + return HttpResponseBadRequest("Invalid user id") + query_kwargs = {'username': username, 'mastodon_site': site} + elif isinstance(id, int): + query_kwargs = {'pk': id} + try: + user = User.objects.get(**query_kwargs) + except ObjectDoesNotExist: + msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") + sec_msg = _("目前只开放本站用户注册") + return render( + request, + 'common/error.html', + { + 'msg': msg, + 'secondary_msg': sec_msg, + } + ) + if not user == request.user: + # mastodon request + relation = get_relationship(request.user, user, request.session['oauth_token'])[0] + if relation['blocked_by']: + msg = _("你没有访问TA主页的权限😥") + return render( + request, + 'common/error.html', + { + 'msg': msg, + } + ) + queryset = list(AlbumMark.get_available_by_user(user, relation['following']).filter( + status=MarkStatusEnum[status.upper()])) \ + + list(SongMark.get_available_by_user(user, relation['following']).filter( + status=MarkStatusEnum[status.upper()])) + + user.target_site_id = get_cross_site_id( + user, request.user.mastodon_site, request.session['oauth_token']) + else: + queryset = list(AlbumMark.objects.filter(owner=user, status=MarkStatusEnum[status.upper()])) \ + + list(SongMark.objects.filter(owner=user, status=MarkStatusEnum[status.upper()])) + queryset = sorted(queryset, key=lambda e: e.edited_time, reverse=True) + paginator = Paginator(queryset, ITEMS_PER_PAGE) + page_number = request.GET.get('page', default=1) + marks = paginator.get_page(page_number) + for mark in marks: + if mark.__class__ == AlbumMark: + mark.music = mark.album + mark.music.tag_list = mark.album.get_tags_manager().values('content').annotate( + tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST] + elif mark.__class__ == SongMark: + mark.music = mark.song + mark.music.tag_list = mark.song.get_tags_manager().values('content').annotate( + tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST] + + marks.pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages) + list_title = str(MovieMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("标记的音乐")) + return render( + request, + 'users/music_list.html', + { + 'marks': marks, + 'user': user, + 'list_title' : list_title, + } + ) + else: + return HttpResponseBadRequest() + + @login_required def report(request): if request.method == 'GET':