`;
- // 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/index.js b/bookmarks/frontend/index.js
index 2f115fcb..a6e3929c 100644
--- a/bookmarks/frontend/index.js
+++ b/bookmarks/frontend/index.js
@@ -1,3 +1,4 @@
+import "./behaviors/bookmark-details";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
diff --git a/bookmarks/styles/bookmark-details.scss b/bookmarks/styles/bookmark-details.scss
new file mode 100644
index 00000000..0ac6108f
--- /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 2efd822e..ceb7afaf 100644
--- a/bookmarks/styles/bookmark-page.scss
+++ b/bookmarks/styles/bookmark-page.scss
@@ -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;
@@ -268,55 +271,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;
}
}
diff --git a/bookmarks/styles/markdown.scss b/bookmarks/styles/markdown.scss
new file mode 100644
index 00000000..df88b5d9
--- /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 77e8d332..6c33771e 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/spectre.scss b/bookmarks/styles/spectre.scss
index 1cb8b349..54b62733 100644
--- a/bookmarks/styles/spectre.scss
+++ b/bookmarks/styles/spectre.scss
@@ -127,6 +127,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 8229ab8b..6aebb114 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 fd4eb32f..ac823efc 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 56a69c41..bea59e8a 100644
--- a/bookmarks/styles/variables-dark.scss
+++ b/bookmarks/styles/variables-dark.scss
@@ -30,4 +30,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/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index 1abca548..e64e490b 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -60,7 +60,7 @@
{% endif %}
{% if bookmark_item.notes %}
-
+
{% markdown bookmark_item.notes %}
@@ -79,6 +79,10 @@
{% endif %}
|
{% endif %}
+ {# View link is always visible #}
+
View
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
Edit
diff --git a/bookmarks/templates/bookmarks/details.html b/bookmarks/templates/bookmarks/details.html
new file mode 100644
index 00000000..8af50d5b
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details.html
@@ -0,0 +1,13 @@
+{% extends 'bookmarks/layout.html' %}
+
+{% block content %}
+
+ {% if request.user == bookmark.owner %}
+ {% include 'bookmarks/details/actions.html' %}
+ {% endif %}
+ {% include 'bookmarks/details/title.html' %}
+
+ {% include 'bookmarks/details/content.html' %}
+
+
+{% endblock %}
diff --git a/bookmarks/templates/bookmarks/details/actions.html b/bookmarks/templates/bookmarks/details/actions.html
new file mode 100644
index 00000000..0fc844f4
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details/actions.html
@@ -0,0 +1,13 @@
+
diff --git a/bookmarks/templates/bookmarks/details/content.html b/bookmarks/templates/bookmarks/details/content.html
new file mode 100644
index 00000000..ea4377e2
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details/content.html
@@ -0,0 +1,85 @@
+{% load static %}
+{% load shared %}
+
+
+
+ {% if request.user == bookmark.owner %}
+
+ {% endif %}
+ {% if bookmark.tag_names %}
+
+ {% endif %}
+
+
- Date added
+ -
+ {{ bookmark.date_added }}
+
+
+ {% if bookmark.resolved_description %}
+
+
- Description
+ - {{ bookmark.resolved_description }}
+
+ {% endif %}
+ {% if bookmark.notes %}
+
+
- Notes
+ - {% markdown bookmark.notes %}
+
+ {% endif %}
+
diff --git a/bookmarks/templates/bookmarks/details/title.html b/bookmarks/templates/bookmarks/details/title.html
new file mode 100644
index 00000000..a5982a74
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details/title.html
@@ -0,0 +1,3 @@
+
+ {{ bookmark.resolved_title }}
+
diff --git a/bookmarks/templates/bookmarks/details_modal.html b/bookmarks/templates/bookmarks/details_modal.html
new file mode 100644
index 00000000..ec24bc58
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details_modal.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ {% include 'bookmarks/details/content.html' %}
+
+
+
+ {% if request.user == bookmark.owner %}
+
+ {% endif %}
+
+
diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py
new file mode 100644
index 00000000..8f1b6bc8
--- /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(), "
Test notes
")
+
+ 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 00000000..d509bc37
--- /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 64e9d93a..ed5fb37a 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -59,6 +59,19 @@ def assertWebArchiveLink(
html,
)
+ def assertViewLink(
+ self, html: str, bookmark: Bookmark, 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"""
+
View
+ """,
+ html,
+ count=1,
+ )
+
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=1)
@@ -101,6 +114,7 @@ def assertNoShareInfo(self, html: str, bookmark: Bookmark):
self.assertShareInfoCount(html, bookmark, 0)
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
+ # Shared by link
self.assertInHTML(
f"""
Shared by
@@ -154,7 +168,7 @@ def assertNotes(self, html: str, notes_html: str, count=1):
self.assertInHTML(
f"""
-
@@ -517,6 +531,7 @@ def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark()
html = self.render_template()
+ self.assertViewLink(html, bookmark)
self.assertBookmarkActions(html, bookmark)
self.assertNoShareInfo(html, bookmark)
@@ -530,6 +545,7 @@ def test_show_share_info_for_non_owned_bookmarks(self):
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)
@@ -785,6 +801,7 @@ def test_with_anonymous_user(self):
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/urls.py b/bookmarks/urls.py
index 3b8c953b..4a254bb1 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -34,6 +34,16 @@
path("bookmarks/new", views.bookmarks.new, name="new"),
path("bookmarks/close", views.bookmarks.close, name="close"),
path("bookmarks/
/edit", views.bookmarks.edit, name="edit"),
+ path(
+ "bookmarks//details",
+ views.bookmarks.details,
+ name="details",
+ ),
+ path(
+ "bookmarks//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 5b077161..969df3a7 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