Repository: sissbruecker/linkding

Issue:
Configure bookmarks per page
Is it anyhow possible to adjust the number of links being shown per page? My list of tags is so long, that i could easily get more links listed.

please see [my screenshot](https://github.com/sissbruecker/linkding/assets/97055/2faffe71-6eec-4940-87f8-e181ea031eae) for what i mean.
Feature: Allow change using LD_* pagination size
**After using this app for nearly 6 months, my only regret is not finding it sooner... thank you for such a great tool!**

Currently, I have around 3,000 bookmarks, and for more complex searches, I find myself relying heavily on pagination. However, I’ve noticed that navigating between pages could be more efficient if the pagination controls were available at the top of the page as well. This would save time, especially since the height of each page varies due to the varying content in link descriptions.

Additionally, it would be very useful to have the option to adjust the number of items displayed per page. Personally, I would prefer to limit the number of items to avoid unnecessary scrolling.


Discussion:
As far as I can tell it's hard-coded here:

https://github.com/sissbruecker/linkding/blob/e83d519cab9b4ace997d9cdc385a1ff133e8be2f/bookmarks/views/partials/contexts.py#L13
Is this something that might get merged in the near future? It seems like krzysztofjeziorny has resolved the issue and it just needs a style check and merge?
> As far as I can tell it's hard-coded here:
> 
> https://github.com/sissbruecker/linkding/blob/e83d519cab9b4ace997d9cdc385a1ff133e8be2f/bookmarks/views/partials/contexts.py#L13

can confirm: I changed the value of DEFAULT_PAGE_SIZE from 30 to 10, restarted the container and I now have 10 bookmarks per page. 

of course: the next time I upgrade (that won't happen anytime soon: I just went from 1.8.4 to 1.31.0), or even just reinstall, I need to do that again, but for now I can live with that, and I have it documented anyway





Patch:
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 00000000..0b112474
--- /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 6a4d112b..7d8bc5a2 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 Meta:
             "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 42a823a4..8adc88d7 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 def80ae6..d68bdc11 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -149,7 +149,7 @@
     {% endfor %}
   </ul>
 
-  <div class="bookmark-pagination">
+  <div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
     {% pagination bookmark_list.bookmarks_page %}
   </div>
 {% endif %}
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index ec01aea8..0cd051de 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -101,6 +101,29 @@ <h2>Profile</h2>
             Whether to open bookmarks a new page or in the same page.
           </div>
         </div>
+        <div class="form-group{% if form.items_per_page.errors %} has-error{% endif %}">
+          <label for="{{ form.items_per_page.id_for_label }}" class="form-label">Items per page</label>
+          {{ form.items_per_page|add_class:"form-input width-25 width-sm-100"|attr:"min:10" }}
+          {% if form.items_per_page.errors %}
+            <div class="form-input-hint is-error">
+              {{ form.items_per_page.errors }}
+            </div>
+          {% else %}
+          {% endif %}
+          <div class="form-input-hint">
+            The number of bookmarks to display per page.
+          </div>
+        </div>
+        <div class="form-group">
+          <label for="{{ form.sticky_pagination.id_for_label }}" class="form-checkbox">
+            {{ form.sticky_pagination }}
+            <i class="form-icon"></i> Sticky pagination
+          </label>
+          <div class="form-input-hint">
+            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.
+          </div>
+        </div>
         <div class="form-group">
           <label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
           {{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index 730741bd..f8abdc9c 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -38,8 +38,6 @@
 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 85934510..7b50e17a 100644
--- a/bookmarks/views/contexts.py
+++ b/bookmarks/views/contexts.py
@@ -21,7 +21,6 @@
 )
 from bookmarks.services.wayback import generate_fallback_webarchive_url
 
-DEFAULT_PAGE_SIZE = 30
 CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
 
 
@@ -181,7 +180,7 @@ def __init__(self, request: WSGIRequest) -> None:
 
         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 3566d85f..2f5f88e9 100644
--- a/bookmarks/views/settings.py
+++ b/bookmarks/views/settings.py
@@ -28,7 +28,7 @@
 
 
 @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):


Test patch:
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index 8a6f5d0c..4cc6bdcb 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -955,3 +955,37 @@ def test_empty_state(self):
         self.assertInHTML(
             '<p class="empty-title h5">You have no bookmarks yet</p>', html
         )
+
+    def test_pagination_is_not_sticky_by_default(self):
+        self.setup_bookmark()
+        html = self.render_template()
+
+        self.assertIn('<div class="bookmark-pagination">', 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('<div class="bookmark-pagination sticky">', 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 c9c2bd9a..3aaab133 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -43,6 +43,8 @@ def create_profile_form_data(self, overrides=None):
             "permanent_notes": False,
             "custom_css": "",
             "auto_tagging_rules": "",
+            "items_per_page": "30",
+            "sticky_pagination": False,
         }
 
         return {**form_data, **overrides}
@@ -111,6 +113,8 @@ def test_update_profile(self):
             "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 @@ def test_update_profile(self):
         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):