Repository: sissbruecker/linkding
Pull request #661 (2024-03-20 09:54:21)

Add new hosting option


Patch:
diff --git a/README.md b/README.md
index 6967bfd..5c9f270 100644
--- a/README.md
+++ b/README.md
@@ -184,6 +184,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
 - [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
 - [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](#pikapods))
 - [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
+- [Railway.app](https://railway.app/) - Deploy linkding through [template](https://railway.app/template/BxdomX). By [tianheg](https://github.com/tianheg)
 
 ## Documentation
 
diff --git a/bookmarks/frontend/behaviors/bookmark-details.js b/bookmarks/frontend/behaviors/bookmark-details.js
new file mode 100644
index 0000000..eccc8c3
--- /dev/null
+++ b/bookmarks/frontend/behaviors/bookmark-details.js
@@ -0,0 +1,38 @@
+import { registerBehavior } from "./index";
+
+class BookmarkDetails {
+  constructor(element) {
+    this.form = element.querySelector(".status form");
+    if (!this.form) {
+      // Form may not exist if user does not own the bookmark
+      return;
+    }
+    this.form.addEventListener("submit", (event) => {
+      event.preventDefault();
+      this.submitForm();
+    });
+
+    const inputs = this.form.querySelectorAll("input");
+    inputs.forEach((input) => {
+      input.addEventListener("change", () => {
+        this.submitForm();
+      });
+    });
+  }
+
+  async submitForm() {
+    const url = this.form.action;
+    const formData = new FormData(this.form);
+
+    await fetch(url, {
+      method: "POST",
+      body: formData,
+      redirect: "manual", // ignore redirect
+    });
+
+    // Refresh bookmark page if it exists
+    document.dispatchEvent(new CustomEvent("bookmark-page-refresh"));
+  }
+}
+
+registerBehavior("ld-bookmark-details", BookmarkDetails);
diff --git a/bookmarks/frontend/behaviors/bookmark-page.js b/bookmarks/frontend/behaviors/bookmark-page.js
index 4b5574f..89b1b76 100644
--- a/bookmarks/frontend/behaviors/bookmark-page.js
+++ b/bookmarks/frontend/behaviors/bookmark-page.js
@@ -8,6 +8,10 @@ class BookmarkPage {
 
     this.bookmarkList = element.querySelector(".bookmark-list-container");
     this.tagCloud = element.querySelector(".tag-cloud-container");
+
+    document.addEventListener("bookmark-page-refresh", () => {
+      this.refresh();
+    });
   }
 
   async onFormSubmit(event) {
diff --git a/bookmarks/frontend/behaviors/confirm-button.js b/bookmarks/frontend/behaviors/confirm-button.js
index 6b6ea01..fbea591 100644
--- a/bookmarks/frontend/behaviors/confirm-button.js
+++ b/bookmarks/frontend/behaviors/confirm-button.js
@@ -38,10 +38,14 @@ class ConfirmButtonBehavior {
       container.append(question);
     }
 
+    const buttonClasses = Array.from(this.button.classList.values())
+      .filter((cls) => cls.startsWith("btn"))
+      .join(" ");
+
     const cancelButton = document.createElement(this.button.nodeName);
     cancelButton.type = "button";
     cancelButton.innerText = question ? "No" : "Cancel";
-    cancelButton.className = "btn btn-link btn-sm mr-1";
+    cancelButton.className = `${buttonClasses} mr-1`;
     cancelButton.addEventListener("click", this.reset.bind(this));
 
     const confirmButton = document.createElement(this.button.nodeName);
@@ -49,7 +53,7 @@ class ConfirmButtonBehavior {
     confirmButton.name = this.button.dataset.name;
     confirmButton.value = this.button.dataset.value;
     confirmButton.innerText = question ? "Yes" : "Confirm";
-    confirmButton.className = "btn btn-link btn-sm";
+    confirmButton.className = buttonClasses;
     confirmButton.addEventListener("click", this.reset.bind(this));
 
     container.append(cancelButton, confirmButton);
diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/behaviors/modal.js
index e75543d..592fde7 100644
--- a/bookmarks/frontend/behaviors/modal.js
+++ b/bookmarks/frontend/behaviors/modal.js
@@ -1,4 +1,4 @@
-import { registerBehavior } from "./index";
+import { applyBehaviors, registerBehavior } from "./index";
 
 class ModalBehavior {
   constructor(element) {
@@ -7,14 +7,50 @@ class ModalBehavior {
     this.toggle = toggle;
   }
 
-  onToggleClick() {
+  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;
+    }
+
+    // Register close handlers
+    const modalOverlay = modal.querySelector(".modal-overlay");
+    const closeButton = modal.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;
     }
 
-    // Create modal
+    // Todo: make title configurable, only used for tag cloud for now
     const modal = document.createElement("div");
     modal.classList.add("modal", "active");
     modal.innerHTML = `
@@ -22,7 +58,7 @@ class ModalBehavior {
       <div class="modal-container">
         <div class="modal-header d-flex justify-between align-center">
           <div class="modal-title h5">Tags</div>
-          <button class="btn btn-link close">
+          <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>
@@ -36,29 +72,28 @@ class ModalBehavior {
       </div>
     `;
 
-    // Teleport content element
     const contentOwner = content.parentElement;
     const contentContainer = modal.querySelector(".content");
     contentContainer.append(content);
     this.content = content;
     this.contentOwner = contentOwner;
 
-    // Register close handlers
-    const modalOverlay = modal.querySelector(".modal-overlay");
-    const closeButton = modal.querySelector(".btn.close");
-    modalOverlay.addEventListener("click", this.onClose.bind(this));
-    closeButton.addEventListener("click", this.onClose.bind(this));
-
-    document.body.append(modal);
-    this.modal = modal;
+    return modal;
   }
 
   onClose() {
     // Teleport content back
-    this.contentOwner.append(this.content);
+    if (this.content && this.contentOwner) {
+      this.contentOwner.append(this.content);
+    }
 
     // Remove modal
-    this.modal.remove();
+    this.modal.classList.add("closing");
+    this.modal.addEventListener("animationend", (event) => {
+      if (event.animationName === "fade-out") {
+        this.modal.remove();
+      }
+    });
   }
 }
 
diff --git a/bookmarks/frontend/components/TagAutocomplete.svelte b/bookmarks/frontend/components/TagAutocomplete.svelte
index 83d4750..5e2d736 100644
--- a/bookmarks/frontend/components/TagAutocomplete.svelte
+++ b/bookmarks/frontend/components/TagAutocomplete.svelte
@@ -151,18 +151,20 @@
     }
 
     .form-autocomplete.small .form-autocomplete-input {
-        height: 1.4rem;
-        min-height: 1.4rem;
+        height: var(--control-size-sm);
+        min-height: var(--control-size-sm);
         padding: 0.05rem 0.3rem;
     }
 
     .form-autocomplete.small .form-autocomplete-input input {
+        width: 100%;
+        height: 100%;
         margin: 0;
         padding: 0;
-        font-size: 0.7rem;
+        font-size: var(--font-size-sm);
     }
 
     .form-autocomplete.small .menu .menu-item {
-        font-size: 0.7rem;
+        font-size: var(--font-size-sm);
     }
 </style>
diff --git a/bookmarks/frontend/index.js b/bookmarks/frontend/index.js
index 94d9ebc..a6e3929 100644
--- a/bookmarks/frontend/index.js
+++ b/bookmarks/frontend/index.js
@@ -1,10 +1,11 @@
-import './behaviors/bookmark-page';
-import './behaviors/bulk-edit';
-import './behaviors/confirm-button';
-import './behaviors/dropdown';
-import './behaviors/modal';
-import './behaviors/global-shortcuts';
-import './behaviors/tag-autocomplete';
-export { default as TagAutoComplete } from './components/TagAutocomplete.svelte';
-export { default as SearchAutoComplete } from './components/SearchAutoComplete.svelte';
-export { ApiClient } from './api';
+import "./behaviors/bookmark-details";
+import "./behaviors/bookmark-page";
+import "./behaviors/bulk-edit";
+import "./behaviors/confirm-button";
+import "./behaviors/dropdown";
+import "./behaviors/modal";
+import "./behaviors/global-shortcuts";
+import "./behaviors/tag-autocomplete";
+export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
+export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
+export { ApiClient } from "./api";
diff --git a/bookmarks/migrations/0027_userprofile_bookmark_description_display_and_more.py b/bookmarks/migrations/0027_userprofile_bookmark_description_display_and_more.py
new file mode 100644
index 0000000..917fbba
--- /dev/null
+++ b/bookmarks/migrations/0027_userprofile_bookmark_description_display_and_more.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.0.2 on 2024-03-23 21:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0026_userprofile_custom_css"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="userprofile",
+            name="bookmark_description_display",
+            field=models.CharField(
+                choices=[("inline", "Inline"), ("separate", "Separate")],
+                default="inline",
+                max_length=10,
+            ),
+        ),
+        migrations.AddField(
+            model_name="userprofile",
+            name="bookmark_description_max_lines",
+            field=models.IntegerField(default=1),
+        ),
+    ]
diff --git a/bookmarks/migrations/0028_userprofile_display_archive_bookmark_action_and_more.py b/bookmarks/migrations/0028_userprofile_display_archive_bookmark_action_and_more.py
new file mode 100644
index 0000000..6af3d30
--- /dev/null
+++ b/bookmarks/migrations/0028_userprofile_display_archive_bookmark_action_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.0.2 on 2024-03-29 20:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="userprofile",
+            name="display_archive_bookmark_action",
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name="userprofile",
+            name="display_edit_bookmark_action",
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name="userprofile",
+            name="display_remove_bookmark_action",
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name="userprofile",
+            name="display_view_bookmark_action",
+            field=models.BooleanField(default=True),
+        ),
+    ]
diff --git a/bookmarks/migrations/0029_bookmark_list_actions_toast.py b/bookmarks/migrations/0029_bookmark_list_actions_toast.py
new file mode 100644
index 0000000..05891b4
--- /dev/null
+++ b/bookmarks/migrations/0029_bookmark_list_actions_toast.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.0.2 on 2024-03-29 21:25
+
+from django.db import migrations
+from django.contrib.auth import get_user_model
+
+from bookmarks.models import Toast
+
+User = get_user_model()
+
+
+def forwards(apps, schema_editor):
+
+    for user in User.objects.all():
+        toast = Toast(
+            key="bookmark_list_actions_hint",
+            message="This version adds a new link to each bookmark to view details in a dialog. If you feel there is too much clutter you can now hide individual links in the settings.",
+            owner=user,
+        )
+        toast.save()
+
+
+def reverse(apps, schema_editor):
+    Toast.objects.filter(key="bookmark_list_actions_hint").delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
+    ]
+
+    operations = [
+        migrations.RunPython(forwards, reverse),
+    ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index 8714283..c321dad 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -278,6 +278,12 @@ class UserProfile(models.Model):
         (BOOKMARK_DATE_DISPLAY_ABSOLUTE, "Absolute"),
         (BOOKMARK_DATE_DISPLAY_HIDDEN, "Hidden"),
     ]
+    BOOKMARK_DESCRIPTION_DISPLAY_INLINE = "inline"
+    BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE = "separate"
+    BOOKMARK_DESCRIPTION_DISPLAY_CHOICES = [
+        (BOOKMARK_DESCRIPTION_DISPLAY_INLINE, "Inline"),
+        (BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE, "Separate"),
+    ]
     BOOKMARK_LINK_TARGET_BLANK = "_blank"
     BOOKMARK_LINK_TARGET_SELF = "_self"
     BOOKMARK_LINK_TARGET_CHOICES = [
@@ -308,6 +314,16 @@ class UserProfile(models.Model):
         blank=False,
         default=BOOKMARK_DATE_DISPLAY_RELATIVE,
     )
+    bookmark_description_display = models.CharField(
+        max_length=10,
+        choices=BOOKMARK_DESCRIPTION_DISPLAY_CHOICES,
+        blank=False,
+        default=BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
+    )
+    bookmark_description_max_lines = models.IntegerField(
+        null=False,
+        default=1,
+    )
     bookmark_link_target = models.CharField(
         max_length=10,
         choices=BOOKMARK_LINK_TARGET_CHOICES,
@@ -330,6 +346,10 @@ class UserProfile(models.Model):
     enable_public_sharing = models.BooleanField(default=False, null=False)
     enable_favicons = models.BooleanField(default=False, null=False)
     display_url = models.BooleanField(default=False, null=False)
+    display_view_bookmark_action = models.BooleanField(default=True, null=False)
+    display_edit_bookmark_action = models.BooleanField(default=True, null=False)
+    display_archive_bookmark_action = models.BooleanField(default=True, null=False)
+    display_remove_bookmark_action = models.BooleanField(default=True, null=False)
     permanent_notes = models.BooleanField(default=False, null=False)
     custom_css = models.TextField(blank=True, null=False)
     search_preferences = models.JSONField(default=dict, null=False)
@@ -341,6 +361,8 @@ class UserProfileForm(forms.ModelForm):
         fields = [
             "theme",
             "bookmark_date_display",
+            "bookmark_description_display",
+            "bookmark_description_max_lines",
             "bookmark_link_target",
             "web_archive_integration",
             "tag_search",
@@ -348,6 +370,10 @@ class UserProfileForm(forms.ModelForm):
             "enable_public_sharing",
             "enable_favicons",
             "display_url",
+            "display_view_bookmark_action",
+            "display_edit_bookmark_action",
+            "display_archive_bookmark_action",
+            "display_remove_bookmark_action",
             "permanent_notes",
             "custom_css",
         ]
diff --git a/bookmarks/styles/base.scss b/bookmarks/styles/base.scss
index dd221fb..9caf728 100644
--- a/bookmarks/styles/base.scss
+++ b/bookmarks/styles/base.scss
@@ -9,7 +9,7 @@ body {
 }
 
 header {
-  margin-bottom: $unit-10;
+  margin-bottom: $unit-9;
 
   .logo {
     width: 28px;
@@ -50,14 +50,14 @@ section.content-area {
     border-bottom: solid 1px $border-color;
     display: flex;
     flex-wrap: wrap;
-    column-gap: $unit-6;
-    padding-bottom: $unit-2;
-    margin-bottom: $unit-4;
+    column-gap: $unit-5;
+    padding-bottom: $unit-1;
+    margin-bottom: $unit-3;
 
     h2 {
       flex: 0 0 auto;
-      line-height: 1.8rem;
-      margin-bottom: 0;
+      line-height: $unit-9;
+      margin: 0;
     }
 
     .header-controls {
@@ -95,10 +95,6 @@ span.confirmation {
   text-overflow: ellipsis;
 }
 
-.text-sm {
-  font-size: 0.7rem;
-}
-
 .text-gray-dark {
   color: $gray-color-dark;
 }
@@ -124,10 +120,6 @@ span.confirmation {
   margin-right: auto;
 }
 
-.ml-auto {
-  margin-left: auto;
-}
-
 .btn.btn-wide {
   padding-left: $unit-6;
   padding-right: $unit-6;
diff --git a/bookmarks/styles/bookmark-details.scss b/bookmarks/styles/bookmark-details.scss
new file mode 100644
index 0000000..0ac6108
--- /dev/null
+++ b/bookmarks/styles/bookmark-details.scss
@@ -0,0 +1,79 @@
+/* Common styles */
+.bookmark-details {
+  h2 {
+    flex: 1 1 0;
+    align-items: flex-start;
+    font-size: 1rem;
+    margin: 0;
+  }
+
+  .weblinks {
+    display: flex;
+    flex-direction: column;
+    gap: $unit-2;
+  }
+
+  a.weblink {
+    display: flex;
+    align-items: center;
+    gap: $unit-2;
+  }
+
+  a.weblink img, a.weblink svg {
+    flex: 0 0 auto;
+    width: 16px;
+    height: 16px;
+    color: $body-font-color;
+  }
+
+  a.weblink span {
+    flex: 1 1 0;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  dl {
+    margin-bottom: 0;
+  }
+
+  .tags a {
+    color: $alternative-color;
+  }
+
+  .status form {
+    display: flex;
+    gap: $unit-2;
+  }
+
+  .status form .form-group, .status form .form-switch {
+    margin: 0;
+  }
+
+  .actions {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+}
+
+/* Bookmark details view specific */
+.bookmark-details.page {
+  display: flex;
+  flex-direction: column;
+  gap: $unit-6;
+}
+
+/* Bookmark details modal specific */
+.bookmark-details.modal {
+  .modal-header {
+    display: flex;
+    align-items: flex-start;
+    gap: $unit-2;
+  }
+
+  .modal-body {
+    padding-top: 0;
+    padding-bottom: 0;
+  }
+}
diff --git a/bookmarks/styles/bookmark-page.scss b/bookmarks/styles/bookmark-page.scss
index 983b428..cc0e010 100644
--- a/bookmarks/styles/bookmark-page.scss
+++ b/bookmarks/styles/bookmark-page.scss
@@ -1,11 +1,10 @@
 .bookmarks-page.grid {
-  grid-gap: $unit-10;
+  grid-gap: $unit-9;
 }
 
 /* Bookmark area header controls */
 .bookmarks-page .content-area-header {
   --searchbox-max-width: 350px;
-  --searchbox-height: 1.8rem;
 
   @media (max-width: $size-sm) {
     --searchbox-max-width: initial;
@@ -20,18 +19,18 @@
 
   // Regular input
   input[type='search'] {
-    height: var(--searchbox-height);
+    height: $control-size;
     -webkit-appearance: none;
   }
 
   // Enhanced auto-complete input
   // This needs a bit more wrangling to make the CSS component align with the attached button
   .form-autocomplete {
-    height: var(--searchbox-height);
+    height: $control-size;
 
     .form-autocomplete-input {
       width: 100%;
-      height: var(--searchbox-height);
+      height: $control-size;
 
       input[type='search'] {
         width: 100%;
@@ -72,6 +71,7 @@
     .menu {
       padding: $unit-4;
       min-width: 250px;
+      font-size: $font-size-sm;
     }
 
     .menu .actions {
@@ -82,9 +82,11 @@
 
     .radio-group {
       margin-bottom: $unit-1;
+
       .form-label {
         padding-bottom: 0;
       }
+
       .form-radio.form-inline {
         margin: 0 $unit-2 0 0;
         padding: 0;
@@ -92,6 +94,7 @@
         align-items: center;
         column-gap: $unit-1;
       }
+
       .form-icon {
         top: 0;
         position: relative;
@@ -105,6 +108,9 @@ ul.bookmark-list {
   list-style: none;
   margin: 0;
   padding: 0;
+
+  /* Increase line-height for better separation within / between items */
+  line-height: 1.1rem;
 }
 
 @keyframes appear {
@@ -122,59 +128,81 @@ ul.bookmark-list {
 /* Bookmarks */
 li[ld-bookmark-item] {
   position: relative;
+  margin-top: $unit-2;
 
   [ld-bulk-edit-checkbox].form-checkbox {
     display: none;
   }
 
+  .title {
+    position: relative;
+  }
+
+  .title img {
+    position: absolute;
+    width: 16px;
+    height: 16px;
+    left: 0;
+    top: 50%;
+    transform: translateY(-50%);
+    pointer-events: none;
+  }
+
+  .title img + a {
+    padding-left: 22px;
+  }
+
   .title a {
-    display: inline-block;
-    vertical-align: top;
+    display: block;
     width: 100%;
     white-space: nowrap;
     overflow: hidden;
     text-overflow: ellipsis;
+  }
 
-    &[data-tooltip]:hover::after, &[data-tooltip]:focus::after {
-      content: attr(data-tooltip);
-      position: absolute;
-      z-index: 10;
-      top: 20px;
-      left: 50%;
-      transform: translateX(-50%);
-      width: max-content;
-      max-width: 100%;
-      height: fit-content;
-      background-color: #292f62;
-      color: #fff;
-      padding: $unit-1;
-      border-radius: $border-radius;
-      border: 1px solid #424a8c;
-      font-size: $font-size-sm;
-      font-style: normal;
-      white-space: normal;
-      animation: 0.3s ease 0s appear;
-    }
+  .title a[data-tooltip]:hover::after, .title a[data-tooltip]:focus::after {
+    content: attr(data-tooltip);
+    position: absolute;
+    z-index: 10;
+    top: 100%;
+    left: 50%;
+    transform: translateX(-50%);
+    width: max-content;
+    max-width: 90%;
+    height: fit-content;
+    background-color: #292f62;
+    color: #fff;
+    padding: $unit-1;
+    border-radius: $border-radius;
+    border: 1px solid #424a8c;
+    font-size: $font-size-sm;
+    font-style: normal;
+    white-space: normal;
+    pointer-events: none;
+    animation: 0.3s ease 0s appear;
   }
 
   &.unread .title a {
     font-style: italic;
   }
 
-  .title img {
-    width: 16px;
-    height: 16px;
-    margin-right: $unit-h;
-    vertical-align: text-top;
-  }
-
-  .url-display {
+  .url-path, .url-display {
+    font-size: $font-size-sm;
     color: $secondary-link-color;
   }
 
   .description {
     color: $gray-color-dark;
+  }
 
+  .description.separate {
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
+    overflow: hidden;
+  }
+
+  .tags {
     a, a:visited:hover {
       color: $alternative-color;
     }
@@ -195,6 +223,8 @@ li[ld-bookmark-item] {
   }
 
   .actions {
+    font-size: $font-size-sm;
+
     a, button.btn-link {
       color: $gray-color;
       padding: 0;
@@ -211,10 +241,6 @@ li[ld-bookmark-item] {
         color: $gray-color-dark;
       }
     }
-
-    .separator {
-      align-self: flex-start;
-    }
   }
 }
 
@@ -223,6 +249,8 @@ li[ld-bookmark-item] {
 }
 
 .tag-cloud {
+  /* Increase line-height for better separation within / between items */
+  line-height: 1.1rem;
 
   .selected-tags {
     margin-bottom: $unit-4;
@@ -258,55 +286,13 @@ ul.bookmark-list {
     overflow-y: auto;
   }
 
-  &.show-notes .notes,
-  li.show-notes .notes {
-    display: block;
-  }
-}
-
-/* Bookmark notes markdown styles */
-ul.bookmark-list .notes-content {
-  & {
+  .notes .markdown {
     padding: $unit-2 $unit-3;
   }
 
-  p, ul, ol, pre, blockquote {
-    margin: 0 0 $unit-2 0;
-  }
-
-  > *:first-child {
-    margin-top: 0;
-  }
-
-  > *:last-child {
-    margin-bottom: 0;
-  }
-
-  ul, ol {
-    margin-left: $unit-4;
-  }
-
-  ul li, ol li {
-    margin-top: $unit-1;
-  }
-
-  pre {
-    padding: $unit-1 $unit-2;
-    background-color: $code-bg-color;
-    border-radius: $unit-1;
-    overflow-x: auto;
-  }
-
-  pre code {
-    background: none;
-    box-shadow: none;
-    padding: 0;
-  }
-
-  > pre:first-child:last-child {
-    padding: 0;
-    background: none;
-    border-radius: 0;
+  &.show-notes .notes,
+  li.show-notes .notes {
+    display: block;
   }
 }
 
@@ -320,7 +306,7 @@ $bulk-edit-transition-duration: 400ms;
   .bulk-edit-bar {
     margin-top: -1px;
     margin-left: -$bulk-edit-bar-offset;
-    margin-bottom: $unit-4;
+    margin-bottom: $unit-3;
     max-height: 0;
     overflow: hidden;
     transition: max-height $bulk-edit-transition-duration;
@@ -342,7 +328,6 @@ $bulk-edit-transition-duration: 400ms;
     width: $bulk-edit-toggle-width;
     margin: 0 0 0 $bulk-edit-toggle-offset;
     padding: 0;
-    min-height: 1rem;
   }
 
   /* Bookmark checkboxes */
@@ -350,8 +335,10 @@ $bulk-edit-transition-duration: 400ms;
     display: block;
     position: absolute;
     width: $bulk-edit-toggle-width;
+    min-height: $bulk-edit-toggle-width;
     left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
-    top: 0;
+    top: 50%;
+    transform: translateY(-50%);
     padding: 0;
     margin: 0;
     visibility: hidden;
@@ -359,7 +346,7 @@ $bulk-edit-transition-duration: 400ms;
     transition: all $bulk-edit-transition-duration;
 
     .form-icon {
-      top: $unit-1;
+        top: 0;
     }
   }
 
@@ -371,7 +358,7 @@ $bulk-edit-transition-duration: 400ms;
   /* Actions */
   .bulk-edit-actions {
     display: flex;
-    align-items: baseline;
+    align-items: center;
     padding: $unit-1 0;
     border-top: solid 1px $border-color;
     gap: $unit-2;
diff --git a/bookmarks/styles/markdown.scss b/bookmarks/styles/markdown.scss
new file mode 100644
index 0000000..df88b5d
--- /dev/null
+++ b/bookmarks/styles/markdown.scss
@@ -0,0 +1,40 @@
+.markdown {
+  p, ul, ol, pre, blockquote {
+    margin: 0 0 $unit-2 0;
+  }
+
+  > *:first-child {
+    margin-top: 0;
+  }
+
+  > *:last-child {
+    margin-bottom: 0;
+  }
+
+  ul, ol {
+    margin-left: $unit-4;
+  }
+
+  ul li, ol li {
+    margin-top: $unit-1;
+  }
+
+  pre {
+    padding: $unit-1 $unit-2;
+    background-color: $code-bg-color;
+    border-radius: $unit-1;
+    overflow-x: auto;
+  }
+
+  pre code {
+    background: none;
+    box-shadow: none;
+    padding: 0;
+  }
+
+  > pre:first-child:last-child {
+    padding: 0;
+    background: none;
+    border-radius: 0;
+  }
+}
diff --git a/bookmarks/styles/responsive.scss b/bookmarks/styles/responsive.scss
index 77e8d33..6c33771 100644
--- a/bookmarks/styles/responsive.scss
+++ b/bookmarks/styles/responsive.scss
@@ -37,6 +37,14 @@
   min-width: 0;
 }
 
+.columns-2 {
+  --grid-columns: 2;
+}
+
+.gap-0 {
+  gap: 0;
+}
+
 .col-1 {
   grid-column: unquote("span min(1, var(--grid-columns))");
 }
diff --git a/bookmarks/styles/settings.scss b/bookmarks/styles/settings.scss
index b62e76f..a22d5ef 100644
--- a/bookmarks/styles/settings.scss
+++ b/bookmarks/styles/settings.scss
@@ -1,9 +1,9 @@
 .settings-page {
   section.content-area {
-    margin-bottom: $unit-12;
+    margin-bottom: $unit-10;
 
     h2 {
-      margin-bottom: $unit-4;
+      margin-bottom: $unit-3;
     }
   }
 
diff --git a/bookmarks/styles/spectre.scss b/bookmarks/styles/spectre.scss
index 1cb8b34..24c2a91 100644
--- a/bookmarks/styles/spectre.scss
+++ b/bookmarks/styles/spectre.scss
@@ -3,6 +3,32 @@
 
 // Variables and mixins
 @import "../../node_modules/spectre.css/src/variables";
+
+// Customize variables to reduce font and control sizes
+
+// Can use CSS variables for font sizes, as they are not used in SCSS calculations
+$font-size: var(--font-size);
+$font-size-sm: var(--font-size-sm);
+$font-size-lg: var(--font-size-lg);
+
+// Can't use CSS variables for these, used in SCSS calculations
+$line-height: 1rem;
+$control-size: $unit-8;
+$control-size-sm: $unit-6;
+$control-size-lg: $unit-9;
+
+// Declare defaults for CSS variables, expose SCSS variables as CSS variables
+html {
+  --font-size: 0.7rem;
+  --font-size-sm: 0.65rem;
+  --font-size-lg: 0.8rem;
+
+  --control-size: #{$control-size};
+  --control-size-sm: #{$control-size-sm};
+  --control-size-lg: #{$control-size-lg};
+}
+
+// Mixins
 @import "../../node_modules/spectre.css/src/mixins";
 
 /*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
@@ -64,19 +90,6 @@ a:visited:hover {
   transition: none !important;
 }
 
-// Fix radio button sub-pixel size
-.form-radio .form-icon {
-  width: 14px;
-  height: 14px;
-  border-width: 1px;
-}
-
-.form-radio input:checked + .form-icon::before {
-  top: 3px;
-  left: 3px;
-  transform: unset;
-}
-
 // Make code work with light and dark theme
 code {
   color: $gray-color-dark;
@@ -127,6 +140,53 @@ ul.menu li:first-child {
   }
 }
 
+// Customize modal animation
+@keyframes fade-in {
+  0% {
+    opacity: 0;
+  }
+  100% {
+    opacity: 1;
+  }
+}
+
+@keyframes fade-out {
+  0% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0;
+  }
+}
+
+.modal.active .modal-container, .modal.active .modal-overlay {
+  animation: fade-in .15s ease 1;
+}
+
+.modal.active.closing .modal-container, .modal.active.closing .modal-overlay {
+  animation: fade-out .15s ease 1;
+}
+
+// Customize menu animation
+.dropdown .menu {
+  animation: fade-in .15s ease 1;
+}
+
+// Modal close button
+.modal .modal-header button.close {
+  background: none;
+  border: none;
+  padding: 0;
+  line-height: 0;
+  cursor: pointer;
+  opacity: .85;
+  color: $gray-color-dark;
+
+  &:hover {
+    opacity: 1;
+  }
+}
+
 // Increase input font size on small viewports to prevent zooming on focus the input
 // on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
 // viewport size
diff --git a/bookmarks/styles/theme-dark.scss b/bookmarks/styles/theme-dark.scss
index 8229ab8..6aebb11 100644
--- a/bookmarks/styles/theme-dark.scss
+++ b/bookmarks/styles/theme-dark.scss
@@ -7,9 +7,11 @@
 // Import style modules
 @import "base";
 @import "responsive";
+@import "bookmark-details";
 @import "bookmark-page";
 @import "bookmark-form";
 @import "settings";
+@import "markdown";
 
 /* Dark theme overrides */
 
@@ -40,8 +42,17 @@ a:focus, .btn:focus {
 }
 
 .form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
-  background: $dt-primary-button-color;
-  border-color: $dt-primary-button-color;
+  background: $dt-primary-input-color;
+  border-color: $dt-primary-input-color;
+}
+
+.form-switch .form-icon::before, .form-switch input:active + .form-icon::before {
+  background: $light-color;
+}
+
+.form-switch input:checked + .form-icon {
+  background: $dt-primary-input-color;
+  border-color: $dt-primary-input-color;
 }
 
 .form-radio input:checked + .form-icon::before {
diff --git a/bookmarks/styles/theme-light.scss b/bookmarks/styles/theme-light.scss
index fd4eb32..ac823ef 100644
--- a/bookmarks/styles/theme-light.scss
+++ b/bookmarks/styles/theme-light.scss
@@ -7,6 +7,8 @@
 // Import style modules
 @import "base";
 @import "responsive";
+@import "bookmark-details";
 @import "bookmark-page";
 @import "bookmark-form";
 @import "settings";
+@import "markdown";
diff --git a/bookmarks/styles/variables-dark.scss b/bookmarks/styles/variables-dark.scss
index 56a69c4..17d0fed 100644
--- a/bookmarks/styles/variables-dark.scss
+++ b/bookmarks/styles/variables-dark.scss
@@ -1,5 +1,3 @@
-$html-font-size: 18px !default;
-
 $body-bg: #161822 !default;
 $bg-color: lighten($body-bg, 5%) !default;
 $bg-color-light: lighten($body-bg, 5%) !default;
@@ -30,4 +28,5 @@ $code-bg-color: rgba(255, 255, 255, 0.1);
 $code-shadow-color: rgba(255, 255, 255, 0.2);
 
 /* Dark theme specific */
+$dt-primary-input-color: #5C68E7 !default;
 $dt-primary-button-color: #5761cb !default;
diff --git a/bookmarks/styles/variables-light.scss b/bookmarks/styles/variables-light.scss
index 38d8c34..c0b42cd 100644
--- a/bookmarks/styles/variables-light.scss
+++ b/bookmarks/styles/variables-light.scss
@@ -1,5 +1,3 @@
-$html-font-size: 18px !default;
-
 $alternative-color: #05a6a3;
 $alternative-color-dark: darken($alternative-color, 5%);
 
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index c5b6c0a..bc1808c 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -6,50 +6,66 @@
   {% include 'bookmarks/empty_bookmarks.html' %}
 {% else %}
   <ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
+      style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
       data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
     {% for bookmark_item in bookmark_list.items %}
       <li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
-        <label ld-bulk-edit-checkbox class="form-checkbox">
-          <input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
-          <i class="form-icon"></i>
-        </label>
         <div class="title">
-          <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" >
-            {% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
-              <img src="{% static bookmark_item.favicon_file %}" alt="">
-            {% endif %}
-              <span>{{ bookmark_item.title }}</span>
+          <label ld-bulk-edit-checkbox class="form-checkbox">
+            <input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
+            <i class="form-icon"></i>
+          </label>
+          {% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
+            <img src="{% static bookmark_item.favicon_file %}" alt="">
+          {% endif %}
+          <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
+            <span>{{ bookmark_item.title }}</span>
           </a>
         </div>
         {% if bookmark_list.show_url %}
           <div class="url-path truncate">
             <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
-               class="url-display text-sm">
+               class="url-display">
               {{ bookmark_item.url }}
             </a>
           </div>
         {% endif %}
-        <div class="description truncate">
+        {% if bookmark_list.description_display == 'inline' %}
+          <div class="description inline truncate">
+            {% if bookmark_item.tag_names %}
+              <span class="tags">
+                {% for tag_name in bookmark_item.tag_names %}
+                  <a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
+                {% endfor %}
+              </span>
+            {% endif %}
+            {% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
+            {% if bookmark_item.description %}
+              <span>{{ bookmark_item.description }}</span>
+            {% endif %}
+          </div>
+        {% else %}
+          {% if bookmark_item.description %}
+            <div class="description separate">
+              {{ bookmark_item.description }}
+            </div>
+          {% endif %}
           {% if bookmark_item.tag_names %}
-            <span>
+            <div class="tags">
               {% for tag_name in bookmark_item.tag_names %}
                 <a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
               {% endfor %}
-            </span>
-          {% endif %}
-          {% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
-          {% if bookmark_item.description %}
-            <span>{{ bookmark_item.description }}</span>
+            </div>
           {% endif %}
-        </div>
+        {% endif %}
         {% if bookmark_item.notes %}
           <div class="notes bg-gray text-gray-dark">
-            <div class="notes-content">
+            <div class="markdown">
               {% markdown bookmark_item.notes %}
             </div>
           </div>
         {% endif %}
-        <div class="actions text-gray text-sm">
+        <div class="actions text-gray">
           {% if bookmark_item.display_date %}
             {% if bookmark_item.web_archive_snapshot_url %}
               <a href="{{ bookmark_item.web_archive_snapshot_url }}"
@@ -61,23 +77,35 @@
             {% else %}
               <span>{{ bookmark_item.display_date }}</span>
             {% endif %}
-            <span class="separator">|</span>
+            <span>|</span>
+          {% 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 }}"
+               href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
           {% endif %}
           {% if bookmark_item.is_editable %}
             {# Bookmark owner actions #}
-            <a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
-            {% 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
+            {% if bookmark_list.show_edit_action %}
+              <a href="{% url '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 %}
+            {% 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 %}
-            <button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
-                    class="btn btn-link btn-sm">Remove
-            </button>
           {% else %}
             {# Shared bookmark actions #}
             <span>Shared by
@@ -86,7 +114,7 @@
           {% endif %}
           {% if bookmark_item.has_extra_actions %}
             <div class="extra-actions">
-              <span class="separator hide-sm">|</span>
+              <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"
diff --git a/bookmarks/templates/bookmarks/details.html b/bookmarks/templates/bookmarks/details.html
new file mode 100644
index 0000000..8af50d5
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details.html
@@ -0,0 +1,13 @@
+{% extends 'bookmarks/layout.html' %}
+
+{% block content %}
+  <div ld-bookmark-details class="bookmark-details page">
+    {% if request.user == bookmark.owner %}
+      {% include 'bookmarks/details/actions.html' %}
+    {% endif %}
+    {% include 'bookmarks/details/title.html' %}
+    <div>
+      {% include 'bookmarks/details/content.html' %}
+    </div>
+  </div>
+{% endblock %}
diff --git a/bookmarks/templates/bookmarks/details/actions.html b/bookmarks/templates/bookmarks/details/actions.html
new file mode 100644
index 0000000..0fc844f
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details/actions.html
@@ -0,0 +1,13 @@
+<div class="actions">
+  <div class="left-actions">
+    <a class="btn" href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ edit_return_url|urlencode }}">Edit</a>
+  </div>
+  <div class="right-actions">
+    <form action="{% url 'bookmarks:index.action' %}?return_url={{ delete_return_url|urlencode }}" method="post">
+      {% csrf_token %}
+      <button ld-confirm-button type="submit" name="remove" value="{{ bookmark.id }}" class="btn btn-link text-error">
+        Delete...
+      </button>
+    </form>
+  </div>
+</div>
diff --git a/bookmarks/templates/bookmarks/details/content.html b/bookmarks/templates/bookmarks/details/content.html
new file mode 100644
index 0000000..ea4377e
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details/content.html
@@ -0,0 +1,85 @@
+{% load static %}
+{% load shared %}
+
+<div class="weblinks">
+  <a class="weblink" href="{{ bookmark.url }}" rel="noopener"
+     target="{{ request.user_profile.bookmark_link_target }}">
+    {% if bookmark.favicon_file and request.user_profile.enable_favicons %}
+      <img class="favicon" src="{% static bookmark.favicon_file %}" alt="">
+    {% endif %}
+    <span>{{ bookmark.url }}</span>
+  </a>
+  {% if bookmark.web_archive_snapshot_url %}
+    <a class="weblink" href="{{ bookmark.web_archive_snapshot_url }}"
+       target="{{ request.user_profile.bookmark_link_target }}">
+      {% if bookmark.favicon_file and request.user_profile.enable_favicons %}
+        <svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
+          <path
+              d="m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z"
+              fill="currentColor" fill-rule="evenodd"/>
+        </svg>
+      {% endif %}
+      <span>View on Internet Archive</span>
+    </a>
+  {% endif %}
+</div>
+<dl class="grid columns-2 columns-sm-1 gap-0">
+  {% if request.user == bookmark.owner %}
+    <div class="status col-2">
+      <dt>Status</dt>
+      <dd class="d-flex" style="gap: .8rem">
+        <form action="{% url 'bookmarks:details' bookmark.id %}" method="post">
+          {% csrf_token %}
+          <div class="form-group">
+            <label class="form-switch">
+              <input type="checkbox" name="is_archived" {% if bookmark.is_archived %}checked{% endif %}>
+              <i class="form-icon"></i> Archived
+            </label>
+          </div>
+          <div class="form-group">
+            <label class="form-switch">
+              <input type="checkbox" name="unread" {% if bookmark.unread %}checked{% endif %}>
+              <i class="form-icon"></i> Unread
+            </label>
+          </div>
+          {% if request.user_profile.enable_sharing %}
+            <div class="form-group">
+              <label class="form-switch">
+                <input type="checkbox" name="shared" {% if bookmark.shared %}checked{% endif %}>
+                <i class="form-icon"></i> Shared
+              </label>
+            </div>
+          {% endif %}
+        </form>
+      </dd>
+    </div>
+  {% endif %}
+  {% if bookmark.tag_names %}
+    <div class="tags col-1">
+      <dt>Tags</dt>
+      <dd>
+        {% for tag_name in bookmark.tag_names %}
+          <a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
+        {% endfor %}
+      </dd>
+    </div>
+  {% endif %}
+  <div class="date-added col-1">
+    <dt>Date added</dt>
+    <dd>
+      <span>{{ bookmark.date_added }}</span>
+    </dd>
+  </div>
+  {% if bookmark.resolved_description %}
+    <div class="description col-2">
+      <dt>Description</dt>
+      <dd>{{ bookmark.resolved_description }}</dd>
+    </div>
+  {% endif %}
+  {% if bookmark.notes %}
+    <div class="notes col-2">
+      <dt>Notes</dt>
+      <dd class="markdown">{% markdown bookmark.notes %}</dd>
+    </div>
+  {% endif %}
+</dl>
diff --git a/bookmarks/templates/bookmarks/details/title.html b/bookmarks/templates/bookmarks/details/title.html
new file mode 100644
index 0000000..a5982a7
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details/title.html
@@ -0,0 +1,3 @@
+<h2>
+  {{ bookmark.resolved_title }}
+</h2>
diff --git a/bookmarks/templates/bookmarks/details_modal.html b/bookmarks/templates/bookmarks/details_modal.html
new file mode 100644
index 0000000..ec24bc5
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details_modal.html
@@ -0,0 +1,27 @@
+<div ld-bookmark-details class="modal active bookmark-details">
+  <div class="modal-overlay" aria-label="Close"></div>
+  <div class="modal-container">
+    <div class="modal-header">
+      {% include 'bookmarks/details/title.html' %}
+      <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/details/content.html' %}
+      </div>
+    </div>
+
+    {% if request.user == bookmark.owner %}
+      <div class="modal-footer">
+        {% include 'bookmarks/details/actions.html' %}
+      </div>
+    {% endif %}
+  </div>
+</div>
diff --git a/bookmarks/templates/bookmarks/index.html b/bookmarks/templates/bookmarks/index.html
index 65db471..5608ad1 100644
--- a/bookmarks/templates/bookmarks/index.html
+++ b/bookmarks/templates/bookmarks/index.html
@@ -35,7 +35,7 @@
 
     {# Tag cloud #}
     <section class="content-area col-1 hide-md">
-      <div class="content-area-header mb-4">
+      <div class="content-area-header">
         <h2>Tags</h2>
       </div>
       <div class="tag-cloud-container">
diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html
index fca6a9a..b75959d 100644
--- a/bookmarks/templates/bookmarks/layout.html
+++ b/bookmarks/templates/bookmarks/layout.html
@@ -105,9 +105,9 @@
       <form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
         {% csrf_token %}
         {% for toast in toast_messages %}
-          <div class="toast">
+          <div class="toast d-flex">
             {{ toast.message }}
-            <button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear float-right"></button>
+            <button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear"></button>
           </div>
         {% endfor %}
       </form>
diff --git a/bookmarks/templates/bookmarks/nav_menu.html b/bookmarks/templates/bookmarks/nav_menu.html
index 13693c5..68d16cd 100644
--- a/bookmarks/templates/bookmarks/nav_menu.html
+++ b/bookmarks/templates/bookmarks/nav_menu.html
@@ -6,8 +6,8 @@
   <div class="dropdown">
     <a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
       Bookmarks
-      <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
-           style="height:1rem;width:1rem;vertical-align: text-bottom;">
+      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
+           style="height:1rem;width:1rem;vertical-align: middle;">
         <path fill-rule="evenodd"
               d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
               clip-rule="evenodd"/>
diff --git a/bookmarks/templates/bookmarks/search.html b/bookmarks/templates/bookmarks/search.html
index 8663a2b..87e6caf 100644
--- a/bookmarks/templates/bookmarks/search.html
+++ b/bookmarks/templates/bookmarks/search.html
@@ -25,7 +25,7 @@
         <path d="M18 9v11"></path>
       </svg>
     </button>
-    <div class="menu text-sm" tabindex="0">
+    <div class="menu" tabindex="0">
       <form id="search_preferences" action="" method="post">
         {% csrf_token %}
         {% if 'sort' in preferences_form.editable_fields %}
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index 823f0b1..c71696b 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -29,6 +29,23 @@
             be hidden.
           </div>
         </div>
+        <div class="form-group">
+          <label for="{{ form.bookmark_description_display.id_for_label }}" class="form-label">Bookmark
+            description</label>
+          {{ form.bookmark_description_display|add_class:"form-select width-25 width-sm-100" }}
+          <div class="form-input-hint">
+            Whether to show bookmark descriptions and tags in the same line, or as separate blocks.
+          </div>
+        </div>
+        <div
+            class="form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}">
+          <label for="{{ form.bookmark_description_max_lines.id_for_label }}" class="form-label">Bookmark description
+            max lines</label>
+          {{ form.bookmark_description_max_lines|add_class:"form-input width-25 width-sm-100" }}
+          <div class="form-input-hint">
+            Limits the number of lines that are displayed for the bookmark description.
+          </div>
+        </div>
         <div class="form-group">
           <label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
             {{ form.display_url }}
@@ -48,6 +65,28 @@
             Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
           </div>
         </div>
+        <div class="form-group">
+          <label>Bookmark actions</label>
+          <label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox">
+            {{ form.display_view_bookmark_action }}
+            <i class="form-icon"></i> View
+          </label>
+          <label for="{{ form.display_edit_bookmark_action.id_for_label }}" class="form-checkbox">
+            {{ form.display_edit_bookmark_action }}
+            <i class="form-icon"></i> Edit
+          </label>
+          <label for="{{ form.display_archive_bookmark_action.id_for_label }}" class="form-checkbox">
+            {{ form.display_archive_bookmark_action }}
+            <i class="form-icon"></i> Archive
+          </label>
+          <label for="{{ form.display_remove_bookmark_action.id_for_label }}" class="form-checkbox">
+            {{ form.display_remove_bookmark_action }}
+            <i class="form-icon"></i> Remove
+          </label>
+          <div class="form-input-hint">
+            Which actions to display for each bookmark.
+          </div>
+        </div>
         <div class="form-group">
           <label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
           {{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
@@ -124,18 +163,18 @@
               href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
           </div>
         </div>
-      <div class="form-group">
-        <details {% if form.custom_css.value %}open{% endif %}>
-          <summary>Custom CSS</summary>
-          <label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
-          <div class="mt-2">
-            {{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
+        <div class="form-group">
+          <details {% if form.custom_css.value %}open{% endif %}>
+            <summary>Custom CSS</summary>
+            <label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
+            <div class="mt-2">
+              {{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
+            </div>
+          </details>
+          <div class="form-input-hint">
+            Allows to add custom CSS to the page.
           </div>
-        </details>
-        <div class="form-input-hint">
-          Allows to add custom CSS to the page.
         </div>
-      </div>
         <div class="form-group">
           <input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
           {% if update_profile_success_message %}
@@ -195,10 +234,6 @@
     <section class="content-area">
       <h2>Export</h2>
       <p>Export all bookmarks in Netscape HTML format.</p>
-      <p>
-        Note that exporting bookmark notes is currently not supported due to limitations of the format.
-        For proper backups please use a database backup as described in the documentation.
-      </p>
       <a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
       {% if export_error %}
         <div class="has-error">
@@ -237,10 +272,12 @@
   </div>
 
   <script>
-    // Automatically disable public bookmark sharing if bookmark sharing is disabled
     const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
     const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
+    const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
+    const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
 
+    // Automatically disable public bookmark sharing if bookmark sharing is disabled
     function updatePublicSharing() {
       if (enableSharing.checked) {
         enablePublicSharing.disabled = false;
@@ -252,6 +289,18 @@
 
     updatePublicSharing();
     enableSharing.addEventListener("change", updatePublicSharing);
+
+    // Automatically hide the bookmark description max lines input if the description display is set to inline
+    function updateBookmarkDescriptionMaxLines() {
+      if (bookmarkDescriptionDisplay.value === "inline") {
+        bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide");
+      } else {
+        bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide");
+      }
+    }
+
+    updateBookmarkDescriptionMaxLines();
+    bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
   </script>
 
 {% endblock %}
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index 3b8c953..4a254bb 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -34,6 +34,16 @@ urlpatterns = [
     path("bookmarks/new", views.bookmarks.new, name="new"),
     path("bookmarks/close", views.bookmarks.close, name="close"),
     path("bookmarks/<int:bookmark_id>/edit", views.bookmarks.edit, name="edit"),
+    path(
+        "bookmarks/<int:bookmark_id>/details",
+        views.bookmarks.details,
+        name="details",
+    ),
+    path(
+        "bookmarks/<int:bookmark_id>/details_modal",
+        views.bookmarks.details_modal,
+        name="details_modal",
+    ),
     # Partials
     path(
         "bookmarks/partials/bookmark-list/active",
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index 5b07716..969df3a 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -104,6 +104,59 @@ def search_action(request):
     return HttpResponseRedirect(url)
 
 
+def _details(request, bookmark_id: int, template: str):
+    try:
+        bookmark = Bookmark.objects.get(pk=bookmark_id)
+    except Bookmark.DoesNotExist:
+        raise Http404("Bookmark does not exist")
+
+    is_owner = bookmark.owner == request.user
+    is_shared = (
+        request.user.is_authenticated
+        and bookmark.shared
+        and bookmark.owner.profile.enable_sharing
+    )
+    is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
+    if not is_owner and not is_shared and not is_public_shared:
+        raise Http404("Bookmark does not exist")
+
+    edit_return_url = get_safe_return_url(
+        request.GET.get("return_url"), reverse("bookmarks:details", args=[bookmark_id])
+    )
+    delete_return_url = get_safe_return_url(
+        request.GET.get("return_url"), reverse("bookmarks:index")
+    )
+
+    # handles status actions form
+    if request.method == "POST":
+        if not is_owner:
+            raise Http404("Bookmark does not exist")
+        bookmark.is_archived = request.POST.get("is_archived") == "on"
+        bookmark.unread = request.POST.get("unread") == "on"
+        bookmark.shared = request.POST.get("shared") == "on"
+        bookmark.save()
+
+        return HttpResponseRedirect(edit_return_url)
+
+    return render(
+        request,
+        template,
+        {
+            "bookmark": bookmark,
+            "edit_return_url": edit_return_url,
+            "delete_return_url": delete_return_url,
+        },
+    )
+
+
+def details(request, bookmark_id: int):
+    return _details(request, bookmark_id, "bookmarks/details.html")
+
+
+def details_modal(request, bookmark_id: int):
+    return _details(request, bookmark_id, "bookmarks/details_modal.html")
+
+
 def convert_tag_string(tag_string: str):
     # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
     # strings
diff --git a/bookmarks/views/manifest.py b/bookmarks/views/manifest.py
index 42eca5f..d52400d 100644
--- a/bookmarks/views/manifest.py
+++ b/bookmarks/views/manifest.py
@@ -11,43 +11,45 @@ def manifest(request):
         "display": "standalone",
         "scope": "/" + settings.LD_CONTEXT_PATH,
         "theme_color": "#5856e0",
-        "background_color": "#161822" if request.user_profile.theme == "dark" else "#ffffff",
+        "background_color": (
+            "#161822" if request.user_profile.theme == "dark" else "#ffffff"
+        ),
         "icons": [
             {
                 "src": "/" + settings.LD_CONTEXT_PATH + "static/logo.svg",
                 "type": "image/svg+xml",
                 "sizes": "512x512",
-                "purpose": "any"
+                "purpose": "any",
             },
             {
                 "src": "/" + settings.LD_CONTEXT_PATH + "static/logo-512.png",
                 "type": "image/png",
                 "sizes": "512x512",
-                "purpose": "any"
+                "purpose": "any",
             },
             {
                 "src": "/" + settings.LD_CONTEXT_PATH + "static/logo-192.png",
                 "type": "image/png",
                 "sizes": "192x192",
-                "purpose": "any"
+                "purpose": "any",
             },
             {
                 "src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo.svg",
                 "type": "image/svg+xml",
                 "sizes": "512x512",
-                "purpose": "maskable"
+                "purpose": "maskable",
             },
             {
                 "src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-512.png",
                 "type": "image/png",
                 "sizes": "512x512",
-                "purpose": "maskable"
+                "purpose": "maskable",
             },
             {
                 "src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-192.png",
                 "type": "image/png",
                 "sizes": "192x192",
-                "purpose": "maskable"
+                "purpose": "maskable",
             },
         ],
         "shortcuts": [
@@ -70,14 +72,16 @@ def manifest(request):
             {
                 "name": "Shared",
                 "url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/shared",
-            }
+            },
         ],
         "screenshots": [
             {
-                "src": "/" + settings.LD_CONTEXT_PATH + "static/linkding-screenshot.png",
+                "src": "/"
+                + settings.LD_CONTEXT_PATH
+                + "static/linkding-screenshot.png",
                 "type": "image/png",
                 "sizes": "2158x1160",
-                "form_factor": "wide"
+                "form_factor": "wide",
             }
         ],
         "share_target": {
@@ -88,8 +92,8 @@ def manifest(request):
                 "url": "url",
                 "text": "url",
                 "title": "title",
-            }
-        }
+            },
+        },
     }
 
     return JsonResponse(response, status=200)
diff --git a/bookmarks/views/partials/contexts.py b/bookmarks/views/partials/contexts.py
index 700a74d..2320268 100644
--- a/bookmarks/views/partials/contexts.py
+++ b/bookmarks/views/partials/contexts.py
@@ -96,7 +96,13 @@ class BookmarkListContext:
         )
         self.link_target = user_profile.bookmark_link_target
         self.date_display = user_profile.bookmark_date_display
+        self.description_display = user_profile.bookmark_description_display
+        self.description_max_lines = user_profile.bookmark_description_max_lines
         self.show_url = user_profile.display_url
+        self.show_view_action = user_profile.display_view_bookmark_action
+        self.show_edit_action = user_profile.display_edit_bookmark_action
+        self.show_archive_action = user_profile.display_archive_bookmark_action
+        self.show_remove_action = user_profile.display_remove_bookmark_action
         self.show_favicons = user_profile.enable_favicons
         self.show_notes = user_profile.permanent_notes
 
diff --git a/package.json b/package.json
index 2d7090c..09aa5eb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "linkding",
-  "version": "1.25.0",
+  "version": "1.26.0",
   "description": "",
   "main": "index.js",
   "scripts": {
diff --git a/requirements.dev.txt b/requirements.dev.txt
index 73b833c..aa7c775 100644
--- a/requirements.dev.txt
+++ b/requirements.dev.txt
@@ -6,7 +6,7 @@
 #
 asgiref==3.7.2
     # via django
-black==24.1.1
+black==24.3.0
     # via -r requirements.dev.in
 click==8.1.7
     # via black
diff --git a/version.txt b/version.txt
index ad21919..5ff8c4f 100644
--- a/version.txt
+++ b/version.txt
@@ -1,1 +1,1 @@
-1.25.0
+1.26.0


Test patch:
diff --git a/bookmarks/e2e/e2e_test_bookmark_details_modal.py b/bookmarks/e2e/e2e_test_bookmark_details_modal.py
new file mode 100644
index 0000000..d3eb183
--- /dev/null
+++ b/bookmarks/e2e/e2e_test_bookmark_details_modal.py
@@ -0,0 +1,133 @@
+from django.urls import reverse
+from playwright.sync_api import sync_playwright, expect
+
+from bookmarks.e2e.helpers import LinkdingE2ETestCase
+from bookmarks.models import Bookmark
+
+
+class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
+    def test_show_details(self):
+        bookmark = self.setup_bookmark()
+
+        with sync_playwright() as p:
+            self.open(reverse("bookmarks:index"), p)
+
+            details_modal = self.open_details_modal(bookmark)
+            title = details_modal.locator("h2")
+            expect(title).to_have_text(bookmark.title)
+
+    def test_close_details(self):
+        bookmark = self.setup_bookmark()
+
+        with sync_playwright() as p:
+            self.open(reverse("bookmarks:index"), p)
+
+            # close with close button
+            details_modal = self.open_details_modal(bookmark)
+            details_modal.locator("button.close").click()
+            expect(details_modal).to_be_hidden()
+
+            # close with backdrop
+            details_modal = self.open_details_modal(bookmark)
+            overlay = details_modal.locator(".modal-overlay")
+            overlay.click(position={"x": 0, "y": 0})
+            expect(details_modal).to_be_hidden()
+
+    def test_toggle_archived(self):
+        bookmark = self.setup_bookmark()
+
+        with sync_playwright() as p:
+            # archive
+            url = reverse("bookmarks:index")
+            self.open(url, p)
+
+            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()
+
+            # unarchive
+            url = reverse("bookmarks:archived")
+            self.page.goto(self.live_server_url + url)
+
+            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()
+
+    def test_toggle_unread(self):
+        bookmark = self.setup_bookmark()
+
+        with sync_playwright() as p:
+            # mark as unread
+            url = reverse("bookmarks:index")
+            self.open(url, p)
+
+            details_modal = self.open_details_modal(bookmark)
+
+            details_modal.get_by_text("Unread").click()
+            bookmark_item = self.locate_bookmark(bookmark.title)
+            expect(bookmark_item.get_by_text("Unread")).to_be_visible()
+
+            # 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()
+
+    def test_toggle_shared(self):
+        profile = self.get_or_create_test_user().profile
+        profile.enable_sharing = True
+        profile.save()
+
+        bookmark = self.setup_bookmark()
+
+        with sync_playwright() as p:
+            # share bookmark
+            url = reverse("bookmarks:index")
+            self.open(url, p)
+
+            details_modal = self.open_details_modal(bookmark)
+
+            details_modal.get_by_text("Shared").click()
+            bookmark_item = self.locate_bookmark(bookmark.title)
+            expect(bookmark_item.get_by_text("Shared")).to_be_visible()
+
+            # 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()
+
+    def test_edit_return_url(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)
+
+            # Navigate to edit page
+            with self.page.expect_navigation():
+                details_modal.get_by_text("Edit").click()
+
+            # Cancel edit, verify return url
+            with self.page.expect_navigation(url=self.live_server_url + url):
+                self.page.get_by_text("Nevermind").click()
+
+    def test_delete(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)
+
+            # Delete bookmark, verify return url
+            with self.page.expect_navigation(url=self.live_server_url + url):
+                details_modal.get_by_text("Delete...").click()
+                details_modal.get_by_text("Confirm").click()
+
+            # verify bookmark is deleted
+            self.locate_bookmark(bookmark.title)
+            expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
+
+        self.assertEqual(Bookmark.objects.count(), 0)
diff --git a/bookmarks/e2e/e2e_test_bookmark_details_view.py b/bookmarks/e2e/e2e_test_bookmark_details_view.py
new file mode 100644
index 0000000..8b792ff
--- /dev/null
+++ b/bookmarks/e2e/e2e_test_bookmark_details_view.py
@@ -0,0 +1,37 @@
+from django.urls import reverse
+from playwright.sync_api import sync_playwright
+
+from bookmarks.e2e.helpers import LinkdingE2ETestCase
+
+
+class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
+    def test_edit_return_url(self):
+        bookmark = self.setup_bookmark()
+
+        with sync_playwright() as p:
+            self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
+
+            # Navigate to edit page
+            with self.page.expect_navigation():
+                self.page.get_by_text("Edit").click()
+
+            # Cancel edit, verify return url
+            with self.page.expect_navigation(
+                url=self.live_server_url
+                + reverse("bookmarks:details", args=[bookmark.id])
+            ):
+                self.page.get_by_text("Nevermind").click()
+
+    def test_delete_return_url(self):
+        bookmark = self.setup_bookmark()
+
+        with sync_playwright() as p:
+            self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
+
+            # Trigger delete, verify return url
+            # Should probably return to last bookmark list page, but for now just returns to index
+            with self.page.expect_navigation(
+                url=self.live_server_url + reverse("bookmarks:index")
+            ):
+                self.page.get_by_text("Delete...").click()
+                self.page.get_by_text("Confirm").click()
diff --git a/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py b/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
index 1706cdd..ac20326 100644
--- a/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
+++ b/bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
@@ -39,6 +39,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
         with sync_playwright() as p:
             self.open(reverse("bookmarks:index"), p)
 
+            bookmark_list = self.locate_bookmark_list()
             self.locate_bulk_edit_toggle().click()
             self.locate_bulk_edit_select_all().click()
             self.locate_bulk_edit_select_across().click()
@@ -46,6 +47,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
             self.select_bulk_action("Delete")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
             self.locate_bulk_edit_bar().get_by_text("Confirm").click()
+            # Wait until bookmark list is updated (old reference becomes invisible)
+            expect(bookmark_list).not_to_be_visible()
 
         self.assertEqual(
             0,
@@ -74,6 +77,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
         with sync_playwright() as p:
             self.open(reverse("bookmarks:archived"), p)
 
+            bookmark_list = self.locate_bookmark_list()
             self.locate_bulk_edit_toggle().click()
             self.locate_bulk_edit_select_all().click()
             self.locate_bulk_edit_select_across().click()
@@ -81,6 +85,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
             self.select_bulk_action("Delete")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
             self.locate_bulk_edit_bar().get_by_text("Confirm").click()
+            # Wait until bookmark list is updated (old reference becomes invisible)
+            expect(bookmark_list).not_to_be_visible()
 
         self.assertEqual(
             50,
@@ -109,6 +115,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
         with sync_playwright() as p:
             self.open(reverse("bookmarks:index") + "?q=foo", p)
 
+            bookmark_list = self.locate_bookmark_list()
             self.locate_bulk_edit_toggle().click()
             self.locate_bulk_edit_select_all().click()
             self.locate_bulk_edit_select_across().click()
@@ -116,6 +123,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
             self.select_bulk_action("Delete")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
             self.locate_bulk_edit_bar().get_by_text("Confirm").click()
+            # Wait until bookmark list is updated (old reference becomes invisible)
+            expect(bookmark_list).not_to_be_visible()
 
         self.assertEqual(
             50,
@@ -144,6 +153,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
         with sync_playwright() as p:
             self.open(reverse("bookmarks:archived") + "?q=foo", p)
 
+            bookmark_list = self.locate_bookmark_list()
             self.locate_bulk_edit_toggle().click()
             self.locate_bulk_edit_select_all().click()
             self.locate_bulk_edit_select_across().click()
@@ -151,6 +161,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
             self.select_bulk_action("Delete")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
             self.locate_bulk_edit_bar().get_by_text("Confirm").click()
+            # Wait until bookmark list is updated (old reference becomes invisible)
+            expect(bookmark_list).not_to_be_visible()
 
         self.assertEqual(
             50,
@@ -269,14 +281,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
             url = reverse("bookmarks:index")
             page = self.open(url, p)
 
+            bookmark_list = self.locate_bookmark_list()
+
             # Select all bookmarks, enable select across
             self.locate_bulk_edit_toggle().click()
             self.locate_bulk_edit_select_all().click()
             self.locate_bulk_edit_select_across().click()
 
-            # Get reference for bookmark list
-            bookmark_list = page.locator("ul[ld-bookmark-list]")
-
             # Execute bulk action
             self.select_bulk_action("Mark as unread")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -302,6 +313,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
             url = reverse("bookmarks:index")
             self.open(url, p)
 
+            bookmark_list = self.locate_bookmark_list()
             self.locate_bulk_edit_toggle().click()
             self.locate_bulk_edit_select_all().click()
 
@@ -312,6 +324,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
             self.select_bulk_action("Delete")
             self.locate_bulk_edit_bar().get_by_text("Execute").click()
             self.locate_bulk_edit_bar().get_by_text("Confirm").click()
+            # Wait until bookmark list is updated (old reference becomes invisible)
+            expect(bookmark_list).not_to_be_visible()
 
             expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
             self.locate_bulk_edit_select_all().click()
diff --git a/bookmarks/e2e/e2e_test_settings_general.py b/bookmarks/e2e/e2e_test_settings_general.py
index cdbf192..3b480c5 100644
--- a/bookmarks/e2e/e2e_test_settings_general.py
+++ b/bookmarks/e2e/e2e_test_settings_general.py
@@ -2,6 +2,7 @@ from django.urls import reverse
 from playwright.sync_api import sync_playwright, expect
 
 from bookmarks.e2e.helpers import LinkdingE2ETestCase
+from bookmarks.models import UserProfile
 
 
 class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
@@ -39,3 +40,49 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
             expect(enable_sharing).not_to_be_checked()
             expect(enable_public_sharing).not_to_be_checked()
             expect(enable_public_sharing).to_be_disabled()
+
+    def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
+        profile = self.get_or_create_test_user().profile
+        profile.bookmark_description_display = (
+            UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
+        )
+        profile.save()
+
+        with sync_playwright() as p:
+            browser = self.setup_browser(p)
+            page = browser.new_page()
+            page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
+
+            max_lines = page.get_by_label("Bookmark description max lines")
+            expect(max_lines).to_be_hidden()
+
+    def test_should_show_bookmark_description_max_lines_when_display_separate(self):
+        profile = self.get_or_create_test_user().profile
+        profile.bookmark_description_display = (
+            UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
+        )
+        profile.save()
+
+        with sync_playwright() as p:
+            browser = self.setup_browser(p)
+            page = browser.new_page()
+            page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
+
+            max_lines = page.get_by_label("Bookmark description max lines")
+            expect(max_lines).to_be_visible()
+
+    def test_should_update_bookmark_description_max_lines_when_changing_display(self):
+        with sync_playwright() as p:
+            browser = self.setup_browser(p)
+            page = browser.new_page()
+            page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
+
+            max_lines = page.get_by_label("Bookmark description max lines")
+            expect(max_lines).to_be_hidden()
+
+            display = page.get_by_label("Bookmark description", exact=True)
+            display.select_option("separate")
+            expect(max_lines).to_be_visible()
+
+            display.select_option("inline")
+            expect(max_lines).to_be_hidden()
diff --git a/bookmarks/e2e/helpers.py b/bookmarks/e2e/helpers.py
index a6cbcd5..99c4fed 100644
--- a/bookmarks/e2e/helpers.py
+++ b/bookmarks/e2e/helpers.py
@@ -1,5 +1,6 @@
 from django.contrib.staticfiles.testing import LiveServerTestCase
 from playwright.sync_api import BrowserContext, Playwright, Page
+from playwright.sync_api import expect
 
 from bookmarks.tests.helpers import BookmarkFactoryMixin
 
@@ -38,10 +39,25 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
     def assertReloads(self, count: int):
         self.assertEqual(self.num_loads, count)
 
+    def locate_bookmark_list(self):
+        return self.page.locator("ul[ld-bookmark-list]")
+
     def locate_bookmark(self, title: str):
         bookmark_tags = self.page.locator("li[ld-bookmark-item]")
         return bookmark_tags.filter(has_text=title)
 
+    def locate_details_modal(self):
+        return self.page.locator(".modal.bookmark-details")
+
+    def open_details_modal(self, bookmark):
+        details_button = self.locate_bookmark(bookmark.title).get_by_text("View")
+        details_button.click()
+
+        details_modal = self.locate_details_modal()
+        expect(details_modal).to_be_visible()
+
+        return details_modal
+
     def locate_bulk_edit_bar(self):
         return self.page.locator(".bulk-edit-bar")
 
diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py
new file mode 100644
index 0000000..8f1b6bc
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_details_modal.py
@@ -0,0 +1,562 @@
+from django.test import TestCase
+from django.urls import reverse
+from django.utils import formats
+
+from bookmarks.models import UserProfile
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
+
+
+class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
+    def setUp(self):
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def get_base_url(self, bookmark):
+        return reverse("bookmarks:details_modal", args=[bookmark.id])
+
+    def get_details(self, bookmark, return_url=""):
+        url = self.get_base_url(bookmark)
+        if return_url:
+            url += f"?return_url={return_url}"
+        response = self.client.get(url)
+        soup = self.make_soup(response.content)
+        return soup
+
+    def find_section(self, soup, section_name):
+        dt = soup.find("dt", string=section_name)
+        dd = dt.find_next_sibling("dd") if dt else None
+        return dd
+
+    def get_section(self, soup, section_name):
+        dd = self.find_section(soup, section_name)
+        self.assertIsNotNone(dd)
+        return dd
+
+    def find_weblink(self, soup, url):
+        return soup.find("a", {"class": "weblink", "href": url})
+
+    def test_access(self):
+        # own bookmark
+        bookmark = self.setup_bookmark()
+
+        response = self.client.get(
+            reverse("bookmarks:details_modal", args=[bookmark.id])
+        )
+        self.assertEqual(response.status_code, 200)
+
+        # other user's bookmark
+        other_user = self.setup_user()
+        bookmark = self.setup_bookmark(user=other_user)
+
+        response = self.client.get(
+            reverse("bookmarks:details_modal", args=[bookmark.id])
+        )
+        self.assertEqual(response.status_code, 404)
+
+        # non-existent bookmark
+        response = self.client.get(reverse("bookmarks:details_modal", args=[9999]))
+        self.assertEqual(response.status_code, 404)
+
+        # guest user
+        self.client.logout()
+        response = self.client.get(
+            reverse("bookmarks:details_modal", args=[bookmark.id])
+        )
+        self.assertEqual(response.status_code, 404)
+
+    def test_access_with_sharing(self):
+        # shared bookmark, sharing disabled
+        other_user = self.setup_user()
+        bookmark = self.setup_bookmark(shared=True, user=other_user)
+
+        response = self.client.get(
+            reverse("bookmarks:details_modal", args=[bookmark.id])
+        )
+        self.assertEqual(response.status_code, 404)
+
+        # shared bookmark, sharing enabled
+        profile = other_user.profile
+        profile.enable_sharing = True
+        profile.save()
+
+        response = self.client.get(
+            reverse("bookmarks:details_modal", args=[bookmark.id])
+        )
+        self.assertEqual(response.status_code, 200)
+
+        # shared bookmark, guest user, no public sharing
+        self.client.logout()
+        response = self.client.get(
+            reverse("bookmarks:details_modal", args=[bookmark.id])
+        )
+        self.assertEqual(response.status_code, 404)
+
+        # shared bookmark, guest user, public sharing
+        profile.enable_public_sharing = True
+        profile.save()
+
+        response = self.client.get(
+            reverse("bookmarks:details_modal", args=[bookmark.id])
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_displays_title(self):
+        # with title
+        bookmark = self.setup_bookmark(title="Test title")
+        soup = self.get_details(bookmark)
+
+        title = soup.find("h2")
+        self.assertIsNotNone(title)
+        self.assertEqual(title.text.strip(), bookmark.title)
+
+        # with website title
+        bookmark = self.setup_bookmark(title="", website_title="Website title")
+        soup = self.get_details(bookmark)
+
+        title = soup.find("h2")
+        self.assertIsNotNone(title)
+        self.assertEqual(title.text.strip(), bookmark.website_title)
+
+        # with URL only
+        bookmark = self.setup_bookmark(title="", website_title="")
+        soup = self.get_details(bookmark)
+
+        title = soup.find("h2")
+        self.assertIsNotNone(title)
+        self.assertEqual(title.text.strip(), bookmark.url)
+
+    def test_website_link(self):
+        # basics
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+        link = self.find_weblink(soup, bookmark.url)
+        self.assertIsNotNone(link)
+        self.assertEqual(link["href"], bookmark.url)
+        self.assertEqual(link.text.strip(), bookmark.url)
+
+        # favicons disabled
+        bookmark = self.setup_bookmark(favicon_file="example.png")
+        soup = self.get_details(bookmark)
+        link = self.find_weblink(soup, bookmark.url)
+        image = link.select_one("img")
+        self.assertIsNone(image)
+
+        # favicons enabled, no favicon
+        profile = self.get_or_create_test_user().profile
+        profile.enable_favicons = True
+        profile.save()
+
+        bookmark = self.setup_bookmark(favicon_file="")
+        soup = self.get_details(bookmark)
+        link = self.find_weblink(soup, bookmark.url)
+        image = link.select_one("img")
+        self.assertIsNone(image)
+
+        # favicons enabled, favicon present
+        bookmark = self.setup_bookmark(favicon_file="example.png")
+        soup = self.get_details(bookmark)
+        link = self.find_weblink(soup, bookmark.url)
+        image = link.select_one("img")
+        self.assertIsNotNone(image)
+        self.assertEqual(image["src"], "/static/example.png")
+
+    def test_internet_archive_link(self):
+        # without snapshot url
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+        link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
+        self.assertIsNone(link)
+
+        # with snapshot url
+        bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
+        soup = self.get_details(bookmark)
+        link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
+        self.assertIsNotNone(link)
+        self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
+        self.assertEqual(link.text.strip(), "View on Internet Archive")
+
+        # favicons disabled
+        bookmark = self.setup_bookmark(
+            web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
+        )
+        soup = self.get_details(bookmark)
+        link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
+        image = link.select_one("svg")
+        self.assertIsNone(image)
+
+        # favicons enabled, no favicon
+        profile = self.get_or_create_test_user().profile
+        profile.enable_favicons = True
+        profile.save()
+
+        bookmark = self.setup_bookmark(
+            web_archive_snapshot_url="https://example.com/", favicon_file=""
+        )
+        soup = self.get_details(bookmark)
+        link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
+        image = link.select_one("svg")
+        self.assertIsNone(image)
+
+        # favicons enabled, favicon present
+        bookmark = self.setup_bookmark(
+            web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
+        )
+        soup = self.get_details(bookmark)
+        link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
+        image = link.select_one("svg")
+        self.assertIsNotNone(image)
+
+    def test_weblinks_respect_target_setting(self):
+        bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
+
+        # target blank
+        profile = self.get_or_create_test_user().profile
+        profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK
+        profile.save()
+
+        soup = self.get_details(bookmark)
+
+        website_link = self.find_weblink(soup, bookmark.url)
+        self.assertIsNotNone(website_link)
+        self.assertEqual(website_link["target"], UserProfile.BOOKMARK_LINK_TARGET_BLANK)
+
+        web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
+        self.assertIsNotNone(web_archive_link)
+        self.assertEqual(
+            web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_BLANK
+        )
+
+        # target self
+        profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
+        profile.save()
+
+        soup = self.get_details(bookmark)
+
+        website_link = self.find_weblink(soup, bookmark.url)
+        self.assertIsNotNone(website_link)
+        self.assertEqual(website_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF)
+
+        web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
+        self.assertIsNotNone(web_archive_link)
+        self.assertEqual(
+            web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF
+        )
+
+    def test_status(self):
+        # renders form
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+        section = self.get_section(soup, "Status")
+
+        form = section.find("form")
+        self.assertIsNotNone(form)
+        self.assertEqual(
+            form["action"], reverse("bookmarks:details", args=[bookmark.id])
+        )
+        self.assertEqual(form["method"], "post")
+
+        # sharing disabled
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+        section = self.get_section(soup, "Status")
+
+        archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
+        self.assertIsNotNone(archived)
+        unread = section.find("input", {"type": "checkbox", "name": "unread"})
+        self.assertIsNotNone(unread)
+        shared = section.find("input", {"type": "checkbox", "name": "shared"})
+        self.assertIsNone(shared)
+
+        # sharing enabled
+        profile = self.get_or_create_test_user().profile
+        profile.enable_sharing = True
+        profile.save()
+
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+        section = self.get_section(soup, "Status")
+
+        archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
+        self.assertIsNotNone(archived)
+        unread = section.find("input", {"type": "checkbox", "name": "unread"})
+        self.assertIsNotNone(unread)
+        shared = section.find("input", {"type": "checkbox", "name": "shared"})
+        self.assertIsNotNone(shared)
+
+        # unchecked
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+        section = self.get_section(soup, "Status")
+
+        archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
+        self.assertFalse(archived.has_attr("checked"))
+        unread = section.find("input", {"type": "checkbox", "name": "unread"})
+        self.assertFalse(unread.has_attr("checked"))
+        shared = section.find("input", {"type": "checkbox", "name": "shared"})
+        self.assertFalse(shared.has_attr("checked"))
+
+        # checked
+        bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
+        soup = self.get_details(bookmark)
+        section = self.get_section(soup, "Status")
+
+        archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
+        self.assertTrue(archived.has_attr("checked"))
+        unread = section.find("input", {"type": "checkbox", "name": "unread"})
+        self.assertTrue(unread.has_attr("checked"))
+        shared = section.find("input", {"type": "checkbox", "name": "shared"})
+        self.assertTrue(shared.has_attr("checked"))
+
+    def test_status_visibility(self):
+        # own bookmark
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+        section = self.find_section(soup, "Status")
+        form_action = reverse("bookmarks:details", args=[bookmark.id])
+        form = soup.find("form", {"action": form_action})
+        self.assertIsNotNone(section)
+        self.assertIsNotNone(form)
+
+        # other user's bookmark
+        other_user = self.setup_user(enable_sharing=True)
+        bookmark = self.setup_bookmark(user=other_user, shared=True)
+        soup = self.get_details(bookmark)
+        section = self.find_section(soup, "Status")
+        form_action = reverse("bookmarks:details", args=[bookmark.id])
+        form = soup.find("form", {"action": form_action})
+        self.assertIsNone(section)
+        self.assertIsNone(form)
+
+        # guest user
+        self.client.logout()
+        bookmark = self.setup_bookmark(user=other_user, shared=True)
+        soup = self.get_details(bookmark)
+        section = self.find_section(soup, "Status")
+        form_action = reverse("bookmarks:details", args=[bookmark.id])
+        form = soup.find("form", {"action": form_action})
+        self.assertIsNone(section)
+        self.assertIsNone(form)
+
+    def test_status_update(self):
+        bookmark = self.setup_bookmark()
+
+        # update status
+        response = self.client.post(
+            self.get_base_url(bookmark),
+            {"is_archived": "on", "unread": "on", "shared": "on"},
+        )
+        self.assertEqual(response.status_code, 302)
+
+        bookmark.refresh_from_db()
+        self.assertTrue(bookmark.is_archived)
+        self.assertTrue(bookmark.unread)
+        self.assertTrue(bookmark.shared)
+
+        # update individual status
+        response = self.client.post(
+            self.get_base_url(bookmark),
+            {"is_archived": "", "unread": "on", "shared": ""},
+        )
+        self.assertEqual(response.status_code, 302)
+
+        bookmark.refresh_from_db()
+        self.assertFalse(bookmark.is_archived)
+        self.assertTrue(bookmark.unread)
+        self.assertFalse(bookmark.shared)
+
+    def test_status_update_access(self):
+        # no sharing
+        other_user = self.setup_user()
+        bookmark = self.setup_bookmark(user=other_user)
+        response = self.client.post(
+            self.get_base_url(bookmark),
+            {"is_archived": "on", "unread": "on", "shared": "on"},
+        )
+        self.assertEqual(response.status_code, 404)
+
+        # shared, sharing disabled
+        bookmark = self.setup_bookmark(user=other_user, shared=True)
+        response = self.client.post(
+            self.get_base_url(bookmark),
+            {"is_archived": "on", "unread": "on", "shared": "on"},
+        )
+        self.assertEqual(response.status_code, 404)
+
+        # shared, sharing enabled
+        bookmark = self.setup_bookmark(user=other_user, shared=True)
+        profile = other_user.profile
+        profile.enable_sharing = True
+        profile.save()
+
+        response = self.client.post(
+            self.get_base_url(bookmark),
+            {"is_archived": "on", "unread": "on", "shared": "on"},
+        )
+        self.assertEqual(response.status_code, 404)
+
+        # shared, public sharing enabled
+        bookmark = self.setup_bookmark(user=other_user, shared=True)
+        profile = other_user.profile
+        profile.enable_public_sharing = True
+        profile.save()
+
+        response = self.client.post(
+            self.get_base_url(bookmark),
+            {"is_archived": "on", "unread": "on", "shared": "on"},
+        )
+        self.assertEqual(response.status_code, 404)
+
+        # guest user
+        self.client.logout()
+        bookmark = self.setup_bookmark(user=other_user, shared=True)
+
+        response = self.client.post(
+            self.get_base_url(bookmark),
+            {"is_archived": "on", "unread": "on", "shared": "on"},
+        )
+        self.assertEqual(response.status_code, 404)
+
+    def test_date_added(self):
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+        section = self.get_section(soup, "Date added")
+
+        expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
+        date = section.find("span", string=expected_date)
+        self.assertIsNotNone(date)
+
+    def test_tags(self):
+        # without tags
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+
+        section = self.find_section(soup, "Tags")
+        self.assertIsNone(section)
+
+        # with tags
+        bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
+
+        soup = self.get_details(bookmark)
+        section = self.get_section(soup, "Tags")
+
+        for tag in bookmark.tags.all():
+            tag_link = section.find("a", string=f"#{tag.name}")
+            self.assertIsNotNone(tag_link)
+            expected_url = reverse("bookmarks:index") + f"?q=%23{tag.name}"
+            self.assertEqual(tag_link["href"], expected_url)
+
+    def test_description(self):
+        # without description
+        bookmark = self.setup_bookmark(description="", website_description="")
+        soup = self.get_details(bookmark)
+
+        section = self.find_section(soup, "Description")
+        self.assertIsNone(section)
+
+        # with description
+        bookmark = self.setup_bookmark(description="Test description")
+        soup = self.get_details(bookmark)
+
+        section = self.get_section(soup, "Description")
+        self.assertEqual(section.text.strip(), bookmark.description)
+
+        # with website description
+        bookmark = self.setup_bookmark(
+            description="", website_description="Website description"
+        )
+        soup = self.get_details(bookmark)
+
+        section = self.get_section(soup, "Description")
+        self.assertEqual(section.text.strip(), bookmark.website_description)
+
+    def test_notes(self):
+        # without notes
+        bookmark = self.setup_bookmark()
+        soup = self.get_details(bookmark)
+
+        section = self.find_section(soup, "Notes")
+        self.assertIsNone(section)
+
+        # with notes
+        bookmark = self.setup_bookmark(notes="Test notes")
+        soup = self.get_details(bookmark)
+
+        section = self.get_section(soup, "Notes")
+        self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
+
+    def test_edit_link(self):
+        bookmark = self.setup_bookmark()
+
+        # with default return URL
+        soup = self.get_details(bookmark)
+        edit_link = soup.find("a", string="Edit")
+        self.assertIsNotNone(edit_link)
+        details_url = reverse("bookmarks:details", args=[bookmark.id])
+        expected_url = (
+            reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=" + details_url
+        )
+        self.assertEqual(edit_link["href"], expected_url)
+
+        # with custom return URL
+        soup = self.get_details(bookmark, return_url="/custom")
+        edit_link = soup.find("a", string="Edit")
+        self.assertIsNotNone(edit_link)
+        expected_url = (
+            reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=/custom"
+        )
+        self.assertEqual(edit_link["href"], expected_url)
+
+    def test_delete_button(self):
+        bookmark = self.setup_bookmark()
+
+        # basics
+        soup = self.get_details(bookmark)
+        delete_button = soup.find("button", {"type": "submit", "name": "remove"})
+        self.assertIsNotNone(delete_button)
+        self.assertEqual(delete_button.text.strip(), "Delete...")
+        self.assertEqual(delete_button["value"], str(bookmark.id))
+
+        form = delete_button.find_parent("form")
+        self.assertIsNotNone(form)
+        expected_url = reverse("bookmarks:index.action") + f"?return_url=/bookmarks"
+        self.assertEqual(form["action"], expected_url)
+
+        # with custom return URL
+        soup = self.get_details(bookmark, return_url="/custom")
+        delete_button = soup.find("button", {"type": "submit", "name": "remove"})
+        form = delete_button.find_parent("form")
+        expected_url = reverse("bookmarks:index.action") + f"?return_url=/custom"
+        self.assertEqual(form["action"], expected_url)
+
+    def test_actions_visibility(self):
+        # with sharing
+        other_user = self.setup_user(enable_sharing=True)
+        bookmark = self.setup_bookmark(user=other_user, shared=True)
+
+        soup = self.get_details(bookmark)
+        edit_link = soup.find("a", string="Edit")
+        delete_button = soup.find("button", {"type": "submit", "name": "remove"})
+        self.assertIsNone(edit_link)
+        self.assertIsNone(delete_button)
+
+        # with public sharing
+        profile = other_user.profile
+        profile.enable_public_sharing = True
+        profile.save()
+        bookmark = self.setup_bookmark(user=other_user, shared=True)
+
+        soup = self.get_details(bookmark)
+        edit_link = soup.find("a", string="Edit")
+        delete_button = soup.find("button", {"type": "submit", "name": "remove"})
+        self.assertIsNone(edit_link)
+        self.assertIsNone(delete_button)
+
+        # guest user
+        self.client.logout()
+        bookmark = self.setup_bookmark(user=other_user, shared=True)
+
+        soup = self.get_details(bookmark)
+        edit_link = soup.find("a", string="Edit")
+        delete_button = soup.find("button", {"type": "submit", "name": "remove"})
+        self.assertIsNone(edit_link)
+        self.assertIsNone(delete_button)
diff --git a/bookmarks/tests/test_bookmark_details_view.py b/bookmarks/tests/test_bookmark_details_view.py
new file mode 100644
index 0000000..d509bc3
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_details_view.py
@@ -0,0 +1,8 @@
+from django.urls import reverse
+
+from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
+
+
+class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
+    def get_base_url(self, bookmark):
+        return reverse("bookmarks:details", args=[bookmark.id])
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index f80dc25..e76bc7c 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -10,11 +10,11 @@ from django.utils import timezone, formats
 
 from bookmarks.middlewares import UserProfileMiddleware
 from bookmarks.models import Bookmark, UserProfile, User
-from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
 from bookmarks.views.partials import contexts
 
 
-class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
+class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
 
     def assertBookmarksLink(
         self, html: str, bookmark: Bookmark, link_target: str = "_blank"
@@ -26,10 +26,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         )
         self.assertInHTML(
             f"""
+            {favicon_img}
             <a href="{bookmark.url}" 
                 target="{link_target}" 
                 rel="noopener">
-                {favicon_img}
                 <span>{bookmark.resolved_title}</span>
             </a>
             """,
@@ -40,7 +40,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         self.assertInHTML(
             f"""
         <span>{label_content}</span>
-        <span class="separator">|</span>
+        <span>|</span>
         """,
             html,
         )
@@ -54,19 +54,39 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
            title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
             {label_content} ∞
         </a>
-        <span class="separator">|</span>
+        <span>|</span>
         """,
             html,
         )
 
-    def assertBookmarkActions(self, html: str, bookmark: Bookmark):
-        self.assertBookmarkActionsCount(html, bookmark, count=1)
+    def assertViewLink(
+        self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
+    ):
+        self.assertViewLinkCount(html, bookmark, return_url=return_url)
 
-    def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
-        self.assertBookmarkActionsCount(html, bookmark, count=0)
+    def assertNoViewLink(
+        self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
+    ):
+        self.assertViewLinkCount(html, bookmark, count=0, return_url=return_url)
 
-    def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
-        # Edit link
+    def assertViewLinkCount(
+        self,
+        html: str,
+        bookmark: Bookmark,
+        count=1,
+        return_url=reverse("bookmarks:index"),
+    ):
+        details_url = reverse("bookmarks:details", args=[bookmark.id])
+        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>
+            """,
+            html,
+            count=count,
+        )
+
+    def assertEditLinkCount(self, html: str, bookmark: Bookmark, count=1):
         edit_url = reverse("bookmarks:edit", args=[bookmark.id])
         self.assertInHTML(
             f"""
@@ -75,7 +95,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
             html,
             count=count,
         )
-        # Archive link
+
+    def assertArchiveLinkCount(self, html: str, bookmark: Bookmark, count=1):
         self.assertInHTML(
             f"""
             <button type="submit" name="archive" value="{bookmark.id}"
@@ -84,7 +105,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
             html,
             count=count,
         )
-        # Delete link
+
+    def assertDeleteLinkCount(self, html: str, bookmark: Bookmark, count=1):
         self.assertInHTML(
             f"""
             <button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
@@ -94,6 +116,17 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
             count=count,
         )
 
+    def assertBookmarkActions(self, html: str, bookmark: Bookmark):
+        self.assertBookmarkActionsCount(html, bookmark, count=1)
+
+    def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
+        self.assertBookmarkActionsCount(html, bookmark, count=0)
+
+    def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
+        self.assertEditLinkCount(html, bookmark, count=count)
+        self.assertArchiveLinkCount(html, bookmark, count=count)
+        self.assertDeleteLinkCount(html, bookmark, count=count)
+
     def assertShareInfo(self, html: str, bookmark: Bookmark):
         self.assertShareInfoCount(html, bookmark, 1)
 
@@ -101,6 +134,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         self.assertShareInfoCount(html, bookmark, 0)
 
     def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
+        # Shared by link
         self.assertInHTML(
             f"""
             <span>Shared by 
@@ -133,7 +167,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
             f"""
         <div class="url-path truncate">
           <a href="{bookmark.url}" target="{link_target}" rel="noopener" 
-          class="url-display text-sm">
+          class="url-display">
             {bookmark.url}
           </a>
         </div>
@@ -154,7 +188,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         self.assertInHTML(
             f"""
         <div class="notes bg-gray text-gray-dark">
-          <div class="notes-content">
+          <div class="markdown">
             {notes_html}
           </div>
         </div>
@@ -241,6 +275,172 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         user.profile.save()
         return bookmark
 
+    def inline_bookmark_description_test(self, bookmark):
+        html = self.render_template()
+        soup = self.make_soup(html)
+
+        has_description = bool(bookmark.description)
+        has_tags = len(bookmark.tags.all()) > 0
+
+        # inline description block exists
+        description = soup.select_one(".description.inline.truncate")
+        self.assertIsNotNone(description)
+
+        # separate description block does not exist
+        separate_description = soup.select_one(".description.separate")
+        self.assertIsNone(separate_description)
+
+        # one direct child element per description or tags
+        children = description.find_all(recursive=False)
+        expected_child_count = (
+            0 + (1 if has_description else 0) + (1 if has_tags else 0)
+        )
+        self.assertEqual(len(children), expected_child_count)
+
+        # has separator between description and tags
+        if has_description and has_tags:
+            self.assertTrue("|" in description.text)
+
+        # contains description text
+        if has_description:
+            description_text = description.find("span", text=bookmark.description)
+            self.assertIsNotNone(description_text)
+
+        if not has_tags:
+            # no tags element
+            tags = soup.select_one(".tags")
+            self.assertIsNone(tags)
+        else:
+            # tags element exists
+            tags = soup.select_one(".tags")
+            self.assertIsNotNone(tags)
+
+            # one link for each tag
+            tag_links = tags.find_all("a")
+            self.assertEqual(len(tag_links), len(bookmark.tags.all()))
+
+            for tag in bookmark.tags.all():
+                tag_link = tags.find("a", text=f"#{tag.name}")
+                self.assertIsNotNone(tag_link)
+                self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
+
+    def test_inline_bookmark_description(self):
+        profile = self.get_or_create_test_user().profile
+        profile.bookmark_description_display = (
+            UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
+        )
+        profile.save()
+
+        # no description, no tags
+        bookmark = self.setup_bookmark(description="")
+        self.inline_bookmark_description_test(bookmark)
+
+        # with description, no tags
+        bookmark = self.setup_bookmark(description="Test description")
+        self.inline_bookmark_description_test(bookmark)
+
+        # no description, with tags
+        Bookmark.objects.all().delete()
+        bookmark = self.setup_bookmark(
+            description="", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]
+        )
+        self.inline_bookmark_description_test(bookmark)
+
+        # with description, with tags
+        Bookmark.objects.all().delete()
+        bookmark = self.setup_bookmark(
+            description="Test description",
+            tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],
+        )
+        self.inline_bookmark_description_test(bookmark)
+
+    def separate_bookmark_description_test(self, bookmark):
+        html = self.render_template()
+        soup = self.make_soup(html)
+
+        has_description = bool(bookmark.description)
+        has_tags = len(bookmark.tags.all()) > 0
+
+        # inline description block does not exist
+        inline_description = soup.select_one(".description.inline")
+        self.assertIsNone(inline_description)
+
+        if not has_description:
+            # no description element
+            description = soup.select_one(".description")
+            self.assertIsNone(description)
+        else:
+            # contains description text
+            description = soup.select_one(".description.separate")
+            self.assertIsNotNone(description)
+            self.assertEqual(description.text.strip(), bookmark.description)
+
+        if not has_tags:
+            # no tags element
+            tags = soup.select_one(".tags")
+            self.assertIsNone(tags)
+        else:
+            # tags element exists
+            tags = soup.select_one(".tags")
+            self.assertIsNotNone(tags)
+
+            # one link for each tag
+            tag_links = tags.find_all("a")
+            self.assertEqual(len(tag_links), len(bookmark.tags.all()))
+
+            for tag in bookmark.tags.all():
+                tag_link = tags.find("a", text=f"#{tag.name}")
+                self.assertIsNotNone(tag_link)
+                self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
+
+    def test_separate_bookmark_description(self):
+        profile = self.get_or_create_test_user().profile
+        profile.bookmark_description_display = (
+            UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
+        )
+        profile.save()
+
+        # no description, no tags
+        bookmark = self.setup_bookmark(description="")
+        self.separate_bookmark_description_test(bookmark)
+
+        # with description, no tags
+        bookmark = self.setup_bookmark(description="Test description")
+        self.separate_bookmark_description_test(bookmark)
+
+        # no description, with tags
+        Bookmark.objects.all().delete()
+        bookmark = self.setup_bookmark(
+            description="", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]
+        )
+        self.separate_bookmark_description_test(bookmark)
+
+        # with description, with tags
+        Bookmark.objects.all().delete()
+        bookmark = self.setup_bookmark(
+            description="Test description",
+            tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],
+        )
+        self.separate_bookmark_description_test(bookmark)
+
+    def test_bookmark_description_max_lines(self):
+        self.setup_bookmark()
+        html = self.render_template()
+        soup = self.make_soup(html)
+        bookmark_list = soup.select_one("ul.bookmark-list")
+        style = bookmark_list["style"]
+        self.assertIn("--ld-bookmark-description-max-lines:1;", style)
+
+        profile = self.get_or_create_test_user().profile
+        profile.bookmark_description_max_lines = 3
+        profile.save()
+
+        html = self.render_template()
+        soup = self.make_soup(html)
+        bookmark_list = soup.select_one("ul.bookmark-list")
+        style = bookmark_list["style"]
+        self.assertIn("--ld-bookmark-description-max-lines:3;", style)
+
     def test_should_respect_absolute_date_setting(self):
         bookmark = self.setup_date_format_test(
             UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
@@ -351,9 +551,58 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         bookmark = self.setup_bookmark()
         html = self.render_template()
 
+        self.assertViewLink(html, bookmark)
         self.assertBookmarkActions(html, bookmark)
         self.assertNoShareInfo(html, bookmark)
 
+    def test_hide_view_link(self):
+        bookmark = self.setup_bookmark()
+        profile = self.get_or_create_test_user().profile
+        profile.display_view_bookmark_action = False
+        profile.save()
+
+        html = self.render_template()
+        self.assertViewLinkCount(html, bookmark, count=0)
+        self.assertEditLinkCount(html, bookmark, count=1)
+        self.assertArchiveLinkCount(html, bookmark, count=1)
+        self.assertDeleteLinkCount(html, bookmark, count=1)
+
+    def test_hide_edit_link(self):
+        bookmark = self.setup_bookmark()
+        profile = self.get_or_create_test_user().profile
+        profile.display_edit_bookmark_action = False
+        profile.save()
+
+        html = self.render_template()
+        self.assertViewLinkCount(html, bookmark, count=1)
+        self.assertEditLinkCount(html, bookmark, count=0)
+        self.assertArchiveLinkCount(html, bookmark, count=1)
+        self.assertDeleteLinkCount(html, bookmark, count=1)
+
+    def test_hide_archive_link(self):
+        bookmark = self.setup_bookmark()
+        profile = self.get_or_create_test_user().profile
+        profile.display_archive_bookmark_action = False
+        profile.save()
+
+        html = self.render_template()
+        self.assertViewLinkCount(html, bookmark, count=1)
+        self.assertEditLinkCount(html, bookmark, count=1)
+        self.assertArchiveLinkCount(html, bookmark, count=0)
+        self.assertDeleteLinkCount(html, bookmark, count=1)
+
+    def test_hide_remove_link(self):
+        bookmark = self.setup_bookmark()
+        profile = self.get_or_create_test_user().profile
+        profile.display_remove_bookmark_action = False
+        profile.save()
+
+        html = self.render_template()
+        self.assertViewLinkCount(html, bookmark, count=1)
+        self.assertEditLinkCount(html, bookmark, count=1)
+        self.assertArchiveLinkCount(html, bookmark, count=1)
+        self.assertDeleteLinkCount(html, bookmark, count=0)
+
     def test_show_share_info_for_non_owned_bookmarks(self):
         other_user = User.objects.create_user(
             "otheruser", "otheruser@example.com", "password123"
@@ -364,6 +613,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         bookmark = self.setup_bookmark(user=other_user, shared=True)
         html = self.render_template(context_type=contexts.SharedBookmarkListContext)
 
+        self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
         self.assertNoBookmarkActions(html, bookmark)
         self.assertShareInfo(html, bookmark)
 
@@ -539,9 +789,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
 
     def test_notes_are_hidden_initially_by_default(self):
         self.setup_bookmark(notes="Test note")
-        html = collapse_whitespace(self.render_template())
+        html = self.render_template()
+        soup = self.make_soup(html)
+        bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
 
-        self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
+        self.assertIsNone(bookmark_list)
 
     def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
         profile = self.get_or_create_test_user().profile
@@ -549,9 +801,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         profile.save()
 
         self.setup_bookmark(notes="Test note")
-        html = collapse_whitespace(self.render_template())
+        html = self.render_template()
+        soup = self.make_soup(html)
+        bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
 
-        self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
+        self.assertIsNone(bookmark_list)
 
     def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
         profile = self.get_or_create_test_user().profile
@@ -559,11 +813,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         profile.save()
 
         self.setup_bookmark(notes="Test note")
-        html = collapse_whitespace(self.render_template())
+        html = self.render_template()
+        soup = self.make_soup(html)
+        bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
 
-        self.assertIn(
-            '<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html
-        )
+        self.assertIsNotNone(bookmark_list)
 
     def test_toggle_notes_is_visible_by_default(self):
         self.setup_bookmark(notes="Test note")
@@ -615,6 +869,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
         self.assertWebArchiveLink(
             html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
         )
+        self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
         self.assertNoBookmarkActions(html, bookmark)
         self.assertShareInfo(html, bookmark)
         self.assertMarkAsReadButton(html, bookmark, count=0)
diff --git a/bookmarks/tests/test_metadata_view.py b/bookmarks/tests/test_metadata_view.py
index b6318db..2129ab0 100644
--- a/bookmarks/tests/test_metadata_view.py
+++ b/bookmarks/tests/test_metadata_view.py
@@ -23,37 +23,37 @@ class MetadataViewTestCase(TestCase):
                     "src": "/static/logo.svg",
                     "type": "image/svg+xml",
                     "sizes": "512x512",
-                    "purpose": "any"
+                    "purpose": "any",
                 },
                 {
                     "src": "/static/logo-512.png",
                     "type": "image/png",
                     "sizes": "512x512",
-                    "purpose": "any"
+                    "purpose": "any",
                 },
                 {
                     "src": "/static/logo-192.png",
                     "type": "image/png",
                     "sizes": "192x192",
-                    "purpose": "any"
+                    "purpose": "any",
                 },
                 {
                     "src": "/static/maskable-logo.svg",
                     "type": "image/svg+xml",
                     "sizes": "512x512",
-                    "purpose": "maskable"
+                    "purpose": "maskable",
                 },
                 {
                     "src": "/static/maskable-logo-512.png",
                     "type": "image/png",
                     "sizes": "512x512",
-                    "purpose": "maskable"
+                    "purpose": "maskable",
                 },
                 {
                     "src": "/static/maskable-logo-192.png",
                     "type": "image/png",
                     "sizes": "192x192",
-                    "purpose": "maskable"
+                    "purpose": "maskable",
                 },
             ],
             "shortcuts": [
@@ -76,14 +76,14 @@ class MetadataViewTestCase(TestCase):
                 {
                     "name": "Shared",
                     "url": "/bookmarks/shared",
-                }
+                },
             ],
             "screenshots": [
                 {
                     "src": "/static/linkding-screenshot.png",
                     "type": "image/png",
                     "sizes": "2158x1160",
-                    "form_factor": "wide"
+                    "form_factor": "wide",
                 }
             ],
             "share_target": {
@@ -94,8 +94,8 @@ class MetadataViewTestCase(TestCase):
                     "url": "url",
                     "text": "url",
                     "title": "title",
-                }
-            }
+                },
+            },
         }
         self.assertDictEqual(response_body, expected_body)
 
@@ -120,37 +120,37 @@ class MetadataViewTestCase(TestCase):
                     "src": "/linkding/static/logo.svg",
                     "type": "image/svg+xml",
                     "sizes": "512x512",
-                    "purpose": "any"
+                    "purpose": "any",
                 },
                 {
                     "src": "/linkding/static/logo-512.png",
                     "type": "image/png",
                     "sizes": "512x512",
-                    "purpose": "any"
+                    "purpose": "any",
                 },
                 {
                     "src": "/linkding/static/logo-192.png",
                     "type": "image/png",
                     "sizes": "192x192",
-                    "purpose": "any"
+                    "purpose": "any",
                 },
                 {
                     "src": "/linkding/static/maskable-logo.svg",
                     "type": "image/svg+xml",
                     "sizes": "512x512",
-                    "purpose": "maskable"
+                    "purpose": "maskable",
                 },
                 {
                     "src": "/linkding/static/maskable-logo-512.png",
                     "type": "image/png",
                     "sizes": "512x512",
-                    "purpose": "maskable"
+                    "purpose": "maskable",
                 },
                 {
                     "src": "/linkding/static/maskable-logo-192.png",
                     "type": "image/png",
                     "sizes": "192x192",
-                    "purpose": "maskable"
+                    "purpose": "maskable",
                 },
             ],
             "shortcuts": [
@@ -173,14 +173,14 @@ class MetadataViewTestCase(TestCase):
                 {
                     "name": "Shared",
                     "url": "/linkding/bookmarks/shared",
-                }
+                },
             ],
             "screenshots": [
                 {
                     "src": "/linkding/static/linkding-screenshot.png",
                     "type": "image/png",
                     "sizes": "2158x1160",
-                    "form_factor": "wide"
+                    "form_factor": "wide",
                 }
             ],
             "share_target": {
@@ -191,7 +191,7 @@ class MetadataViewTestCase(TestCase):
                     "url": "url",
                     "text": "url",
                     "title": "title",
-                }
-            }
+                },
+            },
         }
         self.assertDictEqual(response_body, expected_body)
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index f9ed787..7f8e7c8 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -24,6 +24,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
         form_data = {
             "theme": UserProfile.THEME_AUTO,
             "bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
+            "bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
+            "bookmark_description_max_lines": 1,
             "bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_BLANK,
             "web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
             "enable_sharing": False,
@@ -31,6 +33,10 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "enable_favicons": False,
             "tag_search": UserProfile.TAG_SEARCH_STRICT,
             "display_url": False,
+            "display_view_bookmark_action": True,
+            "display_edit_bookmark_action": True,
+            "display_archive_bookmark_action": True,
+            "display_remove_bookmark_action": True,
             "permanent_notes": False,
             "custom_css": "",
         }
@@ -56,6 +62,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "update_profile": "",
             "theme": UserProfile.THEME_DARK,
             "bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
+            "bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE,
+            "bookmark_description_max_lines": 3,
             "bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_SELF,
             "web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
             "enable_sharing": True,
@@ -63,6 +71,10 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "enable_favicons": True,
             "tag_search": UserProfile.TAG_SEARCH_LAX,
             "display_url": True,
+            "display_view_bookmark_action": False,
+            "display_edit_bookmark_action": False,
+            "display_archive_bookmark_action": False,
+            "display_remove_bookmark_action": False,
             "permanent_notes": True,
             "custom_css": "body { background-color: #000; }",
         }
@@ -76,6 +88,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(
             self.user.profile.bookmark_date_display, form_data["bookmark_date_display"]
         )
+        self.assertEqual(
+            self.user.profile.bookmark_description_display,
+            form_data["bookmark_description_display"],
+        )
+        self.assertEqual(
+            self.user.profile.bookmark_description_max_lines,
+            form_data["bookmark_description_max_lines"],
+        )
         self.assertEqual(
             self.user.profile.bookmark_link_target, form_data["bookmark_link_target"]
         )
@@ -92,6 +112,22 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
         )
         self.assertEqual(self.user.profile.tag_search, form_data["tag_search"])
         self.assertEqual(self.user.profile.display_url, form_data["display_url"])
+        self.assertEqual(
+            self.user.profile.display_view_bookmark_action,
+            form_data["display_view_bookmark_action"],
+        )
+        self.assertEqual(
+            self.user.profile.display_edit_bookmark_action,
+            form_data["display_edit_bookmark_action"],
+        )
+        self.assertEqual(
+            self.user.profile.display_archive_bookmark_action,
+            form_data["display_archive_bookmark_action"],
+        )
+        self.assertEqual(
+            self.user.profile.display_remove_bookmark_action,
+            form_data["display_remove_bookmark_action"],
+        )
         self.assertEqual(
             self.user.profile.permanent_notes, form_data["permanent_notes"]
         )
diff --git a/bookmarks/tests/test_toasts_view.py b/bookmarks/tests/test_toasts_view.py
index 451288b..23bb426 100644
--- a/bookmarks/tests/test_toasts_view.py
+++ b/bookmarks/tests/test_toasts_view.py
@@ -40,7 +40,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
         # Should render toasts container
         self.assertContains(response, '<div class="toasts">')
         # Should render two toasts
-        self.assertContains(response, '<div class="toast">', count=2)
+        self.assertContains(response, '<div class="toast d-flex">', count=2)
 
     def test_should_not_render_acknowledged_toasts(self):
         self.create_toast(acknowledged=True)
@@ -81,9 +81,9 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
     def test_toast_content(self):
         toast = self.create_toast()
         expected_toast = f"""
-            <div class="toast">
+            <div class="toast d-flex">
                 {toast.message}
-                <button type="submit" name="toast" value="{toast.id}" class="btn btn-clear float-right"></button>
+                <button type="submit" name="toast" value="{toast.id}" class="btn btn-clear"></button>
             </div>        
         """