mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 14:09:26 +02:00
Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
016ff2da66 | ||
![]() |
77d7e6e66a | ||
![]() |
c5a300a435 | ||
![]() |
0d4c47eb81 | ||
![]() |
17442eeb9a | ||
![]() |
2973812626 | ||
![]() |
fc48b266a8 | ||
![]() |
7b42241026 | ||
![]() |
9c648dc67f | ||
![]() |
1624128132 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## v1.37.0 (26/01/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
|
||||
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
|
||||
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
|
||||
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
|
||||
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
|
||||
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
|
||||
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
|
||||
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
|
||||
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
|
||||
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
|
||||
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
|
||||
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
|
||||
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
|
||||
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
|
||||
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
|
||||
|
||||
### New Contributors
|
||||
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
|
||||
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
|
||||
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
|
||||
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
|
||||
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
|
||||
|
||||
---
|
||||
|
||||
## v1.36.0 (02/10/2024)
|
||||
|
||||
### What's Changed
|
||||
|
48
bookmarks/e2e/e2e_test_collapse_side_panel.py
Normal file
48
bookmarks/e2e/e2e_test_collapse_side_panel.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
def assertSidePanelIsVisible(self):
|
||||
expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible()
|
||||
expect(
|
||||
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||
).not_to_be_visible()
|
||||
|
||||
def assertSidePanelIsHidden(self):
|
||||
expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible()
|
||||
expect(
|
||||
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||
).to_be_visible()
|
||||
|
||||
def test_side_panel_should_be_visible_by_default(self):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
self.assertSidePanelIsVisible()
|
||||
|
||||
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
|
||||
self.assertSidePanelIsVisible()
|
||||
|
||||
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
|
||||
self.assertSidePanelIsVisible()
|
||||
|
||||
def test_side_panel_should_be_hidden_when_collapsed(self):
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.collapse_side_panel = True
|
||||
user.profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
self.assertSidePanelIsHidden()
|
||||
|
||||
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
|
||||
self.assertSidePanelIsHidden()
|
||||
|
||||
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
|
||||
self.assertSidePanelIsHidden()
|
@@ -4,7 +4,7 @@ from playwright.sync_api import sync_playwright, expect
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_show_modal_close_modal(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||
@@ -12,31 +12,31 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
# use smaller viewport to make filter button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
# open drawer
|
||||
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Filters"
|
||||
)
|
||||
modal_trigger.click()
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify modal is visible
|
||||
modal = page.locator(".modal")
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.locator("h2")).to_have_text("Tags")
|
||||
# verify drawer is visible
|
||||
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||
expect(drawer).to_be_visible()
|
||||
expect(drawer.locator("h2")).to_have_text("Filters")
|
||||
|
||||
# close with close button
|
||||
modal.locator("button.close").click()
|
||||
expect(modal).to_be_hidden()
|
||||
drawer.locator("button.close").click()
|
||||
expect(drawer).to_be_hidden()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
# open drawer again
|
||||
drawer_trigger.click()
|
||||
|
||||
# close with backdrop
|
||||
backdrop = modal.locator(".modal-overlay")
|
||||
backdrop = drawer.locator(".modal-overlay")
|
||||
backdrop.click(position={"x": 0, "y": 0})
|
||||
expect(modal).to_be_hidden()
|
||||
expect(drawer).to_be_hidden()
|
||||
|
||||
def test_select_tag(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
@@ -45,29 +45,29 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
# use smaller viewport to make filter button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Filters"
|
||||
)
|
||||
modal_trigger.click()
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify tags are displayed
|
||||
modal = page.locator(".modal")
|
||||
unselected_tags = modal.locator(".unselected-tags")
|
||||
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||
unselected_tags = drawer.locator(".unselected-tags")
|
||||
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||
|
||||
# select tag
|
||||
unselected_tags.get_by_text("cooking").click()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
# open drawer again
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify tag is selected, other tag is not visible anymore
|
||||
selected_tags = modal.locator(".selected-tags")
|
||||
selected_tags = drawer.locator(".selected-tags")
|
||||
expect(selected_tags.get_by_text("cooking")).to_be_visible()
|
||||
|
||||
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
@@ -1,61 +1,28 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { registerBehavior } from "./index";
|
||||
import { isKeyboardActive } from "./focus-utils";
|
||||
import { ModalBehavior } from "./modal";
|
||||
|
||||
class DetailsModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
class DetailsModalBehavior extends ModalBehavior {
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
// Navigate to close URL
|
||||
const closeUrl = this.element.dataset.closeUrl;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
|
||||
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
|
||||
this.buttonLink = element.querySelector("a:has(button.close)");
|
||||
// Try restore focus to view details to view details link of respective bookmark
|
||||
const bookmarkId = this.element.dataset.bookmarkId;
|
||||
const restoreFocusElement =
|
||||
document.querySelector(
|
||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||
) ||
|
||||
document.querySelector("ul.bookmark-list") ||
|
||||
document.body;
|
||||
|
||||
this.overlayLink.addEventListener("click", this.onClose);
|
||||
this.buttonLink.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.overlayLink.removeEventListener("click", this.onClose);
|
||||
this.buttonLink.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.element.remove();
|
||||
|
||||
const closeUrl = this.overlayLink.href;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
|
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { ModalBehavior } from "./modal";
|
||||
import { isKeyboardActive } from "./focus-utils";
|
||||
|
||||
class FilterDrawerTriggerBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "drawer", "filter-drawer");
|
||||
modal.setAttribute("ld-filter-drawer", "");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>Filters</h2>
|
||||
<button class="close" aria-label="Close dialog">
|
||||
<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">
|
||||
<section class="content content-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.querySelector(".modals").appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterDrawerBehavior extends ModalBehavior {
|
||||
init() {
|
||||
// Teleport content before creating focus trap, otherwise it will not detect
|
||||
// focusable content elements
|
||||
this.teleport();
|
||||
super.init();
|
||||
// Add active class to start slide-in animation
|
||||
this.element.classList.add("active");
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
// Always close on destroy to restore drawer content to original location
|
||||
// before turbo caches DOM
|
||||
this.doClose();
|
||||
}
|
||||
|
||||
mapHeading(container, from, to) {
|
||||
const headings = container.querySelectorAll(from);
|
||||
headings.forEach((heading) => {
|
||||
const newHeading = document.createElement(to);
|
||||
newHeading.textContent = heading.textContent;
|
||||
heading.replaceWith(newHeading);
|
||||
});
|
||||
}
|
||||
|
||||
teleport() {
|
||||
const content = this.element.querySelector(".content");
|
||||
const sidePanel = document.querySelector("section.side-panel");
|
||||
content.append(...sidePanel.children);
|
||||
this.mapHeading(content, "h2", "h3");
|
||||
}
|
||||
|
||||
teleportBack() {
|
||||
const sidePanel = document.querySelector("section.side-panel");
|
||||
const content = this.element.querySelector(".content");
|
||||
sidePanel.append(...content.children);
|
||||
this.mapHeading(sidePanel, "h3", "h2");
|
||||
}
|
||||
|
||||
doClose() {
|
||||
super.doClose();
|
||||
this.teleportBack();
|
||||
|
||||
// Try restore focus to drawer trigger
|
||||
const restoreFocusElement =
|
||||
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
|
||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
|
||||
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);
|
59
bookmarks/frontend/behaviors/focus-utils.js
Normal file
59
bookmarks/frontend/behaviors/focus-utils.js
Normal file
@@ -0,0 +1,59 @@
|
||||
let keyboardActive = false;
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
() => {
|
||||
keyboardActive = true;
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"mousedown",
|
||||
() => {
|
||||
keyboardActive = false;
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
export function isKeyboardActive() {
|
||||
return keyboardActive;
|
||||
}
|
||||
|
||||
export class FocusTrapController {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.focusableElements = this.element.querySelectorAll(
|
||||
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
|
||||
);
|
||||
this.firstFocusableElement = this.focusableElements[0];
|
||||
this.lastFocusableElement =
|
||||
this.focusableElements[this.focusableElements.length - 1];
|
||||
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
|
||||
this.element.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === this.firstFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.lastFocusableElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === this.lastFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.firstFocusableElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
91
bookmarks/frontend/behaviors/modal.js
Normal file
91
bookmarks/frontend/behaviors/modal.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Behavior } from "./index";
|
||||
import { FocusTrapController } from "./focus-utils";
|
||||
|
||||
export class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.overlay = element.querySelector(".modal-overlay");
|
||||
this.closeButton = element.querySelector(".modal-header .close");
|
||||
|
||||
this.overlay.addEventListener("click", this.onClose);
|
||||
this.closeButton.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.overlay.removeEventListener("click", this.onClose);
|
||||
this.closeButton.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.clearInert();
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupInert();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.element.querySelector(".modal-container"),
|
||||
);
|
||||
}
|
||||
|
||||
setupInert() {
|
||||
// Inert all other elements on the page
|
||||
document
|
||||
.querySelectorAll("body > *:not(.modals)")
|
||||
.forEach((el) => el.setAttribute("inert", ""));
|
||||
// Lock scroll on the body
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
clearInert() {
|
||||
// Clear inert attribute from all elements to allow focus outside the modal again
|
||||
document
|
||||
.querySelectorAll("body > *")
|
||||
.forEach((el) => el.removeAttribute("inert"));
|
||||
// Remove scroll lock from the body
|
||||
document.body.classList.remove("scroll-lock");
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.doClose();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
doClose() {
|
||||
this.element.remove();
|
||||
this.clearInert();
|
||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||
}
|
||||
}
|
@@ -1,68 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class TagModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.onClose();
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "active");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2>Tags</h2>
|
||||
<button class="close" aria-label="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tagCloud = document.querySelector(".tag-cloud");
|
||||
const tagCloudContainer = tagCloud.parentElement;
|
||||
|
||||
const content = modal.querySelector(".content");
|
||||
content.appendChild(tagCloud);
|
||||
|
||||
const overlay = modal.querySelector(".modal-overlay");
|
||||
const closeButton = modal.querySelector(".close");
|
||||
overlay.addEventListener("click", this.onClose);
|
||||
closeButton.addEventListener("click", this.onClose);
|
||||
|
||||
this.modal = modal;
|
||||
this.tagCloud = tagCloud;
|
||||
this.tagCloudContainer = tagCloudContainer;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal.remove();
|
||||
this.tagCloudContainer.appendChild(this.tagCloud);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-tag-modal", TagModalBehavior);
|
@@ -3,13 +3,13 @@ import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/clear-button";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/details-modal";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/filter-drawer";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
import "./behaviors/tag-modal";
|
||||
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
|
18
bookmarks/migrations/0043_userprofile_collapse_side_panel.py
Normal file
18
bookmarks/migrations/0043_userprofile_collapse_side_panel.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-02 09:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0042_userprofile_custom_css_hash"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="collapse_side_panel",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@@ -440,6 +440,7 @@ class UserProfile(models.Model):
|
||||
null=False, default=30, validators=[MinValueValidator(10)]
|
||||
)
|
||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_css:
|
||||
@@ -479,6 +480,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"auto_tagging_rules",
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
"collapse_side_panel",
|
||||
]
|
||||
|
||||
|
||||
|
@@ -36,8 +36,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
& dl {
|
||||
margin-bottom: 0;
|
||||
& .sections section {
|
||||
margin-top: var(--unit-4);
|
||||
}
|
||||
|
||||
& .sections h3 {
|
||||
margin-bottom: var(--unit-2);
|
||||
font-size: var(--font-size);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .assets {
|
||||
|
@@ -10,8 +10,38 @@
|
||||
}
|
||||
|
||||
/* Bookmark page grid */
|
||||
.bookmarks-page.grid {
|
||||
grid-gap: var(--unit-9);
|
||||
.bookmarks-page {
|
||||
&.grid {
|
||||
grid-gap: var(--unit-9);
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
section.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&.collapse-side-panel {
|
||||
section.main {
|
||||
grid-column: span var(--grid-columns);
|
||||
}
|
||||
|
||||
section.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
|
@@ -2,7 +2,8 @@
|
||||
|
||||
/* Content area component */
|
||||
section.content-area {
|
||||
h2 {
|
||||
h2,
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
@@ -14,7 +15,8 @@ section.content-area {
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
h2 {
|
||||
h2,
|
||||
h3 {
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
|
@@ -10,7 +10,6 @@ html {
|
||||
font-size: var(--html-font-size);
|
||||
line-height: var(--html-line-height);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||
|
@@ -62,13 +62,14 @@
|
||||
gap: var(--unit-4);
|
||||
max-height: 75vh;
|
||||
max-width: var(--control-width-md);
|
||||
padding: var(--unit-6);
|
||||
width: 100%;
|
||||
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-6);
|
||||
padding-bottom: 0;
|
||||
color: var(--text-color);
|
||||
|
||||
& h2 {
|
||||
@@ -78,7 +79,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& button.close {
|
||||
& .close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
@@ -95,10 +96,53 @@
|
||||
|
||||
& .modal-body {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
padding: 0 var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-body:not(:has(+ .modal-footer)) {
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-footer {
|
||||
padding: var(--unit-6);
|
||||
padding-top: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.modal.drawer {
|
||||
display: block;
|
||||
|
||||
& .modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border: none;
|
||||
border-left: solid 1px var(--modal-container-border-color);
|
||||
border-radius: 0;
|
||||
transform: translateX(100%);
|
||||
animation: fade-in 0.25s ease 1;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&.active {
|
||||
& .modal-container {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
&.active.closing {
|
||||
& .modal-container {
|
||||
animation: fade-out 0.25s ease 1;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-lock {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
@@ -4,17 +4,17 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
|
||||
<div ld-bulk-edit
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
<section class="main content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</section>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<section class="side-panel content-area col-1">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
@@ -39,12 +39,14 @@
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
|
@@ -6,10 +6,12 @@
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||
role="list" tabindex="-1"
|
||||
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 %}>
|
||||
<li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
|
||||
{% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
@@ -78,7 +80,8 @@
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
|
@@ -40,14 +40,14 @@
|
||||
</div>
|
||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||
<div class="preview-image">
|
||||
<img src="{% static details.bookmark.preview_image_file %}"/>
|
||||
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<dl class="grid columns-2 columns-sm-1 gap-0">
|
||||
<div class="sections grid columns-2 columns-sm-1 gap-0">
|
||||
{% if details.is_editable %}
|
||||
<div class="status col-2">
|
||||
<dt>Status</dt>
|
||||
<dd class="d-flex" style="gap: .8rem">
|
||||
<section class="status col-2">
|
||||
<h3>Status</h3>
|
||||
<div class="d-flex" style="gap: .8rem">
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input ld-auto-submit type="checkbox" name="is_archived"
|
||||
@@ -71,44 +71,44 @@
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.show_files %}
|
||||
<div class="files col-2">
|
||||
<dt>Files</dt>
|
||||
<dd>
|
||||
<section class="files col-2">
|
||||
<h3>Files</h3>
|
||||
<div>
|
||||
{% include 'bookmarks/details/assets.html' %}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.bookmark.tag_names %}
|
||||
<div class="tags col-1">
|
||||
<dt>Tags</dt>
|
||||
<dd>
|
||||
<section class="tags col-1">
|
||||
<h3 id="details-modal-tags-title">Tags</h3>
|
||||
<div>
|
||||
{% for tag_name in details.bookmark.tag_names %}
|
||||
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<div class="date-added col-1">
|
||||
<dt>Date added</dt>
|
||||
<dd>
|
||||
<section class="date-added col-1">
|
||||
<h3>Date added</h3>
|
||||
<div>
|
||||
<span>{{ details.bookmark.date_added }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<div class="description col-2">
|
||||
<dt>Description</dt>
|
||||
<dd>{{ details.bookmark.resolved_description }}</dd>
|
||||
</div>
|
||||
</section>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<section class="description col-2">
|
||||
<h3>Description</h3>
|
||||
<div>{{ details.bookmark.resolved_description }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.bookmark.notes %}
|
||||
<div class="notes col-2">
|
||||
<dt>Notes</dt>
|
||||
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
|
||||
</div>
|
||||
<section class="notes col-2">
|
||||
<h3>Notes</h3>
|
||||
<div class="markdown">{% markdown details.bookmark.notes %}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,21 +1,17 @@
|
||||
<div class="modal active bookmark-details"
|
||||
ld-details-modal>
|
||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
</a>
|
||||
<div class="modal-container">
|
||||
<div class="modal active bookmark-details" ld-details-modal
|
||||
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>{{ details.bookmark.resolved_title }}</h2>
|
||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||
<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>
|
||||
</a>
|
||||
<button class="close" aria-label="Close dialog">
|
||||
<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">
|
||||
|
@@ -36,5 +36,8 @@
|
||||
{% if not request.global_settings.enable_link_prefetch %}
|
||||
<meta name="turbo-prefetch" content="false">
|
||||
{% endif %}
|
||||
{% if rss_feed_url %}
|
||||
<link rel="alternate" type="application/rss+xml" href="{{ rss_feed_url }}" />
|
||||
{% endif %}
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
</head>
|
||||
|
@@ -4,16 +4,17 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
|
||||
<div ld-bulk-edit
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
<section class="main content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +31,7 @@
|
||||
</section>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<section class="side-panel content-area col-1">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
@@ -38,12 +39,14 @@
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
@@ -97,5 +97,9 @@
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div class="modals">
|
||||
{% block overlays %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -7,7 +7,7 @@
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
Bookmarks
|
||||
</button>
|
||||
<ul class="menu" role="list">
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
|
||||
</li>
|
||||
@@ -35,21 +35,21 @@
|
||||
</div>
|
||||
{# Menu drop-down for smaller devices #}
|
||||
<div class="show-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
||||
<a href="{% url 'bookmarks:new' %}" aria-label="Add bookmark" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div ld-dropdown class="dropdown dropdown-right">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
<button class="btn btn-link dropdown-toggle" aria-label="Navigation menu" tabindex="0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- menu component -->
|
||||
<ul class="menu" role="list">
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
|
||||
</li>
|
||||
|
@@ -4,16 +4,16 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page grid columns-md-1">
|
||||
<div
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
<section class="main content-area col-2">
|
||||
<div class="content-area-header">
|
||||
<h2>Shared bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='shared' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</section>
|
||||
|
||||
{# Filters #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<section class="side-panel content-area col-1">
|
||||
<div class="content-area-header">
|
||||
<h2>User</h2>
|
||||
</div>
|
||||
@@ -43,12 +43,14 @@
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
|
@@ -124,6 +124,16 @@
|
||||
visible without having to scroll to the end of the page first.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.collapse_side_panel.id_for_label }}" class="form-checkbox">
|
||||
{{ form.collapse_side_panel }}
|
||||
<i class="form-icon"></i> Collapse side panel
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
When enabled, the tags side panel will be collapsed by default to give more space to the bookmark list.
|
||||
Instead, the tags are shown in an expandable drawer.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||
|
@@ -503,3 +503,10 @@ class BookmarkArchivedViewTestCase(
|
||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||
|
||||
def test_does_not_include_rss_feed(self):
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNone(feed)
|
||||
|
@@ -5,11 +5,11 @@ from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import GlobalSettings
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BookmarkArchivedViewPerformanceTestCase(
|
||||
TransactionTestCase, BookmarkFactoryMixin
|
||||
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
):
|
||||
|
||||
def setUp(self) -> None:
|
||||
@@ -32,9 +32,10 @@ class BookmarkArchivedViewPerformanceTestCase(
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
self.assertContains(
|
||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
||||
)
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(len(list_items), num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
|
||||
@@ -46,8 +47,9 @@ class BookmarkArchivedViewPerformanceTestCase(
|
||||
# assert num queries doesn't increase
|
||||
with self.assertNumQueries(number_of_queries):
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
self.assertContains(
|
||||
response,
|
||||
"<li ld-bookmark-item>",
|
||||
num_initial_bookmarks + num_additional_bookmarks,
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(
|
||||
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||
)
|
||||
|
@@ -32,15 +32,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
||||
return modal
|
||||
|
||||
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 find_section_content(self, soup, section_name):
|
||||
h3 = soup.find("h3", string=section_name)
|
||||
content = h3.find_next_sibling("div") if h3 else None
|
||||
return content
|
||||
|
||||
def get_section(self, soup, section_name):
|
||||
dd = self.find_section(soup, section_name)
|
||||
self.assertIsNotNone(dd)
|
||||
return dd
|
||||
def get_section_content(self, soup, section_name):
|
||||
content = self.find_section_content(soup, section_name)
|
||||
self.assertIsNotNone(content)
|
||||
return content
|
||||
|
||||
def find_weblink(self, soup, url):
|
||||
return soup.find("a", {"class": "weblink", "href": url})
|
||||
@@ -367,7 +367,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# sharing disabled
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
section = self.get_section_content(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
self.assertIsNotNone(archived)
|
||||
@@ -383,7 +383,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
section = self.get_section_content(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
self.assertIsNotNone(archived)
|
||||
@@ -395,7 +395,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# unchecked
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
section = self.get_section_content(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
self.assertFalse(archived.has_attr("checked"))
|
||||
@@ -407,7 +407,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# checked
|
||||
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
section = self.get_section_content(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
self.assertTrue(archived.has_attr("checked"))
|
||||
@@ -420,14 +420,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# own bookmark
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Status")
|
||||
section = self.find_section_content(soup, "Status")
|
||||
self.assertIsNotNone(section)
|
||||
|
||||
# other user's bookmark
|
||||
other_user = self.setup_user(enable_sharing=True)
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
soup = self.get_shared_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Status")
|
||||
section = self.find_section_content(soup, "Status")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# guest user
|
||||
@@ -436,13 +436,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
other_user.profile.save()
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
soup = self.get_shared_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Status")
|
||||
section = self.find_section_content(soup, "Status")
|
||||
self.assertIsNone(section)
|
||||
|
||||
def test_date_added(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Date added")
|
||||
section = self.get_section_content(soup, "Date added")
|
||||
|
||||
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
|
||||
date = section.find("span", string=expected_date)
|
||||
@@ -453,14 +453,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.find_section(soup, "Tags")
|
||||
section = self.find_section_content(soup, "Tags")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# with tags
|
||||
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Tags")
|
||||
section = self.get_section_content(soup, "Tags")
|
||||
|
||||
for tag in bookmark.tags.all():
|
||||
tag_link = section.find("a", string=f"#{tag.name}")
|
||||
@@ -473,14 +473,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark(description="")
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.find_section(soup, "Description")
|
||||
section = self.find_section_content(soup, "Description")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# with description
|
||||
bookmark = self.setup_bookmark(description="Test description")
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.get_section(soup, "Description")
|
||||
section = self.get_section_content(soup, "Description")
|
||||
self.assertEqual(section.text.strip(), bookmark.description)
|
||||
|
||||
def test_notes(self):
|
||||
@@ -488,14 +488,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.find_section(soup, "Notes")
|
||||
section = self.find_section_content(soup, "Notes")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# with notes
|
||||
bookmark = self.setup_bookmark(notes="Test notes")
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.get_section(soup, "Notes")
|
||||
section = self.get_section_content(soup, "Notes")
|
||||
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
|
||||
|
||||
def test_edit_link(self):
|
||||
@@ -568,7 +568,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Files")
|
||||
section = self.find_section_content(soup, "Files")
|
||||
self.assertIsNone(section)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
@@ -576,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Files")
|
||||
section = self.find_section_content(soup, "Files")
|
||||
self.assertIsNotNone(section)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
@@ -585,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Files")
|
||||
section = self.get_section_content(soup, "Files")
|
||||
asset_list = section.find("div", {"class": "assets"})
|
||||
self.assertIsNone(asset_list)
|
||||
|
||||
@@ -594,7 +594,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
self.setup_asset(bookmark)
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Files")
|
||||
section = self.get_section_content(soup, "Files")
|
||||
asset_list = section.find("div", {"class": "assets"})
|
||||
self.assertIsNotNone(asset_list)
|
||||
|
||||
@@ -608,7 +608,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
]
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Files")
|
||||
section = self.get_section_content(soup, "Files")
|
||||
asset_list = section.find("div", {"class": "assets"})
|
||||
|
||||
for asset in assets:
|
||||
@@ -738,7 +738,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
# no pending asset
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
files_section = self.find_section_content(soup, "Files")
|
||||
create_button = files_section.find(
|
||||
"button", string=re.compile("Create HTML snapshot")
|
||||
)
|
||||
@@ -749,7 +749,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
asset.save()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
files_section = self.find_section_content(soup, "Files")
|
||||
create_button = files_section.find(
|
||||
"button", string=re.compile("Create HTML snapshot")
|
||||
)
|
||||
|
@@ -481,3 +481,10 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||
|
||||
def test_does_not_include_rss_feed(self):
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNone(feed)
|
||||
|
@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import GlobalSettings
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
|
||||
class BookmarkIndexViewPerformanceTestCase(
|
||||
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -30,9 +32,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
self.assertContains(
|
||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
||||
)
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(len(list_items), num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
|
||||
@@ -44,8 +47,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
|
||||
# assert num queries doesn't increase
|
||||
with self.assertNumQueries(number_of_queries):
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
self.assertContains(
|
||||
response,
|
||||
"<li ld-bookmark-item>",
|
||||
num_initial_bookmarks + num_additional_bookmarks,
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(
|
||||
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||
)
|
||||
|
@@ -593,3 +593,11 @@ class BookmarkSharedViewTestCase(
|
||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||
|
||||
def test_includes_public_shared_rss_feed(self):
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNotNone(feed)
|
||||
self.assertEqual(feed.attrs["href"], reverse("bookmarks:feeds.public_shared"))
|
||||
|
@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import GlobalSettings
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
|
||||
class BookmarkSharedViewPerformanceTestCase(
|
||||
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -31,9 +33,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
self.assertContains(
|
||||
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks
|
||||
)
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(len(list_items), num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
|
||||
@@ -46,8 +49,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
||||
# assert num queries doesn't increase
|
||||
with self.assertNumQueries(number_of_queries):
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
self.assertContains(
|
||||
response,
|
||||
'<li ld-bookmark-item class="shared">',
|
||||
num_initial_bookmarks + num_additional_bookmarks,
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(
|
||||
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||
)
|
||||
|
@@ -69,7 +69,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
details_url = base_url + f"?details={bookmark.id}"
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
<a href="{details_url}" class="view-action" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
""",
|
||||
html,
|
||||
count=count,
|
||||
@@ -562,8 +562,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def test_should_reflect_unread_state_as_css_class(self):
|
||||
self.setup_bookmark(unread=True)
|
||||
html = self.render_template()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="unread">', html)
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
self.assertIsNotNone(list_item)
|
||||
self.assertListEqual(["unread"], list_item["class"])
|
||||
|
||||
def test_should_reflect_shared_state_as_css_class(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -572,8 +575,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
self.setup_bookmark(shared=True)
|
||||
html = self.render_template()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="shared">', html)
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
self.assertIsNotNone(list_item)
|
||||
self.assertListEqual(["shared"], list_item["class"])
|
||||
|
||||
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -582,8 +588,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
self.setup_bookmark(unread=True, shared=True)
|
||||
html = self.render_template()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="unread shared">', html)
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
self.assertIsNotNone(list_item)
|
||||
self.assertListEqual(["unread", "shared"], list_item["class"])
|
||||
|
||||
def test_show_bookmark_actions_for_owned_bookmarks(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
@@ -4,6 +4,8 @@ import os
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import URLResolver
|
||||
|
||||
from bookmarks import utils
|
||||
|
||||
|
||||
class OidcSupportTest(TestCase):
|
||||
def test_should_not_add_oidc_urls_by_default(self):
|
||||
@@ -55,9 +57,83 @@ class OidcSupportTest(TestCase):
|
||||
base_settings = importlib.import_module("siteroot.settings.base")
|
||||
importlib.reload(base_settings)
|
||||
|
||||
self.assertEqual(
|
||||
True,
|
||||
base_settings.OIDC_VERIFY_SSL,
|
||||
)
|
||||
self.assertEqual(True, base_settings.OIDC_VERIFY_SSL)
|
||||
self.assertEqual("openid email profile", base_settings.OIDC_RP_SCOPES)
|
||||
self.assertEqual("email", base_settings.OIDC_USERNAME_CLAIM)
|
||||
|
||||
del os.environ["LD_ENABLE_OIDC"]
|
||||
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="email")
|
||||
def test_username_should_use_email_by_default(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "test preferred username",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual(claims["email"], username)
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
|
||||
def test_username_should_use_custom_claim(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "test preferred username",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual(claims["preferred_username"], username)
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="nonexistant_claim")
|
||||
def test_username_should_fallback_to_email_for_non_existing_claim(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "test preferred username",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual(claims["email"], username)
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
|
||||
def test_username_should_fallback_to_email_for_empty_claim(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual(claims["email"], username)
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
|
||||
def test_username_should_be_normalized(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "NormalizedUser",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual("NormalizedUser", username)
|
||||
|
@@ -47,6 +47,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"auto_tagging_rules": "",
|
||||
"items_per_page": "30",
|
||||
"sticky_pagination": False,
|
||||
"collapse_side_panel": False,
|
||||
}
|
||||
|
||||
return {**form_data, **overrides}
|
||||
@@ -117,6 +118,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"auto_tagging_rules": "example.com tag",
|
||||
"items_per_page": "10",
|
||||
"sticky_pagination": True,
|
||||
"collapse_side_panel": True,
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:settings.update"), form_data, follow=True
|
||||
@@ -194,6 +196,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
self.user.profile.sticky_pagination, form_data["sticky_pagination"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
|
||||
)
|
||||
|
||||
self.assertSuccessMessage(html, "Profile updated")
|
||||
|
||||
|
@@ -9,6 +9,7 @@ from dateutil.relativedelta import relativedelta
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.template.defaultfilters import pluralize
|
||||
from django.utils import timezone, formats
|
||||
from django.conf import settings
|
||||
|
||||
try:
|
||||
with open("version.txt", "r") as f:
|
||||
@@ -128,10 +129,13 @@ def redirect_with_query(request, redirect_url):
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
|
||||
|
||||
def generate_username(email):
|
||||
def generate_username(email, claims):
|
||||
# taken from mozilla-django-oidc docs :)
|
||||
|
||||
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
|
||||
# (ascii and unicode), _, @, +, . and - characters. So we normalize
|
||||
# it and slice at 150 characters.
|
||||
return unicodedata.normalize("NFKC", email)[:150]
|
||||
if settings.OIDC_USERNAME_CLAIM in claims and claims[settings.OIDC_USERNAME_CLAIM]:
|
||||
username = claims[settings.OIDC_USERNAME_CLAIM]
|
||||
else:
|
||||
username = email
|
||||
return unicodedata.normalize("NFKC", username)[:150]
|
||||
|
@@ -104,6 +104,7 @@ def shared(request):
|
||||
"tag_cloud": tag_cloud,
|
||||
"details": bookmark_details,
|
||||
"users": users,
|
||||
"rss_feed_url": reverse("bookmarks:feeds.public_shared"),
|
||||
},
|
||||
)
|
||||
|
||||
|
@@ -208,6 +208,7 @@ class BookmarkListContext:
|
||||
self.show_favicons = user_profile.enable_favicons
|
||||
self.show_preview_images = user_profile.enable_preview_images
|
||||
self.show_notes = user_profile.permanent_notes
|
||||
self.collapse_side_panel = user_profile.collapse_side_panel
|
||||
|
||||
@staticmethod
|
||||
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
|
||||
|
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS node-build
|
||||
FROM node:18-alpine AS node-build
|
||||
WORKDIR /etc/linkding
|
||||
# install build dependencies
|
||||
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
|
||||
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12.6-alpine3.20 AS build-deps
|
||||
FROM python:3.12.8-alpine3.21 AS build-deps
|
||||
# Add required packages
|
||||
# alpine-sdk linux-headers pkgconfig: build Python packages from source
|
||||
# libpq-dev: build Postgres client from source
|
||||
@@ -49,7 +49,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||
|
||||
|
||||
FROM python:3.12.6-alpine3.20 AS linkding
|
||||
FROM python:3.12.8-alpine3.21 AS linkding
|
||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||
# install runtime dependencies
|
||||
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
||||
|
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS node-build
|
||||
FROM node:18-alpine AS node-build
|
||||
WORKDIR /etc/linkding
|
||||
# install build dependencies
|
||||
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
|
||||
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12.6-slim-bookworm AS build-deps
|
||||
FROM python:3.12.8-slim-bookworm AS build-deps
|
||||
# Add required packages
|
||||
# build-essential pkg-config: build Python packages from source
|
||||
# libpq-dev: build Postgres client from source
|
||||
@@ -51,7 +51,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||
|
||||
|
||||
FROM python:3.12.6-slim-bookworm AS linkding
|
||||
FROM python:3.12.8-slim-bookworm AS linkding
|
||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||
# install runtime dependencies
|
||||
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
||||
|
@@ -11,6 +11,7 @@ This section lists community projects around using linkding, in alphabetical ord
|
||||
- [go-linkding](https://github.com/piero-vic/go-linkding) A Go client library to interact with the linkding REST API. By [piero-vic](https://github.com/piero-vic)
|
||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
|
||||
- [iOS Shortcut and workflow](https://joshdick.net/2025/01/23/how_i_use_linkding_on_ios.html) iOS shortcut that accepts URLs in various ways, and shows a corresponding Linkding add/edit webview in a modal popup
|
||||
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
||||
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||
- [linkding-archiver](https://github.com/sebw/linkding-archiver) A Python application that integrates with SingleFile and Tube Archivist to archive your links and videos. By [sebw](https://github.com/sebw)
|
||||
|
@@ -105,7 +105,7 @@ Values: `True`, `False` | Default = `False`
|
||||
|
||||
Enables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers.
|
||||
When enabled, this shows a button on the login page that allows users to authenticate using an OIDC provider.
|
||||
Users are associated by the email address provided from the OIDC provider, which is used as the username in linkding.
|
||||
Users are associated by the email address provided from the OIDC provider, which is by default also used as username in linkding. You can configure a custom claim to be used as username with `OIDC_USERNAME_CLAIM`.
|
||||
If there is no user with that email address as username, a new user is created automatically.
|
||||
|
||||
This requires configuring a number of options, which of those you need depends on which OIDC provider you use and how it is configured.
|
||||
@@ -124,6 +124,8 @@ The following options can be configured:
|
||||
- `OIDC_RP_SIGN_ALGO` - The algorithm the OIDC provider uses to sign ID tokens. Default is `RS256`.
|
||||
- `OIDC_USE_PKCE` - Whether to use PKCE for the OIDC flow. Default is `True`.
|
||||
- `OIDC_VERIFY_SSL` - Whether to verify the SSL certificate of the OIDC provider. Set to `False` if using self-signed certificates or custom certificate authority. Default is `True`.
|
||||
- `OIDC_RP_SCOPES` - Scopes asked for on the authorization flow. Default is `oidc email profile`.
|
||||
- `OIDC_USERNAME_CLAIM` - A custom claim to used as username for new accounts, for example `preferred_username`. If the configured claim does not exist or is empty, the email claim is used as fallback. Default is `email`.
|
||||
|
||||
<details>
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -76,7 +76,7 @@ urllib3==2.2.3
|
||||
# via
|
||||
# requests
|
||||
# waybackpy
|
||||
uwsgi==2.0.26
|
||||
uwsgi==2.0.28
|
||||
# via -r requirements.in
|
||||
waybackpy==3.0.6
|
||||
# via -r requirements.in
|
||||
|
@@ -194,8 +194,10 @@ if LD_ENABLE_OIDC:
|
||||
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
|
||||
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
|
||||
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
|
||||
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
|
||||
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1")
|
||||
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1")
|
||||
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email")
|
||||
|
||||
# Enable authentication proxy support if configured
|
||||
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")
|
||||
|
@@ -1 +1 @@
|
||||
1.37.0
|
||||
1.38.0
|
||||
|
Reference in New Issue
Block a user