add home layout customization

This commit is contained in:
doubaniux 2021-02-17 15:08:16 +01:00
parent c220cbd9b3
commit 5268fc5aa2
10 changed files with 410 additions and 11 deletions

View file

@ -808,6 +808,20 @@ select::placeholder {
margin-left: 3px;
}
.icon-edit svg {
fill: #ccc;
height: 12px;
position: relative;
top: 2px;
}
.icon-save svg {
fill: #ccc;
height: 12px;
position: relative;
top: 2px;
}
.icon-cross svg {
fill: #ccc;
height: 10px;
@ -1529,6 +1543,7 @@ select::placeholder {
}
.entity-sort {
position: relative;
margin-bottom: 30px;
}
@ -1582,6 +1597,58 @@ select::placeholder {
-webkit-line-clamp: 2;
}
.entity-sort--placeholder {
border: dashed #bbb 4px;
}
.entity-sort--hover {
padding: 10px;
border: dashed #00a1cc 2px !important;
border-radius: 3px;
}
.entity-sort--sortable {
padding: 10px;
margin: 10px 0;
border: dashed #bbb 2px;
cursor: all-scroll;
}
.entity-sort--hidden {
opacity: 0.4;
}
.entity-sort-control {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: end;
-ms-flex-pack: end;
justify-content: flex-end;
}
.entity-sort-control__button {
margin-top: 5px;
margin-left: 12px;
padding: 0 2px;
cursor: pointer;
color: #bbb;
}
.entity-sort-control__button:hover {
color: #00a1cc;
}
.entity-sort-control__button:hover > .icon-save svg, .entity-sort-control__button:hover > .icon-edit svg {
fill: #00a1cc;
}
.entity-sort-control__button--float-right {
position: absolute;
top: 4px;
right: 10px;
}
.related-user-list .related-user-list__title {
margin-bottom: 20px;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,143 @@
$(() => {
// initialization
// add toggle display button
$(".entity-sort").each((i, e) => {
if ($(e).data("visibility") === undefined) {
$(e).data("visibility", true);
}
let btn = $("#toggleDisplayButtonTemplate").clone().removeAttr("id");
btn.click(e => {
if ($(e.currentTarget).parent().data('visibility') === true) {
// flip text
$(e.currentTarget).children("span.showText").show();
$(e.currentTarget).children("span.hideText").hide();
// flip data
$(e.currentTarget).parent().data('visibility', false);
// flip display
$(e.currentTarget).parent().addClass("entity-sort--hidden");
} else {
// flip text
$(e.currentTarget).children("span.showText").hide();
$(e.currentTarget).children("span.hideText").show();
// flip data
$(e.currentTarget).parent().data('visibility', true);
// flip display
$(e.currentTarget).parent().removeClass("entity-sort--hidden");
}
});
$(e).prepend(btn);
});
// initialize toggle buttons
initialLayoutData.forEach(elem => {
if (elem.visibility) {
$('#' + elem.id).find("span.showText").hide();
$('#' + elem.id).find("span.hideText").show();
} else {
$('#' + elem.id).find("span.showText").show();
$('#' + elem.id).find("span.hideText").hide();
}
});
// initialize the sortable plugin
sortable('.sortable', {
forcePlaceholderSize: true,
placeholderClass: 'entity-sort--placeholder',
// hoverClass: 'entity-sort--hover'
});
sortable('.sortable', 'disable');
// set state flag
let isActivated = false;
// set class modifier, because the effect of the plugin is not very well
let dragging = false;
$(".entity-sort").on('mouseenter', (e) => {
if (isActivated && !dragging) {
$(e.currentTarget).addClass("entity-sort--hover");
}
});
$(".entity-sort").on('mouseleave', (e) => {
if (isActivated) {
$(e.currentTarget).removeClass("entity-sort--hover");
}
});
$(".entity-sort").on('dragstart', (e) => {
if (isActivated) {
dragging = true;
}
});
$(".entity-sort").on('dragend', (e) => {
if (isActivated) {
dragging = false;
}
});
// activate sorting
$("#sortEditButton").click(e => {
// test if edit mode is activated
isActivated = $("#sortSaveIcon").is(":visible");
if (isActivated) {
// save edited layout
let rawData = sortable('.sortable', 'serialize')[0].items;
let serializedData = []
// collect layout information
for (const key in rawData) {
if (Object.hasOwnProperty.call(rawData, key)) {
const sort = rawData[key];
let id = $(sort.node).attr("id");
let visibility = $(sort.node).data("visibility") ? true : false;
serializedData.push({
id: id,
visibility: visibility
});
}
}
$("#sortForm input[name='layout']").val(JSON.stringify(serializedData))
$("#sortForm").submit();
// console.log(serializedData)
} else {
// enter edit mode
$("#sortSaveIcon").show();
$("#sortEditIcon").hide();
$("#sortSaveText").show();
$("#sortEditText").hide();
$("#sortExitButton").show();
sortable('.sortable', 'enable');
$(".entity-sort").each((index, elem) => {
$(elem).show();
$(elem).addClass("entity-sort--sortable");
if ($(elem).data('visibility') === true) {
$(elem).find("span.showText").hide();
$(elem).find("span.hideText").show();
} else if ($(elem).data('visibility') === false) {
$(elem).find("span.showText").show();
$(elem).find("span.hideText").hide();
}
$(elem).children(".entity-sort-control__button").show();
if ($(elem).data('visibility') === false) {
$(elem).addClass("entity-sort--hidden");
}
});
}
isActivated = $("#sortSaveIcon").is(":visible");
});
// exit edit mode
$("#sortExitButton").click(e => {
$("#sortSaveIcon").hide();
$("#sortEditIcon").show();
$("#sortSaveText").hide();
$("#sortEditText").show();
$("#sortExitButton").hide();
sortable('.sortable', 'disable');
$(".entity-sort").each((index, elem) => {
$(elem).removeClass("entity-sort--sortable");
});
isActivated = $("#sortSaveIcon").is(":visible");
});
});

View file

@ -5,6 +5,17 @@
top: 1px
margin-left: 3px
.icon-edit svg
fill: $color-light
height: 12px
position: relative
top: 2px
.icon-save svg
fill: $color-light
height: 12px
position: relative
top: 2px
.icon-cross svg
fill: $color-light

View file

@ -262,6 +262,7 @@ $mark-review-padding-wider: 6px 0
// on home page
.entity-sort
position: relative
margin-bottom: 30px
& &__label
font-size: large
@ -299,6 +300,45 @@ $mark-review-padding-wider: 6px 0
-webkit-line-clamp: 2
& &__empty
// for drag and sort purpose
&--placeholder
border: dashed $color-tertiary 4px
&--hover
padding: 10px
border: dashed $color-primary 2px !important
border-radius: 3px
&--sortable
padding: 10px
margin: 10px 0
border: dashed $color-tertiary 2px
cursor: all-scroll
&--hidden
opacity: 0.4
.entity-sort-control
display: flex
justify-content: flex-end
&__button
margin-top: 5px
margin-left: 12px
padding: 0 2px
cursor: pointer
color: $color-tertiary
&:hover
color: $color-primary
& > .icon-save svg, & > .icon-edit svg
fill: $color-primary
&--float-right
position: absolute
top: 4px
right: 10px
&__text
// follower/following list page
.related-user-list

View file

@ -33,7 +33,7 @@
<div class="grid grid--reverse-order">
<div class="grid__main grid__main--reverse-order">
<div class="main-section-wrapper">
<div class="main-section-wrapper sortable">
<div class="entity-sort" id="bookWish">
<h5 class="entity-sort__label">
@ -43,7 +43,6 @@
<a href="{% url 'users:book_list' user.id 'wish' %}"
class="entity-sort__more-link">{% trans '更多' %}</a>
{% endif %}
<ul class="entity-sort__entity-list">
{% for wish_book_mark in wish_book_marks %}
<li class="entity-sort__entity">
@ -300,6 +299,73 @@
</div>
{% if user == request.user %}
<div class="entity-sort-control">
<div class="entity-sort-control__button" id="sortEditButton">
<span class="entity-sort-control__text" id="sortEditText">
{% trans '编辑布局' %}
</span>
<span class="entity-sort-control__text" id="sortSaveText" style="display: none;">
{% trans '保存' %}
</span>
<span class="icon-edit" id="sortEditIcon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 383.947 383.947">
<polygon points="0,303.947 0,383.947 80,383.947 316.053,147.893 236.053,67.893 " />
<path
d="M377.707,56.053L327.893,6.24c-8.32-8.32-21.867-8.32-30.187,0l-39.04,39.04l80,80l39.04-39.04 C386.027,77.92,386.027,64.373,377.707,56.053z" />
</svg>
</span>
<span class="icon-save" id="sortSaveIcon" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 384 384" >
<path
d="M298.667,0h-256C19.093,0,0,19.093,0,42.667v298.667C0,364.907,19.093,384,42.667,384h298.667 C364.907,384,384,364.907,384,341.333v-256L298.667,0z M192,341.333c-35.307,0-64-28.693-64-64c0-35.307,28.693-64,64-64 s64,28.693,64,64C256,312.64,227.307,341.333,192,341.333z M256,128H42.667V42.667H256V128z" />
</svg>
</span>
</div>
<div class="entity-sort-control__button" id="sortExitButton" style="display: none;">
<span class="entity-sort-control__text">
{% trans '取消' %}
</span>
</div>
</div>
<div class="entity-sort-control__button entity-sort-control__button--float-right" id="toggleDisplayButtonTemplate" style="display: none;">
<span class="showText" style="display: none;">
{% trans '显示' %}
</span>
<span class="hideText" style="display: none;">
{% trans '隐藏' %}
</span>
</div>
<form action="{% url 'users:set_layout' %}" method="post" id="sortForm">
{% csrf_token %}
<input type="hidden" name="layout">
</form>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5sortable/0.10.0/html5sortable.min.js"
integrity="sha512-tBlVMq89XaEC9iU5LyRjP2Vxs8SmVhEHGbv2Co6SbGa14Wsxy2qZN0jadrN+Xn5AifORaUbvZcG21/ExcNfWDA=="
crossorigin="anonymous"></script>
<script src="{% static 'js/sort_layout.js' %}"></script>
{% endif %}
<script>
const initialLayoutData = JSON.parse("{{ layout|escapejs }}");
// initialize sort element visibility and order
initialLayoutData.forEach(elem => {
// False to false, True to true
if (elem.visibility === "False") {
elem.visibility = false;
} else {
elem.visibility = true;
}
// set visiblity
$('#' + elem.id).data('visibility', elem.visibility);
if (!elem.visibility) {
$('#' + elem.id).hide();
}
// order
$('#' + elem.id).appendTo('.main-section-wrapper');
});
</script>
</div>
@ -492,6 +558,7 @@
$(this).parents(".modal").hide();
$(".bg-mask").hide();
});
</script>
</body>

View file

@ -13,7 +13,7 @@ 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 users.models import Report, User, Preference
from mastodon.decorators import mastodon_request_included
from common.models import MarkStatusEnum
from common.utils import PageLinksGenerator
@ -105,6 +105,12 @@ def home(request):
reports = Report.objects.order_by('-submitted_time').filter(is_read=False)
# reports = Report.objects.latest('submitted_time').filter(is_read=False)
try:
layout = request.user.preference.get_serialized_home_layout()
except ObjectDoesNotExist:
Preference.objects.create(user=request.user)
layout = request.user.preference.get_serialized_home_layout()
return render(
request,
'common/home.html',
@ -129,6 +135,7 @@ def home(request):
'collect_music_more': collect_music_more,
'reports': reports,
'unread_announcements': unread_announcements,
'layout': layout,
}
)
else:

View file

@ -1,8 +1,11 @@
import uuid
import django.contrib.postgres.fields as postgres
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils import timezone
from boofilsic.settings import REPORT_MEDIA_PATH_ROOT, DEFAULT_PASSWORD
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.translation import ugettext_lazy as _
def report_image_path(instance, filename):
@ -39,6 +42,18 @@ class User(AbstractUser):
return self.username + '@' + self.mastodon_site
class Preference(models.Model):
user = models.OneToOneField(User, models.CASCADE, primary_key=True)
home_layout = postgres.ArrayField(
postgres.HStoreField(),
blank=True,
default=list,
)
def get_serialized_home_layout(self):
return str(self.home_layout).replace("\'","\"")
class Report(models.Model):
submit_user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='sumbitted_reports', null=True)
reported_user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='accused_reports', null=True)
@ -46,6 +61,3 @@ class Report(models.Model):
is_read = models.BooleanField(default=False)
submitted_time = models.DateTimeField(auto_now_add=True)
message = models.CharField(max_length=1000)

View file

@ -7,6 +7,7 @@ urlpatterns = [
path('register/', register, name='register'),
path('logout/', logout, name='logout'),
path('delete/', delete, name='delete'),
path('layout/', set_layout, name='set_layout'),
path('OAuth2_login/', OAuth2_login, name='OAuth2_login'),
path('<int:id>/', home, name='home'),
path('<int:id>/followers/', followers, name='followers'),

View file

@ -7,12 +7,12 @@ 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 .models import User, Report, Preference
from .forms import ReportForm
from mastodon.auth import *
from mastodon.api import *
from mastodon import mastodon_request_included
from common.views import BOOKS_PER_SET, ITEMS_PER_PAGE, PAGE_LINK_NUMBER, TAG_NUMBER_ON_LIST, MOVIES_PER_SET
from common.views import BOOKS_PER_SET, ITEMS_PER_PAGE, PAGE_LINK_NUMBER, TAG_NUMBER_ON_LIST, MOVIES_PER_SET, MUSIC_PER_SET
from common.models import MarkStatusEnum
from common.utils import PageLinksGenerator
from books.models import *
@ -195,11 +195,42 @@ def home(request, id):
wish_movies_more = True if wish_movie_marks.count() > BOOKS_PER_SET else False
collect_movie_marks = movie_marks.filter(status=MarkStatusEnum.COLLECT)
collect_movies_more = True if collect_movie_marks.count() > BOOKS_PER_SET else False
collect_movies_more = True if collect_movie_marks.count() > BOOKS_PER_SET else False
song_marks = SongMark.get_available_by_user(user, relation['following'])
album_marks = AlbumMark.get_available_by_user(user, relation['following'])
do_music_marks = list(song_marks.filter(status=MarkStatusEnum.DO)[:MUSIC_PER_SET]) \
+ list(album_marks.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(song_marks.filter(status=MarkStatusEnum.WISH)[:MUSIC_PER_SET]) \
+ list(album_marks.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(song_marks.filter(status=MarkStatusEnum.COLLECT)[:MUSIC_PER_SET]) \
+ list(album_marks.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"
user.target_site_id = get_cross_site_id(
user, request.user.mastodon_site, request.session['oauth_token'])
try:
layout = user.preference.get_serialized_home_layout()
except ObjectDoesNotExist:
Preference.objects.create(user=request.user)
layout = user.preference.get_serialized_home_layout()
return render(
request,
'common/home.html',
@ -217,6 +248,13 @@ def home(request, id):
'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,
'layout': layout,
}
)
else:
@ -543,7 +581,20 @@ def music_list(request, id, status):
)
else:
return HttpResponseBadRequest()
@login_required
def set_layout(request):
if request.method == 'POST':
# json to python
raw_layout_data = request.POST.get('layout').replace('false', 'False').replace('true', 'True')
layout = eval(raw_layout_data)
request.user.preference.home_layout = eval(raw_layout_data)
request.user.preference.save()
return redirect(reverse("common:home"))
else:
return HttpResponseBadRequest()
@login_required
def report(request):