add tag feature | close #19

This commit is contained in:
doubaniux 2020-07-10 21:28:09 +08:00
parent ef5fc45068
commit d2782d548f
25 changed files with 586 additions and 86 deletions

View file

@ -86,7 +86,7 @@ if DEBUG:
'NAME': 'test',
'USER': 'donotban',
'PASSWORD': 'donotbansilvousplait',
'HOST': '172.17.132.10',
'HOST': '172.17.132.12',
'OPTIONS': {
'client_encoding': 'UTF8',
# 'isolation_level': psycopg2.extensions.ISOLATION_LEVEL_DEFAULT,

View file

@ -4,4 +4,5 @@ from .models import *
admin.site.register(Book)
admin.site.register(BookMark)
admin.site.register(BookReview)
admin.site.register(BookTag)

View file

@ -4,7 +4,7 @@ from django.contrib.postgres.forms import SimpleArrayField
from django.utils.translation import gettext_lazy as _
from .models import Book, BookMark, BookReview
from common.models import MarkStatusEnum
from common.forms import RadioBooleanField, RatingValidator
from common.forms import RadioBooleanField, RatingValidator, TagField, TagInput
from common.forms import PreviewImageInput
@ -77,6 +77,7 @@ class BookForm(forms.ModelForm):
isbn = isbn.strip()
return isbn
class BookMarkForm(forms.ModelForm):
IS_PRIVATE_CHOICES = [
(True, _("仅关注者")),
@ -96,6 +97,11 @@ class BookMarkForm(forms.ModelForm):
initial=True,
choices=IS_PRIVATE_CHOICES
)
tags = TagField(
required=False,
widget=TagInput(attrs={'placeholder': _("回车增加标签")}),
label = _("标签")
)
class Meta:
model = BookMark
fields = [

View file

@ -3,7 +3,7 @@ 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 common.models import Resource, Mark, Review
from common.models import Resource, Mark, Review, Tag
from boofilsic.settings import BOOK_MEDIA_PATH_ROOT, DEFAULT_BOOK_IMAGE
from django.utils import timezone
@ -67,8 +67,13 @@ class Book(Resource):
def __str__(self):
return self.title
def get_tags_manager(self):
return self.book_tags
class BookMark(Mark):
# maybe this is the better solution, for it has less complex index
# book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='book_marks', null=True, unique=True)
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='book_marks', null=True)
class Meta:
constraints = [
@ -82,3 +87,12 @@ class BookReview(Review):
constraints = [
models.UniqueConstraint(fields=['owner', 'book'], name="unique_book_review")
]
class BookTag(Tag):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='book_tags', null=True)
mark = models.ForeignKey(BookMark, on_delete=models.CASCADE, related_name='mark_tags', null=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['content', 'mark'], name="unique_mark_tag")
]

View file

@ -102,6 +102,19 @@
</div>
</div>
<div class="tag-collection">
{% for tag_dict in book_tag_list %}
{% for k, v in tag_dict.items %}
{% if k == 'content' %}
<span class="tag-collection__tag">
<a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a>
</span>
{% endif %}
{% endfor %}
{% endfor %}
</div>
</div>
</div>
<div class="dividing-line"></div>
@ -212,6 +225,13 @@
{% if mark.text %}
<p class="mark-panel__text">{{ mark.text }}</p>
{% endif %}
<div class="tag-collection">
{% for tag in mark_tags %}
<span class="tag-collection__tag">{{ tag }}</span>
{% endfor %}
</div>
</div>
{% else %}
<div class="action-panel" id="addMarkPanel">
@ -264,11 +284,11 @@
</div>
</div>
</section>
</div>
{% include "partial/_footer.html" %}
</div>
<div id="modals">
<div class="mark-modal modal">
<div class="mark-modal__head">
@ -283,19 +303,20 @@
{% else %}
<span class="mark-modal__title">{% trans '我的标记' %}</span>
{% endif %}
<span class="mark-modal__close-button modal-close">
<span class="icon-cross">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<polygon
points="20 2.61 17.39 0 10 7.39 2.61 0 0 2.61 7.39 10 0 17.39 2.61 20 10 12.61 17.39 20 20 17.39 12.61 10 20 2.61">
</polygon>
</svg>
</span>
points="20 2.61 17.39 0 10 7.39 2.61 0 0 2.61 7.39 10 0 17.39 2.61 20 10 12.61 17.39 20 20 17.39 12.61 10 20 2.61">
</polygon>
</svg>
</span>
</div>
<div class="mark-modal__body">
</span>
</div>
<div class="mark-modal__body">
<form action="{% url 'books:create_update_mark' %}" method="post">
{{ mark_form.media }}
{% csrf_token %}
{{ mark_form.id }}
{{ mark_form.book }}
@ -312,6 +333,11 @@
{{ mark_form.text }}
<div class="mark-modal__tag">
<label>{{ mark_form.tags.label }}</label>
{{ mark_form.tags }}
</div>
<div class="mark-modal__option">
<div class="mark-modal__visibility-radio">
<span>{{ mark_form.is_private.label }}:</span>

View file

@ -95,28 +95,41 @@
<p class="entity-list__entity-brief">
{{ mark.book.brief | truncate:170 }}
</p>
<div class="entity-marks" style="margin-bottom: 0;">
<ul class="entity-marks__mark-list">
<li class="entity-marks__mark">
<div class="tag-collection">
{% for tag_dict in mark.book.tag_list %}
{% for k, v in tag_dict.items %}
{% if k == 'content' %}
<span class="tag-collection__tag">
<a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a>
</span>
{% endif %}
{% endfor %}
{% endfor %}
</div>
<div class="clearfix"></div>
<div class="dividing-line dividing-line--dashed"></div>
<div class="entity-marks" style="margin-bottom: 0;">
<ul class="entity-marks__mark-list">
<li class="entity-marks__mark">
{% if mark.rating %}
<span class="entity-marks__rating-star rating-star"
data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span>
{% endif %}
{% if mark.is_private %}
<span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path
d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" />
</svg></span>
{% endif %}
<span class="entity-marks__mark-time">{{ mark.edited_time }}</span>
{% if mark.text %}
<p class="entity-marks__mark-content">{{ mark.text }}</p>
{% endif %}
</li>
</ul>
</div>
{% if mark.rating %}
<span class="entity-marks__rating-star rating-star"
data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span>
{% endif %}
{% if mark.is_private %}
<span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path
d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" />
</svg></span>
{% endif %}
<span class="entity-marks__mark-time">{% trans '于' %} {{ mark.edited_time }} {% trans '标记' %}</span>
{% if mark.text %}
<p class="entity-marks__mark-content">{{ mark.text }}</p>
{% endif %}
</li>
</ul>
</div>
</div>
</li>

View file

@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from django.http import HttpResponseBadRequest, HttpResponseServerError
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
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
@ -25,6 +26,8 @@ MARK_PER_PAGE = 20
REVIEW_NUMBER = 5
# how many reviews at the mark page
REVIEW_PER_PAGE = 20
# max tags on detail page
TAG_NUMBER = 10
# public data
@ -89,18 +92,18 @@ def update(request, id):
form.save()
else:
return render(
request,
'books/create_update.html',
{
'form': form,
'title': _('修改书籍'),
'submit_url': reverse("books:update", args=[book.id])
}
)
request,
'books/create_update.html',
{
'form': form,
'title': _('修改书籍'),
'submit_url': reverse("books:update", args=[book.id])
}
)
return redirect(reverse("books:retrieve", args=[form.instance.id]))
else:
return HttpResponseBadRequest()
return HttpResponseBadRequest()
@mastodon_request_included
@ -109,43 +112,58 @@ def retrieve(request, id):
if request.method == 'GET':
book = get_object_or_404(Book, pk=id)
mark = None
mark_tags = None
review = None
# retreive tags
book_tag_list = book.book_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 = BookMark.objects.get(owner=request.user, book=book)
except ObjectDoesNotExist:
mark = None
if mark:
mark_tags = mark.mark_tags.all()
mark.get_status_display = BookMarkStatusTranslator(mark.status)
mark_form = BookMarkForm(instance=mark)
mark_form = BookMarkForm(instance=mark, initial={
'tags': mark_tags
})
else:
mark_form = BookMarkForm(initial={
'book': book
'book': book,
'tags': mark_tags
})
# retrieve user review
try:
if request.user.is_authenticated:
review = BookReview.objects.get(owner=request.user, book=book)
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 = BookMark.get_available(book, request.user, request.session['oauth_token'])
review_list = BookReview.get_available(book, request.user, request.session['oauth_token'])
mark_list = BookMark.get_available(
book, request.user, request.session['oauth_token'])
review_list = BookReview.get_available(
book, 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 = BookMarkStatusTranslator(m.status)
review_list_more = True if len(review_list) > REVIEW_NUMBER else False
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('<.*?>')
@ -167,6 +185,8 @@ def retrieve(request, id):
'mark_list_more': mark_list_more,
'review_list': review_list,
'review_list_more': review_list_more,
'book_tag_list': book_tag_list,
'mark_tags': mark_tags,
}
)
else:
@ -193,7 +213,7 @@ def delete(request, id):
else:
raise PermissionDenied()
else:
return HttpResponseBadRequest()
return HttpResponseBadRequest()
# user owned entites
@ -208,9 +228,11 @@ def create_update_mark(request):
if request.method == 'POST':
pk = request.POST.get('id')
old_rating = None
old_tags = None
if pk:
mark = get_object_or_404(BookMark, pk=pk)
old_rating = mark.rating
old_tags = mark.mark_tags.all()
# update
form = BookMarkForm(request.POST, instance=mark)
else:
@ -224,11 +246,23 @@ def create_update_mark(request):
form.instance.owner = request.user
form.instance.edited_time = timezone.now()
book = form.instance.book
try:
with transaction.atomic():
# update book rating
book.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']:
BookTag.objects.create(
content=tag,
book=book,
mark=form.instance
)
except IntegrityError as e:
return HttpResponseServerError()
@ -237,9 +271,11 @@ def create_update_mark(request):
visibility = TootVisibilityEnum.PRIVATE
else:
visibility = TootVisibilityEnum.UNLISTED
url = "https://" + request.get_host() + reverse("books:retrieve", args=[book.id])
url = "https://" + request.get_host() + reverse("books:retrieve",
args=[book.id])
words = BookMarkStatusTranslator(int(form.cleaned_data['status'])) +\
f"{book.title}" + rating_to_emoji(form.cleaned_data['rating'])
f"{book.title}" + \
rating_to_emoji(form.cleaned_data['rating'])
content = words + '\n' + url + '\n' + form.cleaned_data['text']
post_toot(content, visibility, request.session['oauth_token'])
else:
@ -255,11 +291,13 @@ def create_update_mark(request):
def retrieve_mark_list(request, book_id):
if request.method == 'GET':
book = get_object_or_404(Book, pk=book_id)
queryset = BookMark.get_available(book, request.user, request.session['oauth_token'])
queryset = BookMark.get_available(
book, 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)
marks.pagination = PageLinksGenerator(
PAGE_LINK_NUMBER, page_number, paginator.num_pages)
for m in marks:
m.get_status_display = BookMarkStatusTranslator(m.status)
return render(
@ -317,9 +355,11 @@ def create_review(request, book_id):
visibility = TootVisibilityEnum.PRIVATE
else:
visibility = TootVisibilityEnum.UNLISTED
url = "https://" + request.get_host() + reverse("books:retrieve_review", args=[form.instance.id])
url = "https://" + request.get_host() + reverse("books:retrieve_review",
args=[form.instance.id])
words = "发布了关于" + f"{form.instance.book.title}" + "的评论"
content = words + '\n' + url + '\n' + form.cleaned_data['title']
content = words + '\n' + url + \
'\n' + form.cleaned_data['title']
post_toot(content, visibility, request.session['oauth_token'])
return redirect(reverse("books:retrieve_review", args=[form.instance.id]))
else:
@ -336,7 +376,7 @@ def update_review(request, id):
if request.method == 'GET':
review = get_object_or_404(BookReview, pk=id)
if request.user != review.owner:
return HttpResponseBadRequest()
return HttpResponseBadRequest()
form = BookReviewForm(instance=review)
book = review.book
return render(
@ -362,15 +402,17 @@ def update_review(request, id):
visibility = TootVisibilityEnum.PRIVATE
else:
visibility = TootVisibilityEnum.UNLISTED
url = "https://" + request.get_host() + reverse("books:retrieve_review", args=[form.instance.id])
url = "https://" + request.get_host() + reverse("books:retrieve_review",
args=[form.instance.id])
words = "发布了关于" + f"{form.instance.book.title}" + "的评论"
content = words + '\n' + url + '\n' + form.cleaned_data['title']
content = words + '\n' + url + \
'\n' + form.cleaned_data['title']
post_toot(content, visibility, request.session['oauth_token'])
return redirect(reverse("books:retrieve_review", args=[form.instance.id]))
else:
return HttpResponseBadRequest()
else:
return HttpResponseBadRequest()
return HttpResponseBadRequest()
@login_required
@ -439,11 +481,13 @@ def retrieve_review(request, id):
def retrieve_review_list(request, book_id):
if request.method == 'GET':
book = get_object_or_404(Book, pk=book_id)
queryset = BookReview.get_available(book, request.user, request.session['oauth_token'])
queryset = BookReview.get_available(
book, 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)
reviews.pagination = PageLinksGenerator(
PAGE_LINK_NUMBER, page_number, paginator.num_pages)
return render(
request,
'books/review_list.html',
@ -485,7 +529,8 @@ def click_to_scrape(request):
return render(request, 'common/error.html', {'msg': _("爬取数据失败😫,请重试")})
except ValueError:
return render(request, 'common/error.html', {'msg': _("链接非法,爬取失败")})
scraped_cover = {'cover': SimpleUploadedFile('temp.jpg', raw_cover)}
scraped_cover = {
'cover': SimpleUploadedFile('temp.jpg', raw_cover)}
form = BookForm(scraped_book, scraped_cover)
if form.is_valid():
form.instance.last_editor = request.user
@ -500,4 +545,4 @@ def click_to_scrape(request):
else:
return HttpResponseBadRequest()
else:
return HttpResponseBadRequest()
return HttpResponseBadRequest()

View file

@ -6,7 +6,6 @@ import json
class KeyValueInput(forms.Widget):
""" jQeury required """
template_name = 'widgets/key_value.html'
def get_context(self, name, value, attrs):
@ -75,4 +74,37 @@ class PreviewImageInput(forms.FileInput):
"""
Return whether value is considered to be initial value.
"""
return bool(value and getattr(value, 'url', False))
return bool(value and getattr(value, 'url', False))
class TagInput(forms.TextInput):
"""
Dump tag queryset into tag list
"""
template_name = 'widgets/tag.html'
def format_value(self, value):
if value == '' or value is None or len(value) == 0:
return ''
tag_list = []
try:
tag_list = [t['content'] for t in value]
except TypeError:
tag_list = [t.content for t in value]
# return ','.join(tag_list)
return tag_list
class Media:
css = {
'all': ('lib/css/tag-input.css',)
}
js = ('lib/js/tag-input.js',)
class TagField(forms.CharField):
"""
Split comma connected string into tag list
"""
widget = TagInput
def to_python(self, value):
value = super().to_python(value)
return [t.strip() for t in value.split(',')]

View file

@ -76,6 +76,13 @@ class Resource(models.Model):
self.calculate_rating(old_rating, new_rating)
self.save()
def get_tags_manager(self):
"""
Since relation between tag and resource is foreign key, and related name has to be unique,
this method works like interface.
"""
raise NotImplementedError
class UserOwnedEntity(models.Model):
is_private = models.BooleanField()
@ -142,6 +149,9 @@ class Mark(UserOwnedEntity):
rating = models.PositiveSmallIntegerField(blank=True, null=True)
text = models.CharField(max_length=500, blank=True, default='')
def __str__(self):
return f"({self.id}) {self.owner} {self.status}"
class Meta:
abstract = True
constraints = [
@ -159,3 +169,13 @@ class Review(UserOwnedEntity):
class Meta:
abstract = True
class Tag(models.Model):
content = models.CharField(max_length=50)
def __str__(self):
return self.content
class Meta:
abstract = True

View file

@ -929,7 +929,7 @@ select::placeholder {
}
.mark-modal .mark-modal__head {
margin-bottom: 30px;
margin-bottom: 20px;
}
.mark-modal .mark-modal__head::after {
@ -946,6 +946,7 @@ select::placeholder {
.mark-modal .mark-modal__close-button {
float: right;
cursor: pointer;
}
.mark-modal .mark-modal__confirm-button {
@ -994,6 +995,10 @@ select::placeholder {
resize: vertical;
}
.mark-modal .mark-modal__tag {
margin-bottom: 20px;
}
.mark-modal .mark-modal__option {
margin-bottom: 24px;
}
@ -1045,7 +1050,7 @@ select::placeholder {
}
.confirm-modal .confirm-modal__head {
margin-bottom: 30px;
margin-bottom: 20px;
}
.confirm-modal .confirm-modal__head::after {
@ -1062,6 +1067,7 @@ select::placeholder {
.confirm-modal .confirm-modal__close-button {
float: right;
cursor: pointer;
}
.confirm-modal .confirm-modal__confirm-button {
@ -1111,6 +1117,7 @@ select::placeholder {
.entity-list .entity-list__entity-text {
margin-left: 20px;
overflow: hidden;
width: 100%;
}
.entity-list .entity-list__entity-link {
@ -1141,6 +1148,7 @@ select::placeholder {
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
margin-bottom: 0;
}
.entity-list .entity-list__rating {
@ -1189,6 +1197,7 @@ select::placeholder {
display: inline-block;
vertical-align: top;
width: 48%;
margin-bottom: 5px;
}
.entity-detail .entity-detail__fields div, .entity-detail .entity-detail__fields span {
@ -1345,6 +1354,13 @@ select::placeholder {
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;
}
@ -1467,6 +1483,32 @@ select::placeholder {
margin-left: 5px;
}
.tag-collection {
position: relative;
left: -3px;
}
.tag-collection .tag-collection__tag {
position: relative;
display: block;
float: left;
color: white;
background: #d5d5d5;
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;
@ -1718,6 +1760,7 @@ select::placeholder {
padding: 0px 3px;
text-align: center;
display: inline-block;
overflow: hidden;
}
.user-relation .user-relation__related-user > a:hover {
@ -1732,7 +1775,6 @@ select::placeholder {
color: inherit;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}

View file

@ -19,7 +19,7 @@ $(document).ready( function() {
// if wish, hide rating widget in modal
if ($(this).attr("id") == "wishButton") {
console.log($(this).attr("id"))
// console.log($(this).attr("id"))
$(".mark-modal .rating-star-edit").hide();
} else {
$(".mark-modal .rating-star-edit").show();

View file

@ -39,7 +39,7 @@ $(document).ready( function() {
function(userList, request) {
if (userList.length == 0) {
$(".mast-followers").hide();
$(".mast-followers").before("<div>暂无</div>");
$(".mast-followers").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4){
@ -72,7 +72,7 @@ $(document).ready( function() {
function(userList, request) {
if (userList.length == 0) {
$(".mast-following").hide();
$(".mast-following").before("<div>暂无</div>");
$(".mast-following").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4){
userList = userList.slice(0, 4);

View file

@ -0,0 +1,65 @@
@charset "UTF-8";
.tag-input {
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;
border-bottom: 0.1rem solid #ccc;
padding-bottom: 2px;
}
.tag-input__tag {
position: relative;
display: block;
float: left;
color: #00a1cc;
background: transparent;
padding: 5px 20px 5px 5px;
margin: 4px;
border: 0.1rem solid #00a1cc;
border-radius: .4rem;
line-height: 1em;
-webkit-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
}
.tag-input__tag.tag-input__tag--highlight {
color: white;
background: #00a1cc;
}
.tag-input__close {
position: absolute;
top: 0;
right: 0;
width: 14px;
height: 100%;
cursor: pointer;
border-radius: 0 2px 2px 0;
-webkit-transition: background 0.2s;
transition: background 0.2s;
}
.tag-input__close:after {
position: absolute;
content: "×";
top: 5px;
left: 3px;
}
.tag-input__close:hover {
color: #606c76;
}
.tag-input input {
border: 0;
margin: 4px;
padding: 3px 7px 3px 0;
width: auto;
outline: none;
}

View file

@ -0,0 +1,146 @@
function inputTags(configs) {
let tagsContainer = configs.container,
input = configs.container.querySelector('input')
let _privateMethods = {
init: function (configs) {
// this.inspectConfigProperties(configs);
let self = this,
input_hidden = document.createElement('input');
let name = input.getAttribute('name'),
id = input.getAttribute('id');
input.removeAttribute('name');
// input.removeAttribute('id');
input_hidden.setAttribute('type', 'hidden');
// input_hidden.setAttribute('id', id);
input_hidden.setAttribute('name', name);
input.parentNode.insertBefore(input_hidden, input);
this.input_hidden = input_hidden
tagsContainer.addEventListener('click', function () {
input.focus();
});
if (configs.tags) {
for (let i = 0; i < configs.tags.length; i++) {
if (configs.tags[i]) {
this.create(configs.tags[i]);
}
}
}
input.addEventListener("focusout", function () {
let tag_txt = this.value.trim(),
tag_exists = false;
if (self.tags_array) {
tag_exists = Boolean(self.tags_array.indexOf(tag_txt) + 1);
}
if (tag_txt && tag_exists && !configs.allowDuplicateTags) {
self.showDuplicate(tag_txt);
}
else if (tag_txt && tag_exists && configs.allowDuplicateTags) {
self.create(tag_txt);
}
else if (tag_txt && !tag_exists) {
self.create(tag_txt);
}
this.value = "";
});
input.addEventListener('keydown', function (ev) {
if (ev.keyCode === 13 || ev.keyCode === 188 ||
(ev.keyCode === 32 && configs.allowDuplicateTags)) { // enter || comma || space
let event = new Event('focusout');
input.dispatchEvent(event);
ev.preventDefault();
}
else if (event.which === 8 && !input.value) { // backspace
let tag_nodes = document.querySelectorAll('.tag-input__tag');
if (tag_nodes.length > 0) {
input.addEventListener('keyup', function (event) {
if (event.which === 8) {
let node_to_del = tag_nodes[tag_nodes.length - 1];
node_to_del.remove();
self.update();
}
});
}
ev.preventDefault();
}
});
},
create: function (tag_txt) {
let tag_nodes = document.querySelectorAll('.tag-input__tag');
if (!configs.maxTags || tag_nodes.length < configs.maxTags) {
let self = this,
span_tag = document.createElement('span'),
input_hidden_field = self.input_hidden;
span_tag.setAttribute('class', 'tag-input__tag');
span_tag.innerText = tag_txt;
let span_tag_close = document.createElement('span');
span_tag_close.setAttribute('class', 'tag-input__close');
span_tag.appendChild(span_tag_close);
tagsContainer.insertBefore(span_tag, input_hidden_field);
span_tag.childNodes[1].addEventListener('click', function () {
self.remove(this);
});
this.update();
}
},
update: function () {
let tags = document.getElementsByClassName('tag-input__tag'),
tags_arr = [];
for (let i = 0; i < tags.length; i++) {
tags_arr.push(tags[i].textContent.toLowerCase());
}
this.tags_array = tags_arr;
this.input_hidden.setAttribute('value', tags_arr.join());
},
remove: function (tag) {
configs.onTagRemove(tag.parentNode.textContent);
tag.parentNode.remove();
this.update();
},
showDuplicate: function (tag_value) {
let tags = document.getElementsByClassName('tag-input__tag');
for (let i = 0; i < tags.length; i++) {
if (tags[i].textContent === tag_value) {
tags[i].classList.add("tag-input__tag--highlight");
window.setTimeout(function () {
tags[i].classList.remove("tag-input__tag--highlight");
}, configs.duplicateTime);
}
}
}
}
_privateMethods.init(configs);
// return false;
}

View file

@ -127,6 +127,7 @@ $aside-section-padding-mobile: 24px 25px 10px 25px
padding: 0px 3px
text-align: center
display: inline-block
overflow: hidden
& > a
&:hover
color: $color-secondary
@ -138,7 +139,7 @@ $aside-section-padding-mobile: 24px 25px 10px 25px
color: inherit
overflow: hidden
text-overflow: ellipsis
display: -webkit-box;
// display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;

View file

@ -40,6 +40,7 @@ $sub-section-title-margin: 8px
& &__entity-text
margin-left: 20px
overflow: hidden
width: 100%
// float: left
& &__entity-link
@ -65,6 +66,7 @@ $sub-section-title-margin: 8px
-webkit-box-orient: vertical
-webkit-line-clamp: 4
overflow: hidden
margin-bottom: 0
$rating-info-gap-width: 5px
& &__rating
@ -109,6 +111,7 @@ $sub-section-title-margin: 8px
display: inline-block
vertical-align: top
width: 48%
margin-bottom: 5px
& div, & span
margin: 1px 0
@ -233,6 +236,11 @@ $mark-review-padding-wider: 6px 0
width: 100%
margin: 40px 0 24px 0
border-top: solid 1px $color-light
&.dividing-line--dashed
margin: 0
margin-top: 10px
margin-bottom: 2px
border-top: 1px dashed $color-quinary;
// on home page
.entity-sort
@ -328,6 +336,29 @@ $mark-review-padding-wider: 6px 0
& &__action-link
&:not(:first-child)
margin-left: 5px
// tag list
.tag-collection
$tag-margin: 3px
position: relative
left: -$tag-margin
& &__tag
position: relative;
display: block;
float: left;
color: white;
background: $color-quaternary;
padding: 5px;
// margin: 4px;
border-radius: .3rem;
line-height: 1.2em;
font-size: 80%
margin: 3px
& a
color: white
&:hover
color: $color-primary
// Small devices (landscape phones, 576px and up)
@media (max-width: $small-devices)
.entity-list

View file

@ -21,7 +21,7 @@
padding: 20px 20px 10px 20px
color: $color-secondary
& &__head
margin-bottom: 30px
margin-bottom: 20px
&::after
@include clear
@ -32,6 +32,7 @@
& &__close-button
float: right
cursor: pointer
& &__body
@ -69,6 +70,10 @@
margin-bottom: 5px
resize: vertical
& &__tag
// margin-top: 10px
margin-bottom: 20px
& &__option
margin-bottom: 24px
&::after

View file

@ -2,4 +2,4 @@
min-height: 100px
.rating-star .jq-star
cursor: unset !important
cursor: unset !important

View file

@ -33,8 +33,13 @@
<div class="entity-list">
{% if request.GET.q %}
<h5 class="entity-list__title">“{{ request.GET.q }}”{% trans '的搜索结果' %}</h5>
<h5 class="entity-list__title">“{{ request.GET.q }}” {% trans '的搜索结果' %}</h5>
{% endif %}
{% if request.GET.tag %}
<h5 class="entity-list__title">{% trans '含有标签' %} “{{ request.GET.tag }}” {% trans '的结果' %}</h5>
{% endif %}
<ul class="entity-list__entities">
{% for book in items %}
@ -95,6 +100,18 @@
<p class="entity-list__entity-brief">
{{ book.brief }}
</p>
<div class="tag-collection">
{% for tag_dict in book.tag_list %}
{% for k, v in tag_dict.items %}
{% if k == 'content' %}
<span class="tag-collection__tag">
<a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a>
</span>
{% endif %}
{% endfor %}
{% endfor %}
</div>
</div>
</li>
{% empty %}

View file

@ -10,6 +10,7 @@
<img src="{% static 'img/logo.svg' %}" alt="" class="navbar__logo-img">
</a>
<form action="#" οnsubmit="" class="navbar__search-box">
<!-- <input type="search" class="" name="q" id="searchInput" required="true" value="{% for v in request.GET.values %}{{ v }}{% endfor %}" -->
<input type="search" class="" name="q" id="searchInput" required="true" value="{% if request.GET.q %}{{ request.GET.q }}{% endif %}"
placeholder="搜索书影音,多个关键字以空格分割">
</form>

View file

@ -0,0 +1,17 @@
<div class="tag-input">
<input type="text" name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
</div>
<script>
new inputTags({
container: document.getElementsByClassName("tag-input")[0],
tags : [{% for tag in widget.value %}"{{ tag }}",{% endfor %}],
allowDuplicateTags : false,
duplicateTime: 300,
onTagRemove : function (tag) {},
});
</script>

View file

@ -5,7 +5,7 @@ from common.models import MarkStatusEnum
from common.utils import PageLinksGenerator
from users.models import Report, User
from django.core.paginator import Paginator
from django.db.models import Q
from django.db.models import Q, Count
from django.http import HttpResponseBadRequest
@ -18,6 +18,9 @@ ITEMS_PER_PAGE = 20
# how many pages links in the pagination
PAGE_LINK_NUMBER = 7
# max tags on list page
TAG_NUMBER_ON_LIST = 5
@login_required
def home(request):
@ -58,19 +61,30 @@ def search(request):
# in the future when more modules are added...
# category = request.GET.get("category")
q = Q()
keywords = request.GET.get("q", default='').split()
query_args = []
# keywords
keywords = request.GET.get("q", default='').split()
for keyword in keywords:
q = q | Q(title__icontains=keyword)
q = q | Q(subtitle__istartswith=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)
query_args.append(q)
queryset = Book.objects.filter(*query_args)
queryset = Book.objects.filter(*query_args).distinct()
paginator = Paginator(queryset, ITEMS_PER_PAGE)
page_number = request.GET.get('page', default=1)
items = paginator.get_page(page_number)
items.pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages)
for item in items:
item.tag_list = item.get_tags_manager().values('content').annotate(
tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST]
return render(
request,
@ -81,4 +95,4 @@ def search(request):
)
else:
return HttpResponseBadRequest()
return HttpResponseBadRequest()

View file

@ -47,7 +47,7 @@ $(document).ready( function() {
let subUserList = null;
if (userList.length == 0) {
$(".mast-followers").hide();
$(".mast-followers").before("<div>暂无</div>");
$(".mast-followers").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4){
subUserList = userList.slice(0, 4);
@ -103,7 +103,7 @@ $(document).ready( function() {
function(userList, request) {
if (userList.length == 0) {
$(".mast-following").hide();
$(".mast-following").before("<div>暂无</div>");
$(".mast-following").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4){
userList = userList.slice(0, 4);

View file

@ -46,7 +46,7 @@ $(document).ready( function() {
function (userList, request) {
if (userList.length == 0) {
$(".mast-followers").hide();
$(".mast-followers").before("<div>暂无</div>");
$(".mast-followers").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4) {
userList = userList.slice(0, 4);
@ -80,7 +80,7 @@ $(document).ready( function() {
let subUserList = null;
if (userList.length == 0) {
$(".mast-following").hide();
$(".mast-following").before("<div>暂无</div>");
$(".mast-following").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4){
subUserList = userList.slice(0, 4);

View file

@ -6,12 +6,13 @@ from django.contrib.auth import authenticate
from django.core.paginator import Paginator
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count
from .models import User, Report
from .forms import ReportForm
from common.mastodon.auth import *
from common.mastodon.api import *
from common.mastodon import mastodon_request_included
from common.views import BOOKS_PER_SET, ITEMS_PER_PAGE, PAGE_LINK_NUMBER
from common.views import BOOKS_PER_SET, ITEMS_PER_PAGE, PAGE_LINK_NUMBER, TAG_NUMBER_ON_LIST
from common.models import MarkStatusEnum
from common.utils import PageLinksGenerator
from books.models import *
@ -307,6 +308,9 @@ def book_list(request, id, status):
paginator = Paginator(queryset, ITEMS_PER_PAGE)
page_number = request.GET.get('page', default=1)
marks = paginator.get_page(page_number)
for mark in marks:
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(_("的书"))
return render(
@ -386,4 +390,4 @@ def auth_login(request, user, token):
def auth_logout(request):
""" Decorates django ``logout()``. Release token in session."""
del request.session['oauth_token']
auth.logout(request)
auth.logout(request)