Repository: sissbruecker/linkding
Pull request #693 (2024-04-11 17:03:37)

Refactor client-side fetch logic


Patch:
diff --git a/bookmarks/frontend/behaviors/bookmark-page.js b/bookmarks/frontend/behaviors/bookmark-page.js
index e60d82e..304d8ce 100644
--- a/bookmarks/frontend/behaviors/bookmark-page.js
+++ b/bookmarks/frontend/behaviors/bookmark-page.js
@@ -1,63 +1,4 @@
-import { registerBehavior, swapContent } from "./index";
-
-class BookmarkPage {
-  constructor(element) {
-    this.element = element;
-    this.form = element.querySelector("form.bookmark-actions");
-    this.form.addEventListener("submit", this.onFormSubmit.bind(this));
-
-    this.bookmarkList = element.querySelector(".bookmark-list-container");
-    this.tagCloud = element.querySelector(".tag-cloud-container");
-
-    document.addEventListener("bookmark-page-refresh", () => {
-      this.refresh();
-    });
-  }
-
-  async onFormSubmit(event) {
-    event.preventDefault();
-
-    const url = this.form.action;
-    const formData = new FormData(this.form);
-    formData.append(event.submitter.name, event.submitter.value);
-
-    await fetch(url, {
-      method: "POST",
-      body: formData,
-      redirect: "manual", // ignore redirect
-    });
-    await this.refresh();
-  }
-
-  async refresh() {
-    const query = window.location.search;
-    const bookmarksUrl = this.element.getAttribute("bookmarks-url");
-    const tagsUrl = this.element.getAttribute("tags-url");
-    Promise.all([
-      fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
-      fetch(`${tagsUrl}${query}`).then((response) => response.text()),
-    ]).then(([bookmarkListHtml, tagCloudHtml]) => {
-      swapContent(this.bookmarkList, bookmarkListHtml);
-      swapContent(this.tagCloud, tagCloudHtml);
-
-      // Dispatch list updated event
-      const listElement = this.bookmarkList.querySelector(
-        "ul[data-bookmarks-total]",
-      );
-      const bookmarksTotal =
-        (listElement && listElement.dataset.bookmarksTotal) || 0;
-
-      this.bookmarkList.dispatchEvent(
-        new CustomEvent("bookmark-list-updated", {
-          bubbles: true,
-          detail: { bookmarksTotal },
-        }),
-      );
-    });
-  }
-}
-
-registerBehavior("ld-bookmark-page", BookmarkPage);
+import { registerBehavior } from "./index";
 
 class BookmarkItem {
   constructor(element) {
diff --git a/bookmarks/frontend/behaviors/bulk-edit.js b/bookmarks/frontend/behaviors/bulk-edit.js
index f0927c6..57af913 100644
--- a/bookmarks/frontend/behaviors/bulk-edit.js
+++ b/bookmarks/frontend/behaviors/bulk-edit.js
@@ -4,43 +4,56 @@ class BulkEdit {
   constructor(element) {
     this.element = element;
     this.active = false;
-    this.actionSelect = element.querySelector("select[name='bulk_action']");
-    this.tagAutoComplete = element.querySelector(".tag-autocomplete");
-    this.selectAcross = element.querySelector("label.select-across");
 
-    element.addEventListener(
-      "bulk-edit-toggle-active",
-      this.onToggleActive.bind(this),
-    );
-    element.addEventListener(
-      "bulk-edit-toggle-all",
-      this.onToggleAll.bind(this),
+    this.onToggleActive = this.onToggleActive.bind(this);
+    this.onToggleAll = this.onToggleAll.bind(this);
+    this.onToggleBookmark = this.onToggleBookmark.bind(this);
+    this.onActionSelected = this.onActionSelected.bind(this);
+
+    this.init();
+    // Reset when bookmarks are refreshed
+    document.addEventListener("refresh-bookmark-list-done", () => this.init());
+  }
+
+  init() {
+    // Update elements
+    this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
+    this.actionSelect = this.element.querySelector(
+      "select[name='bulk_action']",
     );
-    element.addEventListener(
-      "bulk-edit-toggle-bookmark",
-      this.onToggleBookmark.bind(this),
+    this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
+    this.selectAcross = this.element.querySelector("label.select-across");
+    this.allCheckbox = this.element.querySelector(
+      ".bulk-edit-checkbox.all input",
     );
-    element.addEventListener(
-      "bookmark-list-updated",
-      this.onListUpdated.bind(this),
+    this.bookmarkCheckboxes = Array.from(
+      this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
     );
 
-    this.actionSelect.addEventListener(
-      "change",
-      this.onActionSelected.bind(this),
-    );
-  }
+    // Remove previous listeners if elements are the same
+    this.activeToggle.removeEventListener("click", this.onToggleActive);
+    this.actionSelect.removeEventListener("change", this.onActionSelected);
+    this.allCheckbox.removeEventListener("change", this.onToggleAll);
+    this.bookmarkCheckboxes.forEach((checkbox) => {
+      checkbox.removeEventListener("change", this.onToggleBookmark);
+    });
 
-  get allCheckbox() {
-    return this.element.querySelector("[ld-bulk-edit-checkbox][all] input");
-  }
+    // Reset checkbox states
+    this.reset();
 
-  get bookmarkCheckboxes() {
-    return [
-      ...this.element.querySelectorAll(
-        "[ld-bulk-edit-checkbox]:not([all]) input",
-      ),
-    ];
+    // Update total number of bookmarks
+    const totalHolder = this.element.querySelector("[data-bookmarks-total]");
+    const total = totalHolder?.dataset.bookmarksTotal || 0;
+    const totalSpan = this.selectAcross.querySelector("span.total");
+    totalSpan.textContent = total;
+
+    // Add new listeners
+    this.activeToggle.addEventListener("click", this.onToggleActive);
+    this.actionSelect.addEventListener("change", this.onActionSelected);
+    this.allCheckbox.addEventListener("change", this.onToggleAll);
+    this.bookmarkCheckboxes.forEach((checkbox) => {
+      checkbox.addEventListener("change", this.onToggleBookmark);
+    });
   }
 
   onToggleActive() {
@@ -81,16 +94,6 @@ class BulkEdit {
     }
   }
 
-  onListUpdated(event) {
-    // Reset checkbox states
-    this.reset();
-
-    // Update total number of bookmarks
-    const total = event.detail.bookmarksTotal;
-    const totalSpan = this.selectAcross.querySelector("span.total");
-    totalSpan.textContent = total;
-  }
-
   updateSelectAcross(allChecked) {
     if (allChecked) {
       this.selectAcross.classList.remove("d-none");
@@ -109,33 +112,4 @@ class BulkEdit {
   }
 }
 
-class BulkEditActiveToggle {
-  constructor(element) {
-    this.element = element;
-    element.addEventListener("click", this.onClick.bind(this));
-  }
-
-  onClick() {
-    this.element.dispatchEvent(
-      new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
-    );
-  }
-}
-
-class BulkEditCheckbox {
-  constructor(element) {
-    this.element = element;
-    element.addEventListener("change", this.onChange.bind(this));
-  }
-
-  onChange() {
-    const type = this.element.hasAttribute("all") ? "all" : "bookmark";
-    this.element.dispatchEvent(
-      new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
-    );
-  }
-}
-
 registerBehavior("ld-bulk-edit", BulkEdit);
-registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
-registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);
diff --git a/bookmarks/frontend/behaviors/confirm-button.js b/bookmarks/frontend/behaviors/confirm-button.js
index fbea591..7bf0b1a 100644
--- a/bookmarks/frontend/behaviors/confirm-button.js
+++ b/bookmarks/frontend/behaviors/confirm-button.js
@@ -19,7 +19,7 @@ class ConfirmButtonBehavior {
     const container = document.createElement("span");
     container.className = "confirmation";
 
-    const icon = this.button.getAttribute("confirm-icon");
+    const icon = this.button.getAttribute("ld-confirm-icon");
     if (icon) {
       const iconElement = document.createElementNS(
         "http://www.w3.org/2000/svg",
@@ -31,7 +31,7 @@ class ConfirmButtonBehavior {
       container.append(iconElement);
     }
 
-    const question = this.button.getAttribute("confirm-question");
+    const question = this.button.getAttribute("ld-confirm-question");
     if (question) {
       const questionElement = document.createElement("span");
       questionElement.innerText = question;
diff --git a/bookmarks/frontend/behaviors/fetch.js b/bookmarks/frontend/behaviors/fetch.js
new file mode 100644
index 0000000..3a65511
--- /dev/null
+++ b/bookmarks/frontend/behaviors/fetch.js
@@ -0,0 +1,25 @@
+import { fireEvents, registerBehavior, swap } from "./index";
+
+class FetchBehavior {
+  constructor(element) {
+    this.element = element;
+    const eventName = element.getAttribute("ld-on");
+
+    element.addEventListener(eventName, this.onFetch.bind(this));
+  }
+
+  async onFetch(event) {
+    event.preventDefault();
+    const url = this.element.getAttribute("ld-fetch");
+    const html = await fetch(url).then((response) => response.text());
+
+    const target = this.element.getAttribute("ld-target");
+    const select = this.element.getAttribute("ld-select");
+    swap(this.element, html, { target, select });
+
+    const events = this.element.getAttribute("ld-fire");
+    fireEvents(events);
+  }
+}
+
+registerBehavior("ld-fetch", FetchBehavior);
diff --git a/bookmarks/frontend/behaviors/form.js b/bookmarks/frontend/behaviors/form.js
index 5b78c31..7a30f4c 100644
--- a/bookmarks/frontend/behaviors/form.js
+++ b/bookmarks/frontend/behaviors/form.js
@@ -1,12 +1,12 @@
-import { registerBehavior, swap } from "./index";
+import { fireEvents, registerBehavior } from "./index";
 
 class FormBehavior {
   constructor(element) {
     this.element = element;
-    element.addEventListener("submit", this.onFormSubmit.bind(this));
+    element.addEventListener("submit", this.onSubmit.bind(this));
   }
 
-  async onFormSubmit(event) {
+  async onSubmit(event) {
     event.preventDefault();
 
     const url = this.element.action;
@@ -21,34 +21,21 @@ class FormBehavior {
       redirect: "manual", // ignore redirect
     });
 
-    // Dispatch refresh events
-    const refreshEvents = this.element.getAttribute("refresh-events");
-    if (refreshEvents) {
-      refreshEvents.split(",").forEach((eventName) => {
-        document.dispatchEvent(new CustomEvent(eventName));
-      });
+    const events = this.element.getAttribute("ld-fire");
+    if (fireEvents) {
+      fireEvents(events);
     }
-
-    // Refresh form
-    await this.refresh();
-  }
-
-  async refresh() {
-    const refreshUrl = this.element.getAttribute("refresh-url");
-    const html = await fetch(refreshUrl).then((response) => response.text());
-    swap(this.element, html);
   }
 }
 
-class FormAutoSubmitBehavior {
+class AutoSubmitBehavior {
   constructor(element) {
-    this.element = element;
-    this.element.addEventListener("change", () => {
-      const form = this.element.closest("form");
+    element.addEventListener("change", () => {
+      const form = element.closest("form");
       form.dispatchEvent(new Event("submit", { cancelable: true }));
     });
   }
 }
 
 registerBehavior("ld-form", FormBehavior);
-registerBehavior("ld-form-auto-submit", FormAutoSubmitBehavior);
+registerBehavior("ld-auto-submit", AutoSubmitBehavior);
diff --git a/bookmarks/frontend/behaviors/index.js b/bookmarks/frontend/behaviors/index.js
index d7b79e8..6a87653 100644
--- a/bookmarks/frontend/behaviors/index.js
+++ b/bookmarks/frontend/behaviors/index.js
@@ -37,14 +37,49 @@ export function applyBehaviors(container, behaviorNames = null) {
   });
 }
 
-export function swap(element, html) {
+export function swap(element, html, options) {
   const dom = new DOMParser().parseFromString(html, "text/html");
-  const newElement = dom.body.firstChild;
-  element.replaceWith(newElement);
-  applyBehaviors(newElement);
+
+  let targetElement = element;
+  let strategy = "innerHTML";
+  if (options.target) {
+    const parts = options.target.split("|");
+    targetElement =
+      parts[0] === "self" ? element : document.querySelector(parts[0]);
+    strategy = parts[1] || "innerHTML";
+  }
+
+  let contents = Array.from(dom.body.children);
+  if (options.select) {
+    contents = Array.from(dom.querySelectorAll(options.select));
+  }
+
+  switch (strategy) {
+    case "append":
+      targetElement.append(...contents);
+      break;
+    case "outerHTML":
+      targetElement.parentElement.replaceChild(contents[0], targetElement);
+      break;
+    case "innerHTML":
+    default:
+      targetElement.innerHTML = "";
+      targetElement.append(...contents);
+  }
+  contents.forEach((content) => applyBehaviors(content));
 }
 
-export function swapContent(element, html) {
-  element.innerHTML = html;
-  applyBehaviors(element);
+export function fireEvents(events) {
+  if (!events) {
+    return;
+  }
+  events.split(",").forEach((eventName) => {
+    const targets = Array.from(
+      document.querySelectorAll(`[ld-on='${eventName}']`),
+    );
+    targets.push(document);
+    targets.forEach((target) => {
+      target.dispatchEvent(new CustomEvent(eventName));
+    });
+  });
 }
diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/behaviors/modal.js
index 592fde7..b83bb0e 100644
--- a/bookmarks/frontend/behaviors/modal.js
+++ b/bookmarks/frontend/behaviors/modal.js
@@ -1,97 +1,20 @@
-import { applyBehaviors, registerBehavior } from "./index";
+import { registerBehavior } from "./index";
 
 class ModalBehavior {
   constructor(element) {
-    const toggle = element;
-    toggle.addEventListener("click", this.onToggleClick.bind(this));
-    this.toggle = toggle;
-  }
-
-  async onToggleClick(event) {
-    // Ignore Ctrl + click
-    if (event.ctrlKey || event.metaKey) {
-      return;
-    }
-    event.preventDefault();
-    event.stopPropagation();
-
-    // Create modal either by teleporting existing content or fetching from URL
-    const modal = this.toggle.hasAttribute("modal-content")
-      ? this.createFromContent()
-      : await this.createFromUrl();
-
-    if (!modal) {
-      return;
-    }
+    this.element = element;
 
-    // Register close handlers
-    const modalOverlay = modal.querySelector(".modal-overlay");
-    const closeButton = modal.querySelector("button.close");
+    const modalOverlay = element.querySelector(".modal-overlay");
+    const closeButton = element.querySelector("button.close");
     modalOverlay.addEventListener("click", this.onClose.bind(this));
     closeButton.addEventListener("click", this.onClose.bind(this));
-
-    document.body.append(modal);
-    applyBehaviors(document.body);
-    this.modal = modal;
-  }
-
-  async createFromUrl() {
-    const url = this.toggle.getAttribute("modal-url");
-    const modalHtml = await fetch(url).then((response) => response.text());
-    const parser = new DOMParser();
-    const doc = parser.parseFromString(modalHtml, "text/html");
-    return doc.querySelector(".modal");
-  }
-
-  createFromContent() {
-    const contentSelector = this.toggle.getAttribute("modal-content");
-    const content = document.querySelector(contentSelector);
-    if (!content) {
-      return;
-    }
-
-    // Todo: make title configurable, only used for tag cloud for now
-    const modal = document.createElement("div");
-    modal.classList.add("modal", "active");
-    modal.innerHTML = `
-      <div class="modal-overlay" aria-label="Close"></div>
-      <div class="modal-container">
-        <div class="modal-header d-flex justify-between align-center">
-          <div class="modal-title h5">Tags</div>
-          <button class="close">
-          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
-            <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
-            <path d="M18 6l-12 12"></path>
-            <path d="M6 6l12 12"></path>
-          </svg>
-          </button>
-        </div>
-        <div class="modal-body">
-          <div class="content"></div>
-        </div>
-      </div>
-    `;
-
-    const contentOwner = content.parentElement;
-    const contentContainer = modal.querySelector(".content");
-    contentContainer.append(content);
-    this.content = content;
-    this.contentOwner = contentOwner;
-
-    return modal;
   }
 
   onClose() {
-    // Teleport content back
-    if (this.content && this.contentOwner) {
-      this.contentOwner.append(this.content);
-    }
-
-    // Remove modal
-    this.modal.classList.add("closing");
-    this.modal.addEventListener("animationend", (event) => {
+    this.element.classList.add("closing");
+    this.element.addEventListener("animationend", (event) => {
       if (event.animationName === "fade-out") {
-        this.modal.remove();
+        this.element.remove();
       }
     });
   }
diff --git a/bookmarks/frontend/index.js b/bookmarks/frontend/index.js
index 0ea20f0..af81505 100644
--- a/bookmarks/frontend/index.js
+++ b/bookmarks/frontend/index.js
@@ -2,6 +2,7 @@ import "./behaviors/bookmark-page";
 import "./behaviors/bulk-edit";
 import "./behaviors/confirm-button";
 import "./behaviors/dropdown";
+import "./behaviors/fetch";
 import "./behaviors/form";
 import "./behaviors/modal";
 import "./behaviors/global-shortcuts";
diff --git a/bookmarks/styles/bookmark-page.scss b/bookmarks/styles/bookmark-page.scss
index cc0e010..8ce1e03 100644
--- a/bookmarks/styles/bookmark-page.scss
+++ b/bookmarks/styles/bookmark-page.scss
@@ -130,7 +130,7 @@ li[ld-bookmark-item] {
   position: relative;
   margin-top: $unit-2;
 
-  [ld-bulk-edit-checkbox].form-checkbox {
+  .form-checkbox.bulk-edit-checkbox {
     display: none;
   }
 
@@ -323,7 +323,7 @@ $bulk-edit-transition-duration: 400ms;
   }
 
   /* All checkbox */
-  [ld-bulk-edit-checkbox][all].form-checkbox {
+  .form-checkbox.bulk-edit-checkbox.all {
     display: block;
     width: $bulk-edit-toggle-width;
     margin: 0 0 0 $bulk-edit-toggle-offset;
@@ -331,7 +331,7 @@ $bulk-edit-transition-duration: 400ms;
   }
 
   /* Bookmark checkboxes */
-  li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
+  li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
     display: block;
     position: absolute;
     width: $bulk-edit-toggle-width;
@@ -350,7 +350,7 @@ $bulk-edit-transition-duration: 400ms;
     }
   }
 
-  &.active li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
+  &.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
     visibility: visible;
     opacity: 1;
   }
diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html
index d7c0b5c..f7e3b0f 100644
--- a/bookmarks/templates/bookmarks/archive.html
+++ b/bookmarks/templates/bookmarks/archive.html
@@ -4,11 +4,7 @@
 {% load bookmarks %}
 
 {% block content %}
-  <div class="bookmarks-page grid columns-md-1"
-       ld-bulk-edit
-       ld-bookmark-page
-       bookmarks-url="{% url 'bookmarks:partials.bookmark_list.archived' %}"
-       tags-url="{% url 'bookmarks:partials.tag_cloud.archived' %}">
+  <div ld-bulk-edit class="bookmarks-page grid columns-md-1">
 
     {# Bookmark list #}
     <section class="content-area col-2">
@@ -17,17 +13,22 @@
         <div class="header-controls">
           {% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
           {% include 'bookmarks/bulk_edit/toggle.html' %}
-          <button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
+          <button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
+                  class="btn ml-2 show-md">Tags
+          </button>
         </div>
       </div>
 
-      <form class="bookmark-actions"
+      <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
+            class="bookmark-actions"
             action="{{ bookmark_list.action_url|safe }}"
             method="post" autocomplete="off">
         {% csrf_token %}
         {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
 
-        <div class="bookmark-list-container">
+        <div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
+             ld-fire="refresh-bookmark-list-done"
+             class="bookmark-list-container">
           {% include 'bookmarks/bookmark_list.html' %}
         </div>
       </form>
@@ -38,7 +39,8 @@
       <div class="content-area-header">
         <h2>Tags</h2>
       </div>
-      <div class="tag-cloud-container">
+      <div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
+           class="tag-cloud-container">
         {% include 'bookmarks/tag_cloud.html' %}
       </div>
     </section>
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index bc1808c..8478885 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -11,7 +11,7 @@
     {% for bookmark_item in bookmark_list.items %}
       <li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
         <div class="title">
-          <label ld-bulk-edit-checkbox class="form-checkbox">
+          <label class="form-checkbox bulk-edit-checkbox">
             <input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
             <i class="form-icon"></i>
           </label>
@@ -81,8 +81,8 @@
           {% endif %}
           {# View link is visible for both owned and shared bookmarks #}
           {% if bookmark_list.show_view_action %}
-            <a ld-modal
-               modal-url="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
+            <a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
+               ld-on="click" ld-target="body|append"
                href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
           {% endif %}
           {% if bookmark_item.is_editable %}
@@ -118,7 +118,7 @@
               {% 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 confirm-icon="ld-icon-read" confirm-question="Mark as read?">
+                        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>
@@ -128,7 +128,7 @@
               {% 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 confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
+                        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>
diff --git a/bookmarks/templates/bookmarks/bulk_edit/bar.html b/bookmarks/templates/bookmarks/bulk_edit/bar.html
index 11645e9..65837d7 100644
--- a/bookmarks/templates/bookmarks/bulk_edit/bar.html
+++ b/bookmarks/templates/bookmarks/bulk_edit/bar.html
@@ -2,7 +2,7 @@
 {% htmlmin %}
   <div class="bulk-edit-bar">
     <div class="bulk-edit-actions bg-gray">
-      <label ld-bulk-edit-checkbox all class="form-checkbox">
+      <label class="form-checkbox bulk-edit-checkbox all">
         <input type="checkbox">
         <i class="form-icon"></i>
       </label>
diff --git a/bookmarks/templates/bookmarks/bulk_edit/toggle.html b/bookmarks/templates/bookmarks/bulk_edit/toggle.html
index 13cbaed..8eb1d95 100644
--- a/bookmarks/templates/bookmarks/bulk_edit/toggle.html
+++ b/bookmarks/templates/bookmarks/bulk_edit/toggle.html
@@ -1,4 +1,4 @@
-<button ld-bulk-edit-active-toggle class="btn hide-sm ml-2" title="Bulk edit">
+<button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
        height="20px">
     <path
diff --git a/bookmarks/templates/bookmarks/details/form.html b/bookmarks/templates/bookmarks/details/form.html
index f9c3006..e7449b6 100644
--- a/bookmarks/templates/bookmarks/details/form.html
+++ b/bookmarks/templates/bookmarks/details/form.html
@@ -1,9 +1,8 @@
 {% load static %}
 {% load shared %}
 
-<form ld-form action="{% url 'bookmarks:details' details.bookmark.id %}"
-      refresh-url="{% url 'bookmarks:partials.details_form' details.bookmark.id %}"
-      refresh-events="bookmark-page-refresh"
+<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud,refresh-details"
+      action="{% url 'bookmarks:details' details.bookmark.id %}"
       method="post">
   <div class="weblinks">
     <a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
@@ -35,14 +34,14 @@
           {% csrf_token %}
           <div class="form-group">
             <label class="form-switch">
-              <input ld-form-auto-submit type="checkbox" name="is_archived"
+              <input ld-auto-submit type="checkbox" name="is_archived"
                      {% if details.bookmark.is_archived %}checked{% endif %}>
               <i class="form-icon"></i> Archived
             </label>
           </div>
           <div class="form-group">
             <label class="form-switch">
-              <input ld-form-auto-submit type="checkbox" name="unread"
+              <input ld-auto-submit type="checkbox" name="unread"
                      {% if details.bookmark.unread %}checked{% endif %}>
               <i class="form-icon"></i> Unread
             </label>
@@ -50,7 +49,7 @@
           {% if details.profile.enable_sharing %}
             <div class="form-group">
               <label class="form-switch">
-                <input ld-form-auto-submit type="checkbox" name="shared"
+                <input ld-auto-submit type="checkbox" name="shared"
                        {% if details.bookmark.shared %}checked{% endif %}>
                 <i class="form-icon"></i> Shared
               </label>
diff --git a/bookmarks/templates/bookmarks/details_modal.html b/bookmarks/templates/bookmarks/details_modal.html
index 844f318..51551b0 100644
--- a/bookmarks/templates/bookmarks/details_modal.html
+++ b/bookmarks/templates/bookmarks/details_modal.html
@@ -1,4 +1,7 @@
-<div class="modal active bookmark-details">
+<div ld-modal
+     ld-fetch="{% url 'bookmarks:details_modal' details.bookmark.id %}" ld-on="refresh-details"
+     ld-select=".content" ld-target=".modal.bookmark-details .content|outerHTML"
+     class="modal active bookmark-details">
   <div class="modal-overlay" aria-label="Close"></div>
   <div class="modal-container">
     <div class="modal-header">
diff --git a/bookmarks/templates/bookmarks/index.html b/bookmarks/templates/bookmarks/index.html
index 5608ad1..279fa5e 100644
--- a/bookmarks/templates/bookmarks/index.html
+++ b/bookmarks/templates/bookmarks/index.html
@@ -4,11 +4,7 @@
 {% load bookmarks %}
 
 {% block content %}
-  <div class="bookmarks-page grid columns-md-1"
-       ld-bulk-edit
-       ld-bookmark-page
-       bookmarks-url="{% url 'bookmarks:partials.bookmark_list.active' %}"
-       tags-url="{% url 'bookmarks:partials.tag_cloud.active' %}">
+  <div ld-bulk-edit class="bookmarks-page grid columns-md-1">
 
     {# Bookmark list #}
     <section class="content-area col-2">
@@ -17,17 +13,22 @@
         <div class="header-controls">
           {% bookmark_search bookmark_list.search tag_cloud.tags %}
           {% include 'bookmarks/bulk_edit/toggle.html' %}
-          <button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
+          <button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
+                  class="btn ml-2 show-md">Tags
+          </button>
         </div>
       </div>
 
-      <form class="bookmark-actions"
+      <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
+            class="bookmark-actions"
             action="{{ bookmark_list.action_url|safe }}"
             method="post" autocomplete="off">
         {% csrf_token %}
         {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
 
-        <div class="bookmark-list-container">
+        <div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
+             ld-fire="refresh-bookmark-list-done"
+             class="bookmark-list-container">
           {% include 'bookmarks/bookmark_list.html' %}
         </div>
       </form>
@@ -38,7 +39,8 @@
       <div class="content-area-header">
         <h2>Tags</h2>
       </div>
-      <div class="tag-cloud-container">
+      <div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
+           class="tag-cloud-container">
         {% include 'bookmarks/tag_cloud.html' %}
       </div>
     </section>
diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html
index a4913cd..d34ed42 100644
--- a/bookmarks/templates/bookmarks/shared.html
+++ b/bookmarks/templates/bookmarks/shared.html
@@ -4,10 +4,7 @@
 {% load bookmarks %}
 
 {% block content %}
-  <div class="bookmarks-page grid columns-md-1"
-       ld-bookmark-page
-       bookmarks-url="{% url 'bookmarks:partials.bookmark_list.shared' %}"
-       tags-url="{% url 'bookmarks:partials.tag_cloud.shared' %}">
+  <div class="bookmarks-page grid columns-md-1">
 
     {# Bookmark list #}
     <section class="content-area col-2">
@@ -15,15 +12,20 @@
         <h2>Shared bookmarks</h2>
         <div class="header-controls">
           {% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
-          <button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
+          <button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
+                  class="btn ml-2 show-md">Tags
+          </button>
         </div>
       </div>
 
-      <form class="bookmark-actions" action="{{ bookmark_list.action_url|safe }}"
-            method="post">
+      <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
+            class="bookmark-actions"
+            action="{{ bookmark_list.action_url|safe }}"
+            method="post" autocomplete="off">
         {% csrf_token %}
-
-        <div class="bookmark-list-container">
+        <div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
+             ld-fire="refresh-bookmark-list-done"
+             class="bookmark-list-container">
           {% include 'bookmarks/bookmark_list.html' %}
         </div>
       </form>
@@ -41,7 +43,8 @@
       <div class="content-area-header">
         <h2>Tags</h2>
       </div>
-      <div class="tag-cloud-container">
+      <div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
+           class="tag-cloud-container">
         {% include 'bookmarks/tag_cloud.html' %}
       </div>
     </section>
diff --git a/bookmarks/templates/bookmarks/tag_modal.html b/bookmarks/templates/bookmarks/tag_modal.html
new file mode 100644
index 0000000..9549f1e
--- /dev/null
+++ b/bookmarks/templates/bookmarks/tag_modal.html
@@ -0,0 +1,21 @@
+<div ld-modal class="modal active">
+  <div class="modal-overlay" aria-label="Close"></div>
+  <div class="modal-container">
+    <div class="modal-header d-flex justify-between align-center">
+      <div class="modal-title h5">Tags</div>
+      <button class="close">
+        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
+             stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
+          <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+          <path d="M18 6l-12 12"></path>
+          <path d="M6 6l12 12"></path>
+        </svg>
+      </button>
+    </div>
+    <div class="modal-body">
+      <div class="content">
+        {% include 'bookmarks/tag_cloud.html' %}
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index 36e0b11..61a9e99 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -61,6 +61,11 @@ urlpatterns = [
         partials.active_tag_cloud,
         name="partials.tag_cloud.active",
     ),
+    path(
+        "bookmarks/partials/tag-modal/active",
+        partials.active_tag_modal,
+        name="partials.tag_modal.active",
+    ),
     path(
         "bookmarks/partials/bookmark-list/archived",
         partials.archived_bookmark_list,
@@ -71,6 +76,11 @@ urlpatterns = [
         partials.archived_tag_cloud,
         name="partials.tag_cloud.archived",
     ),
+    path(
+        "bookmarks/partials/tag-modal/archived",
+        partials.archived_tag_modal,
+        name="partials.tag_modal.archived",
+    ),
     path(
         "bookmarks/partials/bookmark-list/shared",
         partials.shared_bookmark_list,
@@ -82,9 +92,9 @@ urlpatterns = [
         name="partials.tag_cloud.shared",
     ),
     path(
-        "bookmarks/partials/details-form/<int:bookmark_id>",
-        partials.details_form,
-        name="partials.details_form",
+        "bookmarks/partials/tag-modal/shared",
+        partials.shared_tag_modal,
+        name="partials.tag_modal.shared",
     ),
     # Settings
     path("settings", views.settings.general, name="settings.index"),
diff --git a/bookmarks/views/partials/__init__.py b/bookmarks/views/partials/__init__.py
index 7df6162..2204149 100644
--- a/bookmarks/views/partials/__init__.py
+++ b/bookmarks/views/partials/__init__.py
@@ -1,8 +1,6 @@
 from django.contrib.auth.decorators import login_required
-from django.http import Http404
 from django.shortcuts import render
 
-from bookmarks.models import Bookmark
 from bookmarks.views.partials import contexts
 
 
@@ -24,6 +22,13 @@ def active_tag_cloud(request):
     return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
 
 
+@login_required
+def active_tag_modal(request):
+    tag_cloud_context = contexts.ActiveTagCloudContext(request)
+
+    return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
+
+
 @login_required
 def archived_bookmark_list(request):
     bookmark_list_context = contexts.ArchivedBookmarkListContext(request)
@@ -43,6 +48,12 @@ def archived_tag_cloud(request):
 
 
 @login_required
+def archived_tag_modal(request):
+    tag_cloud_context = contexts.ArchivedTagCloudContext(request)
+
+    return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
+
+
 def shared_bookmark_list(request):
     bookmark_list_context = contexts.SharedBookmarkListContext(request)
 
@@ -53,20 +64,13 @@ def shared_bookmark_list(request):
     )
 
 
-@login_required
 def shared_tag_cloud(request):
     tag_cloud_context = contexts.SharedTagCloudContext(request)
 
     return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
 
 
-@login_required
-def details_form(request, bookmark_id: int):
-    try:
-        bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
-    except Bookmark.DoesNotExist:
-        raise Http404("Bookmark does not exist")
-
-    details_context = contexts.BookmarkDetailsContext(request, bookmark)
+def shared_tag_modal(request):
+    tag_cloud_context = contexts.SharedTagCloudContext(request)
 
-    return render(request, "bookmarks/details/form.html", {"details": details_context})
+    return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
diff --git a/bookmarks/views/partials/contexts.py b/bookmarks/views/partials/contexts.py
index eac5bae..c58ed3b 100644
--- a/bookmarks/views/partials/contexts.py
+++ b/bookmarks/views/partials/contexts.py
@@ -23,6 +23,113 @@ DEFAULT_PAGE_SIZE = 30
 CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
 
 
+class RequestContext:
+    index_view = "bookmarks:index"
+    action_view = "bookmarks:index.action"
+    bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
+    tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
+    tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
+
+    def __init__(self, request: WSGIRequest):
+        self.request = request
+        self.index_url = reverse(self.index_view)
+        self.action_url = reverse(self.action_view)
+        self.bookmark_list_partial_url = reverse(self.bookmark_list_partial_view)
+        self.tag_cloud_partial_url = reverse(self.tag_cloud_partial_view)
+        self.tag_modal_partial_url = reverse(self.tag_modal_partial_view)
+        self.query_params = request.GET.copy()
+        self.query_params.pop("details", None)
+
+    def get_url(self, view_url: str, add: dict = None, remove: dict = None) -> str:
+        query_params = self.query_params.copy()
+        if add:
+            query_params.update(add)
+        if remove:
+            for key in remove:
+                query_params.pop(key, None)
+        encoded_params = query_params.urlencode()
+        return view_url + "?" + encoded_params if encoded_params else view_url
+
+    def index(self) -> str:
+        return self.get_url(self.index_url)
+
+    def action(self, return_url: str) -> str:
+        return self.get_url(self.action_url, add={"return_url": return_url})
+
+    def bookmark_list_partial(self) -> str:
+        return self.get_url(self.bookmark_list_partial_url)
+
+    def tag_cloud_partial(self) -> str:
+        return self.get_url(self.tag_cloud_partial_url)
+
+    def tag_modal_partial(self) -> str:
+        return self.get_url(self.tag_modal_partial_url)
+
+    def get_bookmark_query_set(self, search: BookmarkSearch):
+        raise Exception("Must be implemented by subclass")
+
+    def get_tag_query_set(self, search: BookmarkSearch):
+        raise Exception("Must be implemented by subclass")
+
+
+class ActiveBookmarksContext(RequestContext):
+    index_view = "bookmarks:index"
+    action_view = "bookmarks:index.action"
+    bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
+    tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
+    tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
+
+    def get_bookmark_query_set(self, search: BookmarkSearch):
+        return queries.query_bookmarks(
+            self.request.user, self.request.user_profile, search
+        )
+
+    def get_tag_query_set(self, search: BookmarkSearch):
+        return queries.query_bookmark_tags(
+            self.request.user, self.request.user_profile, search
+        )
+
+
+class ArchivedBookmarksContext(RequestContext):
+    index_view = "bookmarks:archived"
+    action_view = "bookmarks:archived.action"
+    bookmark_list_partial_view = "bookmarks:partials.bookmark_list.archived"
+    tag_cloud_partial_view = "bookmarks:partials.tag_cloud.archived"
+    tag_modal_partial_view = "bookmarks:partials.tag_modal.archived"
+
+    def get_bookmark_query_set(self, search: BookmarkSearch):
+        return queries.query_archived_bookmarks(
+            self.request.user, self.request.user_profile, search
+        )
+
+    def get_tag_query_set(self, search: BookmarkSearch):
+        return queries.query_archived_bookmark_tags(
+            self.request.user, self.request.user_profile, search
+        )
+
+
+class SharedBookmarksContext(RequestContext):
+    index_view = "bookmarks:shared"
+    action_view = "bookmarks:shared.action"
+    bookmark_list_partial_view = "bookmarks:partials.bookmark_list.shared"
+    tag_cloud_partial_view = "bookmarks:partials.tag_cloud.shared"
+    tag_modal_partial_view = "bookmarks:partials.tag_modal.shared"
+
+    def get_bookmark_query_set(self, search: BookmarkSearch):
+        user = User.objects.filter(username=search.user).first()
+        public_only = not self.request.user.is_authenticated
+        return queries.query_shared_bookmarks(
+            user, self.request.user_profile, search, public_only
+        )
+
+    def get_tag_query_set(self, search: BookmarkSearch):
+        user = User.objects.filter(username=search.user).first()
+        public_only = not self.request.user.is_authenticated
+        return queries.query_shared_bookmark_tags(
+            user, self.request.user_profile, search, public_only
+        )
+
+
 class BookmarkItem:
     def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
         self.bookmark = bookmark
@@ -67,7 +174,10 @@ class BookmarkItem:
 
 
 class BookmarkListContext:
+    request_context = RequestContext
+
     def __init__(self, request: WSGIRequest) -> None:
+        request_context = self.request_context(request)
         user = request.user
         user_profile = request.user_profile
 
@@ -76,7 +186,7 @@ class BookmarkListContext:
             self.request.GET, user_profile.search_preferences
         )
 
-        query_set = self.get_bookmark_query_set()
+        query_set = request_context.get_bookmark_query_set(self.search)
         page_number = request.GET.get("page")
         paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
         bookmarks_page = paginator.get_page(page_number)
@@ -86,16 +196,15 @@ class BookmarkListContext:
         self.items = [
             BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page
         ]
-
         self.is_empty = paginator.count == 0
         self.bookmarks_page = bookmarks_page
         self.bookmarks_total = paginator.count
-        self.return_url = self.generate_return_url(
-            self.search, self.get_base_url(), page_number
-        )
-        self.action_url = self.generate_action_url(
-            self.search, self.get_base_action_url(), self.return_url
-        )
+
+        self.return_url = request_context.index()
+        self.action_url = request_context.action(return_url=self.return_url)
+        self.refresh_url = request_context.bookmark_list_partial()
+        self.tag_modal_url = request_context.tag_modal_partial()
+
         self.link_target = user_profile.bookmark_link_target
         self.date_display = user_profile.bookmark_date_display
         self.description_display = user_profile.bookmark_description_display
@@ -131,55 +240,17 @@ class BookmarkListContext:
             else base_action_url + "?" + query_string
         )
 
-    def get_base_url(self):
-        raise Exception("Must be implemented by subclass")
-
-    def get_base_action_url(self):
-        raise Exception("Must be implemented by subclass")
-
-    def get_bookmark_query_set(self):
-        raise Exception("Must be implemented by subclass")
-
 
 class ActiveBookmarkListContext(BookmarkListContext):
-    def get_base_url(self):
-        return reverse("bookmarks:index")
-
-    def get_base_action_url(self):
-        return reverse("bookmarks:index.action")
-
-    def get_bookmark_query_set(self):
-        return queries.query_bookmarks(
-            self.request.user, self.request.user_profile, self.search
-        )
+    request_context = ActiveBookmarksContext
 
 
 class ArchivedBookmarkListContext(BookmarkListContext):
-    def get_base_url(self):
-        return reverse("bookmarks:archived")
-
-    def get_base_action_url(self):
-        return reverse("bookmarks:archived.action")
-
-    def get_bookmark_query_set(self):
-        return queries.query_archived_bookmarks(
-            self.request.user, self.request.user_profile, self.search
-        )
+    request_context = ArchivedBookmarksContext
 
 
 class SharedBookmarkListContext(BookmarkListContext):
-    def get_base_url(self):
-        return reverse("bookmarks:shared")
-
-    def get_base_action_url(self):
-        return reverse("bookmarks:shared.action")
-
-    def get_bookmark_query_set(self):
-        user = User.objects.filter(username=self.search.user).first()
-        public_only = not self.request.user.is_authenticated
-        return queries.query_shared_bookmarks(
-            user, self.request.user_profile, self.search, public_only
-        )
+    request_context = SharedBookmarksContext
 
 
 class TagGroup:
@@ -218,7 +289,10 @@ class TagGroup:
 
 
 class TagCloudContext:
+    request_context = RequestContext
+
     def __init__(self, request: WSGIRequest) -> None:
+        request_context = self.request_context(request)
         user_profile = request.user_profile
 
         self.request = request
@@ -226,7 +300,7 @@ class TagCloudContext:
             self.request.GET, user_profile.search_preferences
         )
 
-        query_set = self.get_tag_query_set()
+        query_set = request_context.get_tag_query_set(self.search)
         tags = list(query_set)
         selected_tags = self.get_selected_tags(tags)
         unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
@@ -242,8 +316,7 @@ class TagCloudContext:
         self.selected_tags = unique_selected_tags
         self.has_selected_tags = has_selected_tags
 
-    def get_tag_query_set(self):
-        raise Exception("Must be implemented by subclass")
+        self.refresh_url = request_context.tag_cloud_partial()
 
     def get_selected_tags(self, tags: List[Tag]):
         parsed_query = queries.parse_query_string(self.search.q)
@@ -256,26 +329,15 @@ class TagCloudContext:
 
 
 class ActiveTagCloudContext(TagCloudContext):
-    def get_tag_query_set(self):
-        return queries.query_bookmark_tags(
-            self.request.user, self.request.user_profile, self.search
-        )
+    request_context = ActiveBookmarksContext
 
 
 class ArchivedTagCloudContext(TagCloudContext):
-    def get_tag_query_set(self):
-        return queries.query_archived_bookmark_tags(
-            self.request.user, self.request.user_profile, self.search
-        )
+    request_context = ArchivedBookmarksContext
 
 
 class SharedTagCloudContext(TagCloudContext):
-    def get_tag_query_set(self):
-        user = User.objects.filter(username=self.search.user).first()
-        public_only = not self.request.user.is_authenticated
-        return queries.query_shared_bookmark_tags(
-            user, self.request.user_profile, self.search, public_only
-        )
+    request_context = SharedBookmarksContext
 
 
 class BookmarkAssetItem:
diff --git a/package.json b/package.json
index 7f182e8..8766df5 100644
--- a/package.json
+++ b/package.json
@@ -28,5 +28,6 @@
   },
   "devDependencies": {
     "prettier": "^3.0.2"
-  }
+  },
+  "web-types": "./web-types.json"
 }
diff --git a/web-types.json b/web-types.json
new file mode 100644
index 0000000..cb5e6ca
--- /dev/null
+++ b/web-types.json
@@ -0,0 +1,116 @@
+{
+  "$schema": "https://raw.githubusercontent.com/JetBrains/web-types/master/schema/web-types.json",
+  "name": "linkding",
+  "version": "1.0.0",
+  "contributions": {
+    "html": {
+      "attributes": [
+        {
+          "name": "ld-fetch",
+          "description": "Fetches the HTML content of the given URL and replaces the content of an element with it. Fires events afterwards to notify other behaviors.",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-on",
+          "description": "The event that triggers a fetch, such as `click` or a custom event name fired by another behavior",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-target",
+          "description": "The target element to replace the content of and the replacement strategy, for example `body|append`",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-fire",
+          "description": "Fires one or more events once a behavior, such as ld-fetch or ld-form, is finished",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-form",
+          "description": "Converts a form into a fetch request. Fires events afterwards to notify other behaviors.",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-auto-submit",
+          "description": "Automatically submits the nearest form when the value of the input changes",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-modal",
+          "description": "Adds Javascript behavior to a modal HTML component",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-dropdown",
+          "description": "Adds Javascript behavior to a dropdown HTML component",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-confirm-button",
+          "description": "Converts a button into a confirmation button that shows confirm / cancel buttons when clicked",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-confirm-icon",
+          "description": "Icon to show when the confirm button is clicked",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-confirm-question",
+          "description": "Question to show when the confirm button is clicked",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-bookmark-item",
+          "description": "Adds Javascript behavior to a bookmark list item",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-bulk-edit",
+          "description": "Adds Javascript behavior for bulk editing the bookmark list",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-global-shortcuts",
+          "description": "Adds Javascript behavior for global shortcuts",
+          "value": {
+            "required": false
+          }
+        },
+        {
+          "name": "ld-tag-autocomplete",
+          "description": "Adds Javascript behavior for converting a plain input into a tag autocomplete Svelte component",
+          "value": {
+            "required": false
+          }
+        }
+      ]
+    }
+  }
+}


Test patch:
diff --git a/bookmarks/e2e/e2e_test_bookmark_details_modal.py b/bookmarks/e2e/e2e_test_bookmark_details_modal.py
index d3eb183..beef9bd 100644
--- a/bookmarks/e2e/e2e_test_bookmark_details_modal.py
+++ b/bookmarks/e2e/e2e_test_bookmark_details_modal.py
@@ -1,3 +1,4 @@
+from django.test import override_settings
 from django.urls import reverse
 from playwright.sync_api import sync_playwright, expect
 
@@ -44,14 +45,17 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
             details_modal = self.open_details_modal(bookmark)
             details_modal.get_by_text("Archived", exact=False).click()
             expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
+            self.assertReloads(0)
 
             # unarchive
             url = reverse("bookmarks:archived")
             self.page.goto(self.live_server_url + url)
+            self.resetReloads()
 
             details_modal = self.open_details_modal(bookmark)
             details_modal.get_by_text("Archived", exact=False).click()
             expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
+            self.assertReloads(0)
 
     def test_toggle_unread(self):
         bookmark = self.setup_bookmark()
@@ -66,11 +70,13 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
             details_modal.get_by_text("Unread").click()
             bookmark_item = self.locate_bookmark(bookmark.title)
             expect(bookmark_item.get_by_text("Unread")).to_be_visible()
+            self.assertReloads(0)
 
             # mark as read
             details_modal.get_by_text("Unread").click()
             bookmark_item = self.locate_bookmark(bookmark.title)
             expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
+            self.assertReloads(0)
 
     def test_toggle_shared(self):
         profile = self.get_or_create_test_user().profile
@@ -89,11 +95,13 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
             details_modal.get_by_text("Shared").click()
             bookmark_item = self.locate_bookmark(bookmark.title)
             expect(bookmark_item.get_by_text("Shared")).to_be_visible()
+            self.assertReloads(0)
 
             # unshare bookmark
             details_modal.get_by_text("Shared").click()
             bookmark_item = self.locate_bookmark(bookmark.title)
             expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
+            self.assertReloads(0)
 
     def test_edit_return_url(self):
         bookmark = self.setup_bookmark()
@@ -131,3 +139,33 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
             expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
 
         self.assertEqual(Bookmark.objects.count(), 0)
+
+    @override_settings(LD_ENABLE_SNAPSHOTS=True)
+    def test_create_snapshot_remove_snapshot(self):
+        bookmark = self.setup_bookmark()
+
+        with sync_playwright() as p:
+            url = reverse("bookmarks:index") + f"?q={bookmark.title}"
+            self.open(url, p)
+
+            details_modal = self.open_details_modal(bookmark)
+            asset_list = details_modal.locator(".assets")
+
+            # No snapshots initially
+            snapshot = asset_list.get_by_text("HTML snapshot from", exact=False)
+            expect(snapshot).not_to_be_visible()
+
+            # Create snapshot
+            details_modal.get_by_text("Create HTML snapshot", exact=False).click()
+            self.assertReloads(0)
+
+            # Has new snapshots
+            expect(snapshot).to_be_visible()
+
+            # Create snapshot
+            asset_list.get_by_text("Remove", exact=False).click()
+            asset_list.get_by_text("Confirm", exact=False).click()
+
+            # Snapshot is removed
+            expect(snapshot).not_to_be_visible()
+            self.assertReloads(0)
diff --git a/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py b/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
index ac20326..91918fd 100644
--- a/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
+++ b/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
@@ -194,7 +194,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
 
             self.locate_bulk_edit_toggle().click()
 
-            checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
+            checkboxes = page.locator("label.bulk-edit-checkbox input")
             self.assertEqual(6, checkboxes.count())
             for i in range(checkboxes.count()):
                 expect(checkboxes.nth(i)).not_to_be_checked()
@@ -264,13 +264,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
 
             # Hide select across by toggling a single bookmark
             self.locate_bookmark("Bookmark 1").locator(
-                "label[ld-bulk-edit-checkbox]"
+                "label.bulk-edit-checkbox"
             ).click()
             expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
 
             # Show select across again, verify it is unchecked
             self.locate_bookmark("Bookmark 1").locator(
-                "label[ld-bulk-edit-checkbox]"
+                "label.bulk-edit-checkbox"
             ).click()
             expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
 
@@ -297,7 +297,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
             expect(bookmark_list).not_to_be_visible()
 
             # Verify bulk edit checkboxes are reset
-            checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
+            checkboxes = page.locator("label.bulk-edit-checkbox input")
             self.assertEqual(31, checkboxes.count())
             for i in range(checkboxes.count()):
                 expect(checkboxes.nth(i)).not_to_be_checked()
diff --git a/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py b/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py
index 2d77283..a2e9d62 100644
--- a/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py
+++ b/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py
@@ -169,7 +169,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
 
             self.locate_bulk_edit_toggle().click()
             self.locate_bookmark("Bookmark 2").locator(
-                "label[ld-bulk-edit-checkbox]"
+                "label.bulk-edit-checkbox"
             ).click()
             self.select_bulk_action("Archive")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -187,7 +187,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
 
             self.locate_bulk_edit_toggle().click()
             self.locate_bookmark("Bookmark 2").locator(
-                "label[ld-bulk-edit-checkbox]"
+                "label.bulk-edit-checkbox"
             ).click()
             self.select_bulk_action("Delete")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -230,7 +230,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
 
             self.locate_bulk_edit_toggle().click()
             self.locate_bookmark("Archived Bookmark 2").locator(
-                "label[ld-bulk-edit-checkbox]"
+                "label.bulk-edit-checkbox"
             ).click()
             self.select_bulk_action("Unarchive")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -248,7 +248,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
 
             self.locate_bulk_edit_toggle().click()
             self.locate_bookmark("Archived Bookmark 2").locator(
-                "label[ld-bulk-edit-checkbox]"
+                "label.bulk-edit-checkbox"
             ).click()
             self.select_bulk_action("Delete")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
diff --git a/bookmarks/e2e/e2e_test_tag_cloud_modal.py b/bookmarks/e2e/e2e_test_tag_cloud_modal.py
new file mode 100644
index 0000000..19793eb
--- /dev/null
+++ b/bookmarks/e2e/e2e_test_tag_cloud_modal.py
@@ -0,0 +1,76 @@
+from django.test import override_settings
+from django.urls import reverse
+from playwright.sync_api import sync_playwright, expect, Locator
+
+from bookmarks.e2e.helpers import LinkdingE2ETestCase
+from bookmarks.models import Bookmark
+
+
+class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
+    def test_show_modal_close_modal(self):
+        self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
+        self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
+
+        with sync_playwright() as p:
+            page = self.open(reverse("bookmarks:index"), p)
+
+            # use smaller viewport to make tags button visible
+            page.set_viewport_size({"width": 375, "height": 812})
+
+            # open tag cloud modal
+            modal_trigger = page.locator(".content-area-header").get_by_role(
+                "button", name="Tags"
+            )
+            modal_trigger.click()
+
+            # verify modal is visible
+            modal = page.locator(".modal")
+            expect(modal).to_be_visible()
+            expect(modal.locator(".modal-title")).to_have_text("Tags")
+
+            # close with close button
+            modal.locator("button.close").click()
+            expect(modal).to_be_hidden()
+
+            # open modal again
+            modal_trigger.click()
+
+            # close with backdrop
+            backdrop = modal.locator(".modal-overlay")
+            backdrop.click(position={"x": 0, "y": 0})
+            expect(modal).to_be_hidden()
+
+    def test_select_tag(self):
+        self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
+        self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
+
+        with sync_playwright() as p:
+            page = self.open(reverse("bookmarks:index"), p)
+
+            # use smaller viewport to make tags button visible
+            page.set_viewport_size({"width": 375, "height": 812})
+
+            # open tag cloud modal
+            modal_trigger = page.locator(".content-area-header").get_by_role(
+                "button", name="Tags"
+            )
+            modal_trigger.click()
+
+            # verify tags are displayed
+            modal = page.locator(".modal")
+            unselected_tags = modal.locator(".unselected-tags")
+            expect(unselected_tags.get_by_text("cooking")).to_be_visible()
+            expect(unselected_tags.get_by_text("hiking")).to_be_visible()
+
+            # select tag
+            unselected_tags.get_by_text("cooking").click()
+
+            # open modal again
+            modal_trigger.click()
+
+            # verify tag is selected, other tag is not visible anymore
+            selected_tags = modal.locator(".selected-tags")
+            expect(selected_tags.get_by_text("cooking")).to_be_visible()
+
+            expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
+            expect(unselected_tags.get_by_text("hiking")).not_to_be_visible()
diff --git a/bookmarks/e2e/helpers.py b/bookmarks/e2e/helpers.py
index 99c4fed..c19852e 100644
--- a/bookmarks/e2e/helpers.py
+++ b/bookmarks/e2e/helpers.py
@@ -39,6 +39,9 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
     def assertReloads(self, count: int):
         self.assertEqual(self.num_loads, count)
 
+    def resetReloads(self):
+        self.num_loads = 0
+
     def locate_bookmark_list(self):
         return self.page.locator("ul[ld-bookmark-list]")
 
@@ -62,7 +65,7 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
         return self.page.locator(".bulk-edit-bar")
 
     def locate_bulk_edit_select_all(self):
-        return self.locate_bulk_edit_bar().locator("label[ld-bulk-edit-checkbox][all]")
+        return self.locate_bulk_edit_bar().locator("label.bulk-edit-checkbox.all")
 
     def locate_bulk_edit_select_across(self):
         return self.locate_bulk_edit_bar().locator("label.select-across")
diff --git a/bookmarks/tests/test_bookmark_archived_view.py b/bookmarks/tests/test_bookmark_archived_view.py
index a082487..4b357b6 100644
--- a/bookmarks/tests/test_bookmark_archived_view.py
+++ b/bookmarks/tests/test_bookmark_archived_view.py
@@ -94,15 +94,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         )
 
     def assertBulkActionForm(self, response, url: str):
-        html = collapse_whitespace(response.content.decode())
-        needle = collapse_whitespace(
-            f"""
-            <form class="bookmark-actions"
-                action="{url}"
-                method="post" autocomplete="off">
-        """
-        )
-        self.assertIn(needle, html)
+        soup = self.make_soup(response.content.decode())
+        form = soup.select_one("form.bookmark-actions")
+        self.assertIsNotNone(form)
+        self.assertEqual(form.attrs["action"], url)
 
     def test_should_list_archived_and_user_owned_bookmarks(self):
         other_user = User.objects.create_user(
diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py
index 2b18435..b9a7065 100644
--- a/bookmarks/tests/test_bookmark_details_modal.py
+++ b/bookmarks/tests/test_bookmark_details_modal.py
@@ -105,18 +105,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
     def test_access_with_sharing(self):
         self.details_route_sharing_access_test(self.get_view_name(), True)
 
-    def test_form_partial_access(self):
-        # form partial is only used when submitting forms, which should be only
-        # accessible to the owner of the bookmark. As such assume it requires
-        # login.
-        self.details_route_access_test("bookmarks:partials.details_form", False)
-
-    def test_form_partial_access_with_sharing(self):
-        # form partial is only used when submitting forms, which should be only
-        # accessible to the owner of the bookmark. As such assume it requires
-        # login.
-        self.details_route_sharing_access_test("bookmarks:partials.details_form", False)
-
     def test_displays_title(self):
         # with title
         bookmark = self.setup_bookmark(title="Test title")
diff --git a/bookmarks/tests/test_bookmark_index_view.py b/bookmarks/tests/test_bookmark_index_view.py
index 9f1859f..f7be4a1 100644
--- a/bookmarks/tests/test_bookmark_index_view.py
+++ b/bookmarks/tests/test_bookmark_index_view.py
@@ -94,15 +94,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         )
 
     def assertBulkActionForm(self, response, url: str):
-        html = collapse_whitespace(response.content.decode())
-        needle = collapse_whitespace(
-            f"""
-            <form class="bookmark-actions"
-                action="{url}"
-                method="post" autocomplete="off">
-        """
-        )
-        self.assertIn(needle, html)
+        soup = self.make_soup(response.content.decode())
+        form = soup.select_one("form.bookmark-actions")
+        self.assertIsNotNone(form)
+        self.assertEqual(form.attrs["action"], url)
 
     def test_should_list_unarchived_and_user_owned_bookmarks(self):
         other_user = User.objects.create_user(
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index e76bc7c..ab89545 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -80,7 +80,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
         self.assertInHTML(
             f"""
-                <a ld-modal modal-url="{details_modal_url}?return_url={return_url}" href="{details_url}">View</a>
+                <a ld-fetch="{details_modal_url}?return_url={return_url}" 
+                   ld-on="click" ld-target="body|append" 
+                   href="{details_url}">View</a>
             """,
             html,
             count=count,
@@ -216,7 +218,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
             f"""
         <button type="submit" name="unshare" value="{bookmark.id}"
                 class="btn btn-link btn-sm btn-icon"
-                ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
+                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>
@@ -232,7 +234,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
             f"""
         <button type="submit" name="mark_as_read" value="{bookmark.id}"
                 class="btn btn-link btn-sm btn-icon"
-                ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
+                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>
@@ -782,10 +784,16 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
 
     def test_note_cleans_html(self):
         self.setup_bookmark(notes='<script>alert("test")</script>')
+        self.setup_bookmark(
+            notes='<b ld-fetch="https://example.com" ld-on="click">bold text</b>'
+        )
         html = self.render_template()
 
         note_html = '&lt;script&gt;alert("test")&lt;/script&gt;'
-        self.assertNotes(html, note_html, 1)
+        self.assertIn(note_html, html, 1)
+
+        note_html = "<b>bold text</b>"
+        self.assertIn(note_html, html, 1)
 
     def test_notes_are_hidden_initially_by_default(self):
         self.setup_bookmark(notes="Test note")