diff --git a/bookmarks/migrations/0040_userprofile_items_per_page_and_more.py b/bookmarks/migrations/0040_userprofile_items_per_page_and_more.py new file mode 100644 index 0000000..0b11247 --- /dev/null +++ b/bookmarks/migrations/0040_userprofile_items_per_page_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.8 on 2024-09-18 20:11 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookmarks", "0039_globalsettings_enable_link_prefetch"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="items_per_page", + field=models.IntegerField( + default=30, validators=[django.core.validators.MinValueValidator(10)] + ), + ), + migrations.AddField( + model_name="userprofile", + name="sticky_pagination", + field=models.BooleanField(default=False), + ), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index 6a4d112..7d8bc5a 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -1,12 +1,13 @@ +import binascii import logging import os from typing import List -import binascii from django import forms from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import User +from django.core.validators import MinValueValidator from django.db import models from django.db.models.signals import post_save, post_delete from django.dispatch import receiver @@ -422,6 +423,10 @@ class UserProfile(models.Model): search_preferences = models.JSONField(default=dict, null=False) enable_automatic_html_snapshots = models.BooleanField(default=True, null=False) default_mark_unread = models.BooleanField(default=False, null=False) + items_per_page = models.IntegerField( + null=False, default=30, validators=[MinValueValidator(10)] + ) + sticky_pagination = models.BooleanField(default=False, null=False) class UserProfileForm(forms.ModelForm): @@ -450,6 +455,8 @@ class UserProfileForm(forms.ModelForm): "default_mark_unread", "custom_css", "auto_tagging_rules", + "items_per_page", + "sticky_pagination", ] diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css index 42a823a..8adc88d 100644 --- a/bookmarks/styles/bookmark-page.css +++ b/bookmarks/styles/bookmark-page.css @@ -300,6 +300,28 @@ li[ld-bookmark-item] { & .page-item:first-child a { padding-left: 0; } + + &.sticky { + position: sticky; + bottom: 0; + border-top: solid 1px var(--secondary-border-color); + background: var(--body-color); + padding-bottom: var(--unit-h); + + &:before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: calc(-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))); + width: calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)); + background: var(--body-color); + } + } + + & .pagination { + overflow: hidden; + } } .tag-cloud { @@ -379,6 +401,7 @@ ul.bookmark-list { } /* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */ + &.active section:first-of-type .content-area-header { border-bottom-color: transparent; } @@ -389,6 +412,19 @@ ul.bookmark-list { overflow: visible; } + /* make sticky pagination expand to cover checkboxes to the left */ + + &.active .bookmark-pagination.sticky:before { + content: ''; + position: absolute; + top: -1px; + bottom: 0; + left: calc(-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))); + width: calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)); + background: var(--body-color); + border-top: solid 1px var(--secondary-border-color); + } + /* All checkbox */ & .form-checkbox.bulk-edit-checkbox.all { diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html index def80ae..d68bdc1 100644 --- a/bookmarks/templates/bookmarks/bookmark_list.html +++ b/bookmarks/templates/bookmarks/bookmark_list.html @@ -149,7 +149,7 @@ {% endfor %} -
+
{% pagination bookmark_list.bookmarks_page %}
{% endif %} diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html index ec01aea..0cd051d 100644 --- a/bookmarks/templates/settings/general.html +++ b/bookmarks/templates/settings/general.html @@ -101,6 +101,29 @@ Whether to open bookmarks a new page or in the same page.
+
+ + {{ form.items_per_page|add_class:"form-input width-25 width-sm-100"|attr:"min:10" }} + {% if form.items_per_page.errors %} +
+ {{ form.items_per_page.errors }} +
+ {% else %} + {% endif %} +
+ The number of bookmarks to display per page. +
+
+
+ +
+ When enabled, the pagination controls will stick to the bottom of the screen, so that they are always + visible without having to scroll to the end of the page first. +
+
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }} diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py index 8a6f5d0..4cc6bdc 100644 --- a/bookmarks/tests/test_bookmarks_list_template.py +++ b/bookmarks/tests/test_bookmarks_list_template.py @@ -955,3 +955,37 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.assertInHTML( '

You have no bookmarks yet

', html ) + + def test_pagination_is_not_sticky_by_default(self): + self.setup_bookmark() + html = self.render_template() + + self.assertIn('
', html) + + def test_pagination_is_sticky_when_enabled_in_profile(self): + self.setup_bookmark() + profile = self.get_or_create_test_user().profile + profile.sticky_pagination = True + profile.save() + html = self.render_template() + + self.assertIn('
', html) + + def test_items_per_page_is_30_by_default(self): + self.setup_numbered_bookmarks(50) + html = self.render_template() + + soup = self.make_soup(html) + bookmarks = soup.select("li[ld-bookmark-item]") + self.assertEqual(30, len(bookmarks)) + + def test_items_per_page_is_configurable(self): + self.setup_numbered_bookmarks(50) + profile = self.get_or_create_test_user().profile + profile.items_per_page = 10 + profile.save() + html = self.render_template() + + soup = self.make_soup(html) + bookmarks = soup.select("li[ld-bookmark-item]") + self.assertEqual(10, len(bookmarks)) diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py index c9c2bd9..3aaab13 100644 --- a/bookmarks/tests/test_settings_general_view.py +++ b/bookmarks/tests/test_settings_general_view.py @@ -43,6 +43,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin): "permanent_notes": False, "custom_css": "", "auto_tagging_rules": "", + "items_per_page": "30", + "sticky_pagination": False, } return {**form_data, **overrides} @@ -111,6 +113,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin): "default_mark_unread": True, "custom_css": "body { background-color: #000; }", "auto_tagging_rules": "example.com tag", + "items_per_page": "10", + "sticky_pagination": True, } response = self.client.post( reverse("bookmarks:settings.update"), form_data, follow=True @@ -182,6 +186,13 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin): self.assertEqual( self.user.profile.auto_tagging_rules, form_data["auto_tagging_rules"] ) + self.assertEqual( + self.user.profile.items_per_page, int(form_data["items_per_page"]) + ) + self.assertEqual( + self.user.profile.sticky_pagination, form_data["sticky_pagination"] + ) + self.assertSuccessMessage(html, "Profile updated") def test_update_profile_should_not_be_called_without_respective_form_action(self): diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py index 730741b..f8abdc9 100644 --- a/bookmarks/views/bookmarks.py +++ b/bookmarks/views/bookmarks.py @@ -38,8 +38,6 @@ from bookmarks.services.bookmarks import ( from bookmarks.utils import get_safe_return_url from bookmarks.views import contexts, partials, turbo -_default_page_size = 30 - @login_required def index(request): diff --git a/bookmarks/views/contexts.py b/bookmarks/views/contexts.py index 8593451..7b50e17 100644 --- a/bookmarks/views/contexts.py +++ b/bookmarks/views/contexts.py @@ -21,7 +21,6 @@ from bookmarks.models import ( ) from bookmarks.services.wayback import generate_fallback_webarchive_url -DEFAULT_PAGE_SIZE = 30 CJK_RE = re.compile(r"[\u4e00-\u9fff]+") @@ -181,7 +180,7 @@ class BookmarkListContext: query_set = request_context.get_bookmark_query_set(self.search) page_number = request.GET.get("page") - paginator = Paginator(query_set, DEFAULT_PAGE_SIZE) + paginator = Paginator(query_set, user_profile.items_per_page) bookmarks_page = paginator.get_page(page_number) # Prefetch related objects, this avoids n+1 queries when accessing fields in templates models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags") diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py index 3566d85..2f5f88e 100644 --- a/bookmarks/views/settings.py +++ b/bookmarks/views/settings.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) @login_required -def general(request): +def general(request, status=200, context_overrides=None): enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS success_message = _find_message_with_tag( @@ -44,6 +44,9 @@ def general(request): if request.user.is_superuser: global_settings_form = GlobalSettingsForm(instance=GlobalSettings.get()) + if context_overrides is None: + context_overrides = {} + return render( request, "settings/general.html", @@ -55,7 +58,9 @@ def general(request): "success_message": success_message, "error_message": error_message, "version_info": version_info, + **context_overrides, }, + status=status, ) @@ -63,8 +68,7 @@ def general(request): def update(request): if request.method == "POST": if "update_profile" in request.POST: - update_profile(request) - messages.success(request, "Profile updated", "settings_success_message") + return update_profile(request) if "update_global_settings" in request.POST: update_global_settings(request) messages.success( @@ -101,13 +105,22 @@ def update_profile(request): form = UserProfileForm(request.POST, instance=profile) if form.is_valid(): form.save() + messages.success(request, "Profile updated", "settings_success_message") # Load missing favicons if the feature was just enabled if profile.enable_favicons and not favicons_were_enabled: tasks.schedule_bookmarks_without_favicons(request.user) # Load missing preview images if the feature was just enabled if profile.enable_preview_images and not previews_were_enabled: tasks.schedule_bookmarks_without_previews(request.user) - return form + + return HttpResponseRedirect(reverse("bookmarks:settings.general")) + + messages.error( + request, + "Profile update failed, check the form below for errors", + "settings_error_message", + ) + return general(request, 422, {"form": form}) def update_global_settings(request):