Repository: sissbruecker/linkding
Pull request #1079 (2025-05-28 21:27:06)

Add CloudBreak on Managed Hosting


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2afff10..e2952f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,26 @@
 # Changelog
 
+## v1.41.0 (19/06/2025)
+
+### What's Changed
+* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097
+* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100
+* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080
+* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089
+* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098
+* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088
+* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084
+* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086
+* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090
+* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096
+
+### New Contributors
+* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088
+
+**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0
+
+---
+
 ## v1.40.0 (17/05/2025)
 
 ### What's Changed
diff --git a/bookmarks/admin.py b/bookmarks/admin.py
index ca6423a..13da9f9 100644
--- a/bookmarks/admin.py
+++ b/bookmarks/admin.py
@@ -11,7 +11,15 @@ from huey.contrib.djhuey import HUEY as huey
 from rest_framework.authtoken.admin import TokenAdmin
 from rest_framework.authtoken.models import TokenProxy
 
-from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
+from bookmarks.models import (
+    Bookmark,
+    BookmarkAsset,
+    BookmarkBundle,
+    Tag,
+    UserProfile,
+    Toast,
+    FeedToken,
+)
 from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
 
 
@@ -256,6 +264,21 @@ class AdminTag(admin.ModelAdmin):
             )
 
 
+class AdminBookmarkBundle(admin.ModelAdmin):
+    list_display = (
+        "name",
+        "owner",
+        "order",
+        "search",
+        "any_tags",
+        "all_tags",
+        "excluded_tags",
+        "date_created",
+    )
+    search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
+    list_filter = ("owner__username",)
+
+
 class AdminUserProfileInline(admin.StackedInline):
     model = UserProfile
     can_delete = False
@@ -289,6 +312,7 @@ linkding_admin_site = LinkdingAdminSite()
 linkding_admin_site.register(Bookmark, AdminBookmark)
 linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
 linkding_admin_site.register(Tag, AdminTag)
+linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
 linkding_admin_site.register(User, AdminCustomUser)
 linkding_admin_site.register(TokenProxy, TokenAdmin)
 linkding_admin_site.register(Toast, AdminToast)
diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py
index eac5b5d..dd87363 100644
--- a/bookmarks/api/routes.py
+++ b/bookmarks/api/routes.py
@@ -16,9 +16,17 @@ from bookmarks.api.serializers import (
     BookmarkAssetSerializer,
     TagSerializer,
     UserProfileSerializer,
+    BookmarkBundleSerializer,
 )
-from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
-from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
+from bookmarks.models import (
+    Bookmark,
+    BookmarkAsset,
+    BookmarkSearch,
+    Tag,
+    User,
+    BookmarkBundle,
+)
+from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
 from bookmarks.type_defs import HttpRequest
 from bookmarks.views import access
 
@@ -50,7 +58,7 @@ class BookmarkViewSet(
     def get_queryset(self):
         # Provide filtered queryset for list actions
         user = self.request.user
-        search = BookmarkSearch.from_request(self.request.GET)
+        search = BookmarkSearch.from_request(self.request, self.request.GET)
         if self.action == "list":
             return queries.query_bookmarks(user, user.profile, search)
         elif self.action == "archived":
@@ -264,6 +272,28 @@ class UserViewSet(viewsets.GenericViewSet):
         return Response(UserProfileSerializer(request.user.profile).data)
 
 
+class BookmarkBundleViewSet(
+    viewsets.GenericViewSet,
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.CreateModelMixin,
+    mixins.UpdateModelMixin,
+    mixins.DestroyModelMixin,
+):
+    request: HttpRequest
+    serializer_class = BookmarkBundleSerializer
+
+    def get_queryset(self):
+        user = self.request.user
+        return BookmarkBundle.objects.filter(owner=user).order_by("order")
+
+    def get_serializer_context(self):
+        return {"user": self.request.user}
+
+    def perform_destroy(self, instance):
+        bundles.delete_bundle(instance)
+
+
 # DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
 # Instead create separate routers for each view set and manually register them in urls.py
 # The default router is only used to allow reversing a URL for the API root
@@ -278,5 +308,8 @@ tag_router.register("", TagViewSet, basename="tag")
 user_router = SimpleRouter()
 user_router.register("", UserViewSet, basename="user")
 
+bundle_router = SimpleRouter()
+bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
+
 bookmark_asset_router = SimpleRouter()
 bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
diff --git a/bookmarks/api/serializers.py b/bookmarks/api/serializers.py
index 6abedcc..ecc9b03 100644
--- a/bookmarks/api/serializers.py
+++ b/bookmarks/api/serializers.py
@@ -1,10 +1,17 @@
-from django.db.models import prefetch_related_objects
+from django.db.models import Max, prefetch_related_objects
 from django.templatetags.static import static
 from rest_framework import serializers
 from rest_framework.serializers import ListSerializer
 
-from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
-from bookmarks.services import bookmarks
+from bookmarks.models import (
+    Bookmark,
+    BookmarkAsset,
+    Tag,
+    build_tag_string,
+    UserProfile,
+    BookmarkBundle,
+)
+from bookmarks.services import bookmarks, bundles
 from bookmarks.services.tags import get_or_create_tag
 from bookmarks.services.wayback import generate_fallback_webarchive_url
 from bookmarks.utils import app_version
@@ -27,6 +34,32 @@ class EmtpyField(serializers.ReadOnlyField):
         return None
 
 
+class BookmarkBundleSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = BookmarkBundle
+        fields = [
+            "id",
+            "name",
+            "search",
+            "any_tags",
+            "all_tags",
+            "excluded_tags",
+            "order",
+            "date_created",
+            "date_modified",
+        ]
+        read_only_fields = [
+            "id",
+            "date_created",
+            "date_modified",
+        ]
+
+    def create(self, validated_data):
+        bundle = BookmarkBundle(**validated_data)
+        bundle.order = validated_data["order"] if "order" in validated_data else None
+        return bundles.create_bundle(bundle, self.context["user"])
+
+
 class BookmarkSerializer(serializers.ModelSerializer):
     class Meta:
         model = Bookmark
diff --git a/bookmarks/forms.py b/bookmarks/forms.py
index bdd15c0..5aa3efc 100644
--- a/bookmarks/forms.py
+++ b/bookmarks/forms.py
@@ -1,4 +1,5 @@
 from django import forms
+from django.forms.utils import ErrorList
 
 from bookmarks.models import Bookmark, build_tag_string
 from bookmarks.validators import BookmarkURLValidator
@@ -6,6 +7,10 @@ from bookmarks.type_defs import HttpRequest
 from bookmarks.services.bookmarks import create_bookmark, update_bookmark
 
 
+class CustomErrorList(ErrorList):
+    template_name = "shared/error_list.html"
+
+
 class BookmarkForm(forms.ModelForm):
     # Use URLField for URL
     url = forms.CharField(validators=[BookmarkURLValidator()])
@@ -48,7 +53,9 @@ class BookmarkForm(forms.ModelForm):
         if instance is not None and request.method == "GET":
             initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
         data = request.POST if request.method == "POST" else None
-        super().__init__(data, instance=instance, initial=initial)
+        super().__init__(
+            data, instance=instance, initial=initial, error_class=CustomErrorList
+        )
 
     @property
     def is_auto_close(self):
diff --git a/bookmarks/frontend/behaviors/tag-autocomplete.js b/bookmarks/frontend/behaviors/tag-autocomplete.js
index 7221582..1e0c245 100644
--- a/bookmarks/frontend/behaviors/tag-autocomplete.js
+++ b/bookmarks/frontend/behaviors/tag-autocomplete.js
@@ -19,6 +19,7 @@ class TagAutocomplete extends Behavior {
         name: input.name,
         value: input.value,
         placeholder: input.getAttribute("placeholder") || "",
+        ariaDescribedBy: input.getAttribute("aria-describedby") || "",
         variant: input.getAttribute("variant"),
       },
     });
diff --git a/bookmarks/frontend/components/TagAutocomplete.svelte b/bookmarks/frontend/components/TagAutocomplete.svelte
index 09553b4..7a7b95d 100644
--- a/bookmarks/frontend/components/TagAutocomplete.svelte
+++ b/bookmarks/frontend/components/TagAutocomplete.svelte
@@ -6,6 +6,7 @@
   export let name;
   export let value;
   export let placeholder;
+  export let ariaDescribedBy;
   export let variant = 'default';
 
   let isFocus = false;
@@ -77,6 +78,7 @@
     const bounds = getCurrentWordBounds(input);
     const value = input.value;
     input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
+    input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
 
     close();
   }
@@ -109,6 +111,7 @@
     <!-- autocomplete real input box -->
     <input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
            class="form-input" type="text" autocomplete="off" autocapitalize="off"
+           aria-describedby="{ariaDescribedBy}"
            on:input={handleInput} on:keydown={handleKeyDown}
            on:focus={handleFocus} on:blur={handleBlur}>
   </div>
@@ -128,41 +131,41 @@
 </div>
 
 <style>
-    .menu {
-        display: none;
-        max-height: 200px;
-        overflow: auto;
-    }
+  .menu {
+    display: none;
+    max-height: 200px;
+    overflow: auto;
+  }
 
-    .menu.open {
-        display: block;
-    }
+  .menu.open {
+    display: block;
+  }
 
-    .form-autocomplete-input {
-        box-sizing: border-box;
-        height: var(--control-size);
-        min-height: var(--control-size);
-        padding: 0;
-    }
+  .form-autocomplete-input {
+    box-sizing: border-box;
+    height: var(--control-size);
+    min-height: var(--control-size);
+    padding: 0;
+  }
 
-    .form-autocomplete-input input {
-        width: 100%;
-        height: 100%;
-        border: none;
-        margin: 0;
-    }
+  .form-autocomplete-input input {
+    width: 100%;
+    height: 100%;
+    border: none;
+    margin: 0;
+  }
 
-    .form-autocomplete.small .form-autocomplete-input {
-        height: var(--control-size-sm);
-        min-height: var(--control-size-sm);
-    }
+  .form-autocomplete.small .form-autocomplete-input {
+    height: var(--control-size-sm);
+    min-height: var(--control-size-sm);
+  }
 
-    .form-autocomplete.small .form-autocomplete-input input {
-        padding: 0.05rem 0.3rem;
-        font-size: var(--font-size-sm);
-    }
+  .form-autocomplete.small .form-autocomplete-input input {
+    padding: 0.05rem 0.3rem;
+    font-size: var(--font-size-sm);
+  }
 
-    .form-autocomplete.small .menu .menu-item {
-        font-size: var(--font-size-sm);
-    }
+  .form-autocomplete.small .menu .menu-item {
+    font-size: var(--font-size-sm);
+  }
 </style>
diff --git a/bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py b/bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py
new file mode 100644
index 0000000..7be682f
--- /dev/null
+++ b/bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py
@@ -0,0 +1,50 @@
+# Generated by Django 5.1.9 on 2025-06-19 08:48
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0044_bookmark_latest_snapshot"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="userprofile",
+            name="hide_bundles",
+            field=models.BooleanField(default=False),
+        ),
+        migrations.CreateModel(
+            name="BookmarkBundle",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=256)),
+                ("search", models.CharField(blank=True, max_length=256)),
+                ("any_tags", models.CharField(blank=True, max_length=1024)),
+                ("all_tags", models.CharField(blank=True, max_length=1024)),
+                ("excluded_tags", models.CharField(blank=True, max_length=1024)),
+                ("order", models.IntegerField(default=0)),
+                ("date_created", models.DateTimeField(auto_now_add=True)),
+                ("date_modified", models.DateTimeField(auto_now=True)),
+                (
+                    "owner",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index f92475f..375c0cd 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -2,6 +2,7 @@ import binascii
 import hashlib
 import logging
 import os
+from functools import cached_property
 from typing import List
 
 from django import forms
@@ -157,6 +158,27 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
                 logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
 
 
+class BookmarkBundle(models.Model):
+    name = models.CharField(max_length=256, blank=False)
+    search = models.CharField(max_length=256, blank=True)
+    any_tags = models.CharField(max_length=1024, blank=True)
+    all_tags = models.CharField(max_length=1024, blank=True)
+    excluded_tags = models.CharField(max_length=1024, blank=True)
+    order = models.IntegerField(null=False, default=0)
+    date_created = models.DateTimeField(auto_now_add=True, null=False)
+    date_modified = models.DateTimeField(auto_now=True, null=False)
+    owner = models.ForeignKey(User, on_delete=models.CASCADE)
+
+    def __str__(self):
+        return self.name
+
+
+class BookmarkBundleForm(forms.ModelForm):
+    class Meta:
+        model = BookmarkBundle
+        fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
+
+
 class BookmarkSearch:
     SORT_ADDED_ASC = "added_asc"
     SORT_ADDED_DESC = "added_desc"
@@ -171,34 +193,54 @@ class BookmarkSearch:
     FILTER_UNREAD_YES = "yes"
     FILTER_UNREAD_NO = "no"
 
-    params = ["q", "user", "sort", "shared", "unread"]
+    params = [
+        "q",
+        "user",
+        "bundle",
+        "sort",
+        "shared",
+        "unread",
+        "modified_since",
+        "added_since",
+    ]
     preferences = ["sort", "shared", "unread"]
     defaults = {
         "q": "",
         "user": "",
+        "bundle": None,
         "sort": SORT_ADDED_DESC,
         "shared": FILTER_SHARED_OFF,
         "unread": FILTER_UNREAD_OFF,
+        "modified_since": None,
+        "added_since": None,
     }
 
     def __init__(
         self,
         q: str = None,
         user: str = None,
+        bundle: BookmarkBundle = None,
         sort: str = None,
         shared: str = None,
         unread: str = None,
+        modified_since: str = None,
+        added_since: str = None,
         preferences: dict = None,
+        request: any = None,
     ):
         if not preferences:
             preferences = {}
         self.defaults = {**BookmarkSearch.defaults, **preferences}
+        self.request = request
 
         self.q = q or self.defaults["q"]
         self.user = user or self.defaults["user"]
+        self.bundle = bundle or self.defaults["bundle"]
         self.sort = sort or self.defaults["sort"]
         self.shared = shared or self.defaults["shared"]
         self.unread = unread or self.defaults["unread"]
+        self.modified_since = modified_since or self.defaults["modified_since"]
+        self.added_since = added_since or self.defaults["added_since"]
 
     def is_modified(self, param):
         value = self.__dict__[param]
@@ -226,7 +268,14 @@ class BookmarkSearch:
 
     @property
     def query_params(self):
-        return {param: self.__dict__[param] for param in self.modified_params}
+        query_params = {}
+        for param in self.modified_params:
+            value = self.__dict__[param]
+            if isinstance(value, models.Model):
+                query_params[param] = value.id
+            else:
+                query_params[param] = value
+        return query_params
 
     @property
     def preferences_dict(self):
@@ -235,14 +284,21 @@ class BookmarkSearch:
         }
 
     @staticmethod
-    def from_request(query_dict: QueryDict, preferences: dict = None):
+    def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
         initial_values = {}
         for param in BookmarkSearch.params:
             value = query_dict.get(param)
             if value:
-                initial_values[param] = value
+                if param == "bundle":
+                    initial_values[param] = BookmarkBundle.objects.filter(
+                        owner=request.user, pk=value
+                    ).first()
+                else:
+                    initial_values[param] = value
 
-        return BookmarkSearch(**initial_values, preferences=preferences)
+        return BookmarkSearch(
+            **initial_values, preferences=preferences, request=request
+        )
 
 
 class BookmarkSearchForm(forms.Form):
@@ -265,9 +321,12 @@ class BookmarkSearchForm(forms.Form):
 
     q = forms.CharField()
     user = forms.ChoiceField(required=False)
+    bundle = forms.CharField(required=False)
     sort = forms.ChoiceField(choices=SORT_CHOICES)
     shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
     unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
+    modified_since = forms.CharField(required=False)
+    added_since = forms.CharField(required=False)
 
     def __init__(
         self,
@@ -287,7 +346,11 @@ class BookmarkSearchForm(forms.Form):
 
         for param in search.params:
             # set initial values for modified params
-            self.fields[param].initial = search.__dict__[param]
+            value = search.__dict__.get(param)
+            if isinstance(value, models.Model):
+                self.fields[param].initial = value.id
+            else:
+                self.fields[param].initial = value
 
             # Mark non-editable modified fields as hidden. That way, templates
             # rendering a form can just loop over hidden_fields to ensure that
@@ -408,6 +471,7 @@ class UserProfile(models.Model):
     )
     sticky_pagination = models.BooleanField(default=False, null=False)
     collapse_side_panel = models.BooleanField(default=False, null=False)
+    hide_bundles = models.BooleanField(default=False, null=False)
 
     def save(self, *args, **kwargs):
         if self.custom_css:
@@ -448,6 +512,7 @@ class UserProfileForm(forms.ModelForm):
             "items_per_page",
             "sticky_pagination",
             "collapse_side_panel",
+            "hide_bundles",
         ]
 
 
diff --git a/bookmarks/queries.py b/bookmarks/queries.py
index 8fd6a08..5337b0b 100644
--- a/bookmarks/queries.py
+++ b/bookmarks/queries.py
@@ -2,16 +2,26 @@ from typing import Optional
 
 from django.conf import settings
 from django.contrib.auth.models import User
+from django.core.exceptions import ValidationError
 from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
 from django.db.models.expressions import RawSQL
 from django.db.models.functions import Lower
 
-from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
+from bookmarks.models import (
+    Bookmark,
+    BookmarkBundle,
+    BookmarkSearch,
+    Tag,
+    UserProfile,
+    parse_tag_string,
+)
 from bookmarks.utils import unique
 
 
 def query_bookmarks(
-    user: User, profile: UserProfile, search: BookmarkSearch
+    user: User,
+    profile: UserProfile,
+    search: BookmarkSearch,
 ) -> QuerySet:
     return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
 
@@ -35,8 +45,51 @@ def query_shared_bookmarks(
     return _base_bookmarks_query(user, profile, search).filter(conditions)
 
 
+def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
+    # Search terms
+    search_terms = parse_query_string(bundle.search)["search_terms"]
+    for term in search_terms:
+        conditions = (
+            Q(title__icontains=term)
+            | Q(description__icontains=term)
+            | Q(notes__icontains=term)
+            | Q(url__icontains=term)
+        )
+        query_set = query_set.filter(conditions)
+
+    # Any tags - at least one tag must match
+    any_tags = parse_tag_string(bundle.any_tags, " ")
+    if len(any_tags) > 0:
+        tag_conditions = Q()
+        for tag in any_tags:
+            tag_conditions |= Q(tags__name__iexact=tag)
+
+        query_set = query_set.filter(
+            Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
+        )
+
+    # All tags - all tags must match
+    all_tags = parse_tag_string(bundle.all_tags, " ")
+    for tag in all_tags:
+        query_set = query_set.filter(tags__name__iexact=tag)
+
+    # Excluded tags - no tags must match
+    exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
+    if len(exclude_tags) > 0:
+        tag_conditions = Q()
+        for tag in exclude_tags:
+            tag_conditions |= Q(tags__name__iexact=tag)
+        query_set = query_set.exclude(
+            Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
+        )
+
+    return query_set
+
+
 def _base_bookmarks_query(
-    user: Optional[User], profile: UserProfile, search: BookmarkSearch
+    user: Optional[User],
+    profile: UserProfile,
+    search: BookmarkSearch,
 ) -> QuerySet:
     query_set = Bookmark.objects
 
@@ -44,6 +97,22 @@ def _base_bookmarks_query(
     if user:
         query_set = query_set.filter(owner=user)
 
+    # Filter by modified_since if provided
+    if search.modified_since:
+        try:
+            query_set = query_set.filter(date_modified__gt=search.modified_since)
+        except ValidationError:
+            # If the date format is invalid, ignore the filter
+            pass
+
+    # Filter by added_since if provided
+    if search.added_since:
+        try:
+            query_set = query_set.filter(date_added__gt=search.added_since)
+        except ValidationError:
+            # If the date format is invalid, ignore the filter
+            pass
+
     # Split query into search terms and tags
     query = parse_query_string(search.q)
 
@@ -85,6 +154,10 @@ def _base_bookmarks_query(
     elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
         query_set = query_set.filter(shared=False)
 
+    # Filter by bundle
+    if search.bundle:
+        query_set = _filter_bundle(query_set, search.bundle)
+
     # Sort
     if (
         search.sort == BookmarkSearch.SORT_TITLE_ASC
diff --git a/bookmarks/services/assets.py b/bookmarks/services/assets.py
index 4409c61..ac5d843 100644
--- a/bookmarks/services/assets.py
+++ b/bookmarks/services/assets.py
@@ -53,6 +53,7 @@ def create_snapshot(asset: BookmarkAsset):
         asset.save()
 
         asset.bookmark.latest_snapshot = asset
+        asset.bookmark.date_modified = timezone.now()
         asset.bookmark.save()
     except Exception as error:
         asset.status = BookmarkAsset.STATUS_FAILURE
@@ -75,6 +76,7 @@ def upload_snapshot(bookmark: Bookmark, html: bytes):
     asset.save()
 
     asset.bookmark.latest_snapshot = asset
+    asset.bookmark.date_modified = timezone.now()
     asset.bookmark.save()
 
     return asset
@@ -92,14 +94,33 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
             gzip=False,
         )
         name, extension = os.path.splitext(upload_file.name)
-        filename = _generate_asset_filename(asset, name, extension.lstrip("."))
-        filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
-        with open(filepath, "wb") as f:
-            for chunk in upload_file.chunks():
-                f.write(chunk)
-        asset.file = filename
-        asset.file_size = upload_file.size
+
+        # automatically gzip the file if it is not already gzipped
+        if upload_file.content_type != "application/gzip":
+            filename = _generate_asset_filename(
+                asset, name, extension.lstrip(".") + ".gz"
+            )
+            filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
+            with gzip.open(filepath, "wb", compresslevel=9) as f:
+                for chunk in upload_file.chunks():
+                    f.write(chunk)
+            asset.gzip = True
+            asset.file = filename
+            asset.file_size = os.path.getsize(filepath)
+        else:
+            filename = _generate_asset_filename(asset, name, extension.lstrip("."))
+            filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
+            with open(filepath, "wb") as f:
+                for chunk in upload_file.chunks():
+                    f.write(chunk)
+            asset.file = filename
+            asset.file_size = upload_file.size
+
         asset.save()
+
+        asset.bookmark.date_modified = timezone.now()
+        asset.bookmark.save()
+
         logger.info(
             f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
         )
@@ -128,9 +149,10 @@ def remove_asset(asset: BookmarkAsset):
         )
 
         bookmark.latest_snapshot = latest
-        bookmark.save()
 
     asset.delete()
+    bookmark.date_modified = timezone.now()
+    bookmark.save()
 
 
 def _generate_asset_filename(
diff --git a/bookmarks/services/bundles.py b/bookmarks/services/bundles.py
new file mode 100644
index 0000000..402723e
--- /dev/null
+++ b/bookmarks/services/bundles.py
@@ -0,0 +1,37 @@
+from django.db.models import Max
+
+from bookmarks.models import BookmarkBundle, User
+
+
+def create_bundle(bundle: BookmarkBundle, current_user: User):
+    bundle.owner = current_user
+    if bundle.order is None:
+        max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(
+            Max("order", default=-1)
+        )
+        bundle.order = max_order_result["order__max"] + 1
+    bundle.save()
+    return bundle
+
+
+def move_bundle(bundle_to_move: BookmarkBundle, new_order: int):
+    user_bundles = list(
+        BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by("order")
+    )
+
+    if new_order != user_bundles.index(bundle_to_move):
+        user_bundles.remove(bundle_to_move)
+        user_bundles.insert(new_order, bundle_to_move)
+        for bundle_index, bundle in enumerate(user_bundles):
+            bundle.order = bundle_index
+
+        BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
+
+
+def delete_bundle(bundle: BookmarkBundle):
+    bundle.delete()
+
+    user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by("order")
+    for index, user_bundle in enumerate(user_bundles):
+        user_bundle.order = index
+    BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
diff --git a/bookmarks/settings/base.py b/bookmarks/settings/base.py
index 559ef8d..563337d 100644
--- a/bookmarks/settings/base.py
+++ b/bookmarks/settings/base.py
@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
 
 USE_I18N = True
 
-USE_L10N = True
-
 USE_TZ = True
 
 # Static files (CSS, JavaScript, Images)
diff --git a/bookmarks/styles/bookmark-details.css b/bookmarks/styles/bookmark-details.css
index 5aa0215..0dad87d 100644
--- a/bookmarks/styles/bookmark-details.css
+++ b/bookmarks/styles/bookmark-details.css
@@ -49,50 +49,9 @@
   & .assets {
     margin-top: var(--unit-2);
 
-    & .asset {
-      display: flex;
-      align-items: center;
-      gap: var(--unit-2);
-      padding: var(--unit-2) 0;
-      border-top: var(--unit-o) solid var(--secondary-border-color);
-    }
-
-    & .asset:last-child {
-      border-bottom: var(--unit-o) solid var(--secondary-border-color);
-    }
-
-    & .asset-icon {
-      display: flex;
-      align-items: center;
-      justify-content: center;
-    }
-
-    & .asset-text {
-      flex: 1 1 0;
-      gap: var(--unit-2);
-      min-width: 0;
-      display: flex;
-    }
-
-    & .asset-text .truncate {
-      flex-shrink: 1;
-    }
-
-    & .asset-text .filesize {
+    & .filesize {
       color: var(--tertiary-text-color);
     }
-
-    & .asset-actions {
-      display: flex;
-      gap: var(--unit-4);
-      align-items: center;
-
-      & .btn.btn-link {
-        height: unset;
-        padding: 0;
-        border: none;
-      }
-    }
   }
 
   & .assets-actions {
diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css
index f3f5d7d..e5e332a 100644
--- a/bookmarks/styles/bookmark-page.css
+++ b/bookmarks/styles/bookmark-page.css
@@ -379,6 +379,26 @@ li[ld-bookmark-item] {
   }
 }
 
+.bundle-menu {
+  list-style-type: none;
+  margin: 0 0 var(--unit-6);
+
+  .bundle-menu-item {
+    margin: 0;
+    margin-bottom: var(--unit-2);
+  }
+
+  .bundle-menu-item a {
+    padding: var(--unit-1) var(--unit-2);
+    border-radius: var(--border-radius);
+  }
+
+  .bundle-menu-item.selected a {
+    background: var(--primary-color);
+    color: var(--contrast-text-color);
+  }
+}
+
 .tag-cloud {
   /* Increase line-height for better separation within / between items */
   line-height: 1.1rem;
diff --git a/bookmarks/styles/bundles.css b/bookmarks/styles/bundles.css
new file mode 100644
index 0000000..97c24bc
--- /dev/null
+++ b/bookmarks/styles/bundles.css
@@ -0,0 +1,34 @@
+.bundles-page {
+  h1 {
+    font-size: var(--font-size-lg);
+    margin-bottom: var(--unit-6);
+  }
+
+  .item-list {
+    .list-item .list-item-icon {
+      cursor: grab;
+    }
+
+    .list-item.drag-start {
+      --secondary-border-color: transparent;
+    }
+
+    .list-item.dragging > * {
+      visibility: hidden;
+    }
+  }
+}
+
+.bundles-editor-page {
+  &.grid {
+    gap: var(--unit-9);
+  }
+
+  .form-footer {
+    position: sticky;
+    bottom: 0;
+    border-top: solid 1px var(--secondary-border-color);
+    background: var(--body-color);
+    padding: var(--unit-3) 0;
+  }
+}
diff --git a/bookmarks/styles/components.css b/bookmarks/styles/components.css
index 9f4c021..3904a37 100644
--- a/bookmarks/styles/components.css
+++ b/bookmarks/styles/components.css
@@ -60,3 +60,60 @@ span.confirmation {
 .turbo-progress-bar {
   background-color: var(--primary-color);
 }
+
+/* Messages */
+.message-list {
+  margin: var(--unit-4) 0;
+
+  .toast {
+    margin-bottom: var(--unit-2);
+  }
+
+  .toast a.btn-clear:visited {
+    color: currentColor;
+  }
+}
+
+/* Item list */
+.item-list {
+  & .list-item {
+    display: flex;
+    align-items: center;
+    gap: var(--unit-2);
+    padding: var(--unit-2) 0;
+    border-top: var(--unit-o) solid var(--secondary-border-color);
+  }
+
+  & .list-item:last-child {
+    border-bottom: var(--unit-o) solid var(--secondary-border-color);
+  }
+
+  & .list-item-icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  & .list-item-text {
+    flex: 1 1 0;
+    gap: var(--unit-2);
+    min-width: 0;
+    display: flex;
+  }
+
+  & .list-item-text .truncate {
+    flex-shrink: 1;
+  }
+
+  & .list-item-actions {
+    display: flex;
+    gap: var(--unit-4);
+    align-items: center;
+
+    & .btn.btn-link {
+      height: unset;
+      padding: 0;
+      border: none;
+    }
+  }
+}
diff --git a/bookmarks/styles/layout.css b/bookmarks/styles/layout.css
index bc951d1..4e02435 100644
--- a/bookmarks/styles/layout.css
+++ b/bookmarks/styles/layout.css
@@ -27,15 +27,3 @@ header {
     line-height: 1.2;
   }
 }
-
-header .toasts {
-  margin-bottom: 20px;
-
-  .toast {
-    margin-bottom: 0.4rem;
-  }
-
-  .toast a.btn-clear:visited {
-    color: currentColor;
-  }
-}
diff --git a/bookmarks/styles/theme-light.css b/bookmarks/styles/theme-light.css
index 57a5cd9..ac68d39 100644
--- a/bookmarks/styles/theme-light.css
+++ b/bookmarks/styles/theme-light.css
@@ -28,3 +28,4 @@
 @import "markdown.css";
 @import "reader-mode.css";
 @import "settings.css";
+@import "bundles.css";
diff --git a/bookmarks/styles/theme/forms.css b/bookmarks/styles/theme/forms.css
index 0a5b6ae..6be8773 100644
--- a/bookmarks/styles/theme/forms.css
+++ b/bookmarks/styles/theme/forms.css
@@ -224,12 +224,13 @@ textarea.form-input {
   position: relative;
 
   input {
-    clip: rect(0, 0, 0, 0);
-    height: 1px;
-    margin: -1px;
-    overflow: hidden;
+    opacity: 0;
     position: absolute;
-    width: 1px;
+    top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
+    left: 0;
+    height: var(--control-icon-size);
+    width: var(--control-icon-size);
+    cursor: pointer;
 
     &:focus-visible + .form-icon {
       outline: var(--focus-outline);
@@ -243,9 +244,9 @@ textarea.form-input {
   }
 
   .form-icon {
+    pointer-events: none;
     border: var(--border-width) solid var(--checkbox-border-color);
     box-shadow: var(--input-box-shadow);
-    cursor: pointer;
     display: inline-block;
     position: absolute;
     transition:
diff --git a/bookmarks/styles/theme/utilities.css b/bookmarks/styles/theme/utilities.css
index bbdd0d2..91437f4 100644
--- a/bookmarks/styles/theme/utilities.css
+++ b/bookmarks/styles/theme/utilities.css
@@ -242,6 +242,14 @@
   margin-top: var(--unit-4) !important;
 }
 
+.ml-auto {
+  margin-left: auto;
+}
+
+.mr-auto {
+  margin-right: auto;
+}
+
 .mx-auto {
   margin-left: auto;
   margin-right: auto;
diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html
index cdf7afd..5e699d5 100644
--- a/bookmarks/templates/bookmarks/archive.html
+++ b/bookmarks/templates/bookmarks/archive.html
@@ -30,16 +30,10 @@
       </form>
     </main>
 
-    {# Tag cloud #}
-    <div class="side-panel col-1">
-      <section aria-labelledby="tags-heading">
-        <div class="section-header">
-          <h2 id="tags-heading">Tags</h2>
-        </div>
-        <div id="tag-cloud-container">
-          {% include 'bookmarks/tag_cloud.html' %}
-        </div>
-      </section>
+    {# Filters #}
+    <div class="side-panel col-1 hide-md">
+      {% include 'bookmarks/bundle_section.html' %}
+      {% include 'bookmarks/tag_section.html' %}
     </div>
   </div>
 {% endblock %}
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index d23e649..2e38354 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -77,72 +77,76 @@
                 {% else %}
                   <span>{{ bookmark_item.display_date }}</span>
                 {% endif %}
-                <span>|</span>
-              {% endif %}
-              {# View link is visible for both owned and shared bookmarks #}
-              {% if bookmark_list.show_view_action %}
-                <a href="{{ bookmark_item.details_url }}" class="view-action"
-                   data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
-              {% endif %}
-              {% if bookmark_item.is_editable %}
-                {# Bookmark owner actions #}
-                {% if bookmark_list.show_edit_action %}
-                  <a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
-                {% endif %}
-                {% if bookmark_list.show_archive_action %}
-                  {% if bookmark_item.is_archived %}
-                    <button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
-                            class="btn btn-link btn-sm">Unarchive
-                    </button>
-                  {% else %}
-                    <button type="submit" name="archive" value="{{ bookmark_item.id }}"
-                            class="btn btn-link btn-sm">Archive
-                    </button>
-                  {% endif %}
+                {% if not bookmark_list.is_preview %}
+                  <span>|</span>
                 {% endif %}
-                {% if bookmark_list.show_remove_action %}
-                  <button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
-                          class="btn btn-link btn-sm">Remove
-                  </button>
-                {% endif %}
-              {% else %}
-                {# Shared bookmark actions #}
-                <span>Shared by
-                <a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
-              </span>
               {% endif %}
-              {% if bookmark_item.has_extra_actions %}
-                <div class="extra-actions">
-                  <span class="hide-sm">|</span>
-                  {% if bookmark_item.show_mark_as_read %}
-                    <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
-                            class="btn btn-link btn-sm btn-icon"
-                            ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
-                      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
-                        <use xlink:href="#ld-icon-unread"></use>
-                      </svg>
-                      Unread
-                    </button>
+              {% if not bookmark_list.is_preview %}
+                {# View link is visible for both owned and shared bookmarks #}
+                {% if bookmark_list.show_view_action %}
+                  <a href="{{ bookmark_item.details_url }}" class="view-action"
+                    data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
+                {% endif %}
+                {% if bookmark_item.is_editable %}
+                  {# Bookmark owner actions #}
+                  {% if bookmark_list.show_edit_action %}
+                    <a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
                   {% endif %}
-                  {% if bookmark_item.show_unshare %}
-                    <button type="submit" name="unshare" value="{{ bookmark_item.id }}"
-                            class="btn btn-link btn-sm btn-icon"
-                            ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
-                      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
-                        <use xlink:href="#ld-icon-share"></use>
-                      </svg>
-                      Shared
-                    </button>
+                  {% if bookmark_list.show_archive_action %}
+                    {% if bookmark_item.is_archived %}
+                      <button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
+                              class="btn btn-link btn-sm">Unarchive
+                      </button>
+                    {% else %}
+                      <button type="submit" name="archive" value="{{ bookmark_item.id }}"
+                              class="btn btn-link btn-sm">Archive
+                      </button>
+                    {% endif %}
                   {% endif %}
-                  {% if bookmark_item.show_notes_button %}
-                    <button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
-                      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
-                        <use xlink:href="#ld-icon-note"></use>
-                      </svg>
-                      Notes
+                  {% if bookmark_list.show_remove_action %}
+                    <button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
+                            class="btn btn-link btn-sm">Remove
                     </button>
                   {% endif %}
-                </div>
+                {% else %}
+                  {# Shared bookmark actions #}
+                  <span>Shared by
+                  <a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
+                </span>
+                {% endif %}
+                {% if bookmark_item.has_extra_actions %}
+                  <div class="extra-actions">
+                    <span class="hide-sm">|</span>
+                    {% if bookmark_item.show_mark_as_read %}
+                      <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
+                              class="btn btn-link btn-sm btn-icon"
+                              ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
+                        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+                          <use xlink:href="#ld-icon-unread"></use>
+                        </svg>
+                        Unread
+                      </button>
+                    {% endif %}
+                    {% if bookmark_item.show_unshare %}
+                      <button type="submit" name="unshare" value="{{ bookmark_item.id }}"
+                              class="btn btn-link btn-sm btn-icon"
+                              ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
+                        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+                          <use xlink:href="#ld-icon-share"></use>
+                        </svg>
+                        Shared
+                      </button>
+                    {% endif %}
+                    {% if bookmark_item.show_notes_button %}
+                      <button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
+                        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+                          <use xlink:href="#ld-icon-note"></use>
+                        </svg>
+                        Notes
+                      </button>
+                    {% endif %}
+                  </div>
+                {% endif %}
               {% endif %}
             </div>
           </div>
diff --git a/bookmarks/templates/bookmarks/bundle_section.html b/bookmarks/templates/bookmarks/bundle_section.html
new file mode 100644
index 0000000..bd707f4
--- /dev/null
+++ b/bookmarks/templates/bookmarks/bundle_section.html
@@ -0,0 +1,23 @@
+{% if not request.user_profile.hide_bundles %}
+  <section aria-labelledby="bundles-heading">
+    <div class="section-header">
+      <h2 id="bundles-heading">Bundles</h2>
+      <a href="{% url 'linkding:bundles.index' %}" class="btn ml-auto" aria-label="Manage bundles">
+        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
+             stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
+          <path
+              d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
+          <path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>
+        </svg>
+      </a>
+    </div>
+    <ul class="bundle-menu">
+      {% for bundle in bundles.bundles %}
+        <li class="bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}">
+          <a href="?bundle={{ bundle.id }}">{{ bundle.name }}</a>
+        </li>
+      {% endfor %}
+    </ul>
+  </section>
+{% endif %}
diff --git a/bookmarks/templates/bookmarks/details/assets.html b/bookmarks/templates/bookmarks/details/assets.html
index a6a120a..3212e49 100644
--- a/bookmarks/templates/bookmarks/details/assets.html
+++ b/bookmarks/templates/bookmarks/details/assets.html
@@ -1,12 +1,12 @@
 <div>
   {% if details.assets %}
-    <div class="assets">
+    <div class="item-list assets">
       {% for asset in details.assets %}
-        <div class="asset" data-asset-id="{{ asset.id }}">
-          <div class="asset-icon {{ asset.icon_classes }}">
+        <div class="list-item" data-asset-id="{{ asset.id }}">
+          <div class="list-item-icon {{ asset.icon_classes }}">
             {% include 'bookmarks/details/asset_icon.html' %}
           </div>
-          <div class="asset-text {{ asset.text_classes }}">
+          <div class="list-item-text {{ asset.text_classes }}">
           <span class="truncate">
             {{ asset.display_name }}
             {% if asset.status == 'pending' %}(queued){% endif %}
@@ -16,7 +16,7 @@
               <span class="filesize">{{ asset.file_size|filesizeformat }}</span>
             {% endif %}
           </div>
-          <div class="asset-actions">
+          <div class="list-item-actions">
             {% if asset.file %}
               <a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
             {% endif %}
diff --git a/bookmarks/templates/bookmarks/form.html b/bookmarks/templates/bookmarks/form.html
index 309dfc5..542ea59 100644
--- a/bookmarks/templates/bookmarks/form.html
+++ b/bookmarks/templates/bookmarks/form.html
@@ -1,5 +1,6 @@
 {% load widget_tweaks %}
 {% load static %}
+{% load shared %}
 
 <div class="bookmarks-form">
   {% csrf_token %}
@@ -7,7 +8,7 @@
   <div class="form-group {% if form.url.errors %}has-error{% endif %}">
     <label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
     <div class="has-icon-right">
-      {{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
+      {{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }}
       <i class="form-icon loading"></i>
     </div>
     {% if form.url.errors %}
@@ -22,8 +23,8 @@
   </div>
   <div class="form-group" ld-tag-autocomplete>
     <label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
-    {{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
-    <div class="form-input-hint">
+    {{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
+    <div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
       Enter any number of tags separated by space and <strong>without</strong> the hash (#).
       If a tag does not exist it will be automatically created.
     </div>
@@ -35,7 +36,8 @@
       <label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
       <div class="flex">
         <button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
-        <button ld-clear-button data-for="{{ form.title.id_for_label }}" class="ml-2 btn btn-link suffix-button clear-button"
+        <button ld-clear-button data-for="{{ form.title.id_for_label }}"
+                class="ml-2 btn btn-link suffix-button clear-button"
                 type="button">Clear
         </button>
       </div>
@@ -60,31 +62,31 @@
         <span class="form-label d-inline-block">Notes</span>
       </summary>
       <label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
-      {{ form.notes|add_class:"form-input"|attr:"rows:8" }}
-      <div class="form-input-hint">
+      {{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
+      <div id="{{ form.notes.auto_id }}_help" class="form-input-hint">
         Additional notes, supports Markdown.
       </div>
     </details>
     {{ form.notes.errors }}
   </div>
   <div class="form-group">
-    <label for="{{ form.unread.id_for_label }}" class="form-checkbox">
-      {{ form.unread }}
+    <div class="form-checkbox">
+      {{ form.unread|form_field:"help" }}
       <i class="form-icon"></i>
-      <span>Mark as unread</span>
-    </label>
-    <div class="form-input-hint">
+      <label for="{{ form.unread.id_for_label }}">Mark as unread</label>
+    </div>
+    <div id="{{ form.unread.auto_id }}_help" class="form-input-hint">
       Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
     </div>
   </div>
   {% if request.user_profile.enable_sharing %}
     <div class="form-group">
-      <label for="{{ form.shared.id_for_label }}" class="form-checkbox">
-        {{ form.shared }}
+      <div class="form-checkbox">
+        {{ form.shared|form_field:"help" }}
         <i class="form-icon"></i>
-        <span>Share</span>
-      </label>
-      <div class="form-input-hint">
+        <label for="{{ form.shared.id_for_label }}">Share</label>
+      </div>
+      <div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
         {% if request.user_profile.enable_public_sharing %}
           Share this bookmark with other registered users and anonymous users.
         {% else %}
@@ -100,7 +102,7 @@
     {% else %}
       <input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
     {% endif %}
-    <a href="{{ return_url }}" class="btn">Nevermind</a>
+    <a href="{{ return_url }}" class="btn">Cancel</a>
   </div>
   <script type="application/javascript">
     /**
@@ -227,6 +229,7 @@
             }
           });
       }
+
       refreshButton.addEventListener('click', refreshMetadata);
 
       // Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
diff --git a/bookmarks/templates/bookmarks/index.html b/bookmarks/templates/bookmarks/index.html
index 93c4f7d..c8cbd16 100644
--- a/bookmarks/templates/bookmarks/index.html
+++ b/bookmarks/templates/bookmarks/index.html
@@ -32,16 +32,10 @@
       </form>
     </main>
 
-    {# Tag cloud #}
-    <div class="side-panel col-1">
-      <section aria-labelledby="tags-heading">
-        <div class="section-header">
-          <h2 id="tags-heading">Tags</h2>
-        </div>
-        <div id="tag-cloud-container">
-          {% include 'bookmarks/tag_cloud.html' %}
-        </div>
-      </section>
+    {# Filters #}
+    <div class="side-panel col-1 hide-md">
+      {% include 'bookmarks/bundle_section.html' %}
+      {% include 'bookmarks/tag_section.html' %}
     </div>
   </div>
 {% endblock %}
diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html
index 9508cf6..41ff112 100644
--- a/bookmarks/templates/bookmarks/layout.html
+++ b/bookmarks/templates/bookmarks/layout.html
@@ -67,7 +67,7 @@
 
 <header class="container">
   {% if has_toasts %}
-    <div class="toasts">
+    <div class="message-list">
       <form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
         {% csrf_token %}
         {% for toast in toast_messages %}
diff --git a/bookmarks/templates/bookmarks/pagination.html b/bookmarks/templates/bookmarks/pagination.html
index 62a6394..6af81cb 100644
--- a/bookmarks/templates/bookmarks/pagination.html
+++ b/bookmarks/templates/bookmarks/pagination.html
@@ -3,7 +3,7 @@
 <ul class="pagination">
   {% if prev_link %}
     <li class="page-item">
-      <a href="?{{ prev_link }}" tabindex="-1">Previous</a>
+      <a href="{{ prev_link }}" tabindex="-1">Previous</a>
     </li>
   {% else %}
     <li class="page-item disabled">
@@ -14,7 +14,7 @@
   {% for page_link in page_links %}
     {% if page_link %}
       <li class="page-item {% if page_link.active %}active{% endif %}">
-        <a href="?{{ page_link.link }}">{{ page_link.number }}</a>
+        <a href="{{ page_link.link }}">{{ page_link.number }}</a>
       </li>
     {% else %}
       <li class="page-item">
@@ -25,7 +25,7 @@
 
   {% if next_link %}
     <li class="page-item">
-      <a href="?{{ next_link }}" tabindex="-1">Next</a>
+      <a href="{{ next_link }}" tabindex="-1">Next</a>
     </li>
   {% else %}
     <li class="page-item disabled">
diff --git a/bookmarks/templates/bookmarks/read.html b/bookmarks/templates/bookmarks/read.html
index 441f517..329dbbc 100644
--- a/bookmarks/templates/bookmarks/read.html
+++ b/bookmarks/templates/bookmarks/read.html
@@ -21,6 +21,9 @@
     <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
     <meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
   {% endif %}
+  {% if request.user_profile.custom_css %}
+    <link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
+  {% endif %}
 </head>
 <body>
 <template id="content">{{ content|safe }}</template>
diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html
index b60e159..c1d7764 100644
--- a/bookmarks/templates/bookmarks/shared.html
+++ b/bookmarks/templates/bookmarks/shared.html
@@ -28,7 +28,7 @@
     </main>
 
     {# Filters #}
-    <div class="side-panel col-1">
+    <div class="side-panel col-1 hide-md">
       <section aria-labelledby="user-heading">
         <div class="section-header">
           <h2 id="user-heading">User</h2>
@@ -38,14 +38,7 @@
           <br>
         </div>
       </section>
-      <section aria-labelledby="tags-heading">
-        <div class="section-header">
-          <h2 id="tags-heading">Tags</h2>
-        </div>
-        <div id="tag-cloud-container">
-          {% include 'bookmarks/tag_cloud.html' %}
-        </div>
-      </section>
+      {% include 'bookmarks/tag_section.html' %}
     </div>
   </div>
 {% endblock %}
diff --git a/bookmarks/templates/bookmarks/tag_section.html b/bookmarks/templates/bookmarks/tag_section.html
new file mode 100644
index 0000000..78558e0
--- /dev/null
+++ b/bookmarks/templates/bookmarks/tag_section.html
@@ -0,0 +1,8 @@
+<section aria-labelledby="tags-heading">
+  <div class="section-header">
+    <h2 id="tags-heading">Tags</h2>
+  </div>
+  <div id="tag-cloud-container">
+    {% include 'bookmarks/tag_cloud.html' %}
+  </div>
+</section>
diff --git a/bookmarks/templates/bundles/edit.html b/bookmarks/templates/bundles/edit.html
new file mode 100644
index 0000000..a67f855
--- /dev/null
+++ b/bookmarks/templates/bundles/edit.html
@@ -0,0 +1,33 @@
+{% extends 'bookmarks/layout.html' %}
+{% load widget_tweaks %}
+
+{% block head %}
+  {% with page_title="Edit bundle - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <div class="bundles-editor-page grid columns-md-1">
+    <main aria-labelledby="main-heading">
+      <div class="section-header">
+        <h1 id="main-heading">Edit bundle</h1>
+      </div>
+
+      {% include 'shared/messages.html' %}
+
+      <form id="bundle-form" action="{% url 'linkding:bundles.edit' bundle.id %}" method="post" novalidate>
+        {% csrf_token %}
+        {% include 'bundles/form.html' %}
+      </form>
+    </main>
+
+    <aside class="col-2" aria-labelledby="preview-heading">
+      <div class="section-header">
+        <h2 id="preview-heading">Preview</h2>
+      </div>
+
+      {% include 'bundles/preview.html' %}
+    </aside>
+  </div>
+{% endblock %}
diff --git a/bookmarks/templates/bundles/form.html b/bookmarks/templates/bundles/form.html
new file mode 100644
index 0000000..bb119cd
--- /dev/null
+++ b/bookmarks/templates/bundles/form.html
@@ -0,0 +1,91 @@
+{% load widget_tweaks %}
+
+<div class="form-group {% if form.name.errors %}has-error{% endif %}">
+  <label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
+  {{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
+  {% if form.name.errors %}
+    <div class="form-input-hint">
+      {{ form.name.errors }}
+    </div>
+  {% endif %}
+</div>
+
+<div class="form-group {% if form.search.errors %}has-error{% endif %}">
+  <label for="{{ form.search.id_for_label }}" class="form-label">Search</label>
+  {{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
+  {% if form.search.errors %}
+    <div class="form-input-hint">
+      {{ form.search.errors }}
+    </div>
+  {% endif %}
+  <div class="form-input-hint">
+    Search terms to match bookmarks in this bundle.
+  </div>
+</div>
+
+<div class="form-group" ld-tag-autocomplete>
+  <label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
+  {{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
+  <div class="form-input-hint">
+    At least one of these tags must be present in a bookmark to match.
+  </div>
+</div>
+
+<div class="form-group" ld-tag-autocomplete>
+  <label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
+  {{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
+  <div class="form-input-hint">
+    All of these tags must be present in a bookmark to match.
+  </div>
+</div>
+
+<div class="form-group" ld-tag-autocomplete>
+  <label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
+  {{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
+  <div class="form-input-hint">
+    None of these tags must be present in a bookmark to match.
+  </div>
+</div>
+
+<div class="form-footer d-flex mt-4">
+  <input type="submit" name="save" value="Save" class="btn btn-primary btn-wide">
+  <a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a>
+  <a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a>
+</div>
+
+<script>
+  (function init() {
+    const bundleForm = document.getElementById('bundle-form');
+    const previewLink = document.getElementById('preview-link');
+
+    let pendingUpdate;
+
+    function scheduleUpdate() {
+      if (pendingUpdate) {
+        clearTimeout(pendingUpdate);
+      }
+      pendingUpdate = setTimeout(() => {
+        // Ignore if link has been removed (e.g. form submit or navigation)
+        if (!previewLink.isConnected) {
+          return;
+        }
+
+        const baseUrl = previewLink.href.split('?')[0];
+        const params = new URLSearchParams();
+        const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
+
+        inputs.forEach(input => {
+          if (input.name && input.value.trim()) {
+            params.set(input.name, input.value.trim());
+          }
+        });
+
+        previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
+        previewLink.click();
+      }, 500)
+    }
+
+    bundleForm.addEventListener('input', scheduleUpdate);
+    bundleForm.addEventListener('change', scheduleUpdate);
+  })();
+</script>
diff --git a/bookmarks/templates/bundles/index.html b/bookmarks/templates/bundles/index.html
new file mode 100644
index 0000000..a8d1b5f
--- /dev/null
+++ b/bookmarks/templates/bundles/index.html
@@ -0,0 +1,124 @@
+{% extends "bookmarks/layout.html" %}
+
+{% block head %}
+  {% with page_title="Bundles - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <main class="bundles-page" aria-labelledby="main-heading">
+    <h1 id="main-heading">Bundles</h1>
+
+    {% include 'shared/messages.html' %}
+
+    {% if bundles %}
+      <form action="{% url 'linkding:bundles.action' %}" method="post">
+        {% csrf_token %}
+        <div class="item-list bundles">
+          {% for bundle in bundles %}
+            <div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true">
+              <div class="list-item-icon text-secondary">
+                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
+                     stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
+                  <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                </svg>
+              </div>
+              <div class="list-item-text">
+                <span class="truncate">{{ bundle.name }}</span>
+              </div>
+              <div class="list-item-actions">
+                <a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
+                <button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
+                        class="btn btn-link">Remove
+                </button>
+              </div>
+            </div>
+          {% endfor %}
+        </div>
+        <input type="submit" name="move_bundle" value="" class="d-none">
+        <input type="hidden" name="move_position" value="">
+      </form>
+    {% else %}
+      <div class="empty">
+        <p class="empty-title h5">You have no bundles yet</p>
+        <p class="empty-subtitle">Create your first bundle to get started</p>
+      </div>
+    {% endif %}
+
+    <div class="mt-4">
+      <a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
+    </div>
+  </main>
+
+  <script>
+    (function init() {
+      const bundlesList = document.querySelector(".item-list.bundles");
+      if (!bundlesList) return;
+
+      let draggedElement = null;
+
+      const listItems = bundlesList.querySelectorAll('.list-item');
+      listItems.forEach((item) => {
+        item.addEventListener('dragstart', handleDragStart);
+        item.addEventListener('dragend', handleDragEnd);
+        item.addEventListener('dragover', handleDragOver);
+        item.addEventListener('dragenter', handleDragEnter);
+      });
+
+      function handleDragStart(e) {
+        draggedElement = this;
+
+        e.dataTransfer.effectAllowed = 'move';
+        e.dataTransfer.dropEffect = 'move';
+
+        this.classList.add('drag-start');
+        setTimeout(() => {
+          this.classList.remove('drag-start');
+          this.classList.add('dragging');
+        }, 0);
+      }
+
+      function handleDragEnd() {
+        this.classList.remove('dragging');
+
+        const moveBundleInput = document.querySelector('input[name="move_bundle"]');
+        const movePositionInput = document.querySelector('input[name="move_position"]');
+        moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
+        movePositionInput.value = Array.from(bundlesList.children).indexOf(draggedElement);
+
+        const form = this.closest('form');
+        form.requestSubmit(moveBundleInput);
+
+        draggedElement = null;
+      }
+
+      function handleDragOver(e) {
+        if (e.preventDefault) {
+          e.preventDefault();
+        }
+        return false;
+      }
+
+      function handleDragEnter() {
+        if (this !== draggedElement) {
+          const listItems = Array.from(bundlesList.children);
+          const draggedIndex = listItems.indexOf(draggedElement);
+          const currentIndex = listItems.indexOf(this);
+
+          if (draggedIndex < currentIndex) {
+            this.insertAdjacentElement('afterend', draggedElement);
+          } else {
+            this.insertAdjacentElement('beforebegin', draggedElement);
+          }
+        }
+      }
+    })();
+  </script>
+{% endblock %}
diff --git a/bookmarks/templates/bundles/new.html b/bookmarks/templates/bundles/new.html
new file mode 100644
index 0000000..a73b71c
--- /dev/null
+++ b/bookmarks/templates/bundles/new.html
@@ -0,0 +1,33 @@
+{% extends 'bookmarks/layout.html' %}
+{% load widget_tweaks %}
+
+{% block head %}
+  {% with page_title="New bundle - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <div class="bundles-editor-page grid columns-md-1">
+    <main aria-labelledby="main-heading">
+      <div class="section-header">
+        <h1 id="main-heading">New bundle</h1>
+      </div>
+
+      {% include 'shared/messages.html' %}
+
+      <form id="bundle-form" action="{% url 'linkding:bundles.new' %}" method="post" novalidate>
+        {% csrf_token %}
+        {% include 'bundles/form.html' %}
+      </form>
+    </main>
+
+    <aside class="col-2" aria-labelledby="preview-heading">
+      <div class="section-header">
+        <h2 id="preview-heading">Preview</h2>
+      </div>
+
+      {% include 'bundles/preview.html' %}
+    </aside>
+  </div>
+{% endblock %}
diff --git a/bookmarks/templates/bundles/preview.html b/bookmarks/templates/bundles/preview.html
new file mode 100644
index 0000000..a7973fb
--- /dev/null
+++ b/bookmarks/templates/bundles/preview.html
@@ -0,0 +1,12 @@
+<turbo-frame id="preview">
+  {% if bookmark_list.is_empty %}
+    <div>
+      No bookmarks match the current bundle.
+    </div>
+  {% else %}
+    <div class="mb-4">
+      Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.
+    </div>
+    {% include 'bookmarks/bookmark_list.html' %}
+  {% endif %}
+</turbo-frame>
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index c8cfc6d..16800bf 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -139,6 +139,15 @@
             Instead, the tags are shown in an expandable drawer.
           </div>
         </div>
+        <div class="form-group">
+          <label for="{{ form.hide_bundles.id_for_label }}" class="form-checkbox">
+            {{ form.hide_bundles }}
+            <i class="form-icon"></i> Hide bundles
+          </label>
+          <div class="form-input-hint">
+            Allows to hide the bundles in the side panel if you don't intend to use them.
+          </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/templates/shared/error_list.html b/bookmarks/templates/shared/error_list.html
new file mode 100644
index 0000000..13e75ac
--- /dev/null
+++ b/bookmarks/templates/shared/error_list.html
@@ -0,0 +1,6 @@
+{% load i18n %}
+
+{# Force rendering validation errors in English language to align with the rest of the app #}
+{% language 'en-us' %}
+{% if errors %}<ul class="{{ error_class }}"{% if errors.field_id %} id="{{ errors.field_id }}_error"{% endif %}>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
+{% endlanguage %}
\ No newline at end of file
diff --git a/bookmarks/templates/shared/messages.html b/bookmarks/templates/shared/messages.html
new file mode 100644
index 0000000..fce07fc
--- /dev/null
+++ b/bookmarks/templates/shared/messages.html
@@ -0,0 +1,9 @@
+{% if messages %}
+  <div class="message-list">
+    {% for message in messages %}
+      <div class="toast toast-{{ message.tags }}" role="alert">
+        {{ message }}
+      </div>
+    {% endfor %}
+  </div>
+{% endif %}
diff --git a/bookmarks/templatetags/pagination.py b/bookmarks/templatetags/pagination.py
index bb46e1e..9f0245e 100644
--- a/bookmarks/templatetags/pagination.py
+++ b/bookmarks/templatetags/pagination.py
@@ -13,18 +13,21 @@ register = template.Library()
     "bookmarks/pagination.html", name="pagination", takes_context=True
 )
 def pagination(context, page: Page):
+    request = context["request"]
+    base_url = request.build_absolute_uri(request.path)
+
     # remove page number and details from query parameters
-    query_params = context["request"].GET.copy()
+    query_params = request.GET.copy()
     query_params.pop("page", None)
     query_params.pop("details", None)
 
     prev_link = (
-        _generate_link(query_params, page.previous_page_number())
+        _generate_link(base_url, query_params, page.previous_page_number())
         if page.has_previous()
         else None
     )
     next_link = (
-        _generate_link(query_params, page.next_page_number())
+        _generate_link(base_url, query_params, page.next_page_number())
         if page.has_next()
         else None
     )
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
         if page_number == -1:
             page_links.append(None)
         else:
-            link = _generate_link(query_params, page_number)
+            link = _generate_link(base_url, query_params, page_number)
             page_links.append(
                 {
                     "active": page_number == page.number,
@@ -92,6 +95,7 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
     return reduce(append_page, visible_pages, [])
 
 
-def _generate_link(query_params: QueryDict, page_number: int) -> str:
+def _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:
+    query_params = query_params.copy()
     query_params["page"] = page_number
-    return query_params.urlencode()
+    return f"{base_url}?{query_params.urlencode()}"
diff --git a/bookmarks/templatetags/shared.py b/bookmarks/templatetags/shared.py
index 297c453..b067232 100644
--- a/bookmarks/templatetags/shared.py
+++ b/bookmarks/templatetags/shared.py
@@ -145,3 +145,30 @@ def render_markdown(context, markdown_text):
     linkified_html = bleach.linkify(sanitized_html)
 
     return mark_safe(linkified_html)
+
+
+def append_attr(widget, attr, value):
+    attrs = widget.attrs
+    if attrs.get(attr):
+        attrs[attr] += " " + value
+    else:
+        attrs[attr] = value
+
+
+@register.filter("form_field")
+def form_field(field, modifier_string):
+    modifiers = modifier_string.split(",")
+    has_errors = hasattr(field, "errors") and field.errors
+
+    if "validation" in modifiers and has_errors:
+        append_attr(field.field.widget, "aria-describedby", field.auto_id + "_error")
+    if "help" in modifiers:
+        append_attr(field.field.widget, "aria-describedby", field.auto_id + "_help")
+
+    # Some assistive technologies announce a field as invalid when it has the
+    # required attribute, even if the user has not interacted with the field
+    # yet. Set aria-invalid false to prevent this behavior.
+    if field.field.required and not has_errors:
+        append_attr(field.field.widget, "aria-invalid", "false")
+
+    return field
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index be0273a..338af5b 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -43,6 +43,12 @@ urlpatterns = [
         views.assets.read,
         name="assets.read",
     ),
+    # Bundles
+    path("bundles", views.bundles.index, name="bundles.index"),
+    path("bundles/action", views.bundles.action, name="bundles.action"),
+    path("bundles/new", views.bundles.new, name="bundles.new"),
+    path("bundles/<int:bundle_id>/edit", views.bundles.edit, name="bundles.edit"),
+    path("bundles/preview", views.bundles.preview, name="bundles.preview"),
     # Settings
     path("settings", views.settings.general, name="settings.index"),
     path("settings/general", views.settings.general, name="settings.general"),
@@ -64,6 +70,7 @@ urlpatterns = [
         include(api_routes.bookmark_asset_router.urls),
     ),
     path("api/tags/", include(api_routes.tag_router.urls)),
+    path("api/bundles/", include(api_routes.bundle_router.urls)),
     path("api/user/", include(api_routes.user_router.urls)),
     # Feeds
     path("feeds/<str:feed_key>/all", feeds.AllBookmarksFeed(), name="feeds.all"),
diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py
index 802057b..397cda5 100644
--- a/bookmarks/views/__init__.py
+++ b/bookmarks/views/__init__.py
@@ -1,6 +1,7 @@
 from .assets import *
 from .auth import *
 from .bookmarks import *
+from . import bundles
 from .settings import *
 from .toasts import *
 from .health import health
diff --git a/bookmarks/views/access.py b/bookmarks/views/access.py
index a22070e..0228d4a 100644
--- a/bookmarks/views/access.py
+++ b/bookmarks/views/access.py
@@ -1,6 +1,6 @@
 from django.http import Http404
 
-from bookmarks.models import Bookmark, BookmarkAsset, Toast
+from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Toast
 from bookmarks.type_defs import HttpRequest
 
 
@@ -32,6 +32,13 @@ def bookmark_write(request: HttpRequest, bookmark_id: int | str):
         raise Http404("Bookmark does not exist")
 
 
+def bundle_write(request: HttpRequest, bundle_id: int | str):
+    try:
+        return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user)
+    except BookmarkBundle.DoesNotExist:
+        raise Http404("Bundle does not exist")
+
+
 def asset_read(request: HttpRequest, asset_id: int | str):
     try:
         asset = BookmarkAsset.objects.get(pk=asset_id)
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index b19411f..7b4556c 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -42,8 +42,12 @@ def index(request: HttpRequest):
     if request.method == "POST":
         return search_action(request)
 
-    bookmark_list = contexts.ActiveBookmarkListContext(request)
-    tag_cloud = contexts.ActiveTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.ActiveBookmarkListContext(request, search)
+    bundles = contexts.BundlesContext(request)
+    tag_cloud = contexts.ActiveTagCloudContext(request, search)
     bookmark_details = contexts.get_details_context(
         request, contexts.ActiveBookmarkDetailsContext
     )
@@ -54,6 +58,7 @@ def index(request: HttpRequest):
         {
             "page_title": "Bookmarks - Linkding",
             "bookmark_list": bookmark_list,
+            "bundles": bundles,
             "tag_cloud": tag_cloud,
             "details": bookmark_details,
         },
@@ -65,8 +70,12 @@ def archived(request: HttpRequest):
     if request.method == "POST":
         return search_action(request)
 
-    bookmark_list = contexts.ArchivedBookmarkListContext(request)
-    tag_cloud = contexts.ArchivedTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
+    bundles = contexts.BundlesContext(request)
+    tag_cloud = contexts.ArchivedTagCloudContext(request, search)
     bookmark_details = contexts.get_details_context(
         request, contexts.ArchivedBookmarkDetailsContext
     )
@@ -77,6 +86,7 @@ def archived(request: HttpRequest):
         {
             "page_title": "Archived bookmarks - Linkding",
             "bookmark_list": bookmark_list,
+            "bundles": bundles,
             "tag_cloud": tag_cloud,
             "details": bookmark_details,
         },
@@ -87,8 +97,11 @@ def shared(request: HttpRequest):
     if request.method == "POST":
         return search_action(request)
 
-    bookmark_list = contexts.SharedBookmarkListContext(request)
-    tag_cloud = contexts.SharedTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.SharedBookmarkListContext(request, search)
+    tag_cloud = contexts.SharedTagCloudContext(request, search)
     bookmark_details = contexts.get_details_context(
         request, contexts.SharedBookmarkDetailsContext
     )
@@ -132,13 +145,13 @@ def search_action(request: HttpRequest):
     if "save" in request.POST:
         if not request.user.is_authenticated:
             return HttpResponseForbidden()
-        search = BookmarkSearch.from_request(request.POST)
+        search = BookmarkSearch.from_request(request, request.POST)
         request.user_profile.search_preferences = search.preferences_dict
         request.user_profile.save()
 
     # redirect to base url including new query params
     search = BookmarkSearch.from_request(
-        request.POST, request.user_profile.search_preferences
+        request, request.POST, request.user_profile.search_preferences
     )
     base_url = request.path
     query_params = search.query_params
@@ -248,7 +261,9 @@ def update_state(request: HttpRequest, bookmark_id: int | str):
 
 @login_required
 def index_action(request: HttpRequest):
-    search = BookmarkSearch.from_request(request.GET)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
     query = queries.query_bookmarks(request.user, request.user_profile, search)
 
     response = handle_action(request, query)
@@ -263,7 +278,9 @@ def index_action(request: HttpRequest):
 
 @login_required
 def archived_action(request: HttpRequest):
-    search = BookmarkSearch.from_request(request.GET)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
     query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
 
     response = handle_action(request, query)
diff --git a/bookmarks/views/bundles.py b/bookmarks/views/bundles.py
new file mode 100644
index 0000000..52ae2a4
--- /dev/null
+++ b/bookmarks/views/bundles.py
@@ -0,0 +1,97 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.http import HttpRequest, HttpResponseRedirect
+from django.shortcuts import render
+from django.urls import reverse
+
+from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch
+from bookmarks.services import bundles
+from bookmarks.views import access
+from bookmarks.views.contexts import ActiveBookmarkListContext
+
+
+@login_required
+def index(request: HttpRequest):
+    bundles = BookmarkBundle.objects.filter(owner=request.user).order_by("order")
+    context = {"bundles": bundles}
+    return render(request, "bundles/index.html", context)
+
+
+@login_required
+def action(request: HttpRequest):
+    if "remove_bundle" in request.POST:
+        remove_bundle_id = request.POST.get("remove_bundle")
+        bundle = access.bundle_write(request, remove_bundle_id)
+        bundle_name = bundle.name
+        bundles.delete_bundle(bundle)
+        messages.success(request, f"Bundle '{bundle_name}' removed successfully.")
+
+    elif "move_bundle" in request.POST:
+        bundle_id = request.POST.get("move_bundle")
+        bundle_to_move = access.bundle_write(request, bundle_id)
+        move_position = int(request.POST.get("move_position"))
+        bundles.move_bundle(bundle_to_move, move_position)
+
+    return HttpResponseRedirect(reverse("linkding:bundles.index"))
+
+
+def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None):
+    form_data = request.POST if request.method == "POST" else None
+    form = BookmarkBundleForm(form_data, instance=bundle)
+
+    if request.method == "POST":
+        if form.is_valid():
+            instance = form.save(commit=False)
+
+            if bundle is None:
+                instance.order = None
+                bundles.create_bundle(instance, request.user)
+            else:
+                instance.save()
+
+            messages.success(request, "Bundle saved successfully.")
+            return HttpResponseRedirect(reverse("linkding:bundles.index"))
+
+    status = 422 if request.method == "POST" and not form.is_valid() else 200
+    bookmark_list = _get_bookmark_list_preview(request, bundle)
+    context = {"form": form, "bundle": bundle, "bookmark_list": bookmark_list}
+
+    return render(request, template, context, status=status)
+
+
+@login_required
+def new(request: HttpRequest):
+    return _handle_edit(request, "bundles/new.html")
+
+
+@login_required
+def edit(request: HttpRequest, bundle_id: int):
+    bundle = access.bundle_write(request, bundle_id)
+
+    return _handle_edit(request, "bundles/edit.html", bundle)
+
+
+@login_required
+def preview(request: HttpRequest):
+    bookmark_list = _get_bookmark_list_preview(request)
+    context = {"bookmark_list": bookmark_list}
+    return render(request, "bundles/preview.html", context)
+
+
+def _get_bookmark_list_preview(
+    request: HttpRequest, bundle: BookmarkBundle | None = None
+):
+    if request.method == "GET" and bundle:
+        preview_bundle = bundle
+    else:
+        form_data = (
+            request.POST.copy() if request.method == "POST" else request.GET.copy()
+        )
+        form_data["name"] = "Preview Bundle"  # Set dummy name for form validation
+        form = BookmarkBundleForm(form_data)
+        preview_bundle = form.save(commit=False)
+
+    search = BookmarkSearch(bundle=preview_bundle)
+    bookmark_list = ActiveBookmarkListContext(request, search)
+    bookmark_list.is_preview = True
+    return bookmark_list
diff --git a/bookmarks/views/contexts.py b/bookmarks/views/contexts.py
index 86aec9e..f0e6cd4 100644
--- a/bookmarks/views/contexts.py
+++ b/bookmarks/views/contexts.py
@@ -13,6 +13,7 @@ from bookmarks import utils
 from bookmarks.models import (
     Bookmark,
     BookmarkAsset,
+    BookmarkBundle,
     BookmarkSearch,
     User,
     UserProfile,
@@ -178,15 +179,13 @@ class BookmarkItem:
 class BookmarkListContext:
     request_context = RequestContext
 
-    def __init__(self, request: HttpRequest) -> None:
+    def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
         request_context = self.request_context(request)
         user = request.user
         user_profile = request.user_profile
 
         self.request = request
-        self.search = BookmarkSearch.from_request(
-            self.request.GET, user_profile.search_preferences
-        )
+        self.search = search
 
         query_set = request_context.get_bookmark_query_set(self.search)
         page_number = request.GET.get("page")
@@ -219,6 +218,7 @@ class BookmarkListContext:
         self.show_preview_images = user_profile.enable_preview_images
         self.show_notes = user_profile.permanent_notes
         self.collapse_side_panel = user_profile.collapse_side_panel
+        self.is_preview = False
 
     @staticmethod
     def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
@@ -315,14 +315,12 @@ class TagGroup:
 class TagCloudContext:
     request_context = RequestContext
 
-    def __init__(self, request: HttpRequest) -> None:
+    def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
         request_context = self.request_context(request)
         user_profile = request.user_profile
 
         self.request = request
-        self.search = BookmarkSearch.from_request(
-            self.request.GET, user_profile.search_preferences
-        )
+        self.search = search
 
         query_set = request_context.get_tag_query_set(self.search)
         tags = list(query_set)
@@ -371,6 +369,7 @@ class BookmarkAssetItem:
         self.asset_type = asset.asset_type
         self.file = asset.file
         self.file_size = asset.file_size
+        self.content_type = asset.content_type
         self.status = asset.status
 
         icon_classes = []
@@ -461,3 +460,23 @@ def get_details_context(
         return None
 
     return context_type(request, bookmark)
+
+
+class BundlesContext:
+    def __init__(self, request: HttpRequest) -> None:
+        self.request = request
+        self.user = request.user
+        self.user_profile = request.user_profile
+
+        self.bundles = (
+            BookmarkBundle.objects.filter(owner=self.user).order_by("order").all()
+        )
+        self.is_empty = len(self.bundles) == 0
+
+        selected_bundle_id = (
+            int(request.GET.get("bundle")) if request.GET.get("bundle") else None
+        )
+        self.selected_bundle = next(
+            (bundle for bundle in self.bundles if bundle.id == selected_bundle_id),
+            None,
+        )
diff --git a/bookmarks/views/partials.py b/bookmarks/views/partials.py
index 8930a66..34a4eed 100644
--- a/bookmarks/views/partials.py
+++ b/bookmarks/views/partials.py
@@ -1,3 +1,4 @@
+from bookmarks.models import BookmarkSearch
 from bookmarks.views import contexts, turbo
 
 
@@ -14,8 +15,11 @@ def render_bookmark_update(request, bookmark_list, tag_cloud, details):
 
 
 def active_bookmark_update(request):
-    bookmark_list = contexts.ActiveBookmarkListContext(request)
-    tag_cloud = contexts.ActiveTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.ActiveBookmarkListContext(request, search)
+    tag_cloud = contexts.ActiveTagCloudContext(request, search)
     details = contexts.get_details_context(
         request, contexts.ActiveBookmarkDetailsContext
     )
@@ -23,8 +27,11 @@ def active_bookmark_update(request):
 
 
 def archived_bookmark_update(request):
-    bookmark_list = contexts.ArchivedBookmarkListContext(request)
-    tag_cloud = contexts.ArchivedTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
+    tag_cloud = contexts.ArchivedTagCloudContext(request, search)
     details = contexts.get_details_context(
         request, contexts.ArchivedBookmarkDetailsContext
     )
@@ -32,8 +39,11 @@ def archived_bookmark_update(request):
 
 
 def shared_bookmark_update(request):
-    bookmark_list = contexts.SharedBookmarkListContext(request)
-    tag_cloud = contexts.SharedTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.SharedBookmarkListContext(request, search)
+    tag_cloud = contexts.SharedTagCloudContext(request, search)
     details = contexts.get_details_context(
         request, contexts.SharedBookmarkDetailsContext
     )
diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py
index abce0ce..7e6714e 100644
--- a/bookmarks/views/settings.py
+++ b/bookmarks/views/settings.py
@@ -11,6 +11,7 @@ from django.db.models import prefetch_related_objects
 from django.http import HttpResponseRedirect, HttpResponse
 from django.shortcuts import render
 from django.urls import reverse
+from django.utils import timezone
 from rest_framework.authtoken.models import Token
 
 from bookmarks.models import (
@@ -239,8 +240,12 @@ def bookmark_export(request: HttpRequest):
         prefetch_related_objects(bookmarks, "tags")
         file_content = exporter.export_netscape_html(list(bookmarks))
 
+        # Generate filename with current date and time
+        current_time = timezone.now()
+        filename = current_time.strftime("bookmarks_%Y-%m-%d_%H-%M-%S.html")
+
         response = HttpResponse(content_type="text/plain; charset=UTF-8")
-        response["Content-Disposition"] = 'attachment; filename="bookmarks.html"'
+        response["Content-Disposition"] = f'attachment; filename="{filename}"'
         response.write(file_content)
 
         return response
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 7685add..36aec62 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -5584,9 +5584,9 @@
       }
     },
     "node_modules/prebuild-install/node_modules/tar-fs": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
-      "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
+      "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
       "license": "MIT",
       "dependencies": {
         "chownr": "^1.1.1",
@@ -6511,9 +6511,9 @@
       }
     },
     "node_modules/tar-fs": {
-      "version": "3.0.8",
-      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
-      "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz",
+      "integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==",
       "license": "MIT",
       "dependencies": {
         "pump": "^3.0.0",
diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md
index 309204b..ce43b2e 100644
--- a/docs/src/content/docs/api.md
+++ b/docs/src/content/docs/api.md
@@ -35,6 +35,8 @@ Parameters:
 - `q` - Filters results using a search phrase using the same logic as through the UI
 - `limit` - Limits the max. number of results. Default is `100`.
 - `offset` - Index from which to start returning results
+- `modified_since` - Filter results to only include bookmarks modified after the specified date (format: ISO 8601, e.g. "2025-01-01T00:00:00Z")
+- `added_since` - Filter results to only include bookmarks added after the specified date (format: ISO 8601, e.g. "2025-05-29T00:00:00Z")
 
 Example response:
 
@@ -353,6 +355,115 @@ Example payload:
 }
 ```
 
+### Bundles
+
+**List**
+
+```
+GET /api/bundles/
+```
+
+List bundles.
+
+Parameters:
+
+- `limit` - Limits the max. number of results. Default is `100`.
+- `offset` - Index from which to start returning results
+
+Example response:
+
+```json
+{
+  "count": 3,
+  "next": null,
+  "previous": null,
+  "results": [
+    {
+      "id": 1,
+      "name": "Work Resources",
+      "search": "productivity tools",
+      "any_tags": "work productivity",
+      "all_tags": "",
+      "excluded_tags": "personal",
+      "order": 0,
+      "date_created": "2020-09-26T09:46:23.006313Z",
+      "date_modified": "2020-09-26T16:01:14.275335Z"
+    },
+    {
+      "id": 2,
+      "name": "Tech Articles",
+      "search": "",
+      "any_tags": "programming development",
+      "all_tags": "",
+      "excluded_tags": "outdated",
+      "order": 1,
+      "date_created": "2020-09-27T10:15:30.123456Z",
+      "date_modified": "2020-09-27T10:15:30.123456Z"
+    },
+    ...
+  ]
+}
+```
+
+**Retrieve**
+
+```
+GET /api/bundles/<id>/
+```
+
+Retrieves a single bundle by ID.
+
+**Create**
+
+```
+POST /api/bundles/
+```
+
+Creates a new bundle. If no `order` is specified, the bundle will be automatically assigned the next available order position.
+
+Example payload:
+
+```json
+{
+  "name": "My Bundle",
+  "search": "search terms",
+  "any_tags": "tag1 tag2",
+  "all_tags": "required-tag",
+  "excluded_tags": "excluded-tag",
+  "order": 5
+}
+```
+
+**Update**
+
+```
+PUT /api/bundles/<id>/
+PATCH /api/bundles/<id>/
+```
+
+Updates a bundle.
+When using `PUT`, all fields except read-only ones should be provided.
+When using `PATCH`, only the fields that should be updated need to be provided.
+
+Example payload:
+
+```json
+{
+  "name": "Updated Bundle Name",
+  "search": "updated search terms",
+  "any_tags": "new-tag1 new-tag2",
+  "order": 10
+}
+```
+
+**Delete**
+
+```
+DELETE /api/bundles/<id>/
+```
+
+Deletes a bundle by ID.
+
 ### User
 
 **Profile**
diff --git a/docs/src/content/docs/community.md b/docs/src/content/docs/community.md
index 7e68aa6..c7b5deb 100644
--- a/docs/src/content/docs/community.md
+++ b/docs/src/content/docs/community.md
@@ -15,6 +15,7 @@ This section lists community projects around using linkding, in alphabetical ord
 - [iOS Shortcut and workflow](https://joshdick.net/2025/01/23/how_i_use_linkding_on_ios.html) iOS shortcut that accepts URLs in various ways, and shows a corresponding Linkding add/edit webview in a modal popup
 - [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
 - [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
+- [LinkBuddy](https://github.com/peterto/LinkBuddy) An open-source Android and iOS client for linkding, written in React Native. Android apk available on [github](https://github.com/peterto/LinkBuddy/releases) and iOS version on [Apple AppStore](https://apps.apple.com/us/app/linkbuddy-for-linkding/id6740408952). By [peterto](https://github.com/peterto).
 - [linkding-archiver](https://github.com/sebw/linkding-archiver) A Python application that integrates with SingleFile and Tube Archivist to archive your links and videos. By [sebw](https://github.com/sebw)  
 - [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
 - [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
diff --git a/docs/src/content/docs/managed-hosting.md b/docs/src/content/docs/managed-hosting.md
index 1dd30e9..7c1fddb 100644
--- a/docs/src/content/docs/managed-hosting.md
+++ b/docs/src/content/docs/managed-hosting.md
@@ -10,6 +10,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
 The following services provide fully managed hosting for linkding, including automatic updates and backups:
 
 - [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](/acknowledgements#pikapods))
+- [CloudBreak](https://cloudbreak.app/products/linkding/?utm_medium=referral&utm_source=linkding-docs&utm_content=managed-hosting&rby=linkding-docs-managed-hosting) - Managed hosting for linkding, US regions available.
 
 ## Self-Managed
 
diff --git a/package.json b/package.json
index 302b11e..161ba6d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "linkding",
-  "version": "1.40.0",
+  "version": "1.41.0",
   "description": "",
   "main": "index.js",
   "scripts": {
diff --git a/requirements.dev.txt b/requirements.dev.txt
index e480949..92aa7aa 100644
--- a/requirements.dev.txt
+++ b/requirements.dev.txt
@@ -12,9 +12,9 @@ click==8.1.7
     # via black
 coverage==7.6.1
     # via -r requirements.dev.in
-django==5.1.9
+django==5.2.3
     # via django-debug-toolbar
-django-debug-toolbar==4.4.6
+django-debug-toolbar==5.2.0
     # via -r requirements.dev.in
 execnet==2.1.1
     # via pytest-xdist
diff --git a/requirements.txt b/requirements.txt
index f11c319..f95aa0e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@
 # This file is autogenerated by pip-compile with Python 3.12
 # by the following command:
 #
-#    pip-compile requirements.in
+#    pip-compile
 #
 asgiref==3.8.1
     # via django
@@ -27,7 +27,7 @@ cryptography==43.0.1
     #   josepy
     #   mozilla-django-oidc
     #   pyopenssl
-django==5.1.9
+django==5.2.3
     # via
     #   -r requirements.in
     #   django-registration
@@ -57,7 +57,7 @@ pyopenssl==24.2.1
     # via josepy
 python-dateutil==2.9.0.post0
     # via -r requirements.in
-requests==2.32.3
+requests==2.32.4
     # via
     #   -r requirements.in
     #   mozilla-django-oidc
@@ -72,7 +72,7 @@ sqlparse==0.5.1
     # via django
 supervisor==4.2.5
     # via -r requirements.in
-urllib3==2.2.3
+urllib3==2.5.0
     # via
     #   requests
     #   waybackpy
diff --git a/version.txt b/version.txt
index 32b7211..7d47e59 100644
--- a/version.txt
+++ b/version.txt
@@ -1,1 +1,1 @@
-1.40.0
+1.41.0


Test patch:
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index 5607287..cd15c2e 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -17,7 +17,7 @@ from rest_framework import status
 from rest_framework.authtoken.models import Token
 from rest_framework.test import APITestCase
 
-from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
+from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
 
 
 class BookmarkFactoryMixin:
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
     def get_numbered_bookmark(self, title: str):
         return Bookmark.objects.get(title=title)
 
+    def setup_bundle(
+        self,
+        user: User = None,
+        name: str = None,
+        search: str = "",
+        any_tags: str = "",
+        all_tags: str = "",
+        excluded_tags: str = "",
+        order: int = 0,
+    ):
+        if user is None:
+            user = self.get_or_create_test_user()
+        if not name:
+            name = get_random_string(length=32)
+        bundle = BookmarkBundle(
+            name=name,
+            owner=user,
+            date_created=timezone.now(),
+            search=search,
+            any_tags=any_tags,
+            all_tags=all_tags,
+            excluded_tags=excluded_tags,
+            order=order,
+        )
+        bundle.save()
+        return bundle
+
     def setup_asset(
         self,
         bookmark: Bookmark,
@@ -209,8 +236,17 @@ class BookmarkFactoryMixin:
 
     def read_asset_file(self, asset: BookmarkAsset):
         filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
-        with open(filepath, "rb") as f:
-            return f.read()
+
+        if asset.gzip:
+            with gzip.open(filepath, "rb") as f:
+                return f.read()
+        else:
+            with open(filepath, "rb") as f:
+                return f.read()
+
+    def get_asset_filesize(self, asset: BookmarkAsset):
+        filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
+        return os.path.getsize(filepath) if os.path.exists(filepath) else 0
 
     def has_asset_file(self, asset: BookmarkAsset):
         filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
@@ -239,7 +275,7 @@ class BookmarkFactoryMixin:
         user.profile.save()
         return user
 
-    def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
+    def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
         all_tags = []
         for bookmark in bookmarks:
             all_tags = all_tags + list(bookmark.tags.all())
diff --git a/bookmarks/tests/test_assets_service.py b/bookmarks/tests/test_assets_service.py
index 31acd12..5eb3ccc 100644
--- a/bookmarks/tests/test_assets_service.py
+++ b/bookmarks/tests/test_assets_service.py
@@ -55,7 +55,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertIsNone(asset.id)
 
     def test_create_snapshot(self):
-        bookmark = self.setup_bookmark(url="https://example.com")
+        initial_modified = timezone.datetime(
+            2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        bookmark = self.setup_bookmark(
+            url="https://example.com", modified=initial_modified
+        )
         asset = assets.create_snapshot_asset(bookmark)
         asset.save()
         asset.date_created = timezone.datetime(
@@ -91,6 +96,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(asset.file, expected_filename)
         self.assertTrue(asset.gzip)
 
+        # should update bookmark modified date
+        bookmark.refresh_from_db()
+
     def test_create_snapshot_failure(self):
         bookmark = self.setup_bookmark(url="https://example.com")
         asset = assets.create_snapshot_asset(bookmark)
@@ -120,7 +128,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertTrue(saved_file.endswith("aaaa.html.gz"))
 
     def test_upload_snapshot(self):
-        bookmark = self.setup_bookmark(url="https://example.com")
+        initial_modified = timezone.datetime(
+            2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        bookmark = self.setup_bookmark(
+            url="https://example.com", modified=initial_modified
+        )
         asset = assets.upload_snapshot(bookmark, self.html_content.encode())
 
         # should create gzip file in asset folder
@@ -145,6 +158,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(asset.file, saved_file_name)
         self.assertTrue(asset.gzip)
 
+        # should update bookmark modified date
+        bookmark.refresh_from_db()
+        self.assertGreater(bookmark.date_modified, initial_modified)
+
     def test_upload_snapshot_failure(self):
         bookmark = self.setup_bookmark(url="https://example.com")
 
@@ -173,7 +190,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
 
     @disable_logging
     def test_upload_asset(self):
-        bookmark = self.setup_bookmark()
+        initial_modified = timezone.datetime(
+            2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        bookmark = self.setup_bookmark(modified=initial_modified)
         file_content = b"test content"
         upload_file = SimpleUploadedFile(
             "test_file.txt", file_content, content_type="text/plain"
@@ -187,11 +207,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
 
         # verify file name
         self.assertTrue(saved_file_name.startswith("upload_"))
-        self.assertTrue(saved_file_name.endswith("_test_file.txt"))
+        self.assertTrue(saved_file_name.endswith("_test_file.txt.gz"))
 
         # file should contain the correct content
-        with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
-            self.assertEqual(file.read(), file_content)
+        self.assertEqual(self.read_asset_file(asset), file_content)
 
         # should create asset
         self.assertIsNotNone(asset.id)
@@ -201,9 +220,52 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(asset.display_name, upload_file.name)
         self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
         self.assertEqual(asset.file, saved_file_name)
+        self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
+        self.assertTrue(asset.gzip)
+
+        # should update bookmark modified date
+        bookmark.refresh_from_db()
+        self.assertGreater(bookmark.date_modified, initial_modified)
+
+    @disable_logging
+    def test_upload_gzip_asset(self):
+        initial_modified = timezone.datetime(
+            2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        bookmark = self.setup_bookmark(modified=initial_modified)
+        file_content = gzip.compress(b"<html>test content</html>")
+        upload_file = SimpleUploadedFile(
+            "test_file.html.gz", file_content, content_type="application/gzip"
+        )
+
+        asset = assets.upload_asset(bookmark, upload_file)
+
+        # should create file in asset folder
+        saved_file_name = self.get_saved_snapshot_file()
+        self.assertIsNotNone(upload_file)
+
+        # verify file name
+        self.assertTrue(saved_file_name.startswith("upload_"))
+        self.assertTrue(saved_file_name.endswith("_test_file.html.gz"))
+
+        # file should contain the correct content
+        self.assertEqual(self.read_asset_file(asset), file_content)
+
+        # should create asset
+        self.assertIsNotNone(asset.id)
+        self.assertEqual(asset.bookmark, bookmark)
+        self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
+        self.assertEqual(asset.content_type, "application/gzip")
+        self.assertEqual(asset.display_name, upload_file.name)
+        self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
+        self.assertEqual(asset.file, saved_file_name)
         self.assertEqual(asset.file_size, len(file_content))
         self.assertFalse(asset.gzip)
 
+        # should update bookmark modified date
+        bookmark.refresh_from_db()
+        self.assertGreater(bookmark.date_modified, initial_modified)
+
     @disable_logging
     def test_upload_asset_truncates_asset_file_name(self):
         # Create a bookmark with a very long URL
@@ -221,7 +283,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertEqual(192, len(saved_file))
         self.assertTrue(saved_file.startswith("upload_"))
-        self.assertTrue(saved_file.endswith("aaaa.txt"))
+        self.assertTrue(saved_file.endswith("aaaa.txt.gz"))
 
     @disable_logging
     def test_upload_asset_failure(self):
@@ -409,3 +471,36 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
 
         # Verify that latest_snapshot hasn't changed
         self.assertEqual(bookmark.latest_snapshot, latest_asset)
+
+    @disable_logging
+    def test_remove_asset(self):
+        initial_modified = timezone.datetime(
+            2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        bookmark = self.setup_bookmark(modified=initial_modified)
+        file_content = b"test content for removal"
+        upload_file = SimpleUploadedFile(
+            "test_remove_file.txt", file_content, content_type="text/plain"
+        )
+
+        asset = assets.upload_asset(bookmark, upload_file)
+        asset_filepath = os.path.join(self.assets_dir, asset.file)
+
+        # Verify asset and file exist
+        self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
+        self.assertTrue(os.path.exists(asset_filepath))
+
+        bookmark.date_modified = initial_modified
+        bookmark.save()
+
+        # Remove the asset
+        assets.remove_asset(asset)
+
+        # Verify asset is removed from DB
+        self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
+        # Verify file is removed from disk
+        self.assertFalse(os.path.exists(asset_filepath))
+
+        # Verify bookmark modified date is updated
+        bookmark.refresh_from_db()
+        self.assertGreater(bookmark.date_modified, initial_modified)
diff --git a/bookmarks/tests/test_bookmark_action_view.py b/bookmarks/tests/test_bookmark_action_view.py
index ef523f7..29fd3a0 100644
--- a/bookmarks/tests/test_bookmark_action_view.py
+++ b/bookmarks/tests/test_bookmark_action_view.py
@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
         self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
         self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
 
+    def test_index_action_bulk_select_across_respects_bundle(self):
+        self.setup_numbered_bookmarks(3, prefix="foo")
+        self.setup_numbered_bookmarks(3, prefix="bar")
+
+        self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
+
+        bundle = self.setup_bundle(search="foo")
+
+        self.client.post(
+            reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
+            {
+                "bulk_action": ["bulk_delete"],
+                "bulk_execute": [""],
+                "bulk_select_across": ["on"],
+            },
+        )
+
+        self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
+        self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
+
     def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
         self.setup_bulk_edit_scope_test_data()
 
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
         self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
         self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
 
+    def test_archived_action_bulk_select_across_respects_bundle(self):
+        self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
+        self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
+
+        self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
+
+        bundle = self.setup_bundle(search="foo")
+
+        self.client.post(
+            reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
+            {
+                "bulk_action": ["bulk_delete"],
+                "bulk_execute": [""],
+                "bulk_select_across": ["on"],
+            },
+        )
+
+        self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
+        self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
+
     def test_shared_action_bulk_select_across_not_supported(self):
         self.setup_bulk_edit_scope_test_data()
 
diff --git a/bookmarks/tests/test_bookmark_archived_view.py b/bookmarks/tests/test_bookmark_archived_view.py
index c871d8b..00b9c7b 100644
--- a/bookmarks/tests/test_bookmark_archived_view.py
+++ b/bookmarks/tests/test_bookmark_archived_view.py
@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
     BookmarkFactoryMixin,
     BookmarkListTestMixin,
     TagCloudTestMixin,
-    collapse_whitespace,
 )
 
 
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
         )
 
         response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
-        html = collapse_whitespace(response.content.decode())
+
+        self.assertVisibleBookmarks(response, visible_bookmarks)
+        self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
+    def test_should_list_bookmarks_matching_bundle(self):
+        visible_bookmarks = self.setup_numbered_bookmarks(
+            3, prefix="foo", archived=True
+        )
+        invisible_bookmarks = self.setup_numbered_bookmarks(
+            3, prefix="bar", archived=True
+        )
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
+        )
 
         self.assertVisibleBookmarks(response, visible_bookmarks)
         self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
         self.assertVisibleTags(response, visible_tags)
         self.assertInvisibleTags(response, invisible_tags)
 
+    def test_should_list_tags_for_bookmarks_matching_bundle(self):
+        visible_bookmarks = self.setup_numbered_bookmarks(
+            3, with_tags=True, archived=True, prefix="foo", tag_prefix="foo"
+        )
+        invisible_bookmarks = self.setup_numbered_bookmarks(
+            3, with_tags=True, archived=True, prefix="bar", tag_prefix="bar"
+        )
+
+        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
+        invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleTags(response, visible_tags)
+        self.assertInvisibleTags(response, invisible_tags)
+
     def test_should_list_bookmarks_and_tags_for_search_preferences(self):
         user_profile = self.user.profile
         user_profile.search_preferences = {
@@ -515,3 +550,20 @@ class BookmarkArchivedViewTestCase(
 
         feed = soup.select_one('head link[type="application/rss+xml"]')
         self.assertIsNone(feed)
+
+    def test_hide_bundles_when_enabled_in_profile(self):
+        # visible by default
+        response = self.client.get(reverse("linkding:bookmarks.archived"))
+        html = response.content.decode()
+
+        self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
+
+        # hidden when disabled in profile
+        user_profile = self.get_or_create_test_user().profile
+        user_profile.hide_bundles = True
+        user_profile.save()
+
+        response = self.client.get(reverse("linkding:bookmarks.archived"))
+        html = response.content.decode()
+
+        self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
diff --git a/bookmarks/tests/test_bookmark_assets_api.py b/bookmarks/tests/test_bookmark_assets_api.py
index 0517e58..284e8dc 100644
--- a/bookmarks/tests/test_bookmark_assets_api.py
+++ b/bookmarks/tests/test_bookmark_assets_api.py
@@ -253,8 +253,8 @@ class BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
         self.assertEqual(asset.display_name, file_name)
         self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
         self.assertEqual(asset.content_type, "text/plain")
-        self.assertEqual(asset.file_size, len(file_content))
-        self.assertFalse(asset.gzip)
+        self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
+        self.assertTrue(asset.gzip)
 
         content = self.read_asset_file(asset)
         self.assertEqual(content, file_content)
diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py
index c99c58d..8c69876 100644
--- a/bookmarks/tests/test_bookmark_details_modal.py
+++ b/bookmarks/tests/test_bookmark_details_modal.py
@@ -585,10 +585,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
             asset_item = self.find_asset(asset_list, asset)
             self.assertIsNotNone(asset_item)
 
-            asset_icon = asset_item.select_one(".asset-icon svg")
+            asset_icon = asset_item.select_one(".list-item-icon svg")
             self.assertIsNotNone(asset_icon)
 
-            asset_text = asset_item.select_one(".asset-text span")
+            asset_text = asset_item.select_one(".list-item-text span")
             self.assertIsNotNone(asset_text)
             self.assertIn(asset.display_name, asset_text.text)
 
@@ -687,11 +687,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         soup = self.get_index_details_modal(bookmark)
 
         asset_item = self.find_asset(soup, pending_asset)
-        asset_text = asset_item.select_one(".asset-text span")
+        asset_text = asset_item.select_one(".list-item-text span")
         self.assertIn("(queued)", asset_text.text)
 
         asset_item = self.find_asset(soup, failed_asset)
-        asset_text = asset_item.select_one(".asset-text span")
+        asset_text = asset_item.select_one(".list-item-text span")
         self.assertIn("(failed)", asset_text.text)
 
     def test_asset_file_size(self):
@@ -703,15 +703,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         soup = self.get_index_details_modal(bookmark)
 
         asset_item = self.find_asset(soup, asset1)
-        asset_text = asset_item.select_one(".asset-text")
+        asset_text = asset_item.select_one(".list-item-text")
         self.assertEqual(asset_text.text.strip(), asset1.display_name)
 
         asset_item = self.find_asset(soup, asset2)
-        asset_text = asset_item.select_one(".asset-text")
+        asset_text = asset_item.select_one(".list-item-text")
         self.assertIn("53.4\xa0KB", asset_text.text)
 
         asset_item = self.find_asset(soup, asset3)
-        asset_text = asset_item.select_one(".asset-text")
+        asset_text = asset_item.select_one(".list-item-text")
         self.assertIn("11.0\xa0MB", asset_text.text)
 
     def test_asset_actions_visibility(self):
diff --git a/bookmarks/tests/test_bookmark_edit_view.py b/bookmarks/tests/test_bookmark_edit_view.py
index 0275442..bec1ee4 100644
--- a/bookmarks/tests/test_bookmark_edit_view.py
+++ b/bookmarks/tests/test_bookmark_edit_view.py
@@ -114,9 +114,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             f"""
-            <input type="text" name="url" value="{bookmark.url}" placeholder=" "
-                    autofocus class="form-input" required id="id_url">   
-        """,
+            <input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="{bookmark.url}">
+            """,
             html,
         )
 
@@ -124,7 +123,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
         self.assertInHTML(
             f"""
             <input type="text" name="tag_string" value="{tag_string}" 
-                    autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string">
+                    autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string" aria-describedby="id_tag_string_help">
         """,
             html,
         )
@@ -148,7 +147,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             f"""
-            <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">
+            <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes" aria-describedby="id_notes_help">
                 {bookmark.notes}
             </textarea>
         """,
@@ -259,12 +258,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             """
-            <label for="id_shared" class="form-checkbox">
-              <input type="checkbox" name="shared" id="id_shared">
-              <i class="form-icon"></i>
-              <span>Share</span>
-            </label>            
-        """,
+            <div class="form-checkbox">
+                <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
+                <i class="form-icon"></i>
+                <label for="id_shared">Share</label>
+            </div>
+            """,
             html,
             count=0,
         )
@@ -278,12 +277,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             """
-            <label for="id_shared" class="form-checkbox">
-              <input type="checkbox" name="shared" id="id_shared">
-              <i class="form-icon"></i>
-              <span>Share</span>
-            </label>            
-        """,
+            <div class="form-checkbox">
+                <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
+                <i class="form-icon"></i>
+                <label for="id_shared">Share</label>
+            </div>
+            """,
             html,
             count=1,
         )
diff --git a/bookmarks/tests/test_bookmark_index_view.py b/bookmarks/tests/test_bookmark_index_view.py
index 8884e03..a82d014 100644
--- a/bookmarks/tests/test_bookmark_index_view.py
+++ b/bookmarks/tests/test_bookmark_index_view.py
@@ -34,6 +34,21 @@ class BookmarkIndexViewTestCase(
         self.assertIsNotNone(form)
         self.assertEqual(form.attrs["action"], url)
 
+    def assertVisibleBundles(self, soup, bundles):
+        bundle_list = soup.select_one("ul.bundle-menu")
+        self.assertIsNotNone(bundle_list)
+
+        list_items = bundle_list.select("li.bundle-menu-item")
+        self.assertEqual(len(list_items), len(bundles))
+
+        for index, list_item in enumerate(list_items):
+            bundle = bundles[index]
+            link = list_item.select_one("a")
+            href = link.attrs["href"]
+
+            self.assertEqual(bundle.name, list_item.text.strip())
+            self.assertEqual(f"?bundle={bundle.id}", href)
+
     def test_should_list_unarchived_and_user_owned_bookmarks(self):
         other_user = User.objects.create_user(
             "otheruser", "otheruser@example.com", "password123"
@@ -58,6 +73,19 @@ class BookmarkIndexViewTestCase(
         self.assertVisibleBookmarks(response, visible_bookmarks)
         self.assertInvisibleBookmarks(response, invisible_bookmarks)
 
+    def test_should_list_bookmarks_matching_bundle(self):
+        visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
+        invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleBookmarks(response, visible_bookmarks)
+        self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
     def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
         other_user = User.objects.create_user(
             "otheruser", "otheruser@example.com", "password123"
@@ -96,6 +124,26 @@ class BookmarkIndexViewTestCase(
         self.assertVisibleTags(response, visible_tags)
         self.assertInvisibleTags(response, invisible_tags)
 
+    def test_should_list_tags_for_bookmarks_matching_bundle(self):
+        visible_bookmarks = self.setup_numbered_bookmarks(
+            3, with_tags=True, prefix="foo", tag_prefix="foo"
+        )
+        invisible_bookmarks = self.setup_numbered_bookmarks(
+            3, with_tags=True, prefix="bar", tag_prefix="bar"
+        )
+
+        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
+        invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleTags(response, visible_tags)
+        self.assertInvisibleTags(response, invisible_tags)
+
     def test_should_list_bookmarks_and_tags_for_search_preferences(self):
         user_profile = self.user.profile
         user_profile.search_preferences = {
@@ -494,3 +542,43 @@ class BookmarkIndexViewTestCase(
 
         feed = soup.select_one('head link[type="application/rss+xml"]')
         self.assertIsNone(feed)
+
+    def test_list_bundles(self):
+        books = self.setup_bundle(name="Books bundle", order=3)
+        music = self.setup_bundle(name="Music bundle", order=1)
+        tools = self.setup_bundle(name="Tools bundle", order=2)
+        response = self.client.get(reverse("linkding:bookmarks.index"))
+        html = response.content.decode()
+        soup = self.make_soup(html)
+
+        self.assertVisibleBundles(soup, [music, tools, books])
+
+    def test_list_bundles_only_shows_user_owned_bundles(self):
+        user_bundles = [self.setup_bundle(), self.setup_bundle(), self.setup_bundle()]
+        other_user = self.setup_user()
+        self.setup_bundle(user=other_user)
+        self.setup_bundle(user=other_user)
+        self.setup_bundle(user=other_user)
+
+        response = self.client.get(reverse("linkding:bookmarks.index"))
+        html = response.content.decode()
+        soup = self.make_soup(html)
+
+        self.assertVisibleBundles(soup, user_bundles)
+
+    def test_hide_bundles_when_enabled_in_profile(self):
+        # visible by default
+        response = self.client.get(reverse("linkding:bookmarks.index"))
+        html = response.content.decode()
+
+        self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
+
+        # hidden when disabled in profile
+        user_profile = self.get_or_create_test_user().profile
+        user_profile.hide_bundles = True
+        user_profile.save()
+
+        response = self.client.get(reverse("linkding:bookmarks.index"))
+        html = response.content.decode()
+
+        self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
diff --git a/bookmarks/tests/test_bookmark_new_view.py b/bookmarks/tests/test_bookmark_new_view.py
index 5e73d83..c4eaca3 100644
--- a/bookmarks/tests/test_bookmark_new_view.py
+++ b/bookmarks/tests/test_bookmark_new_view.py
@@ -78,9 +78,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
 
         self.assertInHTML(
-            '<input type="text" name="url" value="http://example.com" '
-            'placeholder=" " autofocus class="form-input" required '
-            'id="id_url">',
+            """
+            <input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="http://example.com">
+            """,
             html,
         )
 
@@ -117,9 +117,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
 
         self.assertInHTML(
-            '<input type="text" name="tag_string" value="tag1 tag2 tag3" '
-            'class="form-input" autocomplete="off" autocapitalize="off" '
-            'id="id_tag_string">',
+            """
+            <input type="text" name="tag_string" value="tag1 tag2 tag3" 
+                aria-describedby="id_tag_string_help" autocapitalize="off" autocomplete="off" class="form-input" id="id_tag_string">
+            """,
             html,
         )
 
@@ -137,8 +138,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
                     <span class="form-label d-inline-block">Notes</span>
                 </summary>
                 <label for="id_notes" class="text-assistive">Notes</label>
-                <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea>
-                <div class="form-input-hint">
+                <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes" aria-describedby="id_notes_help">**Find** more info [here](http://example.com)</textarea>
+                <div id="id_notes_help" class="form-input-hint">
                     Additional notes, supports Markdown.
                 </div>
             </details>
@@ -196,12 +197,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             """
-            <label for="id_shared" class="form-checkbox">
-              <input type="checkbox" name="shared" id="id_shared">
-              <i class="form-icon"></i>
-              <span>Share</span>
-            </label>            
-        """,
+            <div class="form-checkbox">
+                <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
+                <i class="form-icon"></i>
+                <label for="id_shared">Share</label>
+            </div>          
+            """,
             html,
             count=0,
         )
@@ -213,12 +214,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             """
-            <label for="id_shared" class="form-checkbox">
-              <input type="checkbox" name="shared" id="id_shared">
-              <i class="form-icon"></i>
-              <span>Share</span>
-            </label>            
-        """,
+            <div class="form-checkbox">
+                <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
+                <i class="form-icon"></i>
+                <label for="id_shared">Share</label>
+            </div>              
+            """,
             html,
             count=1,
         )
@@ -231,10 +232,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
         self.assertInHTML(
             """
-          <div class="form-input-hint">
-              Share this bookmark with other registered users.
-          </div>
-        """,
+              <div id="id_shared_help" class="form-input-hint">
+                  Share this bookmark with other registered users.
+              </div>
+            """,
             html,
         )
 
@@ -245,10 +246,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
         self.assertInHTML(
             """
-          <div class="form-input-hint">
-              Share this bookmark with other registered users and anonymous users.
-          </div>
-        """,
+              <div id="id_shared_help" class="form-input-hint">
+                  Share this bookmark with other registered users and anonymous users.
+              </div>
+            """,
             html,
         )
 
@@ -265,7 +266,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
 
         self.assertInHTML(
-            '<input type="checkbox" name="unread" id="id_unread">',
+            '<input type="checkbox" name="unread" id="id_unread" aria-describedby="id_unread_help">',
             html,
         )
 
@@ -277,6 +278,6 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
 
         self.assertInHTML(
-            '<input type="checkbox" name="unread" id="id_unread" checked="">',
+            '<input type="checkbox" name="unread" id="id_unread" checked="" aria-describedby="id_unread_help">',
             html,
         )
diff --git a/bookmarks/tests/test_bookmark_search_form.py b/bookmarks/tests/test_bookmark_search_form.py
index b516b6e..2143617 100644
--- a/bookmarks/tests/test_bookmark_search_form.py
+++ b/bookmarks/tests/test_bookmark_search_form.py
@@ -11,21 +11,25 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
         form = BookmarkSearchForm(search)
         self.assertEqual(form["q"].initial, "")
         self.assertEqual(form["user"].initial, "")
+        self.assertEqual(form["bundle"].initial, None)
         self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
         self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
         self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
 
         # with params
+        bundle = self.setup_bundle()
         search = BookmarkSearch(
             q="search query",
             sort=BookmarkSearch.SORT_ADDED_ASC,
             user="user123",
+            bundle=bundle,
             shared=BookmarkSearch.FILTER_SHARED_SHARED,
             unread=BookmarkSearch.FILTER_UNREAD_YES,
         )
         form = BookmarkSearchForm(search)
         self.assertEqual(form["q"].initial, "search query")
         self.assertEqual(form["user"].initial, "user123")
+        self.assertEqual(form["bundle"].initial, bundle.id)
         self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
         self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
         self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
@@ -61,17 +65,26 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
         self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
 
         # all modified params
+        bundle = self.setup_bundle()
         search = BookmarkSearch(
             q="search query",
             sort=BookmarkSearch.SORT_ADDED_ASC,
             user="user123",
+            bundle=bundle,
             shared=BookmarkSearch.FILTER_SHARED_SHARED,
             unread=BookmarkSearch.FILTER_UNREAD_YES,
         )
         form = BookmarkSearchForm(search)
         self.assertCountEqual(
             form.hidden_fields(),
-            [form["q"], form["sort"], form["user"], form["shared"], form["unread"]],
+            [
+                form["q"],
+                form["sort"],
+                form["user"],
+                form["bundle"],
+                form["shared"],
+                form["unread"],
+            ],
         )
 
         # some modified params are editable fields
diff --git a/bookmarks/tests/test_bookmark_search_model.py b/bookmarks/tests/test_bookmark_search_model.py
index 69caddf..a2bb8c7 100644
--- a/bookmarks/tests/test_bookmark_search_model.py
+++ b/bookmarks/tests/test_bookmark_search_model.py
@@ -2,16 +2,23 @@ from django.http import QueryDict
 from django.test import TestCase
 
 from bookmarks.models import BookmarkSearch
+from bookmarks.tests.helpers import BookmarkFactoryMixin
 
 
-class BookmarkSearchModelTest(TestCase):
+class MockRequest:
+    def __init__(self, user):
+        self.user = user
+
+
+class BookmarkSearchModelTest(TestCase, BookmarkFactoryMixin):
     def test_from_request(self):
         # no params
         query_dict = QueryDict()
 
-        search = BookmarkSearch.from_request(query_dict)
+        search = BookmarkSearch.from_request(None, query_dict)
         self.assertEqual(search.q, "")
         self.assertEqual(search.user, "")
+        self.assertEqual(search.bundle, None)
         self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
         self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
         self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
@@ -19,7 +26,7 @@ class BookmarkSearchModelTest(TestCase):
         # some params
         query_dict = QueryDict("q=search query&user=user123")
 
-        bookmark_search = BookmarkSearch.from_request(query_dict)
+        bookmark_search = BookmarkSearch.from_request(None, query_dict)
         self.assertEqual(bookmark_search.q, "search query")
         self.assertEqual(bookmark_search.user, "user123")
         self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
@@ -27,13 +34,16 @@ class BookmarkSearchModelTest(TestCase):
         self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
 
         # all params
+        bundle = self.setup_bundle()
+        request = MockRequest(self.get_or_create_test_user())
         query_dict = QueryDict(
-            "q=search query&sort=title_asc&user=user123&shared=yes&unread=yes"
+            f"q=search query&sort=title_asc&user=user123&bundle={bundle.id}&shared=yes&unread=yes"
         )
 
-        search = BookmarkSearch.from_request(query_dict)
+        search = BookmarkSearch.from_request(request, query_dict)
         self.assertEqual(search.q, "search query")
         self.assertEqual(search.user, "user123")
+        self.assertEqual(search.bundle, bundle)
         self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
         self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
         self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
@@ -45,7 +55,7 @@ class BookmarkSearchModelTest(TestCase):
         }
         query_dict = QueryDict("q=search query")
 
-        search = BookmarkSearch.from_request(query_dict, preferences)
+        search = BookmarkSearch.from_request(None, query_dict, preferences)
         self.assertEqual(search.q, "search query")
         self.assertEqual(search.user, "")
         self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
@@ -60,13 +70,110 @@ class BookmarkSearchModelTest(TestCase):
         }
         query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
 
-        search = BookmarkSearch.from_request(query_dict, preferences)
+        search = BookmarkSearch.from_request(None, query_dict, preferences)
         self.assertEqual(search.q, "")
         self.assertEqual(search.user, "")
         self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
         self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
         self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
 
+    def test_from_request_ignores_invalid_bundle_param(self):
+        self.setup_bundle()
+
+        # bundle does not exist
+        request = MockRequest(self.get_or_create_test_user())
+        query_dict = QueryDict("bundle=99999")
+        search = BookmarkSearch.from_request(request, query_dict)
+        self.assertIsNone(search.bundle)
+
+        # bundle belongs to another user
+        other_user = self.setup_user()
+        bundle = self.setup_bundle(user=other_user)
+        query_dict = QueryDict(f"bundle={bundle.id}")
+        search = BookmarkSearch.from_request(request, query_dict)
+        self.assertIsNone(search.bundle)
+
+    def test_query_params(self):
+        # no params
+        search = BookmarkSearch()
+        self.assertEqual(search.query_params, {})
+
+        # params are default values
+        search = BookmarkSearch(
+            q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", bundle=None, shared=""
+        )
+        self.assertEqual(search.query_params, {})
+
+        # some modified params
+        search = BookmarkSearch(q="search query", sort=BookmarkSearch.SORT_ADDED_ASC)
+        self.assertEqual(
+            search.query_params,
+            {"q": "search query", "sort": BookmarkSearch.SORT_ADDED_ASC},
+        )
+
+        # all modified params
+        bundle = self.setup_bundle()
+        search = BookmarkSearch(
+            q="search query",
+            sort=BookmarkSearch.SORT_ADDED_ASC,
+            user="user123",
+            bundle=bundle,
+            shared=BookmarkSearch.FILTER_SHARED_SHARED,
+            unread=BookmarkSearch.FILTER_UNREAD_YES,
+        )
+        self.assertEqual(
+            search.query_params,
+            {
+                "q": "search query",
+                "sort": BookmarkSearch.SORT_ADDED_ASC,
+                "user": "user123",
+                "bundle": bundle.id,
+                "shared": BookmarkSearch.FILTER_SHARED_SHARED,
+                "unread": BookmarkSearch.FILTER_UNREAD_YES,
+            },
+        )
+
+        # preferences are not query params if they match default
+        preferences = {
+            "sort": BookmarkSearch.SORT_TITLE_ASC,
+            "unread": BookmarkSearch.FILTER_UNREAD_YES,
+        }
+        search = BookmarkSearch(preferences=preferences)
+        self.assertEqual(search.query_params, {})
+
+        # param is not a query param if it matches the preference
+        preferences = {
+            "sort": BookmarkSearch.SORT_TITLE_ASC,
+            "unread": BookmarkSearch.FILTER_UNREAD_YES,
+        }
+        search = BookmarkSearch(
+            sort=BookmarkSearch.SORT_TITLE_ASC,
+            unread=BookmarkSearch.FILTER_UNREAD_YES,
+            preferences=preferences,
+        )
+        self.assertEqual(search.query_params, {})
+
+        # overriding preferences is a query param
+        preferences = {
+            "sort": BookmarkSearch.SORT_TITLE_ASC,
+            "shared": BookmarkSearch.FILTER_SHARED_SHARED,
+            "unread": BookmarkSearch.FILTER_UNREAD_YES,
+        }
+        search = BookmarkSearch(
+            sort=BookmarkSearch.SORT_TITLE_DESC,
+            shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
+            unread=BookmarkSearch.FILTER_UNREAD_OFF,
+            preferences=preferences,
+        )
+        self.assertEqual(
+            search.query_params,
+            {
+                "sort": BookmarkSearch.SORT_TITLE_DESC,
+                "shared": BookmarkSearch.FILTER_SHARED_UNSHARED,
+                "unread": BookmarkSearch.FILTER_UNREAD_OFF,
+            },
+        )
+
     def test_modified_params(self):
         # no params
         bookmark_search = BookmarkSearch()
@@ -88,16 +195,18 @@ class BookmarkSearchModelTest(TestCase):
         self.assertCountEqual(modified_params, ["q", "sort"])
 
         # all modified params
+        bundle = self.setup_bundle()
         bookmark_search = BookmarkSearch(
             q="search query",
             sort=BookmarkSearch.SORT_ADDED_ASC,
             user="user123",
+            bundle=bundle,
             shared=BookmarkSearch.FILTER_SHARED_SHARED,
             unread=BookmarkSearch.FILTER_UNREAD_YES,
         )
         modified_params = bookmark_search.modified_params
         self.assertCountEqual(
-            modified_params, ["q", "sort", "user", "shared", "unread"]
+            modified_params, ["q", "sort", "user", "bundle", "shared", "unread"]
         )
 
         # preferences are not modified params
@@ -180,7 +289,10 @@ class BookmarkSearchModelTest(TestCase):
         )
 
         # only returns preferences
-        bookmark_search = BookmarkSearch(q="search query", user="user123")
+        bundle = self.setup_bundle()
+        bookmark_search = BookmarkSearch(
+            q="search query", user="user123", bundle=bundle
+        )
         self.assertEqual(
             bookmark_search.preferences_dict,
             {
diff --git a/bookmarks/tests/test_bookmark_search_tag.py b/bookmarks/tests/test_bookmark_search_tag.py
index eb77675..3ab8a9a 100644
--- a/bookmarks/tests/test_bookmark_search_tag.py
+++ b/bookmarks/tests/test_bookmark_search_tag.py
@@ -12,7 +12,7 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         request = rf.get(url)
         request.user = self.get_or_create_test_user()
         request.user_profile = self.get_or_create_test_user().profile
-        search = BookmarkSearch.from_request(request.GET)
+        search = BookmarkSearch.from_request(request, request.GET)
         context = RequestContext(
             request,
             {
diff --git a/bookmarks/tests/test_bookmark_shared_view.py b/bookmarks/tests/test_bookmark_shared_view.py
index e7746c6..f512ccd 100644
--- a/bookmarks/tests/test_bookmark_shared_view.py
+++ b/bookmarks/tests/test_bookmark_shared_view.py
@@ -114,6 +114,24 @@ class BookmarkSharedViewTestCase(
         self.assertVisibleBookmarks(response, visible_bookmarks)
         self.assertInvisibleBookmarks(response, invisible_bookmarks)
 
+    def test_should_list_bookmarks_matching_bundle(self):
+        self.authenticate()
+        user = self.setup_user(enable_sharing=True)
+
+        visible_bookmarks = self.setup_numbered_bookmarks(
+            3, shared=True, user=user, prefix="foo"
+        )
+        invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleBookmarks(response, visible_bookmarks)
+        self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
     def test_should_list_only_publicly_shared_bookmarks_without_login(self):
         user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
         user2 = self.setup_user(enable_sharing=True)
@@ -224,6 +242,45 @@ class BookmarkSharedViewTestCase(
         self.assertVisibleTags(response, visible_tags)
         self.assertInvisibleTags(response, invisible_tags)
 
+    def test_should_list_tags_for_bookmarks_matching_bundle(self):
+        self.authenticate()
+        user1 = self.setup_user(enable_sharing=True)
+        user2 = self.setup_user(enable_sharing=True)
+        user3 = self.setup_user(enable_sharing=True)
+        visible_tags = [
+            self.setup_tag(user=user1),
+            self.setup_tag(user=user2),
+            self.setup_tag(user=user3),
+        ]
+        invisible_tags = [
+            self.setup_tag(user=user1),
+            self.setup_tag(user=user2),
+            self.setup_tag(user=user3),
+        ]
+
+        self.setup_bookmark(
+            shared=True, user=user1, title="searchvalue", tags=[visible_tags[0]]
+        )
+        self.setup_bookmark(
+            shared=True, user=user2, title="searchvalue", tags=[visible_tags[1]]
+        )
+        self.setup_bookmark(
+            shared=True, user=user3, title="searchvalue", tags=[visible_tags[2]]
+        )
+
+        self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
+        self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
+        self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
+
+        bundle = self.setup_bundle(search="searchvalue")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleTags(response, visible_tags)
+        self.assertInvisibleTags(response, invisible_tags)
+
     def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
         user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
         user2 = self.setup_user(enable_sharing=True)
diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py
index 68a4510..5f22d03 100644
--- a/bookmarks/tests/test_bookmarks_api.py
+++ b/bookmarks/tests/test_bookmarks_api.py
@@ -143,6 +143,19 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
         )
         self.assertBookmarkListEqual(response.data["results"], bookmarks)
 
+    def test_list_bookmarks_should_filter_by_bundle(self):
+        self.authenticate()
+        search_value = self.get_random_string()
+        bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)
+        self.setup_numbered_bookmarks(5)
+        bundle = self.setup_bundle(search=search_value)
+
+        response = self.get(
+            reverse("linkding:bookmark-list") + f"?bundle={bundle.id}",
+            expected_status_code=status.HTTP_200_OK,
+        )
+        self.assertBookmarkListEqual(response.data["results"], bookmarks)
+
     def test_list_bookmarks_filter_unread(self):
         self.authenticate()
         unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
@@ -250,6 +263,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
         )
         self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
 
+    def test_list_archived_bookmarks_should_filter_by_bundle(self):
+        self.authenticate()
+        search_value = self.get_random_string()
+        archived_bookmarks = self.setup_numbered_bookmarks(
+            5, archived=True, prefix=search_value
+        )
+        self.setup_numbered_bookmarks(5, archived=True)
+        bundle = self.setup_bundle(search=search_value)
+
+        response = self.get(
+            reverse("linkding:bookmark-archived") + f"?bundle={bundle.id}",
+            expected_status_code=status.HTTP_200_OK,
+        )
+        self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
+
     def test_list_archived_bookmarks_should_respect_sort(self):
         self.authenticate()
         bookmarks = self.setup_numbered_bookmarks(5, archived=True)
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index 802ae45..cf5d60d 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -10,7 +10,7 @@ from django.urls import reverse
 from django.utils import timezone, formats
 
 from bookmarks.middlewares import LinkdingMiddleware
-from bookmarks.models import Bookmark, UserProfile, User
+from bookmarks.models import Bookmark, BookmarkSearch, UserProfile, User
 from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
 from bookmarks.views import contexts
 
@@ -46,7 +46,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
            title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
             {label_content}
         </a>
-        <span>|</span>
         """,
             html,
         )
@@ -266,6 +265,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
             contexts.BookmarkListContext
         ] = contexts.ActiveBookmarkListContext,
         user: User | AnonymousUser = None,
+        is_preview: bool = False,
     ) -> str:
         rf = RequestFactory()
         request = rf.get(url)
@@ -273,7 +273,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         middleware = LinkdingMiddleware(lambda r: HttpResponse())
         middleware(request)
 
-        bookmark_list_context = context_type(request)
+        search = BookmarkSearch.from_request(request, request.GET)
+        bookmark_list_context = context_type(request, search)
+        if is_preview:
+            bookmark_list_context.is_preview = True
         context = RequestContext(request, {"bookmark_list": bookmark_list_context})
 
         template = Template("{% include 'bookmarks/bookmark_list.html' %}")
@@ -1047,3 +1050,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         soup = self.make_soup(html)
         bookmarks = soup.select("li[ld-bookmark-item]")
         self.assertEqual(10, len(bookmarks))
+
+    def test_no_actions_rendered_when_is_preview(self):
+        bookmark = self.setup_bookmark()
+        bookmark.date_added = timezone.now() - relativedelta(days=8)
+        bookmark.web_archive_snapshot_url = "https://example.com"
+        bookmark.save()
+
+        html = self.render_template(is_preview=True)
+
+        # Verify no actions are rendered
+        self.assertNoViewLink(html, bookmark)
+        self.assertNoBookmarkActions(html, bookmark)
+        self.assertMarkAsReadButton(html, bookmark, count=0)
+        self.assertUnshareButton(html, bookmark, count=0)
+        self.assertNotesToggle(html, count=0)
+
+        # But date should still be rendered
+        self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)
diff --git a/bookmarks/tests/test_bundles_api.py b/bookmarks/tests/test_bundles_api.py
new file mode 100644
index 0000000..2446f27
--- /dev/null
+++ b/bookmarks/tests/test_bundles_api.py
@@ -0,0 +1,321 @@
+from django.urls import reverse
+from rest_framework import status
+
+from bookmarks.models import BookmarkBundle
+from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
+
+
+class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
+    def assertBundle(self, bundle: BookmarkBundle, data: dict):
+        self.assertEqual(bundle.id, data["id"])
+        self.assertEqual(bundle.name, data["name"])
+        self.assertEqual(bundle.search, data["search"])
+        self.assertEqual(bundle.any_tags, data["any_tags"])
+        self.assertEqual(bundle.all_tags, data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, data["excluded_tags"])
+        self.assertEqual(bundle.order, data["order"])
+        self.assertEqual(
+            bundle.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
+        )
+        self.assertEqual(
+            bundle.date_modified.isoformat().replace("+00:00", "Z"),
+            data["date_modified"],
+        )
+
+    def test_bundle_list(self):
+        self.authenticate()
+
+        bundles = [
+            self.setup_bundle(name="Bundle 1", order=0),
+            self.setup_bundle(name="Bundle 2", order=1),
+            self.setup_bundle(name="Bundle 3", order=2),
+        ]
+
+        url = reverse("linkding:bundle-list")
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertEqual(len(response.data["results"]), 3)
+        self.assertBundle(bundles[0], response.data["results"][0])
+        self.assertBundle(bundles[1], response.data["results"][1])
+        self.assertBundle(bundles[2], response.data["results"][2])
+
+    def test_bundle_list_only_returns_own_bundles(self):
+        self.authenticate()
+
+        user_bundles = [
+            self.setup_bundle(name="User Bundle 1"),
+            self.setup_bundle(name="User Bundle 2"),
+        ]
+
+        other_user = self.setup_user()
+        self.setup_bundle(name="Other User Bundle 1", user=other_user)
+        self.setup_bundle(name="Other User Bundle 2", user=other_user)
+
+        url = reverse("linkding:bundle-list")
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertEqual(len(response.data["results"]), 2)
+        self.assertBundle(user_bundles[0], response.data["results"][0])
+        self.assertBundle(user_bundles[1], response.data["results"][1])
+
+    def test_bundle_list_requires_authentication(self):
+        url = reverse("linkding:bundle-list")
+        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_bundle_detail(self):
+        self.authenticate()
+
+        bundle = self.setup_bundle(
+            name="Test Bundle",
+            search="test search",
+            any_tags="tag1 tag2",
+            all_tags="required-tag",
+            excluded_tags="excluded-tag",
+            order=5,
+        )
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertBundle(bundle, response.data)
+
+    def test_bundle_detail_only_returns_own_bundles(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
+        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+    def test_bundle_detail_requires_authentication(self):
+        bundle = self.setup_bundle()
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_create_bundle(self):
+        self.authenticate()
+
+        bundle_data = {
+            "name": "New Bundle",
+            "search": "test search",
+            "any_tags": "tag1 tag2",
+            "all_tags": "required-tag",
+            "excluded_tags": "excluded-tag",
+        }
+
+        url = reverse("linkding:bundle-list")
+        response = self.post(
+            url, bundle_data, expected_status_code=status.HTTP_201_CREATED
+        )
+
+        bundle = BookmarkBundle.objects.get(id=response.data["id"])
+        self.assertEqual(bundle.name, bundle_data["name"])
+        self.assertEqual(bundle.search, bundle_data["search"])
+        self.assertEqual(bundle.any_tags, bundle_data["any_tags"])
+        self.assertEqual(bundle.all_tags, bundle_data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, bundle_data["excluded_tags"])
+        self.assertEqual(bundle.owner, self.user)
+        self.assertEqual(bundle.order, 0)
+
+        self.assertBundle(bundle, response.data)
+
+    def test_create_bundle_auto_increments_order(self):
+        self.authenticate()
+
+        self.setup_bundle(name="Existing Bundle", order=2)
+
+        bundle_data = {"name": "New Bundle", "search": "test search"}
+
+        url = reverse("linkding:bundle-list")
+        response = self.post(
+            url, bundle_data, expected_status_code=status.HTTP_201_CREATED
+        )
+
+        bundle = BookmarkBundle.objects.get(id=response.data["id"])
+        self.assertEqual(bundle.order, 3)
+
+    def test_create_bundle_with_custom_order(self):
+        self.authenticate()
+
+        bundle_data = {"name": "New Bundle", "order": 10}
+
+        url = reverse("linkding:bundle-list")
+        response = self.post(
+            url, bundle_data, expected_status_code=status.HTTP_201_CREATED
+        )
+
+        bundle = BookmarkBundle.objects.get(id=response.data["id"])
+        self.assertEqual(bundle.order, 10)
+
+    def test_create_bundle_requires_name(self):
+        self.authenticate()
+
+        bundle_data = {"search": "test search"}
+
+        url = reverse("linkding:bundle-list")
+        self.post(url, bundle_data, expected_status_code=status.HTTP_400_BAD_REQUEST)
+
+    def test_create_bundle_fields_can_be_empty(self):
+        self.authenticate()
+
+        bundle_data = {
+            "name": "Minimal Bundle",
+            "search": "",
+            "any_tags": "",
+            "all_tags": "",
+            "excluded_tags": "",
+        }
+
+        url = reverse("linkding:bundle-list")
+        response = self.post(
+            url, bundle_data, expected_status_code=status.HTTP_201_CREATED
+        )
+
+        bundle = BookmarkBundle.objects.get(id=response.data["id"])
+        self.assertEqual(bundle.name, "Minimal Bundle")
+        self.assertEqual(bundle.search, "")
+        self.assertEqual(bundle.any_tags, "")
+        self.assertEqual(bundle.all_tags, "")
+        self.assertEqual(bundle.excluded_tags, "")
+
+    def test_create_bundle_requires_authentication(self):
+        bundle_data = {"name": "New Bundle"}
+
+        url = reverse("linkding:bundle-list")
+        self.post(url, bundle_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_update_bundle_put(self):
+        self.authenticate()
+
+        bundle = self.setup_bundle(
+            name="Original Bundle",
+            search="original search",
+            any_tags="original-tag",
+            order=1,
+        )
+
+        updated_data = {
+            "name": "Updated Bundle",
+            "search": "updated search",
+            "any_tags": "updated-tag1 updated-tag2",
+            "all_tags": "required-updated-tag",
+            "excluded_tags": "excluded-updated-tag",
+            "order": 5,
+        }
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        response = self.put(url, updated_data, expected_status_code=status.HTTP_200_OK)
+
+        bundle.refresh_from_db()
+        self.assertEqual(bundle.name, updated_data["name"])
+        self.assertEqual(bundle.search, updated_data["search"])
+        self.assertEqual(bundle.any_tags, updated_data["any_tags"])
+        self.assertEqual(bundle.all_tags, updated_data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, updated_data["excluded_tags"])
+        self.assertEqual(bundle.order, updated_data["order"])
+
+        self.assertBundle(bundle, response.data)
+
+    def test_update_bundle_patch(self):
+        self.authenticate()
+
+        bundle = self.setup_bundle(
+            name="Original Bundle", search="original search", any_tags="original-tag"
+        )
+
+        updated_data = {
+            "name": "Partially Updated Bundle",
+            "search": "partially updated search",
+        }
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        response = self.patch(
+            url, updated_data, expected_status_code=status.HTTP_200_OK
+        )
+
+        bundle.refresh_from_db()
+        self.assertEqual(bundle.name, updated_data["name"])
+        self.assertEqual(bundle.search, updated_data["search"])
+        self.assertEqual(bundle.any_tags, "original-tag")  # Should remain unchanged
+
+        self.assertBundle(bundle, response.data)
+
+    def test_update_bundle_only_allows_own_bundles(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        updated_data = {"name": "Updated Bundle"}
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
+        self.put(url, updated_data, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+    def test_update_bundle_requires_authentication(self):
+        bundle = self.setup_bundle()
+        updated_data = {"name": "Updated Bundle"}
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        self.put(url, updated_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_delete_bundle(self):
+        self.authenticate()
+
+        bundle = self.setup_bundle(name="Bundle to Delete")
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
+
+        self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
+
+    def test_delete_bundle_updates_order(self):
+        self.authenticate()
+
+        bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+        bundle2 = self.setup_bundle(name="Bundle 2", order=1)
+        bundle3 = self.setup_bundle(name="Bundle 3", order=2)
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle2.id})
+        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
+
+        self.assertFalse(BookmarkBundle.objects.filter(id=bundle2.id).exists())
+
+        # Check that the remaining bundles have updated orders
+        bundle1.refresh_from_db()
+        bundle3.refresh_from_db()
+        self.assertEqual(bundle1.order, 0)
+        self.assertEqual(bundle3.order, 1)
+
+    def test_delete_bundle_only_allows_own_bundles(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
+        self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+        self.assertTrue(BookmarkBundle.objects.filter(id=other_bundle.id).exists())
+
+    def test_delete_bundle_requires_authentication(self):
+        bundle = self.setup_bundle()
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+        self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())
+
+    def test_bundles_ordered_by_order_field(self):
+        self.authenticate()
+
+        self.setup_bundle(name="Third Bundle", order=2)
+        self.setup_bundle(name="First Bundle", order=0)
+        self.setup_bundle(name="Second Bundle", order=1)
+
+        url = reverse("linkding:bundle-list")
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertEqual(len(response.data["results"]), 3)
+        self.assertEqual(response.data["results"][0]["name"], "First Bundle")
+        self.assertEqual(response.data["results"][1]["name"], "Second Bundle")
+        self.assertEqual(response.data["results"][2]["name"], "Third Bundle")
diff --git a/bookmarks/tests/test_bundles_edit_view.py b/bookmarks/tests/test_bundles_edit_view.py
new file mode 100644
index 0000000..45e5e8a
--- /dev/null
+++ b/bookmarks/tests/test_bundles_edit_view.py
@@ -0,0 +1,122 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
+
+    def setUp(self) -> None:
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def create_form_data(self, overrides=None):
+        if overrides is None:
+            overrides = {}
+        form_data = {
+            "name": "Test Bundle",
+            "search": "test search",
+            "any_tags": "tag1 tag2",
+            "all_tags": "required-tag",
+            "excluded_tags": "excluded-tag",
+        }
+        return {**form_data, **overrides}
+
+    def test_should_edit_bundle(self):
+        bundle = self.setup_bundle()
+
+        updated_data = self.create_form_data()
+
+        response = self.client.post(
+            reverse("linkding:bundles.edit", args=[bundle.id]), updated_data
+        )
+
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+        bundle.refresh_from_db()
+        self.assertEqual(bundle.name, updated_data["name"])
+        self.assertEqual(bundle.search, updated_data["search"])
+        self.assertEqual(bundle.any_tags, updated_data["any_tags"])
+        self.assertEqual(bundle.all_tags, updated_data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, updated_data["excluded_tags"])
+
+    def test_should_render_edit_form_with_prefilled_fields(self):
+        bundle = self.setup_bundle(
+            name="Test Bundle",
+            search="test search terms",
+            any_tags="tag1 tag2 tag3",
+            all_tags="required-tag all-tag",
+            excluded_tags="excluded-tag banned-tag",
+        )
+
+        response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        self.assertInHTML(
+            f'<input type="text" name="name" value="{bundle.name}" '
+            'autocomplete="off" placeholder=" " class="form-input" '
+            'maxlength="256" required id="id_name">',
+            html,
+        )
+
+        self.assertInHTML(
+            f'<input type="text" name="search" value="{bundle.search}" '
+            'autocomplete="off" placeholder=" " class="form-input" '
+            'maxlength="256" id="id_search">',
+            html,
+        )
+
+        self.assertInHTML(
+            f'<input type="text" name="any_tags" value="{bundle.any_tags}" '
+            'autocomplete="off" autocapitalize="off" class="form-input" '
+            'maxlength="1024" id="id_any_tags">',
+            html,
+        )
+
+        self.assertInHTML(
+            f'<input type="text" name="all_tags" value="{bundle.all_tags}" '
+            'autocomplete="off" autocapitalize="off" class="form-input" '
+            'maxlength="1024" id="id_all_tags">',
+            html,
+        )
+
+        self.assertInHTML(
+            f'<input type="text" name="excluded_tags" value="{bundle.excluded_tags}" '
+            'autocomplete="off" autocapitalize="off" class="form-input" '
+            'maxlength="1024" id="id_excluded_tags">',
+            html,
+        )
+
+    def test_should_return_422_with_invalid_form(self):
+        bundle = self.setup_bundle(
+            name="Test Bundle",
+            search="test search",
+            any_tags="tag1 tag2",
+            all_tags="required-tag",
+            excluded_tags="excluded-tag",
+        )
+
+        invalid_data = self.create_form_data({"name": ""})
+
+        response = self.client.post(
+            reverse("linkding:bundles.edit", args=[bundle.id]), invalid_data
+        )
+
+        self.assertEqual(response.status_code, 422)
+
+    def test_should_not_allow_editing_other_users_bundles(self):
+        other_user = self.setup_user(name="otheruser")
+        other_users_bundle = self.setup_bundle(user=other_user)
+
+        response = self.client.get(
+            reverse("linkding:bundles.edit", args=[other_users_bundle.id])
+        )
+        self.assertEqual(response.status_code, 404)
+
+        updated_data = self.create_form_data()
+        response = self.client.post(
+            reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data
+        )
+        self.assertEqual(response.status_code, 404)
diff --git a/bookmarks/tests/test_bundles_index_view.py b/bookmarks/tests/test_bundles_index_view.py
new file mode 100644
index 0000000..e4dff8e
--- /dev/null
+++ b/bookmarks/tests/test_bundles_index_view.py
@@ -0,0 +1,210 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import BookmarkBundle
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
+
+    def setUp(self) -> None:
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def test_render_bundle_list(self):
+        bundles = [
+            self.setup_bundle(name="Bundle 1"),
+            self.setup_bundle(name="Bundle 2"),
+            self.setup_bundle(name="Bundle 3"),
+        ]
+
+        response = self.client.get(reverse("linkding:bundles.index"))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        for bundle in bundles:
+            expected_list_item = f"""
+            <div class="list-item" data-bundle-id="{bundle.id}" draggable="true">
+              <div class="list-item-icon text-secondary">
+                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
+                     stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
+                  <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                </svg>
+              </div>
+              <div class="list-item-text">
+                <span class="truncate">{bundle.name}</span>
+              </div>
+              <div class="list-item-actions">
+                <a class="btn btn-link" href="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a>
+                <button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
+              </div>
+            </div>
+            """
+
+            self.assertInHTML(expected_list_item, html)
+
+    def test_renders_user_owned_bundles_only(self):
+        user_bundle = self.setup_bundle(name="User Bundle")
+
+        other_user = self.setup_user(name="otheruser")
+        other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        response = self.client.get(reverse("linkding:bundles.index"))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html)
+        self.assertNotIn(other_user_bundle.name, html)
+
+    def test_empty_state(self):
+        response = self.client.get(reverse("linkding:bundles.index"))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        self.assertInHTML('<p class="empty-title h5">You have no bundles yet</p>', html)
+        self.assertInHTML(
+            '<p class="empty-subtitle">Create your first bundle to get started</p>',
+            html,
+        )
+
+    def test_add_new_button(self):
+        response = self.client.get(reverse("linkding:bundles.index"))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        self.assertInHTML(
+            f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>',
+            html,
+        )
+
+    def test_remove_bundle(self):
+        bundle = self.setup_bundle(name="Test Bundle")
+
+        response = self.client.post(
+            reverse("linkding:bundles.action"),
+            {"remove_bundle": str(bundle.id)},
+        )
+
+        self.assertEqual(response.status_code, 302)
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+        self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
+
+    def test_remove_bundle_updates_order(self):
+        bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+        bundle2 = self.setup_bundle(name="Bundle 2", order=1)
+        bundle3 = self.setup_bundle(name="Bundle 3", order=2)
+
+        self.client.post(
+            reverse("linkding:bundles.action"),
+            {"remove_bundle": str(bundle2.id)},
+        )
+
+        self.assertBundleOrder([bundle1, bundle3])
+
+    def test_remove_other_user_bundle(self):
+        other_user = self.setup_user(name="otheruser")
+        other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        response = self.client.post(
+            reverse("linkding:bundles.action"),
+            {"remove_bundle": str(other_user_bundle.id)},
+        )
+
+        self.assertEqual(response.status_code, 404)
+        self.assertTrue(BookmarkBundle.objects.filter(id=other_user_bundle.id).exists())
+
+    def assertBundleOrder(self, expected_bundles, user=None):
+        if user is None:
+            user = self.user
+        actual_bundles = BookmarkBundle.objects.filter(owner=user).order_by("order")
+        self.assertEqual(len(actual_bundles), len(expected_bundles))
+        for i, bundle in enumerate(expected_bundles):
+            self.assertEqual(actual_bundles[i].id, bundle.id)
+            self.assertEqual(actual_bundles[i].order, i)
+
+    def move_bundle(self, bundle: BookmarkBundle, position: int):
+        return self.client.post(
+            reverse("linkding:bundles.action"),
+            {"move_bundle": str(bundle.id), "move_position": position},
+        )
+
+    def test_move_bundle(self):
+        bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+        bundle2 = self.setup_bundle(name="Bundle 2", order=1)
+        bundle3 = self.setup_bundle(name="Bundle 3", order=2)
+
+        self.move_bundle(bundle1, 1)
+        self.assertBundleOrder([bundle2, bundle1, bundle3])
+
+        self.move_bundle(bundle1, 0)
+        self.assertBundleOrder([bundle1, bundle2, bundle3])
+
+        self.move_bundle(bundle1, 2)
+        self.assertBundleOrder([bundle2, bundle3, bundle1])
+
+        self.move_bundle(bundle1, 2)
+        self.assertBundleOrder([bundle2, bundle3, bundle1])
+
+    def test_move_bundle_response(self):
+        bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+        self.setup_bundle(name="Bundle 2", order=1)
+
+        response = self.move_bundle(bundle1, 1)
+
+        self.assertEqual(response.status_code, 302)
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+    def test_can_only_move_user_owned_bundles(self):
+        other_user = self.setup_user()
+        other_user_bundle1 = self.setup_bundle(user=other_user)
+        self.setup_bundle(user=other_user)
+
+        response = self.move_bundle(other_user_bundle1, 1)
+        self.assertEqual(response.status_code, 404)
+
+    def test_move_bundle_only_affects_own_bundles(self):
+        user_bundle1 = self.setup_bundle(name="User Bundle 1", order=0)
+        user_bundle2 = self.setup_bundle(name="User Bundle 2", order=1)
+
+        other_user = self.setup_user(name="otheruser")
+        other_user_bundle = self.setup_bundle(
+            name="Other User Bundle", user=other_user, order=0
+        )
+
+        # Move user bundle
+        self.move_bundle(user_bundle1, 1)
+        self.assertBundleOrder([user_bundle2, user_bundle1], user=self.user)
+
+        # Check that other user's bundle is unaffected
+        self.assertBundleOrder([other_user_bundle], user=other_user)
+
+    def test_remove_non_existing_bundle(self):
+        non_existent_id = 99999
+
+        response = self.client.post(
+            reverse("linkding:bundles.action"),
+            {"remove_bundle": str(non_existent_id)},
+        )
+
+        self.assertEqual(response.status_code, 404)
+
+    def test_post_without_action(self):
+        bundle = self.setup_bundle(name="Test Bundle")
+
+        response = self.client.post(reverse("linkding:bundles.action"), {})
+
+        self.assertEqual(response.status_code, 302)
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+        self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())
diff --git a/bookmarks/tests/test_bundles_new_view.py b/bookmarks/tests/test_bundles_new_view.py
new file mode 100644
index 0000000..db39963
--- /dev/null
+++ b/bookmarks/tests/test_bundles_new_view.py
@@ -0,0 +1,77 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import BookmarkBundle
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin):
+
+    def setUp(self) -> None:
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def create_form_data(self, overrides=None):
+        if overrides is None:
+            overrides = {}
+        form_data = {
+            "name": "Test Bundle",
+            "search": "test search",
+            "any_tags": "tag1 tag2",
+            "all_tags": "required-tag",
+            "excluded_tags": "excluded-tag",
+        }
+        return {**form_data, **overrides}
+
+    def test_should_create_new_bundle(self):
+        form_data = self.create_form_data()
+
+        response = self.client.post(reverse("linkding:bundles.new"), form_data)
+
+        self.assertEqual(BookmarkBundle.objects.count(), 1)
+
+        bundle = BookmarkBundle.objects.first()
+        self.assertEqual(bundle.owner, self.user)
+        self.assertEqual(bundle.name, form_data["name"])
+        self.assertEqual(bundle.search, form_data["search"])
+        self.assertEqual(bundle.any_tags, form_data["any_tags"])
+        self.assertEqual(bundle.all_tags, form_data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, form_data["excluded_tags"])
+
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+    def test_should_increment_order_for_subsequent_bundles(self):
+        # Create first bundle
+        form_data_1 = self.create_form_data({"name": "Bundle 1"})
+        self.client.post(reverse("linkding:bundles.new"), form_data_1)
+        bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
+        self.assertEqual(bundle1.order, 0)
+
+        # Create second bundle
+        form_data_2 = self.create_form_data({"name": "Bundle 2"})
+        self.client.post(reverse("linkding:bundles.new"), form_data_2)
+        bundle2 = BookmarkBundle.objects.get(name="Bundle 2")
+        self.assertEqual(bundle2.order, 1)
+
+        # Create another bundle with a higher order
+        self.setup_bundle(order=5)
+
+        # Create third bundle
+        form_data_3 = self.create_form_data({"name": "Bundle 3"})
+        self.client.post(reverse("linkding:bundles.new"), form_data_3)
+        bundle3 = BookmarkBundle.objects.get(name="Bundle 3")
+        self.assertEqual(bundle3.order, 6)
+
+    def test_incrementing_order_ignores_other_user_bookmark(self):
+        other_user = self.setup_user()
+        self.setup_bundle(user=other_user, order=10)
+
+        form_data = self.create_form_data({"name": "Bundle 1"})
+        self.client.post(reverse("linkding:bundles.new"), form_data)
+        bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
+        self.assertEqual(bundle1.order, 0)
+
+    def test_should_return_422_with_invalid_form(self):
+        form_data = self.create_form_data({"name": ""})
+        response = self.client.post(reverse("linkding:bundles.new"), form_data)
+        self.assertEqual(response.status_code, 422)
diff --git a/bookmarks/tests/test_bundles_preview_view.py b/bookmarks/tests/test_bundles_preview_view.py
new file mode 100644
index 0000000..3eb6a0a
--- /dev/null
+++ b/bookmarks/tests/test_bundles_preview_view.py
@@ -0,0 +1,116 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
+
+
+class BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
+
+    def setUp(self) -> None:
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def test_preview_empty_bundle(self):
+        bookmark1 = self.setup_bookmark(title="Test Bookmark 1")
+        bookmark2 = self.setup_bookmark(title="Test Bookmark 2")
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Found 2 bookmarks matching this bundle")
+        self.assertContains(response, bookmark1.title)
+        self.assertContains(response, bookmark2.title)
+        self.assertNotContains(response, "No bookmarks match the current bundle")
+
+    def test_preview_with_search_terms(self):
+        bookmark1 = self.setup_bookmark(title="Python Programming")
+        bookmark2 = self.setup_bookmark(title="JavaScript Tutorial")
+        bookmark3 = self.setup_bookmark(title="Django Framework")
+
+        response = self.client.get(
+            reverse("linkding:bundles.preview"), {"search": "python"}
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Found 1 bookmarks matching this bundle")
+        self.assertContains(response, bookmark1.title)
+        self.assertNotContains(response, bookmark2.title)
+        self.assertNotContains(response, bookmark3.title)
+
+    def test_preview_no_matching_bookmarks(self):
+        bookmark = self.setup_bookmark(title="Python Guide")
+
+        response = self.client.get(
+            reverse("linkding:bundles.preview"), {"search": "nonexistent"}
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "No bookmarks match the current bundle")
+        self.assertNotContains(response, bookmark.title)
+
+    def test_preview_renders_bookmark(self):
+        tag = self.setup_tag(name="test-tag")
+        bookmark = self.setup_bookmark(
+            title="Test Bookmark",
+            description="Test description",
+            url="https://example.com/test",
+            tags=[tag],
+        )
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, bookmark.title)
+        self.assertContains(response, bookmark.description)
+        self.assertContains(response, bookmark.url)
+        self.assertContains(response, "#test-tag")
+
+    def test_preview_renders_bookmark_in_preview_mode(self):
+        tag = self.setup_tag(name="test-tag")
+        self.setup_bookmark(
+            title="Test Bookmark",
+            description="Test description",
+            url="https://example.com/test",
+            tags=[tag],
+        )
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+        soup = self.make_soup(response.content.decode())
+
+        list_item = soup.select_one("li[ld-bookmark-item]")
+        actions = list_item.select(".actions > *")
+        self.assertEqual(len(actions), 1)
+
+    def test_preview_ignores_archived_bookmarks(self):
+        active_bookmark = self.setup_bookmark(title="Active Bookmark")
+        archived_bookmark = self.setup_bookmark(
+            title="Archived Bookmark", is_archived=True
+        )
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Found 1 bookmarks matching this bundle")
+        self.assertContains(response, active_bookmark.title)
+        self.assertNotContains(response, archived_bookmark.title)
+
+    def test_preview_requires_authentication(self):
+        self.client.logout()
+
+        response = self.client.get(reverse("linkding:bundles.preview"), follow=True)
+
+        self.assertRedirects(
+            response, f"/login/?next={reverse('linkding:bundles.preview')}"
+        )
+
+    def test_preview_only_shows_user_bookmarks(self):
+        other_user = self.setup_user()
+        own_bookmark = self.setup_bookmark(title="Own Bookmark")
+        other_bookmark = self.setup_bookmark(title="Other Bookmark", user=other_user)
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Found 1 bookmarks matching this bundle")
+        self.assertContains(response, own_bookmark.title)
+        self.assertNotContains(response, other_bookmark.title)
diff --git a/bookmarks/tests/test_pagination_tag.py b/bookmarks/tests/test_pagination_tag.py
index 19ec728..ce4530d 100644
--- a/bookmarks/tests/test_pagination_tag.py
+++ b/bookmarks/tests/test_pagination_tag.py
@@ -32,7 +32,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
         )
 
     def assertPrevLink(self, html: str, page_number: int, href: str = None):
-        href = href if href else "?page={0}".format(page_number)
+        href = href if href else "http://testserver/test?page={0}".format(page_number)
         self.assertInHTML(
             """
             <li class="page-item">
@@ -55,7 +55,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
         )
 
     def assertNextLink(self, html: str, page_number: int, href: str = None):
-        href = href if href else "?page={0}".format(page_number)
+        href = href if href else "http://testserver/test?page={0}".format(page_number)
         self.assertInHTML(
             """
             <li class="page-item">
@@ -76,7 +76,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
         href: str = None,
     ):
         active_class = "active" if active else ""
-        href = href if href else "?page={0}".format(page_number)
+        href = href if href else "http://testserver/test?page={0}".format(page_number)
         self.assertInHTML(
             """
             <li class="page-item {1}">
@@ -164,20 +164,38 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
         rendered_template = self.render_template(
             100, 10, 2, url="/test?q=cake&sort=title_asc&page=2"
         )
-        self.assertPrevLink(rendered_template, 1, href="?q=cake&sort=title_asc&page=1")
+        self.assertPrevLink(
+            rendered_template,
+            1,
+            href="http://testserver/test?q=cake&sort=title_asc&page=1",
+        )
         self.assertPageLink(
-            rendered_template, 1, False, href="?q=cake&sort=title_asc&page=1"
+            rendered_template,
+            1,
+            False,
+            href="http://testserver/test?q=cake&sort=title_asc&page=1",
         )
         self.assertPageLink(
-            rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
+            rendered_template,
+            2,
+            True,
+            href="http://testserver/test?q=cake&sort=title_asc&page=2",
+        )
+        self.assertNextLink(
+            rendered_template,
+            3,
+            href="http://testserver/test?q=cake&sort=title_asc&page=3",
         )
-        self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
 
     def test_removes_details_parameter(self):
         rendered_template = self.render_template(
             100, 10, 2, url="/test?details=1&page=2"
         )
-        self.assertPrevLink(rendered_template, 1, href="?page=1")
-        self.assertPageLink(rendered_template, 1, False, href="?page=1")
-        self.assertPageLink(rendered_template, 2, True, href="?page=2")
-        self.assertNextLink(rendered_template, 3, href="?page=3")
+        self.assertPrevLink(rendered_template, 1, href="http://testserver/test?page=1")
+        self.assertPageLink(
+            rendered_template, 1, False, href="http://testserver/test?page=1"
+        )
+        self.assertPageLink(
+            rendered_template, 2, True, href="http://testserver/test?page=2"
+        )
+        self.assertNextLink(rendered_template, 3, href="http://testserver/test?page=3")
diff --git a/bookmarks/tests/test_queries.py b/bookmarks/tests/test_queries.py
index e1b1908..7516aa7 100644
--- a/bookmarks/tests/test_queries.py
+++ b/bookmarks/tests/test_queries.py
@@ -153,7 +153,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
             self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
         ]
 
-    def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
+    def assertQueryResult(self, query: QuerySet, item_lists: list[list]):
         expected_items = []
         for item_list in item_lists:
             expected_items = expected_items + item_list
@@ -1211,3 +1211,343 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
 
         query = queries.query_bookmarks(self.user, self.profile, search)
         self.assertEqual(list(query), sorted_bookmarks)
+
+    def test_query_bookmarks_filter_modified_since(self):
+        # Create bookmarks with different modification dates
+        older_bookmark = self.setup_bookmark(title="old bookmark")
+        recent_bookmark = self.setup_bookmark(title="recent bookmark")
+
+        # Modify date field on bookmark directly to test modified_since
+        older_bookmark.date_modified = timezone.datetime(
+            2025, 1, 1, tzinfo=datetime.timezone.utc
+        )
+        older_bookmark.save()
+        recent_bookmark.date_modified = timezone.datetime(
+            2025, 5, 15, tzinfo=datetime.timezone.utc
+        )
+        recent_bookmark.save()
+
+        # Test with date between the two bookmarks
+        search = BookmarkSearch(modified_since="2025-03-01T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [recent_bookmark])
+
+        # Test with date before both bookmarks
+        search = BookmarkSearch(modified_since="2024-12-31T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+        # Test with date after both bookmarks
+        search = BookmarkSearch(modified_since="2025-05-16T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [])
+
+        # Test with no modified_since - should return all bookmarks
+        search = BookmarkSearch()
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+        # Test with invalid date format - should be ignored
+        search = BookmarkSearch(modified_since="invalid-date")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+    def test_query_bookmarks_filter_added_since(self):
+        # Create bookmarks with different dates
+        older_bookmark = self.setup_bookmark(
+            title="old bookmark",
+            added=timezone.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc),
+        )
+        recent_bookmark = self.setup_bookmark(
+            title="recent bookmark",
+            added=timezone.datetime(2025, 5, 15, tzinfo=datetime.timezone.utc),
+        )
+
+        # Test with date between the two bookmarks
+        search = BookmarkSearch(added_since="2025-03-01T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [recent_bookmark])
+
+        # Test with date before both bookmarks
+        search = BookmarkSearch(added_since="2024-12-31T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+        # Test with date after both bookmarks
+        search = BookmarkSearch(added_since="2025-05-16T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [])
+
+        # Test with no added_since - should return all bookmarks
+        search = BookmarkSearch()
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+        # Test with invalid date format - should be ignored
+        search = BookmarkSearch(added_since="invalid-date")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+    def test_query_bookmarks_with_bundle_search_terms(self):
+        bundle = self.setup_bundle(search="search_term_A search_term_B")
+
+        matching_bookmarks = [
+            self.setup_bookmark(
+                title="search_term_A content", description="search_term_B also here"
+            ),
+            self.setup_bookmark(url="http://example.com/search_term_A/search_term_B"),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(title="search_term_A only")
+        self.setup_bookmark(description="search_term_B only")
+        self.setup_bookmark(title="unrelated content")
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_search_and_bundle_search_terms(self):
+        bundle = self.setup_bundle(search="bundle_term_B")
+        search = BookmarkSearch(q="search_term_A", bundle=bundle)
+
+        matching_bookmarks = [
+            self.setup_bookmark(
+                title="search_term_A content", description="bundle_term_B also here"
+            )
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(title="search_term_A only")
+        self.setup_bookmark(description="bundle_term_B only")
+        self.setup_bookmark(title="unrelated content")
+
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_bundle_any_tags(self):
+        bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
+
+        tag1 = self.setup_tag(name="bundleTag1")
+        tag2 = self.setup_tag(name="bundleTag2")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(tags=[tag1]),
+            self.setup_bookmark(tags=[tag2]),
+            self.setup_bookmark(tags=[tag1, tag2]),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_search_tags_and_bundle_any_tags(self):
+        bundle = self.setup_bundle(any_tags="bundleTagA bundleTagB")
+        search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
+
+        search_tag1 = self.setup_tag(name="searchTag1")
+        search_tag2 = self.setup_tag(name="searchTag2")
+        bundle_tag_a = self.setup_tag(name="bundleTagA")
+        bundle_tag_b = self.setup_tag(name="bundleTagB")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a]),
+            self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_b]),
+            self.setup_bookmark(
+                tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
+            ),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[search_tag1, search_tag2, other_tag])
+        self.setup_bookmark(tags=[search_tag1, search_tag2])
+        self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
+        self.setup_bookmark(tags=[search_tag2, bundle_tag_b])
+        self.setup_bookmark(tags=[bundle_tag_a])
+        self.setup_bookmark(tags=[bundle_tag_b])
+        self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_bundle_all_tags(self):
+        bundle = self.setup_bundle(all_tags="bundleTag1 bundleTag2")
+
+        tag1 = self.setup_tag(name="bundleTag1")
+        tag2 = self.setup_tag(name="bundleTag2")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [self.setup_bookmark(tags=[tag1, tag2])]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[tag1])
+        self.setup_bookmark(tags=[tag2])
+        self.setup_bookmark(tags=[tag1, other_tag])
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_search_tags_and_bundle_all_tags(self):
+        bundle = self.setup_bundle(all_tags="bundleTagA bundleTagB")
+        search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
+
+        search_tag1 = self.setup_tag(name="searchTag1")
+        search_tag2 = self.setup_tag(name="searchTag2")
+        bundle_tag_a = self.setup_tag(name="bundleTagA")
+        bundle_tag_b = self.setup_tag(name="bundleTagB")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(
+                tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
+            )
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a])
+        self.setup_bookmark(tags=[search_tag1, bundle_tag_a, bundle_tag_b])
+        self.setup_bookmark(tags=[search_tag1, search_tag2])
+        self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
+        self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_bundle_excluded_tags(self):
+        bundle = self.setup_bundle(excluded_tags="excludeTag1 excludeTag2")
+
+        exclude_tag1 = self.setup_tag(name="excludeTag1")
+        exclude_tag2 = self.setup_tag(name="excludeTag2")
+        keep_tag = self.setup_tag(name="keepTag")
+        keep_other_tag = self.setup_tag(name="keepOtherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(tags=[keep_tag]),
+            self.setup_bookmark(tags=[keep_other_tag]),
+            self.setup_bookmark(tags=[keep_tag, keep_other_tag]),
+            self.setup_bookmark(),
+        ]
+
+        # Bookmarks that should not be returned
+        self.setup_bookmark(tags=[exclude_tag1])
+        self.setup_bookmark(tags=[exclude_tag2])
+        self.setup_bookmark(tags=[exclude_tag1, keep_tag])
+        self.setup_bookmark(tags=[exclude_tag2, keep_tag])
+        self.setup_bookmark(tags=[exclude_tag1, exclude_tag2])
+        self.setup_bookmark(tags=[exclude_tag1, exclude_tag2, keep_tag])
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_bundle_combined_tags(self):
+        bundle = self.setup_bundle(
+            any_tags="anyTagA anyTagB",
+            all_tags="allTag1 allTag2",
+            excluded_tags="excludedTag",
+        )
+
+        any_tag_a = self.setup_tag(name="anyTagA")
+        any_tag_b = self.setup_tag(name="anyTagB")
+        all_tag_1 = self.setup_tag(name="allTag1")
+        all_tag_2 = self.setup_tag(name="allTag2")
+        other_tag = self.setup_tag(name="otherTag")
+        excluded_tag = self.setup_tag(name="excludedTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2]),
+            self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2]),
+            self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1, all_tag_2]),
+            self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, other_tag]),
+            self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, other_tag]),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[any_tag_a, all_tag_1])
+        self.setup_bookmark(tags=[any_tag_b, all_tag_2])
+        self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1])
+        self.setup_bookmark(tags=[all_tag_1, all_tag_2])
+        self.setup_bookmark(tags=[all_tag_1, all_tag_2, other_tag])
+        self.setup_bookmark(tags=[any_tag_a])
+        self.setup_bookmark(tags=[any_tag_b])
+        self.setup_bookmark(tags=[all_tag_1])
+        self.setup_bookmark(tags=[all_tag_2])
+        self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, excluded_tag])
+        self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, excluded_tag])
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_archived_bookmarks_with_bundle(self):
+        bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
+
+        tag1 = self.setup_tag(name="bundleTag1")
+        tag2 = self.setup_tag(name="bundleTag2")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(is_archived=True, tags=[tag1]),
+            self.setup_bookmark(is_archived=True, tags=[tag2]),
+            self.setup_bookmark(is_archived=True, tags=[tag1, tag2]),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(is_archived=True, tags=[other_tag])
+        self.setup_bookmark(is_archived=True)
+        self.setup_bookmark(tags=[tag1]),
+        self.setup_bookmark(tags=[tag2]),
+        self.setup_bookmark(tags=[tag1, tag2]),
+
+        query = queries.query_archived_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_shared_bookmarks_with_bundle(self):
+        user1 = self.setup_user(enable_sharing=True)
+        user2 = self.setup_user(enable_sharing=True)
+
+        bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
+
+        tag1 = self.setup_tag(name="bundleTag1")
+        tag2 = self.setup_tag(name="bundleTag2")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(user=user1, shared=True, tags=[tag1]),
+            self.setup_bookmark(user=user2, shared=True, tags=[tag2]),
+            self.setup_bookmark(user=user1, shared=True, tags=[tag1, tag2]),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(user=user1, shared=True, tags=[other_tag])
+        self.setup_bookmark(user=user2, shared=True)
+        self.setup_bookmark(user=user1, shared=False, tags=[tag1]),
+        self.setup_bookmark(user=user2, shared=False, tags=[tag2]),
+        self.setup_bookmark(user=user1, shared=False, tags=[tag1, tag2]),
+
+        query = queries.query_shared_bookmarks(
+            None, self.profile, BookmarkSearch(q="", bundle=bundle), False
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
diff --git a/bookmarks/tests/test_settings_export_view.py b/bookmarks/tests/test_settings_export_view.py
index 303f3d9..9593bd9 100644
--- a/bookmarks/tests/test_settings_export_view.py
+++ b/bookmarks/tests/test_settings_export_view.py
@@ -1,3 +1,4 @@
+import datetime
 from unittest.mock import patch
 
 from django.test import TestCase
@@ -29,9 +30,6 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response["content-type"], "text/plain; charset=UTF-8")
-        self.assertEqual(
-            response["Content-Disposition"], 'attachment; filename="bookmarks.html"'
-        )
 
         for bookmark in Bookmark.objects.all():
             self.assertContains(response, bookmark.url)
@@ -78,3 +76,18 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
             self.assertFormErrorHint(
                 response, "An error occurred during bookmark export."
             )
+
+    def test_filename_includes_date_and_time(self):
+        self.setup_bookmark()
+
+        # Mock timezone.now to return a fixed datetime for predictable filename
+        fixed_time = datetime.datetime(
+            2023, 5, 15, 14, 30, 45, tzinfo=datetime.timezone.utc
+        )
+
+        with patch("bookmarks.views.settings.timezone.now", return_value=fixed_time):
+            response = self.client.get(reverse("linkding:settings.export"), follow=True)
+
+        self.assertEqual(response.status_code, 200)
+        expected_filename = 'attachment; filename="bookmarks_2023-05-15_14-30-45.html"'
+        self.assertEqual(response["Content-Disposition"], expected_filename)
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index a99a04a..ad250bb 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -48,6 +48,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "items_per_page": "30",
             "sticky_pagination": False,
             "collapse_side_panel": False,
+            "hide_bundles": False,
         }
 
         return {**form_data, **overrides}
@@ -119,6 +120,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "items_per_page": "10",
             "sticky_pagination": True,
             "collapse_side_panel": True,
+            "hide_bundles": True,
         }
         response = self.client.post(
             reverse("linkding:settings.update"), form_data, follow=True
@@ -199,6 +201,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(
             self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
         )
+        self.assertEqual(self.user.profile.hide_bundles, form_data["hide_bundles"])
 
         self.assertSuccessMessage(html, "Profile updated")
 
diff --git a/bookmarks/tests/test_tag_cloud_template.py b/bookmarks/tests/test_tag_cloud_template.py
index 04cad07..5736cc6 100644
--- a/bookmarks/tests/test_tag_cloud_template.py
+++ b/bookmarks/tests/test_tag_cloud_template.py
@@ -6,7 +6,7 @@ from django.template import Template, RequestContext
 from django.test import TestCase, RequestFactory
 
 from bookmarks.middlewares import LinkdingMiddleware
-from bookmarks.models import UserProfile
+from bookmarks.models import BookmarkSearch, UserProfile
 from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
 from bookmarks.views import contexts
 
@@ -24,7 +24,10 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         middleware = LinkdingMiddleware(lambda r: HttpResponse())
         middleware(request)
 
-        tag_cloud_context = context_type(request)
+        search = BookmarkSearch.from_request(
+            request, request.GET, request.user_profile.search_preferences
+        )
+        tag_cloud_context = context_type(request, search)
         context = RequestContext(request, {"tag_cloud": tag_cloud_context})
         template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
         return template_to_render.render(context)
diff --git a/bookmarks/tests/test_toasts_view.py b/bookmarks/tests/test_toasts_view.py
index 9d4d93a..4144c8d 100644
--- a/bookmarks/tests/test_toasts_view.py
+++ b/bookmarks/tests/test_toasts_view.py
@@ -38,7 +38,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
         response = self.client.get(reverse("linkding:bookmarks.index"))
 
         # Should render toasts container
-        self.assertContains(response, '<div class="toasts">')
+        self.assertContains(response, '<div class="message-list">')
         # Should render two toasts
         self.assertContains(response, '<div class="toast d-flex">', count=2)
 
@@ -50,7 +50,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
         response = self.client.get(reverse("linkding:bookmarks.index"))
 
         # Should not render toasts container
-        self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
+        self.assertContains(response, '<div class="message-list">', count=0)
         # Should not render toasts
         self.assertContains(response, '<div class="toast">', count=0)
 
@@ -66,7 +66,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
         response = self.client.get(reverse("linkding:bookmarks.index"))
 
         # Should not render toasts container
-        self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
+        self.assertContains(response, '<div class="message-list">', count=0)
         # Should not render toasts
         self.assertContains(response, '<div class="toast">', count=0)
 
diff --git a/bookmarks/tests/test_user_select_tag.py b/bookmarks/tests/test_user_select_tag.py
index ed2980f..94b09c8 100644
--- a/bookmarks/tests/test_user_select_tag.py
+++ b/bookmarks/tests/test_user_select_tag.py
@@ -12,7 +12,7 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
         request = rf.get(url)
         request.user = self.get_or_create_test_user()
         request.user_profile = self.get_or_create_test_user().profile
-        search = BookmarkSearch.from_request(request.GET)
+        search = BookmarkSearch.from_request(request, request.GET)
         context = RequestContext(
             request,
             {
diff --git a/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py b/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py
index c36e56f..4aee919 100644
--- a/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py
+++ b/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py
@@ -124,7 +124,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
             # Cancel edit, verify return to details url
             details_url = url + f"&details={bookmark.id}"
             with self.page.expect_navigation(url=self.live_server_url + details_url):
-                self.page.get_by_text("Nevermind").click()
+                self.page.get_by_text("Cancel").click()
 
     def test_delete(self):
         bookmark = self.setup_bookmark()
diff --git a/bookmarks/tests_e2e/e2e_test_bundle_preview.py b/bookmarks/tests_e2e/e2e_test_bundle_preview.py
new file mode 100644
index 0000000..44e5b6e
--- /dev/null
+++ b/bookmarks/tests_e2e/e2e_test_bundle_preview.py
@@ -0,0 +1,50 @@
+from django.urls import reverse
+from playwright.sync_api import sync_playwright, expect
+
+from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
+
+
+class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
+    def test_update_preview_on_filter_changes(self):
+        group1 = self.setup_numbered_bookmarks(3, prefix="foo")
+        group2 = self.setup_numbered_bookmarks(3, prefix="bar")
+
+        with sync_playwright() as p:
+            # shows all bookmarks initially
+            page = self.open(reverse("linkding:bundles.new"), p)
+
+            expect(
+                page.get_by_text(f"Found 6 bookmarks matching this bundle")
+            ).to_be_visible()
+            self.assertVisibleBookmarks(group1 + group2)
+
+            # filter by group1
+            search = page.get_by_label("Search")
+            search.fill("foo")
+
+            expect(
+                page.get_by_text(f"Found 3 bookmarks matching this bundle")
+            ).to_be_visible()
+            self.assertVisibleBookmarks(group1)
+
+            # filter by group2
+            search.fill("bar")
+
+            expect(
+                page.get_by_text(f"Found 3 bookmarks matching this bundle")
+            ).to_be_visible()
+            self.assertVisibleBookmarks(group2)
+
+            # filter by invalid group
+            search.fill("invalid")
+
+            expect(
+                page.get_by_text(f"No bookmarks match the current bundle")
+            ).to_be_visible()
+            self.assertVisibleBookmarks([])
+
+    def assertVisibleBookmarks(self, bookmarks):
+        self.assertEqual(len(bookmarks), self.count_bookmarks())
+
+        for bookmark in bookmarks:
+            expect(self.locate_bookmark(bookmark.title)).to_be_visible()
diff --git a/bookmarks/tests_e2e/helpers.py b/bookmarks/tests_e2e/helpers.py
index 48ce248..cdfce86 100644
--- a/bookmarks/tests_e2e/helpers.py
+++ b/bookmarks/tests_e2e/helpers.py
@@ -49,6 +49,10 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
         bookmark_tags = self.page.locator("li[ld-bookmark-item]")
         return bookmark_tags.filter(has_text=title)
 
+    def count_bookmarks(self):
+        bookmark_tags = self.page.locator("li[ld-bookmark-item]")
+        return bookmark_tags.count()
+
     def locate_details_modal(self):
         return self.page.locator(".modal.bookmark-details")