mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-07 02:13:12 +08:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfe4ff113d | ||
|
|
757dc56277 | ||
|
|
dfbb367857 | ||
|
|
2276832465 | ||
|
|
9d61bdce52 | ||
|
|
1274a9ae0a | ||
|
|
5e7172d17e | ||
|
|
78608135d9 | ||
|
|
51acd1da3f | ||
|
|
016ff2da66 | ||
|
|
77d7e6e66a | ||
|
|
c5a300a435 | ||
|
|
0d4c47eb81 | ||
|
|
17442eeb9a | ||
|
|
2973812626 | ||
|
|
fc48b266a8 | ||
|
|
7b42241026 | ||
|
|
9c648dc67f | ||
|
|
1624128132 | ||
|
|
d1dd85538b | ||
|
|
c5aab3886e | ||
|
|
3f2739e5a6 | ||
|
|
f1ed89a0ba | ||
|
|
a59a7a777c | ||
|
|
9a5c535872 | ||
|
|
e6ebca1436 | ||
|
|
085d67e9f4 | ||
|
|
68825444fb | ||
|
|
b2ca16ec9c | ||
|
|
649f4154e5 | ||
|
|
d2e8a95e3c | ||
|
|
c3149409b0 | ||
|
|
4626fa1c67 | ||
|
|
6548e16baa | ||
|
|
c177de164a | ||
|
|
e9ecad38ac | ||
|
|
621aedd8eb | ||
|
|
4187141ac8 |
26
.github/workflows/build.yaml
vendored
Normal file
26
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
on: workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
file: ./docker/default.Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
push: false
|
||||||
|
tags: sissbruecker/linkding:test
|
||||||
|
target: linkding
|
||||||
75
CHANGELOG.md
75
CHANGELOG.md
@@ -1,6 +1,79 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## (23/09/2024)
|
## v1.38.0 (09/02/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965
|
||||||
|
* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971
|
||||||
|
* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974
|
||||||
|
* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975
|
||||||
|
* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977
|
||||||
|
* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984
|
||||||
|
* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
|
||||||
|
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
|
||||||
|
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
|
||||||
|
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
|
||||||
|
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
|
||||||
|
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
|
||||||
|
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
|
||||||
|
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
|
||||||
|
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
|
||||||
|
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
|
||||||
|
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
|
||||||
|
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
|
||||||
|
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
|
||||||
|
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.35.0 (23/09/2024)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835
|
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from bookmarks.services.bookmarks import (
|
|||||||
enhance_with_website_metadata,
|
enhance_with_website_metadata,
|
||||||
)
|
)
|
||||||
from bookmarks.services.tags import get_or_create_tag
|
from bookmarks.services.tags import get_or_create_tag
|
||||||
|
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||||
|
|
||||||
|
|
||||||
class TagListField(serializers.ListField):
|
class TagListField(serializers.ListField):
|
||||||
@@ -59,9 +60,10 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
# Custom tag_names field to allow passing a list of tag names to create/update
|
# Custom tag_names field to allow passing a list of tag names to create/update
|
||||||
tag_names = TagListField(required=False)
|
tag_names = TagListField(required=False)
|
||||||
# Custom fields to return URLs for favicon and preview image
|
# Custom fields to generate URLs for favicon, preview image, and web archive snapshot
|
||||||
favicon_url = serializers.SerializerMethodField()
|
favicon_url = serializers.SerializerMethodField()
|
||||||
preview_image_url = serializers.SerializerMethodField()
|
preview_image_url = serializers.SerializerMethodField()
|
||||||
|
web_archive_snapshot_url = serializers.SerializerMethodField()
|
||||||
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
||||||
website_title = serializers.SerializerMethodField()
|
website_title = serializers.SerializerMethodField()
|
||||||
website_description = serializers.SerializerMethodField()
|
website_description = serializers.SerializerMethodField()
|
||||||
@@ -82,6 +84,12 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||||
return preview_image_url
|
return preview_image_url
|
||||||
|
|
||||||
|
def get_web_archive_snapshot_url(self, obj: Bookmark):
|
||||||
|
if obj.web_archive_snapshot_url:
|
||||||
|
return obj.web_archive_snapshot_url
|
||||||
|
|
||||||
|
return generate_fallback_webarchive_url(obj.url, obj.date_added)
|
||||||
|
|
||||||
def get_website_title(self, obj: Bookmark):
|
def get_website_title(self, obj: Bookmark):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
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
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
|
||||||
def test_show_modal_close_modal(self):
|
def test_show_modal_close_modal(self):
|
||||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||||
@@ -12,31 +12,31 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
|||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
page = self.open(reverse("bookmarks:index"), 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})
|
page.set_viewport_size({"width": 375, "height": 812})
|
||||||
|
|
||||||
# open tag cloud modal
|
# open drawer
|
||||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||||
"button", name="Tags"
|
"button", name="Filters"
|
||||||
)
|
)
|
||||||
modal_trigger.click()
|
drawer_trigger.click()
|
||||||
|
|
||||||
# verify modal is visible
|
# verify drawer is visible
|
||||||
modal = page.locator(".modal")
|
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||||
expect(modal).to_be_visible()
|
expect(drawer).to_be_visible()
|
||||||
expect(modal.locator("h2")).to_have_text("Tags")
|
expect(drawer.locator("h2")).to_have_text("Filters")
|
||||||
|
|
||||||
# close with close button
|
# close with close button
|
||||||
modal.locator("button.close").click()
|
drawer.locator("button.close").click()
|
||||||
expect(modal).to_be_hidden()
|
expect(drawer).to_be_hidden()
|
||||||
|
|
||||||
# open modal again
|
# open drawer again
|
||||||
modal_trigger.click()
|
drawer_trigger.click()
|
||||||
|
|
||||||
# close with backdrop
|
# close with backdrop
|
||||||
backdrop = modal.locator(".modal-overlay")
|
backdrop = drawer.locator(".modal-overlay")
|
||||||
backdrop.click(position={"x": 0, "y": 0})
|
backdrop.click(position={"x": 0, "y": 0})
|
||||||
expect(modal).to_be_hidden()
|
expect(drawer).to_be_hidden()
|
||||||
|
|
||||||
def test_select_tag(self):
|
def test_select_tag(self):
|
||||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||||
@@ -45,29 +45,29 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
|||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
page = self.open(reverse("bookmarks:index"), 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})
|
page.set_viewport_size({"width": 375, "height": 812})
|
||||||
|
|
||||||
# open tag cloud modal
|
# open tag cloud modal
|
||||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||||
"button", name="Tags"
|
"button", name="Filters"
|
||||||
)
|
)
|
||||||
modal_trigger.click()
|
drawer_trigger.click()
|
||||||
|
|
||||||
# verify tags are displayed
|
# verify tags are displayed
|
||||||
modal = page.locator(".modal")
|
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||||
unselected_tags = modal.locator(".unselected-tags")
|
unselected_tags = drawer.locator(".unselected-tags")
|
||||||
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||||
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||||
|
|
||||||
# select tag
|
# select tag
|
||||||
unselected_tags.get_by_text("cooking").click()
|
unselected_tags.get_by_text("cooking").click()
|
||||||
|
|
||||||
# open modal again
|
# open drawer again
|
||||||
modal_trigger.click()
|
drawer_trigger.click()
|
||||||
|
|
||||||
# verify tag is selected, other tag is not visible anymore
|
# 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(selected_tags.get_by_text("cooking")).to_be_visible()
|
||||||
|
|
||||||
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
expect(unselected_tags.get_by_text("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 {
|
class DetailsModalBehavior extends ModalBehavior {
|
||||||
constructor(element) {
|
doClose() {
|
||||||
super(element);
|
super.doClose();
|
||||||
|
|
||||||
this.onClose = this.onClose.bind(this);
|
// Navigate to close URL
|
||||||
this.onKeyDown = this.onKeyDown.bind(this);
|
const closeUrl = this.element.dataset.closeUrl;
|
||||||
|
Turbo.visit(closeUrl, {
|
||||||
|
action: "replace",
|
||||||
|
frame: "details-modal",
|
||||||
|
});
|
||||||
|
|
||||||
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
|
// Try restore focus to view details to view details link of respective bookmark
|
||||||
this.buttonLink = element.querySelector("a:has(button.close)");
|
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);
|
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
|
|||||||
this.opened = false;
|
this.opened = false;
|
||||||
this.onClick = this.onClick.bind(this);
|
this.onClick = this.onClick.bind(this);
|
||||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||||
|
this.onEscape = this.onEscape.bind(this);
|
||||||
|
this.onFocusOut = this.onFocusOut.bind(this);
|
||||||
|
|
||||||
|
// Prevent opening the dropdown automatically on focus, so that it only
|
||||||
|
// opens on click then JS is enabled
|
||||||
|
this.element.style.setProperty("--dropdown-focus-display", "none");
|
||||||
|
this.element.addEventListener("keydown", this.onEscape);
|
||||||
|
this.element.addEventListener("focusout", this.onFocusOut);
|
||||||
|
|
||||||
this.toggle = element.querySelector(".dropdown-toggle");
|
this.toggle = element.querySelector(".dropdown-toggle");
|
||||||
|
this.toggle.setAttribute("aria-expanded", "false");
|
||||||
this.toggle.addEventListener("click", this.onClick);
|
this.toggle.addEventListener("click", this.onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.close();
|
this.close();
|
||||||
this.toggle.removeEventListener("click", this.onClick);
|
this.toggle.removeEventListener("click", this.onClick);
|
||||||
|
this.element.removeEventListener("keydown", this.onEscape);
|
||||||
|
this.element.removeEventListener("focusout", this.onFocusOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
|
this.opened = true;
|
||||||
this.element.classList.add("active");
|
this.element.classList.add("active");
|
||||||
|
this.toggle.setAttribute("aria-expanded", "true");
|
||||||
document.addEventListener("click", this.onOutsideClick);
|
document.addEventListener("click", this.onOutsideClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
this.opened = false;
|
||||||
this.element.classList.remove("active");
|
this.element.classList.remove("active");
|
||||||
|
this.toggle.setAttribute("aria-expanded", "false");
|
||||||
document.removeEventListener("click", this.onOutsideClick);
|
document.removeEventListener("click", this.onOutsideClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
|
|||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onEscape(event) {
|
||||||
|
if (event.key === "Escape" && this.opened) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.close();
|
||||||
|
this.toggle.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocusOut(event) {
|
||||||
|
if (!this.element.contains(event.relatedTarget)) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerBehavior("ld-dropdown", DropdownBehavior);
|
registerBehavior("ld-dropdown", DropdownBehavior);
|
||||||
|
|||||||
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/bulk-edit";
|
||||||
import "./behaviors/clear-button";
|
import "./behaviors/clear-button";
|
||||||
import "./behaviors/confirm-button";
|
import "./behaviors/confirm-button";
|
||||||
import "./behaviors/dropdown";
|
|
||||||
import "./behaviors/form";
|
|
||||||
import "./behaviors/details-modal";
|
import "./behaviors/details-modal";
|
||||||
|
import "./behaviors/dropdown";
|
||||||
|
import "./behaviors/filter-drawer";
|
||||||
|
import "./behaviors/form";
|
||||||
import "./behaviors/global-shortcuts";
|
import "./behaviors/global-shortcuts";
|
||||||
import "./behaviors/search-autocomplete";
|
import "./behaviors/search-autocomplete";
|
||||||
import "./behaviors/tag-autocomplete";
|
import "./behaviors/tag-autocomplete";
|
||||||
import "./behaviors/tag-modal";
|
|
||||||
|
|
||||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -93,6 +93,19 @@ class Bookmark(models.Model):
|
|||||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=Bookmark)
|
||||||
|
def bookmark_deleted(sender, instance, **kwargs):
|
||||||
|
if instance.preview_image_file:
|
||||||
|
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to delete preview image: {filepath}", exc_info=error
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkAsset(models.Model):
|
class BookmarkAsset(models.Model):
|
||||||
TYPE_SNAPSHOT = "snapshot"
|
TYPE_SNAPSHOT = "snapshot"
|
||||||
TYPE_UPLOAD = "upload"
|
TYPE_UPLOAD = "upload"
|
||||||
@@ -440,6 +453,7 @@ class UserProfile(models.Model):
|
|||||||
null=False, default=30, validators=[MinValueValidator(10)]
|
null=False, default=30, validators=[MinValueValidator(10)]
|
||||||
)
|
)
|
||||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||||
|
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.custom_css:
|
if self.custom_css:
|
||||||
@@ -479,6 +493,7 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"auto_tagging_rules",
|
"auto_tagging_rules",
|
||||||
"items_per_page",
|
"items_per_page",
|
||||||
"sticky_pagination",
|
"sticky_pagination",
|
||||||
|
"collapse_side_panel",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
bookmarks/static/robots.txt
Normal file
2
bookmarks/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -36,8 +36,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& dl {
|
& .sections section {
|
||||||
margin-bottom: 0;
|
margin-top: var(--unit-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .sections h3 {
|
||||||
|
margin-bottom: var(--unit-2);
|
||||||
|
font-size: var(--font-size);
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .assets {
|
& .assets {
|
||||||
|
|||||||
@@ -10,8 +10,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Bookmark page grid */
|
/* Bookmark page grid */
|
||||||
.bookmarks-page.grid {
|
.bookmarks-page {
|
||||||
grid-gap: var(--unit-9);
|
&.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 */
|
/* Bookmark area header controls */
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
/* Content area component */
|
/* Content area component */
|
||||||
section.content-area {
|
section.content-area {
|
||||||
h2 {
|
h2,
|
||||||
|
h3 {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ section.content-area {
|
|||||||
padding-bottom: var(--unit-2);
|
padding-bottom: var(--unit-2);
|
||||||
margin-bottom: var(--unit-4);
|
margin-bottom: var(--unit-4);
|
||||||
|
|
||||||
h2 {
|
h2,
|
||||||
|
h3 {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
line-height: var(--unit-9);
|
line-height: var(--unit-9);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ html {
|
|||||||
font-size: var(--html-font-size);
|
font-size: var(--html-font-size);
|
||||||
line-height: var(--html-line-height);
|
line-height: var(--html-line-height);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/* Dropdown */
|
/* Dropdown */
|
||||||
.dropdown {
|
.dropdown {
|
||||||
|
--dropdown-focus-display: block;
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@@ -20,9 +22,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active .menu,
|
&:focus-within .menu {
|
||||||
.dropdown-toggle:focus + .menu,
|
/* Use custom CSS property to allow disabling opening on focus when using JS */
|
||||||
.menu:hover {
|
display: var(--dropdown-focus-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .menu {
|
||||||
|
/* Always show menu when class is added through JS */
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,13 +62,14 @@
|
|||||||
gap: var(--unit-4);
|
gap: var(--unit-4);
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
max-width: var(--control-width-md);
|
max-width: var(--control-width-md);
|
||||||
padding: var(--unit-6);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
& .modal-header {
|
& .modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--unit-2);
|
gap: var(--unit-2);
|
||||||
|
padding: var(--unit-6);
|
||||||
|
padding-bottom: 0;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
|
||||||
& h2 {
|
& h2 {
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& button.close {
|
& .close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -95,10 +96,53 @@
|
|||||||
|
|
||||||
& .modal-body {
|
& .modal-body {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
padding: 0 var(--unit-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .modal-body:not(:has(+ .modal-footer)) {
|
||||||
|
margin-bottom: var(--unit-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .modal-footer {
|
& .modal-footer {
|
||||||
|
padding: var(--unit-6);
|
||||||
|
padding-top: 0;
|
||||||
text-align: right;
|
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 %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 #}
|
{# Bookmark list #}
|
||||||
<section class="content-area col-2">
|
<section class="main content-area col-2">
|
||||||
<div class="content-area-header mb-0">
|
<div class="content-area-header mb-0">
|
||||||
<h2>Archived bookmarks</h2>
|
<h2>Archived bookmarks</h2>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
{% bookmark_search bookmark_list.search mode='archived' %}
|
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Tag cloud #}
|
{# Tag cloud #}
|
||||||
<section class="content-area col-1 hide-md">
|
<section class="side-panel content-area col-1">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,12 +39,14 @@
|
|||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Bookmark details #}
|
|
||||||
<turbo-frame id="details-modal" target="_top">
|
|
||||||
{% if details %}
|
|
||||||
{% include 'bookmarks/details/modal.html' %}
|
|
||||||
{% endif %}
|
|
||||||
</turbo-frame>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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' %}
|
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
<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 }};"
|
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
|
||||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||||
{% for bookmark_item in bookmark_list.items %}
|
{% 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="content">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<label class="form-checkbox bulk-edit-checkbox">
|
<label class="form-checkbox bulk-edit-checkbox">
|
||||||
@@ -78,7 +80,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{# View link is visible for both owned and shared bookmarks #}
|
{# View link is visible for both owned and shared bookmarks #}
|
||||||
{% if bookmark_list.show_view_action %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if bookmark_item.is_editable %}
|
{% if bookmark_item.is_editable %}
|
||||||
{# Bookmark owner actions #}
|
{# Bookmark owner actions #}
|
||||||
|
|||||||
@@ -40,14 +40,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||||
<div class="preview-image">
|
<div class="preview-image">
|
||||||
<img src="{% static details.bookmark.preview_image_file %}"/>
|
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if details.is_editable %}
|
||||||
<div class="status col-2">
|
<section class="status col-2">
|
||||||
<dt>Status</dt>
|
<h3>Status</h3>
|
||||||
<dd class="d-flex" style="gap: .8rem">
|
<div class="d-flex" style="gap: .8rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-switch">
|
<label class="form-switch">
|
||||||
<input ld-auto-submit type="checkbox" name="is_archived"
|
<input ld-auto-submit type="checkbox" name="is_archived"
|
||||||
@@ -71,44 +71,44 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if details.show_files %}
|
{% if details.show_files %}
|
||||||
<div class="files col-2">
|
<section class="files col-2">
|
||||||
<dt>Files</dt>
|
<h3>Files</h3>
|
||||||
<dd>
|
<div>
|
||||||
{% include 'bookmarks/details/assets.html' %}
|
{% include 'bookmarks/details/assets.html' %}
|
||||||
</dd>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if details.bookmark.tag_names %}
|
{% if details.bookmark.tag_names %}
|
||||||
<div class="tags col-1">
|
<section class="tags col-1">
|
||||||
<dt>Tags</dt>
|
<h3 id="details-modal-tags-title">Tags</h3>
|
||||||
<dd>
|
<div>
|
||||||
{% for tag_name in details.bookmark.tag_names %}
|
{% for tag_name in details.bookmark.tag_names %}
|
||||||
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dd>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="date-added col-1">
|
<section class="date-added col-1">
|
||||||
<dt>Date added</dt>
|
<h3>Date added</h3>
|
||||||
<dd>
|
<div>
|
||||||
<span>{{ details.bookmark.date_added }}</span>
|
<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>
|
</div>
|
||||||
|
</section>
|
||||||
|
{% if details.bookmark.resolved_description %}
|
||||||
|
<section class="description col-2">
|
||||||
|
<h3>Description</h3>
|
||||||
|
<div>{{ details.bookmark.resolved_description }}</div>
|
||||||
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if details.bookmark.notes %}
|
{% if details.bookmark.notes %}
|
||||||
<div class="notes col-2">
|
<section class="notes col-2">
|
||||||
<dt>Notes</dt>
|
<h3>Notes</h3>
|
||||||
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
|
<div class="markdown">{% markdown details.bookmark.notes %}</div>
|
||||||
</div>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dl>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
<div class="modal active bookmark-details"
|
<div class="modal active bookmark-details" ld-details-modal
|
||||||
ld-details-modal>
|
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
|
||||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
<div class="modal-overlay"></div>
|
||||||
<div class="modal-overlay" aria-label="Close"></div>
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
</a>
|
|
||||||
<div class="modal-container">
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>{{ details.bookmark.resolved_title }}</h2>
|
<h2>{{ details.bookmark.resolved_title }}</h2>
|
||||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
<button class="close" aria-label="Close dialog">
|
||||||
<button class="close">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||||
<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">
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path d="M18 6l-12 12"></path>
|
||||||
<path d="M18 6l-12 12"></path>
|
<path d="M6 6l12 12"></path>
|
||||||
<path d="M6 6l12 12"></path>
|
</svg>
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -36,5 +36,8 @@
|
|||||||
{% if not request.global_settings.enable_link_prefetch %}
|
{% if not request.global_settings.enable_link_prefetch %}
|
||||||
<meta name="turbo-prefetch" content="false">
|
<meta name="turbo-prefetch" content="false">
|
||||||
{% endif %}
|
{% 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>
|
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -4,16 +4,17 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 #}
|
{# Bookmark list #}
|
||||||
<section class="content-area col-2">
|
<section class="main content-area col-2">
|
||||||
<div class="content-area-header mb-0">
|
<div class="content-area-header mb-0">
|
||||||
<h2>Bookmarks</h2>
|
<h2>Bookmarks</h2>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
{% bookmark_search bookmark_list.search %}
|
{% bookmark_search bookmark_list.search %}
|
||||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Tag cloud #}
|
{# Tag cloud #}
|
||||||
<section class="content-area col-1 hide-md">
|
<section class="side-panel content-area col-1">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,12 +39,14 @@
|
|||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Bookmark details #}
|
|
||||||
<turbo-frame id="details-modal" target="_top">
|
|
||||||
{% if details %}
|
|
||||||
{% include 'bookmarks/details/modal.html' %}
|
|
||||||
{% endif %}
|
|
||||||
</turbo-frame>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modals">
|
||||||
|
{% block overlays %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
{# Basic menu list #}
|
{# Basic menu list #}
|
||||||
<div class="hide-md">
|
<div class="hide-md">
|
||||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||||
<div class="dropdown">
|
<div ld-dropdown class="dropdown">
|
||||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||||
Bookmarks
|
Bookmarks
|
||||||
</button>
|
</button>
|
||||||
<ul class="menu">
|
<ul class="menu" role="list" tabindex="-1">
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
|
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -28,28 +28,28 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-link">Logout</button>
|
<button type="submit" class="btn btn-link">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{# Menu drop-down for smaller devices #}
|
{# Menu drop-down for smaller devices #}
|
||||||
<div class="show-md">
|
<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"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
style="width: 24px; height: 24px">
|
style="width: 24px; height: 24px">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<div ld-dropdown class="dropdown dropdown-right">
|
<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"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
style="width: 24px; height: 24px">
|
style="width: 24px; height: 24px">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<!-- menu component -->
|
<!-- menu component -->
|
||||||
<ul class="menu">
|
<ul class="menu" role="list" tabindex="-1">
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
|
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
|
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-link menu-link">Logout</button>
|
<button type="submit" class="btn btn-link menu-link">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</form>
|
</form>
|
||||||
<div ld-dropdown class="search-options dropdown dropdown-right">
|
<div ld-dropdown class="search-options dropdown dropdown-right">
|
||||||
<button type="button" class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
|
<button type="button" aria-label="Search preferences"
|
||||||
|
class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
@@ -41,8 +42,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'shared' in preferences_form.editable_fields %}
|
{% if 'shared' in preferences_form.editable_fields %}
|
||||||
<div class="form-group radio-group">
|
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-shared-label">
|
||||||
<div class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">Shared filter</div>
|
<label id="search-shared-label"
|
||||||
|
class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">
|
||||||
|
Shared filter
|
||||||
|
</label>
|
||||||
{% for radio in preferences_form.shared %}
|
{% for radio in preferences_form.shared %}
|
||||||
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
||||||
{{ radio.tag }}
|
{{ radio.tag }}
|
||||||
@@ -53,8 +57,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'unread' in preferences_form.editable_fields %}
|
{% if 'unread' in preferences_form.editable_fields %}
|
||||||
<div class="form-group radio-group">
|
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-unread-label">
|
||||||
<div class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">Unread filter</div>
|
<label id="search-unread-label"
|
||||||
|
class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">
|
||||||
|
Unread filter
|
||||||
|
</label>
|
||||||
{% for radio in preferences_form.unread %}
|
{% for radio in preferences_form.unread %}
|
||||||
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
||||||
{{ radio.tag }}
|
{{ radio.tag }}
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 #}
|
{# Bookmark list #}
|
||||||
<section class="content-area col-2">
|
<section class="main content-area col-2">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Shared bookmarks</h2>
|
<h2>Shared bookmarks</h2>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
{% bookmark_search bookmark_list.search mode='shared' %}
|
{% bookmark_search bookmark_list.search mode='shared' %}
|
||||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Filters #}
|
{# Filters #}
|
||||||
<section class="content-area col-1 hide-md">
|
<section class="side-panel content-area col-1">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>User</h2>
|
<h2>User</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,12 +43,14 @@
|
|||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Bookmark details #}
|
|
||||||
<turbo-frame id="details-modal" target="_top">
|
|
||||||
{% if details %}
|
|
||||||
{% include 'bookmarks/details/modal.html' %}
|
|
||||||
{% endif %}
|
|
||||||
</turbo-frame>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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.
|
visible without having to scroll to the end of the page first.
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
<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" }}
|
{{ 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.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||||
self.assertIsNone(soup.select_one("#tag-cloud-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 django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import GlobalSettings
|
from bookmarks.models import GlobalSettings
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
|
|
||||||
class BookmarkArchivedViewPerformanceTestCase(
|
class BookmarkArchivedViewPerformanceTestCase(
|
||||||
TransactionTestCase, BookmarkFactoryMixin
|
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
):
|
):
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -32,9 +32,10 @@ class BookmarkArchivedViewPerformanceTestCase(
|
|||||||
context = CaptureQueriesContext(self.get_connection())
|
context = CaptureQueriesContext(self.get_connection())
|
||||||
with context:
|
with context:
|
||||||
response = self.client.get(reverse("bookmarks:archived"))
|
response = self.client.get(reverse("bookmarks:archived"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
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
|
number_of_queries = context.final_queries
|
||||||
|
|
||||||
@@ -46,8 +47,9 @@ class BookmarkArchivedViewPerformanceTestCase(
|
|||||||
# assert num queries doesn't increase
|
# assert num queries doesn't increase
|
||||||
with self.assertNumQueries(number_of_queries):
|
with self.assertNumQueries(number_of_queries):
|
||||||
response = self.client.get(reverse("bookmarks:archived"))
|
response = self.client.get(reverse("bookmarks:archived"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response,
|
soup = self.make_soup(html)
|
||||||
"<li ld-bookmark-item>",
|
list_items = soup.select("li[ld-bookmark-item]")
|
||||||
num_initial_bookmarks + num_additional_bookmarks,
|
self.assertEqual(
|
||||||
|
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
from bookmarks.tests.helpers import (
|
|
||||||
BookmarkFactoryMixin,
|
|
||||||
)
|
|
||||||
from bookmarks.services import bookmarks
|
from bookmarks.services import bookmarks
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
|
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.override = override_settings(LD_ASSET_FOLDER=self.temp_dir)
|
||||||
|
self.override.enable()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
temp_files = [
|
self.override.disable()
|
||||||
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
|
shutil.rmtree(self.temp_dir)
|
||||||
]
|
|
||||||
for temp_file in temp_files:
|
|
||||||
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
|
|
||||||
|
|
||||||
def setup_asset_file(self, filename):
|
def setup_asset_file(self, filename):
|
||||||
if not os.path.exists(settings.LD_ASSET_FOLDER):
|
|
||||||
os.makedirs(settings.LD_ASSET_FOLDER)
|
|
||||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||||
with open(filepath, "w") as f:
|
with open(filepath, "w") as f:
|
||||||
f.write("test")
|
f.write("test")
|
||||||
|
|||||||
@@ -32,15 +32,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
||||||
return modal
|
return modal
|
||||||
|
|
||||||
def find_section(self, soup, section_name):
|
def find_section_content(self, soup, section_name):
|
||||||
dt = soup.find("dt", string=section_name)
|
h3 = soup.find("h3", string=section_name)
|
||||||
dd = dt.find_next_sibling("dd") if dt else None
|
content = h3.find_next_sibling("div") if h3 else None
|
||||||
return dd
|
return content
|
||||||
|
|
||||||
def get_section(self, soup, section_name):
|
def get_section_content(self, soup, section_name):
|
||||||
dd = self.find_section(soup, section_name)
|
content = self.find_section_content(soup, section_name)
|
||||||
self.assertIsNotNone(dd)
|
self.assertIsNotNone(content)
|
||||||
return dd
|
return content
|
||||||
|
|
||||||
def find_weblink(self, soup, url):
|
def find_weblink(self, soup, url):
|
||||||
return soup.find("a", {"class": "weblink", "href": url})
|
return soup.find("a", {"class": "weblink", "href": url})
|
||||||
@@ -367,7 +367,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# sharing disabled
|
# sharing disabled
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(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"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
self.assertIsNotNone(archived)
|
self.assertIsNotNone(archived)
|
||||||
@@ -383,7 +383,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
|
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(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"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
self.assertIsNotNone(archived)
|
self.assertIsNotNone(archived)
|
||||||
@@ -395,7 +395,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# unchecked
|
# unchecked
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(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"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
self.assertFalse(archived.has_attr("checked"))
|
self.assertFalse(archived.has_attr("checked"))
|
||||||
@@ -407,7 +407,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# checked
|
# checked
|
||||||
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
||||||
soup = self.get_index_details_modal(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"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
self.assertTrue(archived.has_attr("checked"))
|
self.assertTrue(archived.has_attr("checked"))
|
||||||
@@ -420,14 +420,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# own bookmark
|
# own bookmark
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Status")
|
section = self.find_section_content(soup, "Status")
|
||||||
self.assertIsNotNone(section)
|
self.assertIsNotNone(section)
|
||||||
|
|
||||||
# other user's bookmark
|
# other user's bookmark
|
||||||
other_user = self.setup_user(enable_sharing=True)
|
other_user = self.setup_user(enable_sharing=True)
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
soup = self.get_shared_details_modal(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Status")
|
section = self.find_section_content(soup, "Status")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# guest user
|
# guest user
|
||||||
@@ -436,13 +436,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
other_user.profile.save()
|
other_user.profile.save()
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
soup = self.get_shared_details_modal(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Status")
|
section = self.find_section_content(soup, "Status")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
def test_date_added(self):
|
def test_date_added(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(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")
|
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
|
||||||
date = section.find("span", string=expected_date)
|
date = section.find("span", string=expected_date)
|
||||||
@@ -453,14 +453,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.find_section(soup, "Tags")
|
section = self.find_section_content(soup, "Tags")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# with tags
|
# with tags
|
||||||
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
||||||
|
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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():
|
for tag in bookmark.tags.all():
|
||||||
tag_link = section.find("a", string=f"#{tag.name}")
|
tag_link = section.find("a", string=f"#{tag.name}")
|
||||||
@@ -473,14 +473,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark(description="")
|
bookmark = self.setup_bookmark(description="")
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.find_section(soup, "Description")
|
section = self.find_section_content(soup, "Description")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# with description
|
# with description
|
||||||
bookmark = self.setup_bookmark(description="Test description")
|
bookmark = self.setup_bookmark(description="Test description")
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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)
|
self.assertEqual(section.text.strip(), bookmark.description)
|
||||||
|
|
||||||
def test_notes(self):
|
def test_notes(self):
|
||||||
@@ -488,14 +488,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.find_section(soup, "Notes")
|
section = self.find_section_content(soup, "Notes")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# with notes
|
# with notes
|
||||||
bookmark = self.setup_bookmark(notes="Test notes")
|
bookmark = self.setup_bookmark(notes="Test notes")
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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>")
|
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
|
||||||
|
|
||||||
def test_edit_link(self):
|
def test_edit_link(self):
|
||||||
@@ -568,7 +568,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Files")
|
section = self.find_section_content(soup, "Files")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
@@ -576,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Files")
|
section = self.find_section_content(soup, "Files")
|
||||||
self.assertIsNotNone(section)
|
self.assertIsNotNone(section)
|
||||||
|
|
||||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
@@ -585,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
soup = self.get_index_details_modal(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"})
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
self.assertIsNone(asset_list)
|
self.assertIsNone(asset_list)
|
||||||
|
|
||||||
@@ -594,7 +594,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
self.setup_asset(bookmark)
|
self.setup_asset(bookmark)
|
||||||
|
|
||||||
soup = self.get_index_details_modal(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"})
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
self.assertIsNotNone(asset_list)
|
self.assertIsNotNone(asset_list)
|
||||||
|
|
||||||
@@ -608,7 +608,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
]
|
]
|
||||||
|
|
||||||
soup = self.get_index_details_modal(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"})
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
@@ -738,7 +738,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
|
|
||||||
# no pending asset
|
# no pending asset
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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(
|
create_button = files_section.find(
|
||||||
"button", string=re.compile("Create HTML snapshot")
|
"button", string=re.compile("Create HTML snapshot")
|
||||||
)
|
)
|
||||||
@@ -749,7 +749,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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(
|
create_button = files_section.find(
|
||||||
"button", string=re.compile("Create HTML snapshot")
|
"button", string=re.compile("Create HTML snapshot")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -481,3 +481,10 @@ class BookmarkIndexViewTestCase(
|
|||||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||||
self.assertIsNone(soup.select_one("#tag-cloud-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 django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import GlobalSettings
|
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:
|
def setUp(self) -> None:
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
@@ -30,9 +32,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
|
|||||||
context = CaptureQueriesContext(self.get_connection())
|
context = CaptureQueriesContext(self.get_connection())
|
||||||
with context:
|
with context:
|
||||||
response = self.client.get(reverse("bookmarks:index"))
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
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
|
number_of_queries = context.final_queries
|
||||||
|
|
||||||
@@ -44,8 +47,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
|
|||||||
# assert num queries doesn't increase
|
# assert num queries doesn't increase
|
||||||
with self.assertNumQueries(number_of_queries):
|
with self.assertNumQueries(number_of_queries):
|
||||||
response = self.client.get(reverse("bookmarks:index"))
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response,
|
soup = self.make_soup(html)
|
||||||
"<li ld-bookmark-item>",
|
list_items = soup.select("li[ld-bookmark-item]")
|
||||||
num_initial_bookmarks + num_additional_bookmarks,
|
self.assertEqual(
|
||||||
|
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||||
)
|
)
|
||||||
|
|||||||
70
bookmarks/tests/test_bookmark_previews.py
Normal file
70
bookmarks/tests/test_bookmark_previews.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from bookmarks.services import bookmarks
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkPreviewsTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.override = override_settings(LD_PREVIEW_FOLDER=self.temp_dir)
|
||||||
|
self.override.enable()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.override.disable()
|
||||||
|
shutil.rmtree(self.temp_dir)
|
||||||
|
|
||||||
|
def setup_preview_file(self, filename):
|
||||||
|
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, filename)
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
f.write("test")
|
||||||
|
|
||||||
|
def setup_bookmark_with_preview(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
bookmark.preview_image_file = f"preview_{bookmark.id}.jpg"
|
||||||
|
bookmark.save()
|
||||||
|
self.setup_preview_file(bookmark.preview_image_file)
|
||||||
|
return bookmark
|
||||||
|
|
||||||
|
def assertPreviewImageExists(self, bookmark):
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.exists(
|
||||||
|
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def assertPreviewImageDoesNotExist(self, bookmark):
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(
|
||||||
|
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_bookmark_deletes_preview_image(self):
|
||||||
|
bookmark = self.setup_bookmark_with_preview()
|
||||||
|
self.assertPreviewImageExists(bookmark)
|
||||||
|
|
||||||
|
bookmark.delete()
|
||||||
|
self.assertPreviewImageDoesNotExist(bookmark)
|
||||||
|
|
||||||
|
def test_bulk_delete_bookmarks_deletes_preview_images(self):
|
||||||
|
bookmark1 = self.setup_bookmark_with_preview()
|
||||||
|
bookmark2 = self.setup_bookmark_with_preview()
|
||||||
|
bookmark3 = self.setup_bookmark_with_preview()
|
||||||
|
|
||||||
|
self.assertPreviewImageExists(bookmark1)
|
||||||
|
self.assertPreviewImageExists(bookmark2)
|
||||||
|
self.assertPreviewImageExists(bookmark3)
|
||||||
|
|
||||||
|
bookmarks.delete_bookmarks(
|
||||||
|
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertPreviewImageDoesNotExist(bookmark1)
|
||||||
|
self.assertPreviewImageDoesNotExist(bookmark2)
|
||||||
|
self.assertPreviewImageDoesNotExist(bookmark3)
|
||||||
@@ -71,19 +71,15 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
radios = form.select(f'input[name="{name}"][type="radio"]')
|
radios = form.select(f'input[name="{name}"][type="radio"]')
|
||||||
self.assertTrue(len(radios) == 0)
|
self.assertTrue(len(radios) == 0)
|
||||||
|
|
||||||
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ""):
|
def assertUnmodifiedLabel(self, html: str, text: str):
|
||||||
id_attr = f'for="{id}"' if id else ""
|
soup = self.make_soup(html)
|
||||||
tag = "label" if id else "div"
|
label = soup.find("label", string=lambda s: s and s.strip() == text)
|
||||||
needle = f'<{tag} class="form-label" {id_attr}>{text}</{tag}>'
|
self.assertEqual(label["class"], ["form-label"])
|
||||||
|
|
||||||
self.assertInHTML(needle, html)
|
def assertModifiedLabel(self, html: str, text: str):
|
||||||
|
soup = self.make_soup(html)
|
||||||
def assertModifiedLabel(self, html: str, text: str, id: str = ""):
|
label = soup.find("label", string=lambda s: s and s.strip() == text)
|
||||||
id_attr = f'for="{id}"' if id else ""
|
self.assertEqual(label["class"], ["form-label", "text-bold"])
|
||||||
tag = "label" if id else "div"
|
|
||||||
needle = f'<{tag} class="form-label text-bold" {id_attr}>{text}</{tag}>'
|
|
||||||
|
|
||||||
self.assertInHTML(needle, html)
|
|
||||||
|
|
||||||
def test_search_form_inputs(self):
|
def test_search_form_inputs(self):
|
||||||
# Without params
|
# Without params
|
||||||
@@ -190,54 +186,53 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
# Without modifications
|
# Without modifications
|
||||||
url = "/test"
|
url = "/test"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
|
soup = self.make_soup(rendered_template)
|
||||||
|
button = soup.select_one("button[aria-label='Search preferences']")
|
||||||
|
|
||||||
self.assertIn(
|
self.assertNotIn("badge", button["class"])
|
||||||
'<button type="button" class="btn dropdown-toggle">', rendered_template
|
|
||||||
)
|
|
||||||
|
|
||||||
# With modifications
|
# With modifications
|
||||||
url = "/test?sort=title_asc"
|
url = "/test?sort=title_asc"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
|
soup = self.make_soup(rendered_template)
|
||||||
|
button = soup.select_one("button[aria-label='Search preferences']")
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIn("badge", button["class"])
|
||||||
'<button type="button" class="btn dropdown-toggle badge">',
|
|
||||||
rendered_template,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ignores non-preferences modifications
|
# Ignores non-preferences modifications
|
||||||
url = "/test?q=foo&user=john"
|
url = "/test?q=foo&user=john"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
|
soup = self.make_soup(rendered_template)
|
||||||
|
button = soup.select_one("button[aria-label='Search preferences']")
|
||||||
|
|
||||||
self.assertIn(
|
self.assertNotIn("badge", button["class"])
|
||||||
'<button type="button" class="btn dropdown-toggle">', rendered_template
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_modified_labels(self):
|
def test_modified_labels(self):
|
||||||
# Without modifications
|
# Without modifications
|
||||||
url = "/test"
|
url = "/test"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
|
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
|
self.assertUnmodifiedLabel(rendered_template, "Sort by")
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
|
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
||||||
|
|
||||||
# Modified sort
|
# Modified sort
|
||||||
url = "/test?sort=title_asc"
|
url = "/test?sort=title_asc"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
self.assertModifiedLabel(rendered_template, "Sort by", "id_sort")
|
self.assertModifiedLabel(rendered_template, "Sort by")
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
|
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
||||||
|
|
||||||
# Modified shared
|
# Modified shared
|
||||||
url = "/test?shared=yes"
|
url = "/test?shared=yes"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
|
self.assertUnmodifiedLabel(rendered_template, "Sort by")
|
||||||
self.assertModifiedLabel(rendered_template, "Shared filter")
|
self.assertModifiedLabel(rendered_template, "Shared filter")
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
||||||
|
|
||||||
# Modified unread
|
# Modified unread
|
||||||
url = "/test?unread=yes"
|
url = "/test?unread=yes"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
|
self.assertUnmodifiedLabel(rendered_template, "Sort by")
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
|
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
|
||||||
self.assertModifiedLabel(rendered_template, "Unread filter")
|
self.assertModifiedLabel(rendered_template, "Unread filter")
|
||||||
|
|||||||
@@ -593,3 +593,11 @@ class BookmarkSharedViewTestCase(
|
|||||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||||
self.assertIsNone(soup.select_one("#tag-cloud-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 django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import GlobalSettings
|
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:
|
def setUp(self) -> None:
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
@@ -31,9 +33,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
|||||||
context = CaptureQueriesContext(self.get_connection())
|
context = CaptureQueriesContext(self.get_connection())
|
||||||
with context:
|
with context:
|
||||||
response = self.client.get(reverse("bookmarks:shared"))
|
response = self.client.get(reverse("bookmarks:shared"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks
|
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
|
number_of_queries = context.final_queries
|
||||||
|
|
||||||
@@ -46,8 +49,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
|||||||
# assert num queries doesn't increase
|
# assert num queries doesn't increase
|
||||||
with self.assertNumQueries(number_of_queries):
|
with self.assertNumQueries(number_of_queries):
|
||||||
response = self.client.get(reverse("bookmarks:shared"))
|
response = self.client.get(reverse("bookmarks:shared"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response,
|
soup = self.make_soup(html)
|
||||||
'<li ld-bookmark-item class="shared">',
|
list_items = soup.select("li[ld-bookmark-item]")
|
||||||
num_initial_bookmarks + num_additional_bookmarks,
|
self.assertEqual(
|
||||||
|
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import datetime
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
|
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
|
||||||
from bookmarks.services import website_loader
|
from bookmarks.services import website_loader
|
||||||
|
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||||
from bookmarks.services.website_loader import WebsiteMetadata
|
from bookmarks.services.website_loader import WebsiteMetadata
|
||||||
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
||||||
|
|
||||||
@@ -33,7 +36,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
expectation["title"] = bookmark.title
|
expectation["title"] = bookmark.title
|
||||||
expectation["description"] = bookmark.description
|
expectation["description"] = bookmark.description
|
||||||
expectation["notes"] = bookmark.notes
|
expectation["notes"] = bookmark.notes
|
||||||
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
|
expectation["web_archive_snapshot_url"] = (
|
||||||
|
bookmark.web_archive_snapshot_url
|
||||||
|
or generate_fallback_webarchive_url(bookmark.url, bookmark.date_added)
|
||||||
|
)
|
||||||
expectation["favicon_url"] = (
|
expectation["favicon_url"] = (
|
||||||
f"http://testserver/static/{bookmark.favicon_file}"
|
f"http://testserver/static/{bookmark.favicon_file}"
|
||||||
if bookmark.favicon_file
|
if bookmark.favicon_file
|
||||||
@@ -590,6 +596,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||||
self.assertBookmarkListEqual([response.data], [bookmark])
|
self.assertBookmarkListEqual([response.data], [bookmark])
|
||||||
|
|
||||||
|
def test_get_bookmark_returns_fallback_webarchive_url(self):
|
||||||
|
self.authenticate()
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
web_archive_snapshot_url="",
|
||||||
|
url="https://example.com/",
|
||||||
|
added=timezone.datetime(
|
||||||
|
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
|
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data["web_archive_snapshot_url"],
|
||||||
|
"https://web.archive.org/web/20230811214511/https://example.com/",
|
||||||
|
)
|
||||||
|
|
||||||
def test_update_bookmark(self):
|
def test_update_bookmark(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
details_url = base_url + f"?details={bookmark.id}"
|
details_url = base_url + f"?details={bookmark.id}"
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
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,
|
html,
|
||||||
count=count,
|
count=count,
|
||||||
@@ -562,8 +562,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
def test_should_reflect_unread_state_as_css_class(self):
|
def test_should_reflect_unread_state_as_css_class(self):
|
||||||
self.setup_bookmark(unread=True)
|
self.setup_bookmark(unread=True)
|
||||||
html = self.render_template()
|
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):
|
def test_should_reflect_shared_state_as_css_class(self):
|
||||||
profile = self.get_or_create_test_user().profile
|
profile = self.get_or_create_test_user().profile
|
||||||
@@ -572,8 +575,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
|
|
||||||
self.setup_bookmark(shared=True)
|
self.setup_bookmark(shared=True)
|
||||||
html = self.render_template()
|
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):
|
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
|
||||||
profile = self.get_or_create_test_user().profile
|
profile = self.get_or_create_test_user().profile
|
||||||
@@ -582,8 +588,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
|
|
||||||
self.setup_bookmark(unread=True, shared=True)
|
self.setup_bookmark(unread=True, shared=True)
|
||||||
html = self.render_template()
|
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):
|
def test_show_bookmark_actions_for_owned_bookmarks(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import os
|
|||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import URLResolver
|
from django.urls import URLResolver
|
||||||
|
|
||||||
|
from bookmarks import utils
|
||||||
|
|
||||||
|
|
||||||
class OidcSupportTest(TestCase):
|
class OidcSupportTest(TestCase):
|
||||||
def test_should_not_add_oidc_urls_by_default(self):
|
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")
|
base_settings = importlib.import_module("siteroot.settings.base")
|
||||||
importlib.reload(base_settings)
|
importlib.reload(base_settings)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(True, base_settings.OIDC_VERIFY_SSL)
|
||||||
True,
|
self.assertEqual("openid email profile", base_settings.OIDC_RP_SCOPES)
|
||||||
base_settings.OIDC_VERIFY_SSL,
|
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": "",
|
"auto_tagging_rules": "",
|
||||||
"items_per_page": "30",
|
"items_per_page": "30",
|
||||||
"sticky_pagination": False,
|
"sticky_pagination": False,
|
||||||
|
"collapse_side_panel": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {**form_data, **overrides}
|
return {**form_data, **overrides}
|
||||||
@@ -117,6 +118,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
"auto_tagging_rules": "example.com tag",
|
"auto_tagging_rules": "example.com tag",
|
||||||
"items_per_page": "10",
|
"items_per_page": "10",
|
||||||
"sticky_pagination": True,
|
"sticky_pagination": True,
|
||||||
|
"collapse_side_panel": True,
|
||||||
}
|
}
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("bookmarks:settings.update"), form_data, follow=True
|
reverse("bookmarks:settings.update"), form_data, follow=True
|
||||||
@@ -194,6 +196,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.user.profile.sticky_pagination, form_data["sticky_pagination"]
|
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")
|
self.assertSuccessMessage(html, "Profile updated")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from dateutil.relativedelta import relativedelta
|
|||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.template.defaultfilters import pluralize
|
from django.template.defaultfilters import pluralize
|
||||||
from django.utils import timezone, formats
|
from django.utils import timezone, formats
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open("version.txt", "r") as f:
|
with open("version.txt", "r") as f:
|
||||||
@@ -128,10 +129,13 @@ def redirect_with_query(request, redirect_url):
|
|||||||
return HttpResponseRedirect(redirect_url)
|
return HttpResponseRedirect(redirect_url)
|
||||||
|
|
||||||
|
|
||||||
def generate_username(email):
|
def generate_username(email, claims):
|
||||||
# taken from mozilla-django-oidc docs :)
|
# taken from mozilla-django-oidc docs :)
|
||||||
|
|
||||||
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
|
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
|
||||||
# (ascii and unicode), _, @, +, . and - characters. So we normalize
|
# (ascii and unicode), _, @, +, . and - characters. So we normalize
|
||||||
# it and slice at 150 characters.
|
# 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,
|
"tag_cloud": tag_cloud,
|
||||||
"details": bookmark_details,
|
"details": bookmark_details,
|
||||||
"users": users,
|
"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_favicons = user_profile.enable_favicons
|
||||||
self.show_preview_images = user_profile.enable_preview_images
|
self.show_preview_images = user_profile.enable_preview_images
|
||||||
self.show_notes = user_profile.permanent_notes
|
self.show_notes = user_profile.permanent_notes
|
||||||
|
self.collapse_side_panel = user_profile.collapse_side_panel
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
|
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
|
WORKDIR /etc/linkding
|
||||||
# install build dependencies
|
# install build dependencies
|
||||||
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
|
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
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12.6-alpine3.20 AS build-deps
|
FROM python:3.12.9-alpine3.21 AS build-deps
|
||||||
# Add required packages
|
# Add required packages
|
||||||
# alpine-sdk linux-headers pkgconfig: build Python packages from source
|
# alpine-sdk linux-headers pkgconfig: build Python packages from source
|
||||||
# libpq-dev: build Postgres client 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
|
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.9-alpine3.21 AS linkding
|
||||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||||
# install runtime dependencies
|
# install runtime dependencies
|
||||||
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
||||||
@@ -73,6 +73,8 @@ ENV PATH=/opt/venv/bin:$PATH
|
|||||||
RUN mkdir data && \
|
RUN mkdir data && \
|
||||||
python manage.py collectstatic
|
python manage.py collectstatic
|
||||||
|
|
||||||
|
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
|
||||||
|
ENV UWSGI_MAX_FD=4096
|
||||||
# Expose uwsgi server at port 9090
|
# Expose uwsgi server at port 9090
|
||||||
EXPOSE 9090
|
EXPOSE 9090
|
||||||
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS node-build
|
FROM node:18-alpine AS node-build
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
# install build dependencies
|
# install build dependencies
|
||||||
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
|
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
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12.6-slim-bookworm AS build-deps
|
FROM python:3.12.9-slim-bookworm AS build-deps
|
||||||
# Add required packages
|
# Add required packages
|
||||||
# build-essential pkg-config: build Python packages from source
|
# build-essential pkg-config: build Python packages from source
|
||||||
# libpq-dev: build Postgres client 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
|
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.9-slim-bookworm AS linkding
|
||||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||||
# install runtime dependencies
|
# install runtime dependencies
|
||||||
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
||||||
@@ -71,6 +71,8 @@ ENV PATH=/opt/venv/bin:$PATH
|
|||||||
RUN mkdir data && \
|
RUN mkdir data && \
|
||||||
python manage.py collectstatic
|
python manage.py collectstatic
|
||||||
|
|
||||||
|
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
|
||||||
|
ENV UWSGI_MAX_FD=4096
|
||||||
# Expose uwsgi server at port 9090
|
# Expose uwsgi server at port 9090
|
||||||
EXPOSE 9090
|
EXPOSE 9090
|
||||||
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
||||||
|
|||||||
1199
docs/package-lock.json
generated
1199
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.3",
|
"@astrojs/check": "^0.9.3",
|
||||||
"@astrojs/starlight": "^0.27.1",
|
"@astrojs/starlight": "^0.27.1",
|
||||||
"astro": "^4.15.8",
|
"astro": "^4.16.18",
|
||||||
"sharp": "^0.32.5",
|
"sharp": "^0.32.5",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
BIN
docs/public/donations/2024-10-04-django.png
Normal file
BIN
docs/public/donations/2024-10-04-django.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/public/donations/2024-10-04-internet-archive.png
Normal file
BIN
docs/public/donations/2024-10-04-internet-archive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
BIN
docs/public/donations/2024-10-04-noyb.png
Normal file
BIN
docs/public/donations/2024-10-04-noyb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/public/donations/2024-10-04-singlefile.png
Normal file
BIN
docs/public/donations/2024-10-04-singlefile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -9,9 +9,35 @@ description: "Acknowledgements and thanks to contributors and sponsors"
|
|||||||
|
|
||||||
See the table below for a list of donations.
|
See the table below for a list of donations.
|
||||||
|
|
||||||
| Source | Description | Amount | Donated to |
|
<table>
|
||||||
|---------------------------------------|---------------------------------------------|---------|------------------------------------------------------------------|
|
<thead>
|
||||||
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/2023-10-11-internet-archive.png) |
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Donated to</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
|
||||||
|
<td>Linkding hosting June 2022 - September 2023</td>
|
||||||
|
<td>$163.50</td>
|
||||||
|
<td><a href="/donations/2023-10-11-internet-archive.png">Internet Archive</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
|
||||||
|
<td>Linkding hosting October 2023 - September 2024</td>
|
||||||
|
<td>$287.04</td>
|
||||||
|
<td>
|
||||||
|
<a href="/donations/2024-10-04-django.png">Django</a><br>
|
||||||
|
<a href="/donations/2024-10-04-singlefile.png">SingleFile</a><br>
|
||||||
|
<a href="/donations/2024-10-04-internet-archive.png">Internet Archive</a><br>
|
||||||
|
<a href="/donations/2024-10-04-noyb.png">NOYB</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
## JetBrains
|
## JetBrains
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,24 @@ description: "Community projects around linkding"
|
|||||||
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md) to add your project to this section.
|
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md) to add your project to this section.
|
||||||
|
|
||||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||||
|
- [cosmicding](https://github.com/vkhitrin/cosmicding) Desktop client built using [libcosmic](https://github.com/pop-os/libcosmic). By [vkhitrin](https://github.com/vkhitrin)
|
||||||
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
||||||
- [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)
|
- [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)
|
- [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 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)
|
- [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)
|
- [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)
|
||||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||||
|
- [linkding-healthcheck](https://github.com/sebw/linkding-healthcheck) A Go application that checks the health of your bookmarks and add a tag on dead and problematic URLs. By [sebw](https://github.com/sebw)
|
||||||
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
||||||
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
- [linkding-reminder](https://github.com/sebw/linkding-reminder) A Python application that will send an email reminder for links with a specific tag. By [sebw](https://github.com/sebw)
|
||||||
|
- [linkding-rs](https://github.com/zbrox/linkding-rs) A Rust client library to interact with the linkding REST API with cross platform support to be easily used in Android or iOS apps. By [zbrox](https://github.com/zbrox)
|
||||||
|
- [Linkdy](https://github.com/JGeek00/linkdy): An open-source Android and iOS client created with Flutter. Available on the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy) and [App Store](https://apps.apple.com/us/app/linkdy/id6479930976). By [JGeek00](https://github.com/JGeek00).
|
||||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||||
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
||||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||||
|
- [serchding](https://github.com/ldwgchen/serchding) Full-text search for linkding. By [ldwgchen](https://github.com/ldwgchen)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ You can also check the [Community section](/community) for other pre-made shortc
|
|||||||
The font size can be adjusted globally by adding the following CSS to the custom CSS field in the settings:
|
The font size can be adjusted globally by adding the following CSS to the custom CSS field in the settings:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
html {
|
:root {
|
||||||
--font-size: 0.75rem;
|
--font-size: 0.75rem;
|
||||||
--font-size-sm: 0.7rem;
|
--font-size-sm: 0.7rem;
|
||||||
--font-size-lg: 0.9rem;
|
--font-size-lg: 0.9rem;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ For multiple options, use one `-e` argument per option.
|
|||||||
|
|
||||||
### Docker-compose
|
### Docker-compose
|
||||||
|
|
||||||
For docker-compose options are configured using an `.env` file.
|
For docker-compose options are configured using an `.env` file.
|
||||||
Follow the docker-compose setup in the README and copy `.env.sample` to `.env`. Then modify the options in `.env`.
|
Follow the docker-compose setup in the README and copy `.env.sample` to `.env`. Then modify the options in `.env`.
|
||||||
|
|
||||||
## List of options
|
## List of options
|
||||||
@@ -105,11 +105,11 @@ Values: `True`, `False` | Default = `False`
|
|||||||
|
|
||||||
Enables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers.
|
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.
|
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.
|
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.
|
This requires configuring a number of options, which of those you need depends on which OIDC provider you use and how it is configured.
|
||||||
In general, you should find the required information in the UI of your OIDC provider, or its documentation.
|
In general, you should find the required information in the UI of your OIDC provider, or its documentation.
|
||||||
|
|
||||||
The options are adopted from the [mozilla-django-oidc](https://mozilla-django-oidc.readthedocs.io/en/stable/) library, which is used by linkding for OIDC support.
|
The options are adopted from the [mozilla-django-oidc](https://mozilla-django-oidc.readthedocs.io/en/stable/) library, which is used by linkding for OIDC support.
|
||||||
Please check their documentation for more information on the options.
|
Please check their documentation for more information on the options.
|
||||||
@@ -124,6 +124,15 @@ The following options can be configured:
|
|||||||
- `OIDC_RP_SIGN_ALGO` - The algorithm the OIDC provider uses to sign ID tokens. Default is `RS256`.
|
- `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_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_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`.
|
||||||
|
|
||||||
|
#### `OIDC` and `LD_SUPERUSER_NAME`
|
||||||
|
|
||||||
|
As noted above, OIDC matches users by email address, but `LD_SUPERUSER_NAME` will only set the username.
|
||||||
|
Instead of setting `LD_SUPERUSER_NAME` it is recommended that you use the method described in [User setup](/installation#user-setup) to configure a superuser with both username and email address.
|
||||||
|
This way when OIDC searches for a matching user it will find the superuser account you created.
|
||||||
|
Note that you should create the superuser **before** logging in with OIDC for the first time.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
||||||
@@ -198,7 +207,7 @@ All the other database variables below are only required for configured Postgres
|
|||||||
|
|
||||||
Values: `String` | Default = `linkding`
|
Values: `String` | Default = `linkding`
|
||||||
|
|
||||||
The name of the database.
|
The name of the database.
|
||||||
|
|
||||||
### `LD_DB_USER`
|
### `LD_DB_USER`
|
||||||
|
|
||||||
@@ -258,7 +267,7 @@ Alternative favicon providers:
|
|||||||
Values: `Float` | Default = 60.0
|
Values: `Float` | Default = 60.0
|
||||||
|
|
||||||
When creating HTML archive snapshots, control the timeout for how long to wait for the snapshot to complete, in `seconds`.
|
When creating HTML archive snapshots, control the timeout for how long to wait for the snapshot to complete, in `seconds`.
|
||||||
Defaults to 60 seconds; on lower-powered hardware you may need to increase this value.
|
Defaults to 60 seconds; on lower-powered hardware you may need to increase this value.
|
||||||
|
|
||||||
### `LD_SINGLEFILE_OPTIONS`
|
### `LD_SINGLEFILE_OPTIONS`
|
||||||
|
|
||||||
@@ -268,3 +277,9 @@ When creating HTML archive snapshots, pass additional options to the `single-fil
|
|||||||
See `single-file --help` for complete list of arguments, or browse source: https://github.com/gildas-lormeau/single-file-cli/blob/master/options.js
|
See `single-file --help` for complete list of arguments, or browse source: https://github.com/gildas-lormeau/single-file-cli/blob/master/options.js
|
||||||
|
|
||||||
Example: `LD_SINGLEFILE_OPTIONS=--user-agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0"`
|
Example: `LD_SINGLEFILE_OPTIONS=--user-agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0"`
|
||||||
|
|
||||||
|
### `LD_DISABLE_REQUEST_LOGS`
|
||||||
|
|
||||||
|
Values: `true` or `false` | Default = `false`
|
||||||
|
|
||||||
|
Set uWSGI [disable-logging](https://uwsgi-docs.readthedocs.io/en/latest/Options.html#disable-logging) parameter to disable request logs, except for requests with a client (4xx) or server (5xx) error response.
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.35.0",
|
"version": "1.36.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.35.0",
|
"version": "1.36.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/turbo": "^8.0.6",
|
"@hotwired/turbo": "^8.0.6",
|
||||||
@@ -1310,9 +1310,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.7",
|
"version": "3.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.36.0",
|
"version": "1.38.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ click==8.1.7
|
|||||||
# via black
|
# via black
|
||||||
coverage==7.6.1
|
coverage==7.6.1
|
||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
django==5.1.1
|
django==5.1.5
|
||||||
# via django-debug-toolbar
|
# via django-debug-toolbar
|
||||||
django-debug-toolbar==4.4.6
|
django-debug-toolbar==4.4.6
|
||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ cryptography==43.0.1
|
|||||||
# josepy
|
# josepy
|
||||||
# mozilla-django-oidc
|
# mozilla-django-oidc
|
||||||
# pyopenssl
|
# pyopenssl
|
||||||
django==5.1.1
|
django==5.1.5
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# django-registration
|
# django-registration
|
||||||
@@ -76,7 +76,7 @@ urllib3==2.2.3
|
|||||||
# via
|
# via
|
||||||
# requests
|
# requests
|
||||||
# waybackpy
|
# waybackpy
|
||||||
uwsgi==2.0.26
|
uwsgi==2.0.28
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
waybackpy==3.0.6
|
waybackpy==3.0.6
|
||||||
# via -r requirements.in
|
# 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_ID = os.getenv("OIDC_RP_CLIENT_ID")
|
||||||
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
|
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
|
||||||
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
|
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_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_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
|
# Enable authentication proxy support if configured
|
||||||
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")
|
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
|
|||||||
static-map = /static=static
|
static-map = /static=static
|
||||||
static-map = /static=data/favicons
|
static-map = /static=data/favicons
|
||||||
static-map = /static=data/previews
|
static-map = /static=data/previews
|
||||||
|
static-map = /robots.txt=static/robots.txt
|
||||||
processes = 2
|
processes = 2
|
||||||
threads = 2
|
threads = 2
|
||||||
pidfile = /tmp/linkding.pid
|
pidfile = /tmp/linkding.pid
|
||||||
@@ -18,6 +19,7 @@ if-env = LD_CONTEXT_PATH
|
|||||||
static-map = /%(_)static=static
|
static-map = /%(_)static=static
|
||||||
static-map = /%(_)static=data/favicons
|
static-map = /%(_)static=data/favicons
|
||||||
static-map = /%(_)static=data/previews
|
static-map = /%(_)static=data/previews
|
||||||
|
static-map = /%(_)robots.txt=static/robots.txt
|
||||||
endif =
|
endif =
|
||||||
|
|
||||||
if-env = LD_REQUEST_TIMEOUT
|
if-env = LD_REQUEST_TIMEOUT
|
||||||
@@ -29,3 +31,9 @@ endif =
|
|||||||
if-env = LD_LOG_X_FORWARDED_FOR
|
if-env = LD_LOG_X_FORWARDED_FOR
|
||||||
log-x-forwarded-for = %(_)
|
log-x-forwarded-for = %(_)
|
||||||
endif =
|
endif =
|
||||||
|
|
||||||
|
if-env = LD_DISABLE_REQUEST_LOGS=true
|
||||||
|
disable-logging = true
|
||||||
|
log-4xx = true
|
||||||
|
log-5xx = true
|
||||||
|
endif =
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.36.0
|
1.38.1
|
||||||
|
|||||||
Reference in New Issue
Block a user