Merge remote-tracking branch 'upstream/main' into partially-read-shelf

This commit is contained in:
Thomas Versteeg 2022-02-28 20:44:55 +01:00
commit 539775f370
12 changed files with 142 additions and 123 deletions

View file

@ -227,7 +227,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@classmethod @classmethod
def privacy_filter(cls, viewer, privacy_levels=None): def privacy_filter(cls, viewer, privacy_levels=None):
queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels) queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels)
return queryset.filter(deleted=False) return queryset.filter(deleted=False, user__is_active=True)
@classmethod @classmethod
def direct_filter(cls, queryset, viewer): def direct_filter(cls, queryset, viewer):

View file

@ -21,7 +21,7 @@ RELEASE_API = env(
PAGE_LENGTH = env("PAGE_LENGTH", 15) PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "a60e5a55" JS_CACHE = "c7144efb"
# email # email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")

View file

@ -1,11 +1,11 @@
/* exported TabGroup */ /* exported TabGroup */
/* /*
* The content below is licensed according to the W3C Software License at * The content below is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
* Heavily modified to web component by Zach Leatherman * Heavily modified to web component by Zach Leatherman
* Modified back to vanilla JavaScript with support for Bulma markup and nested tabs by Ned Zimmerman * Modified back to vanilla JavaScript with support for Bulma markup and nested tabs by Ned Zimmerman
*/ */
class TabGroup { class TabGroup {
constructor(container) { constructor(container) {
this.container = container; this.container = container;
@ -15,7 +15,7 @@ class TabGroup {
this.panels = this.container.querySelectorAll(':scope > [role="tabpanel"]'); this.panels = this.container.querySelectorAll(':scope > [role="tabpanel"]');
this.delay = this.determineDelay(); this.delay = this.determineDelay();
if(!this.tablist || !this.tabs.length || !this.panels.length) { if (!this.tablist || !this.tabs.length || !this.panels.length) {
return; return;
} }
@ -32,7 +32,7 @@ class TabGroup {
left: 37, left: 37,
up: 38, up: 38,
right: 39, right: 39,
down: 40 down: 40,
}; };
} }
@ -42,23 +42,21 @@ class TabGroup {
37: -1, 37: -1,
38: -1, 38: -1,
39: 1, 39: 1,
40: 1 40: 1,
}; };
} }
initTabs() { initTabs() {
let count = 0; let count = 0;
for(let tab of this.tabs) {
for (let tab of this.tabs) {
let isSelected = tab.getAttribute("aria-selected") === "true"; let isSelected = tab.getAttribute("aria-selected") === "true";
tab.setAttribute("tabindex", isSelected ? "0" : "-1"); tab.setAttribute("tabindex", isSelected ? "0" : "-1");
tab.addEventListener('click', this.clickEventListener.bind(this)); tab.addEventListener("click", this.clickEventListener.bind(this));
tab.addEventListener('keydown', this.keydownEventListener.bind(this)); tab.addEventListener("keydown", this.keydownEventListener.bind(this));
tab.addEventListener('keyup', this.keyupEventListener.bind(this)); tab.addEventListener("keyup", this.keyupEventListener.bind(this));
if (isSelected) {
tab.scrollIntoView();
}
tab.index = count++; tab.index = count++;
} }
@ -68,8 +66,9 @@ class TabGroup {
let selectedPanelId = this.tablist let selectedPanelId = this.tablist
.querySelector('[role="tab"][aria-selected="true"]') .querySelector('[role="tab"][aria-selected="true"]')
.getAttribute("aria-controls"); .getAttribute("aria-controls");
for(let panel of this.panels) {
if(panel.getAttribute("id") !== selectedPanelId) { for (let panel of this.panels) {
if (panel.getAttribute("id") !== selectedPanelId) {
panel.setAttribute("hidden", ""); panel.setAttribute("hidden", "");
} }
panel.setAttribute("tabindex", "0"); panel.setAttribute("tabindex", "0");
@ -86,16 +85,18 @@ class TabGroup {
// Handle keydown on tabs // Handle keydown on tabs
keydownEventListener(event) { keydownEventListener(event) {
var key = event.keyCode; const key = event.keyCode;
switch (key) { switch (key) {
case this.keys.end: case this.keys.end:
event.preventDefault(); event.preventDefault();
// Activate last tab // Activate last tab
this.activateTab(this.tabs[this.tabs.length - 1]); this.activateTab(this.tabs[this.tabs.length - 1]);
break; break;
case this.keys.home: case this.keys.home:
event.preventDefault(); event.preventDefault();
// Activate first tab // Activate first tab
this.activateTab(this.tabs[0]); this.activateTab(this.tabs[0]);
break; break;
@ -111,7 +112,7 @@ class TabGroup {
// Handle keyup on tabs // Handle keyup on tabs
keyupEventListener(event) { keyupEventListener(event) {
var key = event.keyCode; const key = event.keyCode;
switch (key) { switch (key) {
case this.keys.left: case this.keys.left:
@ -125,17 +126,16 @@ class TabGroup {
// only up and down arrow should function. // only up and down arrow should function.
// In all other cases only left and right arrow function. // In all other cases only left and right arrow function.
determineOrientation(event) { determineOrientation(event) {
var key = event.keyCode; const key = event.keyCode;
var vertical = this.tablist.getAttribute('aria-orientation') == 'vertical'; const vertical = this.tablist.getAttribute("aria-orientation") == "vertical";
var proceed = false; let proceed = false;
if (vertical) { if (vertical) {
if (key === this.keys.up || key === this.keys.down) { if (key === this.keys.up || key === this.keys.down) {
event.preventDefault(); event.preventDefault();
proceed = true; proceed = true;
} }
} } else {
else {
if (key === this.keys.left || key === this.keys.right) { if (key === this.keys.left || key === this.keys.right) {
proceed = true; proceed = true;
} }
@ -149,22 +149,21 @@ class TabGroup {
// Either focus the next, previous, first, or last tab // Either focus the next, previous, first, or last tab
// depending on key pressed // depending on key pressed
switchTabOnArrowPress(event) { switchTabOnArrowPress(event) {
var pressed = event.keyCode; const pressed = event.keyCode;
for (let tab of this.tabs) { for (let tab of this.tabs) {
tab.addEventListener('focus', this.focusEventHandler.bind(this)); tab.addEventListener("focus", this.focusEventHandler.bind(this));
} }
if (this.direction[pressed]) { if (this.direction[pressed]) {
var target = event.target; const target = event.target;
if (target.index !== undefined) { if (target.index !== undefined) {
if (this.tabs[target.index + this.direction[pressed]]) { if (this.tabs[target.index + this.direction[pressed]]) {
this.tabs[target.index + this.direction[pressed]].focus(); this.tabs[target.index + this.direction[pressed]].focus();
} } else if (pressed === this.keys.left || pressed === this.keys.up) {
else if (pressed === this.keys.left || pressed === this.keys.up) {
this.focusLastTab(); this.focusLastTab();
} } else if (pressed === this.keys.right || pressed == this.keys.down) {
else if (pressed === this.keys.right || pressed == this.keys.down) {
this.focusFirstTab(); this.focusFirstTab();
} }
} }
@ -172,8 +171,8 @@ class TabGroup {
} }
// Activates any given tab panel // Activates any given tab panel
activateTab (tab, setFocus) { activateTab(tab, setFocus) {
if(tab.getAttribute("role") !== "tab") { if (tab.getAttribute("role") !== "tab") {
tab = tab.closest('[role="tab"]'); tab = tab.closest('[role="tab"]');
} }
@ -183,19 +182,19 @@ class TabGroup {
this.deactivateTabs(); this.deactivateTabs();
// Remove tabindex attribute // Remove tabindex attribute
tab.removeAttribute('tabindex'); tab.removeAttribute("tabindex");
// Set the tab as selected // Set the tab as selected
tab.setAttribute('aria-selected', 'true'); tab.setAttribute("aria-selected", "true");
// Give the tab is-active class // Give the tab is-active class
tab.classList.add('is-active'); tab.classList.add("is-active");
// Get the value of aria-controls (which is an ID) // Get the value of aria-controls (which is an ID)
var controls = tab.getAttribute('aria-controls'); const controls = tab.getAttribute("aria-controls");
// Remove hidden attribute from tab panel to make it visible // Remove hidden attribute from tab panel to make it visible
document.getElementById(controls).removeAttribute('hidden'); document.getElementById(controls).removeAttribute("hidden");
// Set focus when required // Set focus when required
if (setFocus) { if (setFocus) {
@ -206,14 +205,14 @@ class TabGroup {
// Deactivate all tabs and tab panels // Deactivate all tabs and tab panels
deactivateTabs() { deactivateTabs() {
for (let tab of this.tabs) { for (let tab of this.tabs) {
tab.classList.remove('is-active'); tab.classList.remove("is-active");
tab.setAttribute('tabindex', '-1'); tab.setAttribute("tabindex", "-1");
tab.setAttribute('aria-selected', 'false'); tab.setAttribute("aria-selected", "false");
tab.removeEventListener('focus', this.focusEventHandler.bind(this)); tab.removeEventListener("focus", this.focusEventHandler.bind(this));
} }
for (let panel of this.panels) { for (let panel of this.panels) {
panel.setAttribute('hidden', 'hidden'); panel.setAttribute("hidden", "hidden");
} }
} }
@ -228,15 +227,15 @@ class TabGroup {
// Determine whether there should be a delay // Determine whether there should be a delay
// when user navigates with the arrow keys // when user navigates with the arrow keys
determineDelay() { determineDelay() {
var hasDelay = this.tablist.hasAttribute('data-delay'); const hasDelay = this.tablist.hasAttribute("data-delay");
var delay = 0; let delay = 0;
if (hasDelay) { if (hasDelay) {
var delayValue = this.tablist.getAttribute('data-delay'); const delayValue = this.tablist.getAttribute("data-delay");
if (delayValue) { if (delayValue) {
delay = delayValue; delay = delayValue;
} } else {
else {
// If no value is specified, default to 300ms // If no value is specified, default to 300ms
delay = 300; delay = 300;
} }
@ -246,7 +245,7 @@ class TabGroup {
} }
focusEventHandler(event) { focusEventHandler(event) {
var target = event.target; const target = event.target;
setTimeout(this.checkTabFocus.bind(this), this.delay, target); setTimeout(this.checkTabFocus.bind(this), this.delay, target);
} }

View file

@ -352,7 +352,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% if request.user.list_set.exists %} {% if list_options.exists %}
<form name="list-add" method="post" action="{% url 'list-add-book' %}"> <form name="list-add" method="post" action="{% url 'list-add-book' %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ book.id }}">
@ -361,7 +361,7 @@
<div class="field has-addons"> <div class="field has-addons">
<div class="select control is-clipped"> <div class="select control is-clipped">
<select name="book_list" id="id_list"> <select name="book_list" id="id_list">
{% for list in user.list_set.all %} {% for list in list_options %}
<option value="{{ list.id }}">{{ list.name }}</option> <option value="{{ list.id }}">{{ list.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
@ -386,6 +386,6 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{% static "js/vendor/tabs.js" %}?v={{ js_cache }}"></script> <script src="{% static "js/tabs.js" %}?v={{ js_cache }}"></script>
<script src="{% static "js/autocomplete.js" %}?v={{ js_cache }}"></script> <script src="{% static "js/autocomplete.js" %}?v={{ js_cache }}"></script>
{% endblock %} {% endblock %}

View file

@ -31,5 +31,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{% static "js/vendor/tabs.js" %}?v={{ js_cache }}"></script> <script src="{% static "js/tabs.js" %}?v={{ js_cache }}"></script>
{% endblock %} {% endblock %}

View file

@ -30,13 +30,23 @@
<div class="columns mt-3"> <div class="columns mt-3">
<section class="column is-three-quarters"> <section class="column is-three-quarters">
{% if request.GET.updated %} {% if add_failed %}
<div class="notification is-primary"> <div class="notification is-danger is-light">
{% if list.curation != "open" and request.user != list.user and not list.group|is_member:request.user %} <span class="icon icon-x" aria-hidden="true"></span>
{% trans "You successfully suggested a book for this list!" %} <span>
{% else %} {% trans "That book is already on this list." %}
{% trans "You successfully added a book to this list!" %} </span>
{% endif %} </div>
{% elif add_succeeded %}
<div class="notification is-success is-light">
<span class="icon icon-check" aria-hidden="true"></span>
<span>
{% if list.curation != "open" and request.user != list.user and not list.group|is_member:request.user %}
{% trans "You successfully suggested a book for this list!" %}
{% else %}
{% trans "You successfully added a book to this list!" %}
{% endif %}
</span>
</div> </div>
{% endif %} {% endif %}

View file

@ -78,7 +78,9 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="shelf" value="{{ user_shelf.id }}"> <input type="hidden" name="shelf" value="{{ user_shelf.id }}">
<button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove from" %} {{ user_shelf.name }}</button> <button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">
{% blocktrans with name=user_shelf|translate_shelf_name %}Remove from {{ name }}{% endblocktrans %}
</button>
</form> </form>
</li> </li>
{% endif %} {% endif %}

View file

@ -70,7 +70,7 @@
<input type="hidden" name="book" value="{{ active_shelf.book.id }}"> <input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}"> <input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}">
<button class="button is-fullwidth is-small{% if dropdown %} is-radiusless{% endif %} is-danger is-light" type="submit"> <button class="button is-fullwidth is-small{% if dropdown %} is-radiusless{% endif %} is-danger is-light" type="submit">
{% blocktrans with name=active_shelf.shelf.name %}Remove from {{ name }}{% endblocktrans %} {% blocktrans with name=active_shelf.shelf|translate_shelf_name %}Remove from {{ name }}{% endblocktrans %}
</button> </button>
</form> </form>
</li> </li>

View file

@ -1,14 +1,4 @@
{% load i18n %} {% load i18n %}
{% if shelf.identifier == 'all' %} {% load shelf_tags %}
{% trans "All books" %}
{% elif shelf.identifier == 'to-read' %} {{ shelf|translate_shelf_name }}
{% trans "To Read" %}
{% elif shelf.identifier == 'reading' %}
{% trans "Currently Reading" %}
{% elif shelf.identifier == 'read' %}
{% trans "Read" %}
{% elif shelf.identifier == 'stopped-reading' %}
{% trans "Stopped Reading" %}
{% else %}
{{ shelf.name }}
{% endif %}

View file

@ -1,5 +1,6 @@
""" Filters and tags related to shelving books """ """ Filters and tags related to shelving books """
from django import template from django import template
from django.utils.translation import gettext_lazy as _
from bookwyrm import models from bookwyrm import models
from bookwyrm.utils import cache from bookwyrm.utils import cache
@ -32,6 +33,24 @@ def get_next_shelf(current_shelf):
return "to-read" return "to-read"
@register.filter(name="translate_shelf_name")
def get_translated_shelf_name(shelf):
"""produced translated shelf nidentifierame"""
if not shelf:
return ""
# support obj or dict
identifier = shelf["identifier"] if isinstance(shelf, dict) else shelf.identifier
if identifier == "all":
return _("All books")
if identifier == "to-read":
return _("To Read")
if identifier == "reading":
return _("Currently Reading")
if identifier == "read":
return _("Read")
return shelf["name"] if isinstance(shelf, dict) else shelf.name
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def active_shelf(context, book): def active_shelf(context, book):
"""check what shelf a user has a book on, if any""" """check what shelf a user has a book on, if any"""

View file

@ -83,6 +83,7 @@ class Book(View):
} }
if request.user.is_authenticated: if request.user.is_authenticated:
data["list_options"] = request.user.list_set.exclude(id__in=data["lists"])
data["file_link_form"] = forms.FileLinkForm() data["file_link_form"] = forms.FileLinkForm()
readthroughs = models.ReadThrough.objects.filter( readthroughs = models.ReadThrough.objects.filter(
user=request.user, user=request.user,

View file

@ -1,11 +1,10 @@
""" book list views""" """ book list views"""
from typing import Optional from typing import Optional
from urllib.parse import urlencode
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import IntegrityError, transaction from django.db import transaction
from django.db.models import Avg, DecimalField, Q, Max from django.db.models import Avg, DecimalField, Q, Max
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.http import HttpResponseBadRequest, HttpResponse from django.http import HttpResponseBadRequest, HttpResponse
@ -26,7 +25,7 @@ from bookwyrm.views.helpers import is_api_request
class List(View): class List(View):
"""book list page""" """book list page"""
def get(self, request, list_id): def get(self, request, list_id, add_failed=False, add_succeeded=False):
"""display a book list""" """display a book list"""
book_list = get_object_or_404(models.List, id=list_id) book_list = get_object_or_404(models.List, id=list_id)
book_list.raise_visible_to_user(request.user) book_list.raise_visible_to_user(request.user)
@ -37,33 +36,10 @@ class List(View):
query = request.GET.get("q") query = request.GET.get("q")
suggestions = None suggestions = None
# sort_by shall be "order" unless a valid alternative is given items = book_list.listitem_set.filter(approved=True).prefetch_related(
sort_by = request.GET.get("sort_by", "order") "user", "book", "book__authors"
if sort_by not in ("order", "title", "rating"): )
sort_by = "order" items = sort_list(request, items)
# direction shall be "ascending" unless a valid alternative is given
direction = request.GET.get("direction", "ascending")
if direction not in ("ascending", "descending"):
direction = "ascending"
directional_sort_by = {
"order": "order",
"title": "book__title",
"rating": "average_rating",
}[sort_by]
if direction == "descending":
directional_sort_by = "-" + directional_sort_by
items = book_list.listitem_set.prefetch_related("user", "book", "book__authors")
if sort_by == "rating":
items = items.annotate(
average_rating=Avg(
Coalesce("book__review__rating", 0.0),
output_field=DecimalField(),
)
)
items = items.filter(approved=True).order_by(directional_sort_by)
paginated = Paginator(items, PAGE_LENGTH) paginated = Paginator(items, PAGE_LENGTH)
@ -106,10 +82,10 @@ class List(View):
"suggested_books": suggestions, "suggested_books": suggestions,
"list_form": forms.ListForm(instance=book_list), "list_form": forms.ListForm(instance=book_list),
"query": query or "", "query": query or "",
"sort_form": forms.SortListForm( "sort_form": forms.SortListForm(request.GET),
{"direction": direction, "sort_by": sort_by}
),
"embed_url": embed_url, "embed_url": embed_url,
"add_failed": add_failed,
"add_succeeded": add_succeeded,
} }
return TemplateResponse(request, "lists/list.html", data) return TemplateResponse(request, "lists/list.html", data)
@ -131,6 +107,36 @@ class List(View):
return redirect(book_list.local_path) return redirect(book_list.local_path)
def sort_list(request, items):
"""helper to handle the surprisngly involved sorting"""
# sort_by shall be "order" unless a valid alternative is given
sort_by = request.GET.get("sort_by", "order")
if sort_by not in ("order", "title", "rating"):
sort_by = "order"
# direction shall be "ascending" unless a valid alternative is given
direction = request.GET.get("direction", "ascending")
if direction not in ("ascending", "descending"):
direction = "ascending"
directional_sort_by = {
"order": "order",
"title": "book__title",
"rating": "average_rating",
}[sort_by]
if direction == "descending":
directional_sort_by = "-" + directional_sort_by
if sort_by == "rating":
items = items.annotate(
average_rating=Avg(
Coalesce("book__review__rating", 0.0),
output_field=DecimalField(),
)
)
return items.order_by(directional_sort_by)
@require_POST @require_POST
@login_required @login_required
def save_list(request, list_id): def save_list(request, list_id):
@ -179,8 +185,8 @@ def add_book(request):
form = forms.ListItemForm(request.POST) form = forms.ListItemForm(request.POST)
if not form.is_valid(): if not form.is_valid():
# this shouldn't happen, there aren't validated fields return List().get(request, book_list.id, add_failed=True)
raise Exception(form.errors)
item = form.save(commit=False) item = form.save(commit=False)
if book_list.curation == "curated": if book_list.curation == "curated":
@ -196,17 +202,9 @@ def add_book(request):
) or 0 ) or 0
increment_order_in_reverse(book_list.id, order_max + 1) increment_order_in_reverse(book_list.id, order_max + 1)
item.order = order_max + 1 item.order = order_max + 1
item.save()
try: return List().get(request, book_list.id, add_succeeded=True)
item.save()
except IntegrityError:
# if the book is already on the list, don't flip out
pass
path = reverse("list", args=[book_list.id])
params = request.GET.copy()
params["updated"] = True
return redirect(f"{path}?{urlencode(params)}")
@require_POST @require_POST